/* 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 = `

${zeile}

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

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

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

Terminplaner

${TERMINE_TEXT}

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

` action += knopf(`Für ${esc(preis)} behalten`, { weg: 'behalten', klasse: 'kb-btn-outline' }) } else if (lage === 'abo') { extra += `

${ZEITEN_ABO_WIEDER}

` } } else if (f.state === 'ordered') { pill = ['Zahlung offen', 'setup'] extra = '

Zahlung wird bestätigt …

' 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 = `

${aboEnde ? 'Öffnungszeiten live ist abgeschaltet, weil dein Wartungs-Abo beendet ist.' : ZEITEN_ABGESCHALTET}

` if (daten && zeitenTag(f.loeschungAm)) extra += `

Deine Eingaben bleiben bis ${zeitenTag(f.loeschungAm)} gespeichert.

` 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 += `

${ZEITEN_NICHT_PRUEFBAR}

` else extra += '

Dein Wartungs-Abo läuft wieder – wir schalten Öffnungszeiten live automatisch wieder ein (meist innerhalb eines Tages).

' } else if (angebot.inklusive === true) { extra = '

Im Wartungs-Abo enthalten.

' action = knopf('Einschalten') } else if (angebot.inklusive === false && angebot.grund === 'nicht_aktiv') { extra = '

Im Wartungs-Abo enthalten – einschaltbar, sobald dein Abo aktiv ist.

' } else if (angebot.inklusive === false) { extra = `

${esc(preis)} einmalig

` action = knopf(`Öffnungszeiten einrichten · ${esc(preis)}`) } else { extra = `

${ZEITEN_NICHT_PRUEFBAR}

` } const aktiveAnzahl = projects.filter((p) => zeitenOf(p).state === 'active').length const count = projects.length > 1 && aktiveAnzahl ? `

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

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

Öffnungszeiten live

${ZEITEN_TEXT}

${extra} ${count} ${action}
` } const soonTile = (icon, title, text) => `
${svg(icon)}Kommt bald

${title}

${text}

` export async function initFunktionen(root) { if (!root) return const { projects = [], termine, features, shop, zeiten, unterkunft } = await request('/api/funktionen') root.innerHTML = `
${newsTile(projects, newsOffer(features).price)} ${termine?.verfuegbar ? termineTile(projects, termine) : soonTile('calendar', 'Terminplaner', TERMINE_TEXT)} ${shop?.verfuegbar ? shopTile(projects, features) : ''} ${unterkunft?.verfuegbar === true ? unterkunftTile(projects, features) : ''} ${zeiten?.verfuegbar === true ? zeitenTile(projects, zeiten) : ''} ${analyseTile(projects)}
` root.querySelector('[data-fn-tile="news"] .fn-tile-cta')?.addEventListener('click', (ev) => { selection.projectId = ev.currentTarget.dataset.project }) root.querySelector('[data-fn-tile="analyse"] .fn-tile-cta[data-project]')?.addEventListener('click', (ev) => { analyseSel.projectId = ev.currentTarget.dataset.project }) // Die Terminplaner-Ansicht liest das gewaehlte Projekt aus ihrem Behaelter (termine.js, initTermine) root.querySelector('[data-fn-tile="termine"] .fn-tile-cta[data-project]')?.addEventListener('click', (ev) => { const body = document.getElementById('termine-body') if (body) body.dataset.wunschProjekt = ev.currentTarget.dataset.project }) // Die Webshop-Ansicht liest das gewaehlte Projekt aus ihrem Behaelter (shop.js, initShop) root.querySelector('[data-fn-tile="shop"] .fn-tile-cta[data-project]')?.addEventListener('click', (ev) => { const body = document.getElementById('webshop-body') if (body) body.dataset.wunschProjekt = ev.currentTarget.dataset.project }) // Die Buchungskalender-Ansicht liest das gewaehlte Projekt aus ihrem Behaelter (buchungen.js, initBuchungen) root.querySelector('[data-fn-tile="unterkunft"] .fn-tile-cta[data-project]')?.addEventListener('click', (ev) => { const body = document.getElementById('buchungen-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, einschalten } = {}) { if (!root) return const wunsch = projectId || root.dataset?.wunschProjekt || '' // die Kachel legt ihr Projekt im Behälter ab (wie bei Terminplaner, Webshop, Buchungen) if (root.dataset) delete root.dataset.wunschProjekt if (wunsch) selection.projectId = wunsch const { projects = [], setupEstimate = '', consentText = '', features, ustHinweis, modell } = await request('/api/funktionen') if (!projects.length) { root.innerHTML = '
Noch keine Website-Projekte zugewiesen.
' return } const project = projects.find((p) => p.projectId === selection.projectId) || pickProject(projects) selection.projectId = project.projectId const feature = newsOf(project) // Rueckkehr von Stripe (#news?bestellt=1): Flag nur einmal auswerten und aus der Adresse nehmen const orderedFlag = /[?&]bestellt=1\b/.test(window.location.hash) if (orderedFlag) window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}#news`) const ordered = orderedFlag && isSetup(feature.state) // Editor nicht neu aufbauen, solange Projekt und Zustand gleich sind: Entwurf und hochgeladenes Foto bleiben const renderKey = `${project.projectId}:${feature.state}` if (feature.state === 'active' && root.dataset.newsKey === renderKey && root.querySelector('.nw')) return root.dataset.newsKey = feature.state === 'active' ? renderKey : '' root.innerHTML = ` ${projects.length > 1 ? `
` : ''}
` root.querySelector('#news-project')?.addEventListener('change', (ev) => { selection.projectId = ev.target.value initNews(root).catch((err) => { root.innerHTML = `

${esc(err.message)}

` }) }) const stage = root.querySelector('#news-stage') const thanks = ordered ? '

Danke! Sobald die Zahlung da ist, legen wir los.

' : '' if (feature.state === 'active') renderEditor(stage, project) else if (isSetup(feature.state)) stage.innerHTML = thanks + statusCard(feature, setupEstimate, modell === 'abo') else if (feature.state === 'disabled') stage.innerHTML = '

News-Pinnwand ist abgeschaltet

Für diese Website ist die Pinnwand im Moment nicht aktiv. , dann klären wir das.

' else if (modell === 'abo') renderBookingAbo(stage, project, { einschalten, neuLaden: () => initNews(root, { einschalten }) }) else renderBooking(stage, project, { setupEstimate, offer: { ...newsOffer(features, consentText), ustHinweis: textOr(ustHinweis, UST_HINWEIS) }, thanks }) } /** * Abo-Modell (modell === 'abo' in GET /api/funktionen), Zustand off: statt der Buchungsseite nur der Platz, in den der Ablauf des Einschaltens * zeichnet (einschalten aus den Init-Optionen, die Hülle gibt ihn durch). Kein Preis, kein Haken, kein Checkout. onFertig lädt die Pinnwand * einmal neu. Ohne die Funktion oder wenn sie scheitert, steht Text E4 im Platz. */ function renderBookingAbo(stage, project, { einschalten, neuLaden }) { stage.innerHTML = '
' const platz = stage.querySelector('#news-einschalten') const fehlt = () => { platz.textContent = 'Das Einschalten ist gerade nicht verfügbar. Bitte lade die Seite neu oder schreib uns im Chat.' } if (typeof einschalten !== 'function') { fehlt() return } let fertig = false const onFertig = () => { if (fertig) return fertig = true Promise.resolve().then(neuLaden).catch(() => {}) } try { Promise.resolve(einschalten(platz, { key: 'news', projectId: project.projectId, viewAs: Boolean(window.KbView?.id?.()), onFertig })).catch(fehlt) } catch { fehlt() } } function renderBooking(stage, project, { setupEstimate, offer, thanks }) { stage.innerHTML = ` ${thanks}

News-Pinnwand einrichten lassen

Wir bauen die Pinnwand in deine Website ${esc(project.projectName)} ein. Danach schreibst du deine Neuigkeiten selbst hier im Kundenbereich – mit Text, Foto und Anzeigedauer.

  • Einrichtung und Einbau durch uns
  • Beiträge jederzeit selbst schreiben, ändern und abnehmen
  • Nutzung danach kostenlos, kein Abo

${esc(offer.price)} einmalig,

${setupEstimate ? `

Einrichtung: ${esc(setupEstimate)}

` : ''}
` 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, abo = false) { const rows = [ feature.ticketWoid ? `
Ticketnummer
#${esc(feature.ticketWoid)}
` : '', feature.orderedAt ? `
${abo ? 'Eingeschaltet am' : 'Bestellt am'}
${esc(fmtDay(feature.orderedAt))}
` : '', setupEstimate ? `
Zeiteinschätzung
${esc(setupEstimate)}
` : '', ].join('') const invoice = safeUrl(feature.invoiceUrl) return `
In Einrichtung

Wir richten deine News-Pinnwand ein

${feature.state === 'ordered' ? 'Sobald deine Zahlung bei uns ankommt, legen wir los.' : 'Sobald sie fertig ist, kannst du hier deine ersten Beiträge schreiben.'}

${rows ? `
${rows}
` : ''}
${invoice ? `Rechnung ansehen` : ''}
` } /* -------------------------------------------------------------- Editor --- */ function renderEditor(stage, project) { const pid = project.projectId const q = `projectId=${encodeURIComponent(pid)}` const imageUrl = (imageId, size = 's') => `/api/news/image/${encodeURIComponent(pid)}/${encodeURIComponent(imageId)}-${size}.webp` const fresh = () => ({ editId: '', type: 'combo', title: '', text: '', paper: 'sun', duration: '1m', date: '', imageId: '', imageSrc: '', uploading: false }) let ed = fresh() let liveUrl = '' let list = { hanging: [], expired: [] } stage.innerHTML = `

Neuer Zettel

Was möchtest du aufhängen?

${TYPES.map(([key, label, sub, icon]) => ` `).join('')}

JPEG, PNG, WebP oder iPhone-Foto, bis 10 MB.

Kurz und herzlich wirkt am besten.0 / ${TEXT_MAX}

${PAPERS.map(([key, label]) => ``).join('')}

Vorschau: so sehen es alle

Lade…

` const $ = (sel) => stage.querySelector(sel) const $$ = (sel) => [...stage.querySelectorAll(sel)] const setErr = (field, message) => { const target = $(`[data-err="${field}"]`) || $('[data-err="_"]') if (target) target.textContent = message || '' } const clearErrs = () => $$('[data-err]').forEach((el) => { el.textContent = '' }) const errField = (field) => ({ imageId: 'photo', date: 'duration' }[field] || field || '_') function previewHtml() { const hasPhoto = ed.type !== 'note' const photo = hasPhoto ? (ed.imageSrc ? `` : 'Foto') : '' const title = ed.title.trim() if (ed.type === 'photo') { return `
${photo}
${esc(title)}Heute
` } const note = `
Heute${title ? `

${esc(title)}

` : ''}

${esc(ed.text)}

` return ed.type === 'combo' ? `
${photo}
${note}
` : note } function sync() { $$('.nw-type').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.type === ed.type))) $$('.nw-sw').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.paper === ed.paper))) $('#nw-photo-field').classList.toggle('hidden', ed.type === 'note') $('#nw-text-field').classList.toggle('hidden', ed.type === 'photo') $('#nw-paper-field').classList.toggle('hidden', ed.type === 'photo') $('#nw-title-label').textContent = ed.type === 'photo' ? 'Bildunterschrift (freiwillig)' : 'Überschrift (freiwillig)' $('#nw-count').textContent = `${ed.text.length} / ${TEXT_MAX}` $('#nw-date').classList.toggle('hidden', ed.duration !== 'date') $('#nw-step-title').textContent = ed.editId ? 'Zettel ändern' : 'Neuer Zettel' $('#nw-publish').textContent = ed.editId ? 'Änderungen speichern' : 'Aufhängen' $('#nw-cancel').classList.toggle('hidden', !ed.editId) const img = $('#nw-drop-img') img.classList.toggle('hidden', !ed.imageSrc) if (ed.imageSrc) img.querySelector('img').src = ed.imageSrc $('#nw-pick span').textContent = ed.imageSrc ? 'Anderes Foto wählen' : 'Foto auswählen' $('#nw-publish').disabled = ed.uploading $('#nw-preview').innerHTML = previewHtml() } function reset() { ed = fresh() $('#nw-title').value = '' $('#nw-text').value = '' $('#nw-duration').value = '1m' $('#nw-date').value = '' clearErrs() sync() } // Formular $$('.nw-type').forEach((b) => b.addEventListener('click', () => { ed.type = b.dataset.type; sync() })) $$('.nw-sw').forEach((b) => b.addEventListener('click', () => { ed.paper = b.dataset.paper; sync() })) $('#nw-title').addEventListener('input', (ev) => { ed.title = ev.target.value; sync() }) $('#nw-text').addEventListener('input', (ev) => { ed.text = ev.target.value; sync() }) $('#nw-duration').addEventListener('change', (ev) => { ed.duration = ev.target.value; sync() }) $('#nw-date').addEventListener('change', (ev) => { ed.date = ev.target.value }) $('#nw-prev-toggle').addEventListener('click', (ev) => { const open = $('#nw-prev-box').classList.toggle('is-open') ev.currentTarget.setAttribute('aria-expanded', String(open)) }) $('#nw-cancel').addEventListener('click', reset) // Foto: gleich hochladen, der Server liefert die imageId $$('#nw-photo-field input[type=file]').forEach((input) => input.addEventListener('change', async () => { const file = input.files[0] input.value = '' if (!file) return setErr('photo', '') if (file.size > PHOTO_MAX_BYTES) { setErr('photo', 'Das Foto ist größer als 10 MB.'); return } const previous = { imageId: ed.imageId, imageSrc: ed.imageSrc } ed.uploading = true ed.imageSrc = URL.createObjectURL(file) sync() $('#nw-photo-hint').textContent = 'Foto wird hochgeladen…' try { const { imageId } = await request(`/api/news/image?${q}`, { method: 'POST', raw: file }) ed.imageId = imageId } catch (err) { setErr('photo', err.message) Object.assign(ed, previous) } finally { ed.uploading = false $('#nw-photo-hint').textContent = 'JPEG, PNG, WebP oder iPhone-Foto, bis 10 MB.' sync() } })) $('#nw-publish').addEventListener('click', async () => { clearErrs() $('#nw-done').classList.add('hidden') const body = { type: ed.type, title: ed.title.trim(), text: ed.type === 'photo' ? '' : ed.text, paper: ed.paper, duration: ed.duration } if (ed.duration === 'date') body.date = ed.date if (ed.type !== 'note') body.imageId = ed.imageId const wasEdit = Boolean(ed.editId) const button = $('#nw-publish') button.disabled = true try { if (wasEdit) await request(`/api/news/${encodeURIComponent(ed.editId)}?${q}`, { method: 'PATCH', body }) else await request('/api/news', { method: 'POST', body: { ...body, projectId: pid } }) reset() const done = $('#nw-done') done.textContent = wasEdit ? 'Gespeichert.' : 'Hängt jetzt an deiner Pinnwand.' done.classList.remove('hidden') await loadList() } catch (err) { setErr(errField(err.field), err.message) } finally { button.disabled = ed.uploading } }) // Listen "Hängt gerade" / "Abgelaufen" const untilText = (p) => (p.visibleUntil ? `bis ${fmtDay(p.visibleUntil)}` : 'bis du sie abnimmst') function rowHtml(p, expired) { const label = p.title || (p.text ? p.text.slice(0, 60) : 'Foto') const thumb = p.imageId ? `` : `T` const meta = expired ? (p.takenDownAt ? `Abgenommen am ${fmtDay(p.takenDownAt)}` : `Abgelaufen am ${fmtDay(p.visibleUntil)}`) : `Seit ${fmtDay(p.visibleFrom)} · ${untilText(p)}` const actions = expired ? ` ` : '' return `
  • ${thumb}
    ${esc(label)}${esc(meta)}
    ${actions}
  • ` } function renderLists() { const box = $('#nw-lists') const site = safeUrl(liveUrl) box.innerHTML = `

    Hängt gerade

    ${site ? `Auf meiner Website ansehen` : ''}
    ${list.hanging.length ? `
      ${list.hanging.map((p) => rowHtml(p, false)).join('')}
    ` : '

    Im Moment hängt nichts. Schreib deinen ersten Zettel links.

    '} ${list.expired.length ? `

    Abgelaufen

    Nicht mehr auf deiner Website zu sehen. Du kannst sie wieder aufhängen.

      ${list.expired.map((p) => rowHtml(p, true)).join('')}
    ` : ''}` box.querySelectorAll('.nw-row').forEach(wireRow) } function wireRow(row) { const id = row.dataset.id const all = [...list.hanging, ...list.expired] const post = all.find((p) => p.$id === id) const rowErr = row.querySelector('.nw-row-err') const run = async (fn) => { rowErr.textContent = '' try { await fn() await loadList() } catch (err) { rowErr.textContent = err.message } } row.querySelector('[data-edit]')?.addEventListener('click', () => startEdit(post)) row.querySelector('[data-takedown]')?.addEventListener('click', () => run(() => request(`/api/news/${encodeURIComponent(id)}?${q}`, { method: 'PATCH', body: { action: 'takeDown' } }))) row.querySelector('[data-rehang]')?.addEventListener('click', () => { const duration = row.querySelector('.nw-rehang-dur').value run(() => request(`/api/news/${encodeURIComponent(id)}?${q}`, { method: 'PATCH', body: { action: 'rehang', duration } })) }) // Loeschen mit Rueckfrage in der Zeile (kein confirm()) row.querySelector('[data-del]').addEventListener('click', () => { const actions = row.querySelector('.nw-row-actions') actions.innerHTML = 'Wirklich löschen?' actions.querySelector('[data-cancel]').addEventListener('click', renderLists) actions.querySelector('[data-yes]').addEventListener('click', () => run(async () => { await request(`/api/news/${encodeURIComponent(id)}?${q}`, { method: 'DELETE' }) if (ed.editId === id) reset() })) }) } function startEdit(post) { clearErrs() $('#nw-done').classList.add('hidden') ed = { editId: post.$id, type: post.type, title: post.title || '', text: post.text || '', paper: post.paper, duration: post.duration, date: post.duration === 'date' && post.visibleUntil ? new Date(post.visibleUntil).toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' }) : '', imageId: post.imageId || '', imageSrc: post.imageId ? imageUrl(post.imageId, 'l') : '', uploading: false, } $('#nw-title').value = ed.title $('#nw-text').value = ed.text $('#nw-duration').value = ed.duration $('#nw-date').value = ed.date sync() $('#nw-step-title').scrollIntoView({ block: 'start', behavior: 'smooth' }) } async function loadList() { try { const data = await request(`/api/news?${q}`) list = { hanging: data.hanging || [], expired: data.expired || [] } liveUrl = data.liveUrl || '' renderLists() } catch (err) { $('#nw-lists').innerHTML = `

    Beiträge konnten nicht geladen werden: ${esc(err.message)}

    ` } } sync() return loadList() }