diff --git a/public/app.js b/public/app.js
index ce23af1..c9f388b 100644
--- a/public/app.js
+++ b/public/app.js
@@ -139,7 +139,7 @@ function projectState(project) {
return ['kb-state-off', 'Noch keine Website']
}
-const FEATURE_LABELS = { preview: 'Vorschau', chat: 'Chat', invoices: 'Rechnungen', news: 'News-Pinnwand', termine: 'Terminplaner' }
+const FEATURE_LABELS = { preview: 'Vorschau', chat: 'Chat', invoices: 'Rechnungen', news: 'News-Pinnwand', termine: 'Terminplaner', zeiten: 'Öffnungszeiten' }
function renderProjectCard(project, features) {
const li = document.createElement('li')
@@ -1910,7 +1910,7 @@ function initPasswordView() {
// "projekt" (Projektdetails als eigene Seite) gibt es ebenfalls nur in der App-Ansicht.
// "passwort" oeffnet das Konto-Menue (Profilbild). #fortschritt fuehrt zu Projekte und scrollt zum Abschnitt
// "Was wir zuletzt erledigt haben" (#pj-activity); hinter dem Hash darf ein Query stehen (#news?bestellt=1, Rueckkehr von Stripe).
-const VIEW_HASHES = { projects: '', funktionen: 'funktionen', news: 'news', analyse: 'analyse', termine: 'termine', invoices: 'rechnungen', chat: 'chat', vertraege: 'vertraege', stunden: 'stunden', 'bestellen-hosting': 'bestellen-hosting', 'bestellen-wartung': 'bestellen-wartung', wechsel: 'wechsel', takeover: 'gutschrift', projekt: 'projekt', passwort: 'passwort' }
+const VIEW_HASHES = { projects: '', funktionen: 'funktionen', news: 'news', analyse: 'analyse', termine: 'termine', zeiten: 'zeiten', invoices: 'rechnungen', chat: 'chat', vertraege: 'vertraege', stunden: 'stunden', 'bestellen-hosting': 'bestellen-hosting', 'bestellen-wartung': 'bestellen-wartung', wechsel: 'wechsel', takeover: 'gutschrift', projekt: 'projekt', passwort: 'passwort' }
const HASH_ALIASES = { wartung: 'vertraege', abo: 'vertraege' }
const viewLoaders = { chat: initChat }
// Funktionen und News laden bei jedem Oeffnen neu (Zustand aendert sich durch Bestellung und Freischaltung); Modul erst dann laden
@@ -1919,6 +1919,8 @@ const viewRefreshers = {
news: () => import('/funktionen.js').then((m) => m.initNews(document.getElementById('news-body'))),
analyse: () => import('/funktionen.js').then((m) => m.initAnalyse(document.getElementById('analyse-body'))),
termine: () => import('/termine.js').then((m) => m.initTermine(document.getElementById('termine-body'))),
+ // baut sich bei gleichem Projekt und Stand nicht neu auf, solange der Editor steht (ungespeicherte Eingaben bleiben; zeiten.js, initZeiten)
+ zeiten: () => import('/zeiten.js').then((m) => m.initZeiten(document.getElementById('zeiten-body'))),
}
const loadedViews = new Set()
@@ -2111,7 +2113,7 @@ const appLayout = window.matchMedia('(max-width: 880px), (pointer: coarse) and (
const coarsePointer = window.matchMedia('(pointer: coarse)')
// Unterseite -> Tab, zu dem sie gehoert (und Text neben dem Zurueck-Pfeil).
// "passwort" setzt onAppViewShown: die Ansicht, aus der das Konto-Menue geoeffnet wurde.
-const APP_PARENT = { news: 'funktionen', analyse: 'funktionen', termine: 'funktionen', stunden: 'vertraege', 'bestellen-hosting': 'vertraege', 'bestellen-wartung': 'vertraege', wechsel: 'vertraege', takeover: 'vertraege', projekt: 'projects' }
+const APP_PARENT = { news: 'funktionen', analyse: 'funktionen', termine: 'funktionen', zeiten: 'funktionen', stunden: 'vertraege', 'bestellen-hosting': 'vertraege', 'bestellen-wartung': 'vertraege', wechsel: 'vertraege', takeover: 'vertraege', projekt: 'projects' }
const APP_PARENT_LABEL = { vertraege: 'Verträge', projects: 'Projekte', funktionen: 'Funktionen', invoices: 'Rechnungen', chat: 'Chat' }
const appShell = { view: 'projects', scroll: {}, fromHistory: false, sideCards: [], installPrompt: null }
const TEXT_INPUT = 'textarea, input:not([type=checkbox]):not([type=radio]):not([type=file]):not([type=date]):not([type=button]):not([type=submit]):not([type=range]):not([type=color])'
diff --git a/public/dashboard.html b/public/dashboard.html
index 6b48c97..0a6a4c7 100644
--- a/public/dashboard.html
+++ b/public/dashboard.html
@@ -23,6 +23,7 @@
+
@@ -195,6 +196,15 @@
+
+
+
+ Öffnungszeiten live
+ Deine Website zeigt immer den aktuellen Stand: geöffnet oder geschlossen, mit Feiertagen, Urlaub und Hinweisen.
+
+
+
+
diff --git a/public/funktionen.js b/public/funktionen.js
index a8e6889..855a5e8 100644
--- a/public/funktionen.js
+++ b/public/funktionen.js
@@ -92,6 +92,7 @@ const ICONS = {
news: ' ',
calendar: ' ',
chart: ' ',
+ clock: ' ',
}
const svg = (name) => `${ICONS[name]} `
@@ -238,6 +239,107 @@ async function showTermineCount(root, projects) {
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 K26);
+// 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' })
+}
+
+// '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' } = {}) => `${text} `
+ const chat = 'Chat öffnen '
+ 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; nur wenn sicher ist, dass das Abo nicht wieder laeuft (inklusive false)
+ const frist = f.aboEndeAt && angebot.inklusive === false ? 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 (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.
`
+ if (daten && angebot.inklusive === true) extra += 'Dein Wartungs-Abo läuft wieder – wir schalten Öffnungszeiten live automatisch wieder ein (meist innerhalb eines Tages).
'
+ else if (daten && angebot.inklusive !== false) extra += `${ZEITEN_NICHT_PRUEFBAR}
`
+ else action = daten ? knopf(`Für ${esc(preis)} wieder einschalten`, { weg: 'behalten' }) : chat
+ } 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
@@ -247,11 +349,12 @@ const soonTile = (icon, title, text) => `
export async function initFunktionen(root) {
if (!root) return
- const { projects = [], termine, features } = await request('/api/funktionen')
+ const { projects = [], termine, zeiten, features } = await request('/api/funktionen')
root.innerHTML = `
${newsTile(projects, newsOffer(features).price)}
${termine?.verfuegbar ? termineTile(projects, termine) : soonTile('calendar', 'Terminplaner', TERMINE_TEXT)}
+ ${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 })
@@ -261,6 +364,15 @@ export async function initFunktionen(root) {
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)
}
diff --git a/public/zeiten-bestellen.js b/public/zeiten-bestellen.js
new file mode 100644
index 0000000..561f2cd
--- /dev/null
+++ b/public/zeiten-bestellen.js
@@ -0,0 +1,377 @@
+/* Oeffnungszeiten live im Kundenbereich: Bestell-, Einschalt- und Behalten-Seite und die Statuskarte (ES-Modul, wird relativ von zeiten.js
+ geladen; Spec 3.1 bis 3.3, Server: server/routes/funktionen.js). Alles, was hier steht, kommt vom Server: Preis, Zustimmungen, Knopftexte
+ und Belehrung aus GET /api/funktionen (zeiten = Angebot, features.zeiten = Register-Ansicht der bezahlten Zustimmung), nie aus einer Kopie
+ im Browser. Fremdtext kommt nur ueber textContent in die Seite (h aus termine-dom.js), nie ueber innerHTML.
+
+ renderZeitenBestellen(root, { ctx, angebot, projekt, modus }) zeichnet je nach Abo-Lage (angebot.inklusive, Ruling R20):
+ true Einschalten: Wartungs-Abo laeuft, ein Haken (Leistungsbeschreibung), Knopf "Einschalten", kein Preis und keine
+ Belehrung (es gibt kein Widerrufsverlangen); POST /api/funktionen/zeiten/einschalten
+ false Bestellen: 30 € einmalig, Preis, Widerrufsbelehrung samt Muster-Formular als Text vor dem Knopf (Ruling R32:
+ exakt der Text, den der Server im Nachweis speichert, angebot.belehrung), zwei Haken, Knopf aus dem Register;
+ POST /api/funktionen/zeiten/checkout, dann Weiterleitung zu Stripe
+ false, grund nicht_aktiv Hinweis "einschaltbar, sobald dein Abo aktiv ist", kein Knopf
+ null "Gerade nicht prüfbar – bitte später erneut" mit Knopf "Noch einmal versuchen"
+ modus 'behalten' (nach dem Ende des Wartungs-Abos, Spec 3.3): dieselbe bezahlte Seite, POST /api/funktionen/zeiten/behalten.
+ Fehlt die Belehrung oder die Zustimmung, bestellt die Seite nie (der Nachweis wuerde einen Text behaupten, den der Kunde nicht gesehen hat).
+
+ ctx (baut zeiten.js): { projectId, features, ustHinweis, kundenansicht (Boolean), neuZeichnen({ meldung }), fertig({ danke }), zurueck() }.
+ Bei 409 (inklusive, kein_abo, gekuendigt, nicht_aktiv, fassung_veraltet, schon bestellt …) holt neuZeichnen die Liste neu und zeichnet die Ansicht
+ nach dem neuen Stand mit der Meldung des Servers; fehlt ctx.neuZeichnen oder scheitert es, bleibt die Meldung am Knopf.
+ zeitenStatusKarte({ projekt, feature, angebot, kundenansicht, danke, meldung }, { behalten, weiter }) ist die Karte fuer ordered, setup, active
+ und disabled (Zustand off hat die Seite oben). */
+
+import { bestellKoerper, sicherLink } from './funktion-bestellen.js'
+import { fehlerKarte, h, hakenFeld, request } from './termine-dom.js'
+
+const WEGE = {
+ einschalten: '/api/funktionen/zeiten/einschalten',
+ bestellen: '/api/funktionen/zeiten/checkout',
+ behalten: '/api/funktionen/zeiten/behalten',
+}
+const KUNDENANSICHT = {
+ einschalten: 'In der Kundenansicht kann nicht eingeschaltet werden.',
+ bestellen: 'In der Kundenansicht kann nicht bestellt werden.',
+ behalten: 'In der Kundenansicht kann nicht bestellt werden.',
+}
+const ZAHLUNGSSEITE = 'Die Zahlungsseite konnte nicht geöffnet werden. Bitte versuch es noch einmal oder schreib uns im Chat.'
+const NICHT_GELADEN = 'Die Seite konnte gerade nicht geladen werden. Bitte lade sie neu.'
+const NICHT_PRUEFBAR = 'Gerade nicht prüfbar – bitte später erneut.'
+const ABGESCHALTET = 'Öffnungszeiten live wurde für diese Website abgeschaltet. Schreib uns im Chat, wenn du die Funktion wieder möchtest.'
+const FEHLER = {
+ einschalten: 'Öffnungszeiten live konnte gerade nicht eingeschaltet werden. Bitte versuch es später noch einmal oder schreib uns im Chat.',
+ bestellen: 'Die Bestellung konnte gerade nicht gestartet werden. Bitte versuch es später noch einmal oder schreib uns im Chat.',
+ behalten: 'Das Behalten konnte gerade nicht gestartet werden. Bitte versuch es später noch einmal oder schreib uns im Chat.',
+}
+
+const text = (wert) => (typeof wert === 'string' ? wert : '')
+const istConsent = (wert) => wert !== null && typeof wert === 'object' && typeof wert.version === 'string' && typeof wert.knopf === 'string' && Array.isArray(wert.items)
+const chatKnopf = () => h('button', { type: 'button', class: 'kb-btn-outline', 'data-view': 'chat' }, 'Frage dazu? Chat öffnen')
+
+/** Adresse einer Unterlage: https oder /rechtliches/ (sicherLink) oder eine Datei unter /vertragsunterlagen/ dieses Portals, sonst ''. */
+function unterlagenLink(url) {
+ const sicher = sicherLink(url)
+ if (sicher) return sicher
+ return typeof url === 'string' && /^\/vertragsunterlagen\/[A-Za-z0-9._-]+\.(?:html|pdf)$/.test(url) && !url.includes('..') ? url : ''
+}
+
+/** Zur Zahlungsseite darf nur eine https-Adresse fuehren, nie ein /rechtliches/-Pfad. */
+function zahlungsUrl(url) {
+ const sicher = sicherLink(url)
+ return sicher.startsWith('https://') ? sicher : ''
+}
+
+/** Datum in Berlin als TT.MM.JJJJ, bei fehlendem oder ungueltigem Wert ''. */
+function datumTag(wert) {
+ const tag = new Date(wert)
+ if (!wert || Number.isNaN(tag.getTime())) return ''
+ return tag.toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' })
+}
+
+/**
+ * Frist zum Behalten: Berliner Kalendertag des erkannten Abo-Endes plus 30 Tage (wie fristTag in server/services/zeiten/aboAbgleich.js und die Mail
+ * K26) als TT.MM.JJJJ; ein unlesbares Datum gibt '' (kein erfundenes Datum). Doppelt zu zeitenFrist in funktionen.js (die Kachel importiert nichts).
+ */
+export function behaltenFrist(aboEndeAt) {
+ const ms = typeof aboEndeAt === 'string' ? Date.parse(aboEndeAt) : NaN
+ if (Number.isNaN(ms)) return ''
+ const [jahr, monat, tag] = new Date(ms).toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' }).split('-').map(Number)
+ return new Date(Date.UTC(jahr, monat - 1, tag + 30)).toLocaleDateString('de-DE', { timeZone: 'UTC', day: '2-digit', month: '2-digit', year: 'numeric' })
+}
+
+/** 'JJJJ-MM-TT' als TT.MM.JJJJ, sonst ''. */
+export function tagText(iso) {
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text(iso))
+ return m ? `${m[3]}.${m[2]}.${m[1]}` : ''
+}
+
+/* ---------------------------------------------------------------- Bestellseite --- */
+/**
+ * Bestell-, Einschalt- oder Behalten-Seite in root zeichnen und das Element zurueckgeben (siehe Kopf der Datei).
+ * Der Knopf ist gesperrt, solange ein Haken fehlt, ein Klick laeuft oder die Kundenansicht aktiv ist.
+ */
+export function renderZeitenBestellen(root, { ctx = {}, angebot = {}, projekt = {}, modus = 'bestellen' } = {}) {
+ const zeige = (element) => {
+ root.replaceChildren(element)
+ return element
+ }
+ const projectId = text(ctx.projectId) || text(projekt.projectId)
+ const zurueck = typeof ctx.zurueck === 'function' && modus === 'behalten'
+ ? h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'zurueck', onclick: () => ctx.zurueck() }, 'Zurück')
+ : null
+ const nochmal = typeof ctx.neuZeichnen === 'function' ? () => ctx.neuZeichnen({}).catch(() => {}) : undefined
+
+ // Welche Seite? (Abo-Lage laut Server, Ruling R20)
+ let variante
+ if (angebot.inklusive === null || angebot.inklusive === undefined) {
+ return zeige(h('div', { class: 'kb-panel tm-info zt-hinweis', 'data-hinweis': 'nicht-pruefbar', role: 'alert' }, h('p', null, NICHT_PRUEFBAR), nochmal ? h('button', { type: 'button', class: 'kb-btn-outline', onclick: nochmal }, 'Noch einmal versuchen') : null, zurueck))
+ }
+ if (modus === 'behalten' && angebot.inklusive === true) {
+ return zeige(h('div', { class: 'kb-panel tm-info zt-hinweis', 'data-hinweis': 'abo-laeuft' }, h('h3', null, 'Dein Wartungs-Abo läuft wieder'), h('p', null, 'Öffnungszeiten live ist darin enthalten – du musst nichts bezahlen.'), zurueck))
+ }
+ if (modus === 'behalten') variante = 'behalten'
+ else if (angebot.inklusive === true) variante = 'einschalten'
+ else if (angebot.grund === 'nicht_aktiv') {
+ return zeige(h('div', { class: 'kb-panel tm-info zt-hinweis', 'data-hinweis': 'nicht-aktiv' }, h('h3', null, 'Im Wartungs-Abo enthalten'), h('p', null, 'Öffnungszeiten live ist in deinem Wartungs-Abo enthalten – einschaltbar, sobald dein Abo aktiv ist.')))
+ } else variante = 'bestellen'
+
+ const bezahlt = variante !== 'einschalten'
+ const consent = bezahlt ? ctx.features?.zeiten?.consent : angebot.einschalten?.consent
+ const belehrung = text(angebot.belehrung)
+ // Ohne Zustimmung oder (bezahlt) ohne Belehrung wird nie bestellt: der Nachweis wuerde einen Text behaupten, den der Kunde nicht gesehen hat
+ if (!istConsent(consent) || (bezahlt && belehrung.trim() === '')) return zeige(fehlerKarte(NICHT_GELADEN))
+ const eintraege = consent.items.filter((eintrag) => eintrag !== null && typeof eintrag === 'object')
+ const hakenEintraege = eintraege.filter((eintrag) => eintrag.haken === true)
+ const kundenansicht = ctx.kundenansicht === true
+ const preis = text(angebot.preis) || text(ctx.features?.zeiten?.priceLabel) || '30 €'
+ const einrichtung = text(ctx.features?.zeiten?.setupEstimate)
+ const gewaehlt = new Set()
+ let laeuft = false
+
+ const einleitung = {
+ einschalten: 'Öffnungszeiten live ist in deinem Wartungs-Abo ohne Aufpreis enthalten. Du schaltest die Funktion ein, danach trägst du deine Zeiten ein.',
+ bestellen: 'Deine Website zeigt live, ob du gerade geöffnet hast. Die Einrichtung kostet einmalig – danach läuft die Funktion ohne laufende Kosten.',
+ behalten: projekt.features?.zeiten?.state === 'disabled'
+ ? `Öffnungszeiten live ist abgeschaltet, deine Eingaben sind noch gespeichert. Für einmalig ${preis} schaltest du die Funktion wieder ein.`
+ : `Dein Wartungs-Abo ist beendet. Für einmalig ${preis} behältst du Öffnungszeiten live – ohne neuen Einbau, an deiner Website ändert sich nichts.`,
+ }[variante]
+
+ // Preis: nur auf dem bezahlten Weg; die Einrichtungsdauer nur bei einer Neubestellung (beim Behalten gibt es keinen neuen Einbau)
+ const preisBlock = bezahlt
+ ? h('div', { class: 'tm-bestellen-preis' },
+ h('h4', null, variante === 'bestellen' ? 'Preis und Einrichtung' : 'Preis'),
+ h('p', { class: 'tm-bestellen-betrag' }, h('strong', null, preis), ` einmalig${text(ctx.ustHinweis) ? `, ${ctx.ustHinweis}` : ''}`),
+ variante === 'bestellen' && einrichtung ? h('p', null, `Einrichtung: ${einrichtung}`) : null)
+ : null
+
+ // Widerrufsbelehrung samt Muster-Formular: ein Textbereich zum Scrollen, nie eingeklappt, genau der Text des Nachweises (R32)
+ const belehrungBlock = bezahlt
+ ? h('div', { class: 'tm-bestellen-belehrung' },
+ h('h4', null, 'Widerrufsbelehrung'),
+ h('p', { class: 'tm-hinweis' }, 'Lies die Belehrung bitte ganz. Das Muster-Widerrufsformular gehört dazu.'),
+ h('div', { class: 'tm-rechtstext', tabindex: '0', role: 'region', 'aria-label': 'Widerrufsbelehrung mit Muster-Widerrufsformular' }, belehrung))
+ : null
+
+ const fassung = bezahlt ? ctx.features?.zeiten?.leistungsbeschreibung : angebot.einschalten?.leistungsbeschreibung
+ const link = unterlagenLink(fassung?.url)
+ const unterlagenBlock = link === '' ? null : h('div', { class: 'tm-bestellen-unterlagen' },
+ h('h4', null, 'Unterlagen'),
+ h('ul', { class: 'tm-bestellen-dokumente' }, h('li', null, h('a', { href: link, target: '_blank', rel: 'noopener noreferrer' }, 'Leistungsbeschreibung „Öffnungszeiten live“'))))
+
+ // Zustimmungen in Registerreihenfolge: Eintraege mit haken: true sind Haken, die anderen reiner Text
+ const knopf = h('button', { type: 'button', class: 'kb-btn-primary tm-bestellen-knopf', 'data-aktion': variante, disabled: true, onclick: () => absenden() }, consent.knopf)
+ const meldungEl = h('p', { class: 'tm-fehler', role: 'alert', 'data-meldung': 'bestellen' })
+ const hinweisEl = h('p', { class: 'tm-hinweis', 'data-hinweis': 'bestellen' })
+ const hakenZeile = (eintrag) => {
+ const feld = h('input', { type: 'checkbox', 'data-haken': eintrag.id })
+ feld.addEventListener('change', () => {
+ if (feld.checked) gewaehlt.add(eintrag.id)
+ else gewaehlt.delete(eintrag.id)
+ aktualisiere()
+ })
+ return hakenFeld(text(eintrag.id), feld, text(eintrag.text))
+ }
+ const zustimmungBlock = h('div', { class: 'tm-bestellen-zustimmung' },
+ h('h4', null, 'Deine Zustimmung'),
+ h('div', { class: 'tm-bestellen-haken' }, eintraege.map((eintrag) => (eintrag.haken === true ? hakenZeile(eintrag) : h('p', { class: 'tm-bestellen-text' }, text(eintrag.text))))))
+
+ const titel = { einschalten: 'Öffnungszeiten live einschalten', bestellen: 'Öffnungszeiten live bestellen', behalten: 'Öffnungszeiten live behalten' }[variante]
+ const seite = h('section', { class: 'kb-panel tm-bestellen zt-bestellen', 'data-variante': variante },
+ h('h3', null, titel),
+ h('p', { class: 'tm-abschnitt-text tm-hinweis' }, einleitung),
+ preisBlock,
+ belehrungBlock,
+ unterlagenBlock,
+ zustimmungBlock,
+ h('div', { class: 'tm-bestellen-aktion' }, meldungEl, knopf, hinweisEl, zurueck))
+ zeige(seite)
+
+ function meldung(nachricht) {
+ meldungEl.textContent = nachricht
+ }
+
+ function aktualisiere() {
+ const hakenFehlen = hakenEintraege.some((eintrag) => !gewaehlt.has(eintrag.id))
+ knopf.disabled = laeuft || hakenFehlen || kundenansicht
+ if (kundenansicht) hinweisEl.textContent = KUNDENANSICHT[variante]
+ else hinweisEl.textContent = hakenFehlen ? (variante === 'einschalten' ? 'Setze den Haken, um einzuschalten.' : 'Setze alle Haken, um zu bestellen.') : ''
+ }
+
+ async function absenden() {
+ if (knopf.disabled) return
+ laeuft = true
+ aktualisiere()
+ meldung('')
+ let antwort
+ try {
+ antwort = await request(WEGE[variante], { method: 'POST', body: bestellKoerper({ projectId, bestellung: { consent }, gewaehlt }) })
+ } catch (err) {
+ // 409: der Stand hat sich geaendert (Abo, schon bestellt, neue Fassung): die Ansicht neu nach dem Server zeichnen, mit seiner Meldung
+ if (err?.status === 409 && typeof ctx.neuZeichnen === 'function') {
+ try {
+ await ctx.neuZeichnen({ meldung: err.message })
+ return
+ } catch {
+ // die Meldung steht dann am Knopf
+ }
+ }
+ meldung(err?.message || FEHLER[variante])
+ laeuft = false
+ aktualisiere()
+ return
+ }
+ if (variante === 'einschalten') {
+ // eingeschaltet ist eingeschaltet, auch wenn das Neuladen der Ansicht danach scheitert (ein zweiter Klick waere harmlos, ist aber unnoetig)
+ try {
+ if (typeof ctx.fertig === 'function') await ctx.fertig({ danke: 'eingeschaltet', antwort })
+ else meldung('Öffnungszeiten live ist eingeschaltet.')
+ } catch {
+ meldung('Öffnungszeiten live ist eingeschaltet. Lade die Seite neu, dann siehst du den aktuellen Stand.')
+ }
+ return
+ }
+ const ziel = zahlungsUrl(antwort?.url)
+ if (ziel) {
+ window.location.href = ziel // die Seite wird verlassen, der Knopf bleibt gesperrt
+ return
+ }
+ meldung(ZAHLUNGSSEITE)
+ laeuft = false
+ aktualisiere()
+ }
+
+ aktualisiere()
+ return seite
+}
+
+/* ---------------------------------------------------------------- Statuskarte --- */
+const PILLE = { ordered: ['Zahlung offen', 'wait'], setup: ['In Einrichtung', 'wait'], active: ['Aktiv', 'ok'], disabled: ['Abgeschaltet', 'off'] }
+const DANKE = {
+ bestellt: 'Danke! Sobald die Zahlung da ist, legen wir los.',
+ bestelltBezahlt: 'Danke! Deine Zahlung ist bei uns angekommen.',
+ behalten: 'Danke! Sobald die Zahlung da ist, behältst du Öffnungszeiten live. An deiner Website ändert sich nichts.',
+ behaltenBezahlt: 'Danke! Du behältst Öffnungszeiten live.',
+ eingeschaltet: 'Öffnungszeiten live ist eingeschaltet.',
+}
+
+/** Danke-Satz zur Rueckkehr: 'bestellt' | 'behalten' | 'eingeschaltet', je nachdem, ob die Zahlung schon gebucht ist; sonst ''. */
+function dankeText(danke, feature) {
+ if (danke === 'bestellt') return feature?.state === 'ordered' ? DANKE.bestellt : DANKE.bestelltBezahlt
+ if (danke === 'behalten') return feature?.aboEndeAt ? DANKE.behalten : DANKE.behaltenBezahlt
+ return danke === 'eingeschaltet' ? DANKE.eingeschaltet : ''
+}
+
+/**
+ * Karte fuer ordered („Zahlung offen“), setup („In Einrichtung“), active und disabled.
+ * projekt = Eintrag aus GET /api/funktionen (projectId, zeitenLeer), feature = projekt.features.zeiten (state, einbau, ticketWoid, orderedAt,
+ * invoiceUrl, aboEndeAt, disabledReason, loeschungAm), angebot = zeiten (inklusive, preis), kundenansicht = Sitzung in der Kundenansicht,
+ * danke = 'bestellt' | 'behalten' | 'eingeschaltet' (Rueckkehr von Stripe oder Einschalten), meldung = Text am Knopf.
+ * behalten() oeffnet die Behalten-Seite (Knopf „Für 30 € behalten“ nur, wenn sicher ist, dass das Abo nicht wieder laeuft: inklusive false);
+ * weiter({ bestellen, meldung }) wird gerufen, wenn „Zahlung fortsetzen“ scheitert (bestellen: es gibt keine offene Zahlungsseite mehr, die Ansicht
+ * zeigt wieder die Bestellseite).
+ */
+export function zeitenStatusKarte({ projekt, feature, angebot, kundenansicht, danke, meldung } = {}, { behalten, weiter } = {}) {
+ const state = text(feature?.state) || 'off'
+ const [pilleText, pilleArt] = PILLE[state] || PILLE.setup
+ const preis = text(angebot?.preis) || '30 €'
+ const inklusive = angebot?.inklusive
+ const leer = projekt?.zeitenLeer === true
+ const ticket = feature?.ticketWoid
+ const hatTicket = ticket !== null && ticket !== undefined && String(ticket) !== ''
+ const bestelltAm = datumTag(feature?.orderedAt)
+ const rechnung = sicherLink(feature?.invoiceUrl)
+ const zeile = (name, wert) => h('div', null, h('dt', null, name), h('dd', null, wert))
+ const fakten = [
+ state === 'setup' || state === 'active' ? (hatTicket ? zeile('Ticketnummer', `#${ticket}`) : null) : null,
+ bestelltAm ? zeile('Bestellt am', bestelltAm) : null,
+ ].filter(Boolean)
+
+ const aboEnde = feature?.disabledReason === 'abo_ende'
+ const daten = state === 'disabled' && aboEnde && projekt?.zeitenLeer === false && !(text(feature?.loeschungAm) !== '' && feature.loeschungAm < heuteBerlin())
+ const frist = (state === 'setup' || state === 'active') && feature?.aboEndeAt ? behaltenFrist(feature.aboEndeAt) : ''
+
+ const titel = {
+ ordered: 'Zahlung wird bestätigt …',
+ setup: 'Wir richten Öffnungszeiten live ein',
+ active: 'Öffnungszeiten live ist aktiv',
+ disabled: 'Öffnungszeiten live ist abgeschaltet',
+ }[state] || 'Öffnungszeiten live'
+ const absatz = {
+ ordered: 'Sobald deine Zahlung bei uns ankommt, bauen wir Öffnungszeiten live ein.',
+ setup: feature?.einbau === 'angestossen'
+ ? 'Hinweis- und Kontakt-Leiste erscheinen nach dem nächsten Veröffentlichen deiner Website (meist in wenigen Minuten).'
+ : 'Unser Team baut die Funktion ein.',
+ active: 'Deine Öffnungszeiten stehen live auf deiner Website. Was du hier änderst, übernimmt die Website automatisch.',
+ disabled: aboEnde ? 'Öffnungszeiten live ist abgeschaltet, weil dein Wartungs-Abo beendet ist.' : ABGESCHALTET,
+ }[state] || ''
+
+ const meldungEl = h('p', { class: 'tm-fehler', role: 'alert', 'data-meldung': 'karte' }, text(meldung) || null)
+ const sperre = kundenansicht === true
+
+ // Behalten (30 €): in Einrichtung/aktiv nach dem erkannten Abo-Ende, abgeschaltet solange die Eingaben noch da sind
+ const behaltenKnopf = (beschriftung) => h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'behalten', disabled: sperre, onclick: () => { if (typeof behalten === 'function') behalten() } }, beschriftung)
+ const behaltenBox = frist && inklusive === false
+ ? h('div', { class: 'tm-box tm-box-warn', role: 'note', 'data-hinweis': 'behalten' },
+ h('span', null, `Dein Wartungs-Abo ist beendet – behalte die Funktion bis ${frist} für einmalig ${preis}.`),
+ behaltenKnopf(`Für ${preis} behalten`))
+ : null
+ let abgeschaltetBox = null
+ if (daten && inklusive === false) {
+ abgeschaltetBox = h('div', { class: 'tm-box tm-box-warn', role: 'note', 'data-hinweis': 'wieder-einschalten' },
+ h('span', null, `Deine Eingaben bleiben${tagText(feature?.loeschungAm) ? ` bis ${tagText(feature.loeschungAm)}` : ''} gespeichert. Für einmalig ${preis} schaltest du die Funktion wieder ein.`),
+ behaltenKnopf(`Für ${preis} wieder einschalten`))
+ } else if (daten && inklusive === true) {
+ abgeschaltetBox = h('div', { class: 'tm-box tm-box-info', role: 'note', 'data-hinweis': 'abo-laeuft' }, h('span', null, 'Dein Wartungs-Abo läuft wieder – wir schalten Öffnungszeiten live automatisch wieder ein (meist innerhalb eines Tages).'))
+ } else if (daten) {
+ abgeschaltetBox = h('div', { class: 'tm-box tm-box-info', role: 'note', 'data-hinweis': 'nicht-pruefbar' }, h('span', null, NICHT_PRUEFBAR))
+ }
+
+ // Zahlung fortsetzen (nur ordered): dieselbe Zahlungsseite, ohne neue Zustimmung; gibt es keine mehr, zeigt die Ansicht wieder die Bestellseite
+ const fortsetzenKnopf = state === 'ordered'
+ ? h('button', { type: 'button', class: 'kb-btn-primary', 'data-aktion': 'fortsetzen', disabled: sperre, onclick: () => fortsetzen() }, 'Zahlung fortsetzen')
+ : null
+ async function fortsetzen() {
+ if (fortsetzenKnopf.disabled) return
+ fortsetzenKnopf.disabled = true
+ meldungEl.textContent = ''
+ try {
+ const antwort = await request(WEGE.bestellen, { method: 'POST', body: { projectId: projekt?.projectId, fortsetzen: true } })
+ const ziel = zahlungsUrl(antwort?.url)
+ if (ziel) {
+ window.location.href = ziel
+ return
+ }
+ meldungEl.textContent = ZAHLUNGSSEITE
+ } catch (err) {
+ if (typeof weiter === 'function' && err?.code === 'zustimmung_fehlt') {
+ weiter({ bestellen: true, meldung: err.message })
+ return
+ }
+ meldungEl.textContent = err?.message || ZAHLUNGSSEITE
+ }
+ fortsetzenKnopf.disabled = false
+ }
+
+ const dankeSatz = dankeText(danke, feature)
+ return h('section', { class: 'kb-panel tm-statuskarte zt-status', 'data-state': state },
+ dankeSatz ? h('p', { class: 'tm-danke', role: 'status', 'data-danke': danke }, dankeSatz) : null,
+ h('span', { class: `tm-pille tm-pille-${pilleArt}` }, pilleText),
+ h('h3', null, titel),
+ absatz ? h('p', null, absatz) : null,
+ state === 'setup' && leer ? h('div', { class: 'tm-box tm-box-warn', role: 'note', 'data-hinweis': 'zeiten-eintragen' }, h('span', null, 'Trag jetzt deine Zeiten ein – erst dann kann das Team die Tabelle einbauen.')) : null,
+ state === 'active' && leer ? h('div', { class: 'tm-box tm-box-info', role: 'note', 'data-hinweis': 'zeiten-eintragen' }, h('span', null, 'Trag jetzt deine Zeiten ein.')) : null,
+ fakten.length > 0 ? h('dl', { class: 'tm-fakten' }, fakten) : null,
+ state === 'ordered' ? h('p', { class: 'tm-hinweis' }, 'Zahlst du per SEPA-Lastschrift, kann es einige Tage dauern, bis die Zahlung bei uns ankommt.') : null,
+ behaltenBox,
+ abgeschaltetBox,
+ meldungEl,
+ sperre && (fortsetzenKnopf || behaltenBox || abgeschaltetBox) ? h('p', { class: 'tm-hinweis' }, KUNDENANSICHT.bestellen) : null,
+ h('div', { class: 'tm-aktionen' },
+ fortsetzenKnopf,
+ state === 'setup' && rechnung ? h('a', { class: 'kb-btn-outline', href: rechnung, target: '_blank', rel: 'noopener noreferrer' }, 'Rechnung ansehen') : null,
+ chatKnopf()))
+}
+
+/** Heutiger Berliner Kalendertag 'JJJJ-MM-TT' (zum Vergleich mit loeschungAm). */
+function heuteBerlin() {
+ return new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
+}
diff --git a/public/zeiten.css b/public/zeiten.css
new file mode 100644
index 0000000..c7d82df
--- /dev/null
+++ b/public/zeiten.css
@@ -0,0 +1,35 @@
+/* Öffnungszeiten live im Kundenbereich (zeiten.js, zeiten-bestellen.js; der Editor kommt mit den Aufgaben 29 und 30 dazu).
+ Farben und Abstände aus den Tokens von kundenbereich.css (.kb-page). Die Bausteine der Bestell- und Statusseiten (tm-bestellen, tm-statuskarte,
+ tm-rechtstext, tm-haken, tm-pille, tm-box, tm-meldung, tm-info …) stehen in termine.css und gelten hier mit; neu sind nur Klassen mit zt-
+ und die Regeln für #view-zeiten. Handy: bis 880px wie die App-Ansicht, Tippflächen mindestens 44px. */
+
+#view-zeiten .kb-panel { max-width: none; }
+#view-zeiten .fn-project { margin-bottom: 16px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 14px 18px; }
+#view-zeiten button:focus-visible, #view-zeiten input:focus-visible, #view-zeiten select:focus-visible, #view-zeiten textarea:focus-visible,
+#view-zeiten summary:focus-visible, #view-zeiten a:focus-visible { outline: 3px solid #d4b4ff; outline-offset: 2px; }
+#view-zeiten input, #view-zeiten select, #view-zeiten textarea { color-scheme: dark; } /* Kalender und Auswahllisten der Zeitfelder dunkel */
+#view-zeiten .kb-btn-outline, #view-zeiten .kb-btn-primary { min-height: 40px; }
+
+/* Ansicht: oben die Karte oder die Bestellseite, darunter der Platz des Editors */
+.zt-kopf > .kb-panel { margin-bottom: 16px; }
+.zt-editor { margin-top: 4px; }
+.zt-editor[hidden] { display: none; }
+.zt-editor:empty { display: none; }
+.zt-hinweis h3 { margin-bottom: 8px; }
+.zt-hinweis .kb-btn-outline { margin-top: 4px; }
+.zt-bestellen .tm-bestellen-aktion .kb-btn-outline { justify-self: start; }
+.zt-status .tm-box { margin-bottom: 0; }
+.zt-status .tm-box .kb-btn-outline { flex: none; }
+
+@media (max-width: 880px), (pointer: coarse) {
+ #view-zeiten button, #view-zeiten .kb-btn-outline, #view-zeiten .kb-btn-primary { min-height: 44px; }
+ #view-zeiten .tm-haken { align-items: center; min-height: 44px; margin-top: 0; margin-bottom: 0; }
+ #view-zeiten .tm-haken input { margin-top: 0; }
+}
+@media (max-width: 600px) {
+ .zt-bestellen .tm-bestellen-aktion { justify-items: stretch; }
+ .zt-status .tm-box .kb-btn-outline { width: 100%; }
+ .zt-status .tm-aktionen > * { flex: 1 1 auto; }
+ #view-zeiten .tm-rechtstext { max-height: 260px; }
+}
+@media (prefers-reduced-motion: reduce) { #view-zeiten * { transition: none !important; animation: none !important; } }
diff --git a/public/zeiten.js b/public/zeiten.js
new file mode 100644
index 0000000..24c81be
--- /dev/null
+++ b/public/zeiten.js
@@ -0,0 +1,219 @@
+/* Oeffnungszeiten live im Kundenbereich: die Ansicht #zeiten (ES-Modul, app.js laedt es beim Oeffnen der Ansicht; Styles: zeiten.css, dazu die
+ gemeinsamen tm-Klassen aus termine.css). Server: server/routes/funktionen.js (Liste, Einschalten, Bestellen, Behalten) und server/routes/zeiten.js
+ (Editor, Aufgaben 29 und 30).
+ initZeiten(rootEl, { projectId }): Projektauswahl (bei mehr als einer Website) und je nach Zustand der Funktion (features.zeiten.state aus
+ GET /api/funktionen):
+ off Einschalten (Wartungs-Abo laeuft), Bestellen (30 €) oder ein Hinweis (Abo noch nicht aktiv, nicht lesbar): renderZeitenBestellen
+ ordered Statuskarte „Zahlung wird bestätigt …“ (Zahlung fortsetzen)
+ setup Statuskarte (Einbau, Ticket, „Trag jetzt deine Zeiten ein“) und darunter der Platz des Editors
+ active Statuskarte und der Platz des Editors
+ disabled Statuskarte mit dem Grund; „Für 30 € wieder einschalten“, solange die Eingaben da sind; der Platz des Editors zum Lesen
+ Das Abo-Ende (aboEndeAt) zeigt in setup und active den Hinweis „behalte die Funktion bis für einmalig 30 €“ mit dem Knopf zur Behalten-Seite.
+ Rueckkehr von Stripe: #zeiten?bestellt=1&projekt= (Bestellung) und #zeiten?behalten=1&projekt= (Behalten) waehlen das Projekt und zeigen
+ „Danke!“; #zeiten?projekt= (Abbruch) waehlt nur das Projekt. Beides wird einmal gelesen und aus der Adresse genommen. Die Kachel in Funktionen
+ legt Projekt und Weg in den Behaelter ab (dataset.wunschProjekt, dataset.wunschWeg = 'behalten').
+ Der Editor (Aufgabe 29) haengt in .zt-editor ein (renderEditor(box, ctx) aus zeiten-editor.js). Ruft app.js initZeiten bei jedem Wechsel in die
+ Ansicht, baut sich nichts neu auf, solange Projekt und Stand gleich sind und der Editorplatz steht (Muster initNews): Eingaben im Editor bleiben stehen.
+ Kundenansicht (window.KbView.id() gesetzt): Einschalten, Bestellen und Behalten sind gesperrt, der Server antwortet dort ohnehin mit 403. */
+
+import { h, infoKarte, request } from './termine-dom.js'
+import { renderZeitenBestellen, zeitenStatusKarte } from './zeiten-bestellen.js'
+
+// Auswahl bleibt beim Wechsel zwischen Funktionen und Zeiten erhalten
+const auswahl = { projectId: '' }
+let S = null // aktuelle Sitzung der Ansicht; eine alte Antwort darf eine neuere nicht ueberschreiben (S !== s)
+
+const zustandVon = (projekt) => projekt?.features?.zeiten?.state || 'off'
+const istBestellt = (zustand) => zustand === 'ordered' || zustand === 'setup'
+const kundenansicht = () => Boolean(window.KbView?.id?.())
+
+/** Rueckkehr von Stripe aus der Adresse lesen und die Adresse bereinigen (wie initNews/initTermine). */
+function leseRueckkehr() {
+ const hash = window.location.hash || ''
+ if (!hash.startsWith('#zeiten?')) return { bestellt: false, behalten: false, projekt: '' }
+ const query = new URLSearchParams(hash.slice(hash.indexOf('?') + 1))
+ window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}#zeiten`)
+ return { bestellt: query.get('bestellt') === '1', behalten: query.get('behalten') === '1', projekt: query.get('projekt') || '' }
+}
+
+// Vorauswahl wie bei der Kachel: aktiv, dann bestellt/in Einrichtung, dann abgeschaltet, sonst die erste
+function pickProjekt(projects) {
+ return projects.find((p) => zustandVon(p) === 'active')
+ || projects.find((p) => istBestellt(zustandVon(p)))
+ || projects.find((p) => zustandVon(p) === 'disabled')
+ || projects[0]
+}
+
+/** Darf der Kunde die Funktion jetzt behalten (30 €)? Nach dem erkannten Abo-Ende in Einrichtung/aktiv, abgeschaltet nur mit gespeicherten Eingaben. */
+function behaltenMoeglich(projekt) {
+ const feature = projekt?.features?.zeiten
+ if (!feature) return false
+ if (feature.state === 'setup' || feature.state === 'active') return Boolean(feature.aboEndeAt)
+ return feature.state === 'disabled' && feature.disabledReason === 'abo_ende' && projekt.zeitenLeer === false
+}
+
+export async function initZeiten(root, { projectId } = {}) {
+ if (!root) return
+ const rueckkehr = leseRueckkehr()
+ const wunsch = rueckkehr.projekt || projectId || root.dataset.wunschProjekt || '' // die Kachel in Funktionen legt ihr Projekt hier ab
+ const wunschWeg = root.dataset.wunschWeg === 'behalten' ? 'behalten' : ''
+ delete root.dataset.wunschProjekt
+ delete root.dataset.wunschWeg
+ if (wunsch) auswahl.projectId = wunsch
+ const antwort = await request('/api/funktionen')
+ const projects = Array.isArray(antwort.projects) ? antwort.projects : []
+ const s = {
+ root, projects, angebot: antwort.zeiten || null, features: antwort.features || {}, ustHinweis: typeof antwort.ustHinweis === 'string' ? antwort.ustHinweis : '',
+ projekt: null, lauf: 0, buehne: null, danke: '',
+ }
+ if (!s.angebot?.verfuegbar) {
+ S = s
+ root.dataset.zeitenKey = ''
+ root.replaceChildren(infoKarte('Öffnungszeiten live ist noch nicht verfügbar', 'Sobald es für dich bereit ist, findest du es hier.'))
+ return
+ }
+ if (!projects.length) {
+ S = s
+ root.dataset.zeitenKey = ''
+ root.replaceChildren(h('div', { class: 'kb-panel kb-empty' }, 'Noch keine Website-Projekte zugewiesen.'))
+ return
+ }
+ s.projekt = projects.find((p) => p.projectId === auswahl.projectId) || pickProjekt(projects)
+ auswahl.projectId = s.projekt.projectId
+
+ // Nicht neu aufbauen, solange Projekt und Stand gleich sind und der Editorplatz steht: ungespeicherte Eingaben bleiben (Muster initNews).
+ // Ein Weg (Behalten aus der Kachel) oder die Rueckkehr von Stripe zeichnen immer neu; der Wechsel in die Behalten-Seite behaelt den Editor.
+ const key = stand(s.projekt, s.angebot)
+ const editorDa = root.dataset.zeitenKey === key && root.querySelector('.zt-editor') && S?.root === root
+ if (editorDa && !rueckkehr.bestellt && !rueckkehr.behalten) {
+ if (wunschWeg === 'behalten' && behaltenMoeglich(S.projekt)) zeigeBehalten(S)
+ return
+ }
+ S = s
+ s.danke = rueckkehr.bestellt && (!rueckkehr.projekt || rueckkehr.projekt === s.projekt.projectId) ? 'bestellt'
+ : rueckkehr.behalten && (!rueckkehr.projekt || rueckkehr.projekt === s.projekt.projectId) ? 'behalten' : ''
+ s.buehne = h('div', { id: 'zeiten-buehne' })
+ root.replaceChildren(...(projects.length > 1 ? [projektwahl(s)] : []), s.buehne)
+ zeichneProjekt(s, { weg: wunschWeg })
+}
+
+/** Alles, was die Statuskarte zeigt und der Editorplatz davon abhaengt (auch die Abo-Lage: Behalten gibt es nur ohne Abo); aendert es sich, wird neu aufgebaut. */
+function stand(projekt, angebot) {
+ const f = projekt?.features?.zeiten || {}
+ return [projekt?.projectId, f.state, f.aboEndeAt, f.einbau, f.ticketWoid, f.disabledReason, f.unlockedBy, projekt?.zeitenLeer, angebot?.inklusive, angebot?.grund].join(':')
+}
+
+function projektwahl(s) {
+ const select = h('select', { id: 'zeiten-project' }, s.projects.map((p) => {
+ const option = h('option', { value: p.projectId }, p.projectName)
+ option.selected = p.projectId === s.projekt.projectId
+ return option
+ }))
+ select.addEventListener('change', () => {
+ auswahl.projectId = select.value
+ s.projekt = s.projects.find((p) => p.projectId === select.value)
+ s.danke = '' // der Dank gilt dem Projekt, mit dem man von Stripe zurueckkam
+ zeichneProjekt(s)
+ })
+ return h('div', { class: 'fn-project kb-panel' }, h('label', { for: 'zeiten-project' }, 'Website'), select)
+}
+
+/** Liste der Funktionen neu holen (Projekte, Angebot, Register-Ansicht). Wirft bei einem Fehler und aendert dann nichts. */
+async function ladeListe(s) {
+ const antwort = await request('/api/funktionen')
+ const projects = Array.isArray(antwort.projects) ? antwort.projects : []
+ s.projects = projects
+ s.angebot = antwort.zeiten || null
+ s.features = antwort.features || {}
+ s.ustHinweis = typeof antwort.ustHinweis === 'string' ? antwort.ustHinweis : ''
+ s.projekt = projects.find((p) => p.projectId === s.projekt?.projectId) || s.projekt
+}
+
+/**
+ * Nach einem Konflikt (409) oder dem Einschalten: Liste frisch holen und die Ansicht nach dem neuen Stand zeichnen (optionen: meldung des Servers,
+ * danke). Scheitert das Laden, wirft die Funktion und die Seite bleibt mit ihrer Meldung stehen. Eine Antwort, die erst nach einem Wechsel der
+ * Website eintrifft, wird verworfen.
+ */
+async function neuZeichnen(s, optionen = {}) {
+ const lauf = s.lauf
+ await ladeListe(s)
+ if (S !== s || s.lauf !== lauf) return
+ if (!s.angebot?.verfuegbar) {
+ s.root.dataset.zeitenKey = ''
+ s.root.replaceChildren(infoKarte('Öffnungszeiten live ist noch nicht verfügbar', 'Sobald es für dich bereit ist, findest du es hier.'))
+ return
+ }
+ if (optionen.danke) s.danke = optionen.danke
+ zeichneProjekt(s, { meldung: optionen.meldung })
+}
+
+/** Kontext der Seiten in der Huelle (zeiten-bestellen.js): Projekt, Register-Ansicht, Kundenansicht und die Wege, die Ansicht nach dem Server neu zu zeichnen. */
+function baueCtx(s, projekt) {
+ return {
+ projectId: projekt.projectId,
+ features: s.features,
+ ustHinweis: s.ustHinweis,
+ kundenansicht: kundenansicht(),
+ neuZeichnen: (optionen) => neuZeichnen(s, optionen),
+ fertig: ({ danke } = {}) => neuZeichnen(s, { danke }),
+ }
+}
+
+/** Die Ansicht zum Zustand des gewaehlten Projekts zeichnen (siehe Kopf der Datei). Jeder Aufruf zaehlt s.lauf hoch. */
+function zeichneProjekt(s, { weg = '', meldung = '', bestellformular = false } = {}) {
+ const projekt = s.projekt
+ ++s.lauf
+ const ctx = baueCtx(s, projekt)
+ const zustand = zustandVon(projekt)
+ const feature = projekt.features?.zeiten || {}
+ const danke = s.danke
+ s.danke = '' // nur beim ersten Zeichnen nach der Rueckkehr
+ s.root.dataset.zeitenKey = ''
+ const kopf = h('div', { class: 'zt-kopf' })
+ const teile = []
+ const meldungEl = meldung ? h('p', { class: 'tm-meldung tm-meldung-fehler', role: 'alert', tabindex: '-1', 'data-meldung': 'ansicht' }, meldung) : null
+ if (meldungEl) teile.push(meldungEl)
+ teile.push(kopf)
+
+ const editorPlatz = (zustand === 'setup' || zustand === 'active' || (zustand === 'disabled' && projekt.zeitenLeer === false))
+ ? h('div', { class: 'zt-editor', 'data-state': zustand })
+ : null
+ if (editorPlatz) teile.push(editorPlatz)
+ s.buehne.replaceChildren(...teile)
+ if (editorPlatz) s.root.dataset.zeitenKey = stand(projekt, s.angebot)
+ // die Meldung des Servers ersetzt die Seite, auf der der Kunde geklickt hat: dorthin schauen, nicht ins Leere
+ if (meldungEl) {
+ meldungEl.focus({ preventScroll: true })
+ meldungEl.scrollIntoView({ block: 'nearest' })
+ }
+
+ const statusKarte = (zusatz = {}) => zeitenStatusKarte(
+ { projekt, feature, angebot: s.angebot, kundenansicht: ctx.kundenansicht, danke, ...zusatz },
+ {
+ behalten: () => zeigeBehalten(s),
+ weiter: (fall) => zeichneProjekt(s, { bestellformular: fall?.bestellen === true, meldung: fall?.meldung || '' }),
+ })
+
+ if (zustand === 'off' || (zustand === 'ordered' && bestellformular)) {
+ renderZeitenBestellen(kopf, { ctx, angebot: s.angebot, projekt })
+ return
+ }
+ kopf.replaceChildren(statusKarte())
+ if (weg === 'behalten' && behaltenMoeglich(projekt)) zeigeBehalten(s)
+}
+
+/** Behalten-Seite (30 €) statt der Statuskarte; der Editor darunter bleibt, nur versteckt, damit nichts verloren geht. „Zurück“ zeigt wieder die Karte. */
+function zeigeBehalten(s) {
+ const kopf = s.buehne.querySelector('.zt-kopf')
+ if (!kopf) return
+ const editor = s.buehne.querySelector('.zt-editor')
+ const ctx = baueCtx(s, s.projekt)
+ ctx.zurueck = () => {
+ if (editor) editor.hidden = false
+ kopf.replaceChildren(zeitenStatusKarte(
+ { projekt: s.projekt, feature: s.projekt.features?.zeiten, angebot: s.angebot, kundenansicht: ctx.kundenansicht },
+ { behalten: () => zeigeBehalten(s) }))
+ }
+ if (editor) editor.hidden = true
+ renderZeitenBestellen(kopf, { ctx, angebot: s.angebot, projekt: s.projekt, modus: 'behalten' })
+}
diff --git a/server/routes/funktionen.js b/server/routes/funktionen.js
index c74cfb1..032d89a 100644
--- a/server/routes/funktionen.js
+++ b/server/routes/funktionen.js
@@ -160,8 +160,11 @@ async function zeitenListenfelder(docs, projectId, verfuegbar, jetzt) {
* Oeffnungszeiten-Teil der Antwort von GET /api/funktionen (Spec 3.1). Nicht verfuegbar (Schalter, Pilotliste): genau { verfuegbar: false },
* das Abo wird dann nicht gelesen. Verfuegbar: inklusive (true: laeuft und ist aktiv, die Funktion ist im Wartungs-Abo enthalten und
* einschaltbar; false: sonst, dann sagt grund warum: kein_abo | gekuendigt | nicht_aktiv; null: Abo nicht lesbar), grund (null bei true und
- * null), preis (Register) und einschalten mit den Texten des Gratisweges (Zustimmung mit einem Haken, Knopf "Einschalten", Fassung der
- * Leistungsbeschreibung). Ein Lesefehler im Abo kostet die Liste nichts; im Log stehen nur Art und Status, nie der Text.
+ * null), preis (Register), einschalten mit den Texten des Gratisweges (Zustimmung mit einem Haken, Knopf "Einschalten", Fassung der
+ * Leistungsbeschreibung) und belehrung: die Widerrufsbelehrung samt Muster-Formular, die die Bestellseite des bezahlten Weges (30 €, auch
+ * "Behalten") vor dem Knopf zeigt (Haken belehrung der Funktion, derselbe Text wie im Zustimmungsnachweis, Ruling R32; null, wenn der Haken
+ * sie nicht liefern kann: die Seite bestellt dann nicht). Die Zustimmung des bezahlten Weges steht in features.zeiten (Register-Ansicht).
+ * Ein Lesefehler im Abo kostet die Liste nichts; im Log stehen nur Art und Status, nie der Text.
*/
async function zeitenAngebot(email, customerId, abos) {
if (!zeitenFuerKunde(email)) return { verfuegbar: false }
@@ -175,6 +178,12 @@ async function zeitenAngebot(email, customerId, abos) {
} catch (err) {
console.error(`[funktionen] Abo für Öffnungszeiten live nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim())
}
+ let belehrung = null
+ try {
+ belehrung = (await featureHaken('zeiten').belehrung?.()) ?? null
+ } catch (err) {
+ console.error(`[funktionen] Widerrufsbelehrung (zeiten) nicht lesbar: ${err?.name ?? 'Fehler'}`)
+ }
const { version, url } = register.leistungsbeschreibung
return {
verfuegbar: true,
@@ -182,6 +191,7 @@ async function zeitenAngebot(email, customerId, abos) {
grund,
preis: register.priceLabel,
einschalten: { consent: oeffentlicheConsent(freiweg(register).consent), leistungsbeschreibung: { version, url: url ?? '' } },
+ belehrung,
}
}
diff --git a/test/funktionenZeitenTile.test.js b/test/funktionenZeitenTile.test.js
new file mode 100644
index 0000000..f456e60
--- /dev/null
+++ b/test/funktionenZeitenTile.test.js
@@ -0,0 +1,310 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { readFileSync, writeFileSync, mkdtempSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { pathToFileURL } from 'node:url'
+
+// Kachel „Öffnungszeiten live“ in public/funktionen.js (Spec 3.1, Ruling R20). Die Kachel steht inline in funktionen.js (keine neuen Importe,
+// test/termineView.test.js und test/termineFragebogenView.test.js zählen sie) und macht keine Abfrage über /api/funktionen hinaus.
+// Eigener Loader: ALLE Importe von funktionen.js (absolut /x.js oder relativ ./x.js) zeigen auf die Dateien in public/, damit ein künftiger
+// Import die Tests nicht stillschweigend bricht.
+const dir = mkdtempSync(join(tmpdir(), 'fn-zeiten-tile-'))
+const fileUrl = (name) => pathToFileURL(new URL(`../public/${name}`, import.meta.url).pathname).href
+const quelle = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8')
+const src = quelle.replace(/(\bfrom\s+)'(?:\/|\.\/)([^']+)'/g, (_, von, name) => `${von}'${fileUrl(name)}'`)
+const file = join(dir, 'funktionen.mjs')
+writeFileSync(file, src)
+const { initFunktionen } = await import(pathToFileURL(file).href)
+
+// ---------------------------------------------------------------------------------------------------------------- Hilfen
+const zeitenFeature = (state, extra = {}) => ({
+ state, siteKey: null, ticketWoid: null, orderedAt: null, activatedAt: null, invoiceUrl: null, disabledReason: null, disabledAt: null, loeschungAm: null,
+ unlockedBy: state === 'off' ? null : 'abo', aboEndeAt: null, einbau: null, ...extra,
+})
+const projekt = (id, state = 'off', feature = {}, extra = {}) => ({
+ projectId: id,
+ projectName: `Website ${id}`,
+ liveUrl: '',
+ features: { news: { state: 'off' }, zeiten: zeitenFeature(state, feature) },
+ analyse: { comingSoon: true },
+ zeitenStatus: null,
+ zeitenLeer: true,
+ ...extra,
+})
+const ANGEBOT = (inklusive = true, grund = null) => ({
+ verfuegbar: true,
+ inklusive,
+ grund,
+ preis: '30 €',
+ einschalten: { consent: { version: 'v', knopf: 'Einschalten', items: [{ id: 'leistung', text: 'Haken', haken: true }] }, leistungsbeschreibung: { version: 'v', url: '/vertragsunterlagen/zeiten-leistungsbeschreibung.html' } },
+})
+
+async function render(projects, extra = {}) {
+ const aufrufe = []
+ globalThis.fetch = async (url) => {
+ aufrufe.push(String(url))
+ return { ok: true, status: 200, json: async () => ({ projects, ...extra }) }
+ }
+ const root = { innerHTML: '', querySelector: () => null }
+ await initFunktionen(root)
+ return { html: root.innerHTML, aufrufe }
+}
+const kachel = (html) => {
+ const von = html.indexOf('data-fn-tile="zeiten"')
+ assert.ok(von >= 0, 'die Kachel fehlt')
+ return html.slice(von, html.indexOf(' ', von))
+}
+const nurText = (html) => html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
+const knoepfe = (html) => [...html.matchAll(/]*>([^<]*)<\/button>/g)].map((m) => m[1].trim())
+
+// ---------------------------------------------------------------------------------------------------------------- Tests
+test('ohne verfuegbar keine Kachel', async () => {
+ const P = [projekt('a')]
+ const ohne = (await render(P)).html
+ assert.doesNotMatch(ohne, /data-fn-tile="zeiten"/)
+ assert.doesNotMatch(ohne, /Öffnungszeiten/)
+ const aus = (await render(P, { zeiten: { verfuegbar: false } })).html
+ assert.equal(aus, ohne, 'verfuegbar: false sieht aus wie ein Server ohne Zeiten')
+ assert.equal(ohne.match(/Kommt bald/g).length, 2, 'Terminplaner und Analyse, wie bisher')
+ // auch ein beschädigtes Angebot macht keine Kachel
+ for (const kaputt of [null, 'ja', { verfuegbar: 'ja' }, { inklusive: true }]) assert.doesNotMatch((await render(P, { zeiten: kaputt })).html, /data-fn-tile="zeiten"/, JSON.stringify(kaputt))
+})
+
+test('Kachel steht zwischen Terminplaner und Analyse, mit Angebot genau eine zusätzliche Kachel', async () => {
+ const { html } = await render([projekt('a')], { zeiten: ANGEBOT(true) })
+ const stellen = ['data-fn-tile="news"', 'Terminplaner', 'data-fn-tile="zeiten"', 'Analyse'].map((s) => html.indexOf(s))
+ assert.ok(stellen.every((s) => s >= 0), stellen.join())
+ assert.deepEqual(stellen, [...stellen].sort((x, y) => x - y))
+ assert.equal(html.match(/class="fn-tile[ "]/g).length, 4, 'News, Terminplaner (Kommt bald), Zeiten, Analyse (Kommt bald)')
+ assert.equal(html.match(/Kommt bald/g).length, 2)
+ assert.match(kachel(html), /Öffnungszeiten live<\/h3>/)
+ assert.match(kachel(html), / {
+ const k = kachel((await render([projekt('p1')], { zeiten: ANGEBOT(true) })).html)
+ assert.match(nurText(k), /Im Wartungs-Abo enthalten/)
+ assert.match(k, /fn-pill fn-pill-off">Aus)
+ assert.deepEqual(knoepfe(k), ['Einschalten'])
+ assert.match(k, /Einschalten<\/button>/)
+ assert.doesNotMatch(k, /30 €|Zahlungspflichtig|einmalig/)
+})
+
+test('Abo nicht aktiv: „sobald dein Abo aktiv ist“, kein Bezahlen', async () => {
+ const k = kachel((await render([projekt('p1')], { zeiten: ANGEBOT(false, 'nicht_aktiv') })).html)
+ assert.match(nurText(k), /Im Wartungs-Abo enthalten – einschaltbar, sobald dein Abo aktiv ist/)
+ assert.deepEqual(knoepfe(k), [])
+ assert.doesNotMatch(k, /30 €|Zahlungspflichtig|einmalig| {
+ for (const grund of ['kein_abo', 'gekuendigt', null]) {
+ const k = kachel((await render([projekt('p1')], { zeiten: ANGEBOT(false, grund) })).html)
+ assert.match(nurText(k), /30 € einmalig/, String(grund))
+ assert.deepEqual(knoepfe(k), ['Öffnungszeiten einrichten · 30 €'], String(grund))
+ assert.match(k, /data-view="zeiten" data-project="p1"/)
+ assert.doesNotMatch(k, /Im Wartungs-Abo enthalten/, 'nichts verspricht, was nicht gilt')
+ }
+ // der Preis kommt aus dem Register, maskiert
+ const anderer = kachel((await render([projekt('p1')], { zeiten: { ...ANGEBOT(false, 'kein_abo'), preis: '31 €' } })).html)
+ assert.match(nurText(anderer), /31 € einmalig/)
+ assert.deepEqual(knoepfe(anderer), ['Öffnungszeiten einrichten · 31 €'])
+ assert.doesNotMatch(anderer, /30 €/)
+ const bose = kachel((await render([projekt('p1')], { zeiten: { ...ANGEBOT(false, 'kein_abo'), preis: '31 € ' } })).html)
+ assert.doesNotMatch(bose, //)
+ // ohne Preis im Angebot gilt der Rückfall 30 €
+ assert.match(nurText(kachel((await render([projekt('p1')], { zeiten: { ...ANGEBOT(false, 'kein_abo'), preis: '' } })).html)), /30 € einmalig/)
+})
+
+test('Abo-Lesefehler (inklusive null): „Gerade nicht prüfbar – bitte später erneut“', async () => {
+ const k = kachel((await render([projekt('p1')], { zeiten: ANGEBOT(null, null) })).html)
+ assert.match(nurText(k), /Gerade nicht prüfbar – bitte später erneut/)
+ assert.deepEqual(knoepfe(k), [])
+ assert.doesNotMatch(k, /30 €|Im Wartungs-Abo enthalten/)
+})
+
+test('setup: Text je nach einbau, Hinweis „Trag jetzt deine Zeiten ein“ bei zeitenLeer', async () => {
+ const zeigen = async (feature, extra) => kachel((await render([projekt('p1', 'setup', feature, extra)], { zeiten: ANGEBOT(true) })).html)
+
+ const angestossen = await zeigen({ einbau: 'angestossen' }, { zeitenLeer: false })
+ assert.match(angestossen, /fn-pill fn-pill-setup">In Einrichtung)
+ assert.match(nurText(angestossen), /Hinweis- und Kontakt-Leiste erscheinen nach dem nächsten Veröffentlichen \(meist in wenigen Minuten\)/)
+ assert.doesNotMatch(nurText(angestossen), /Trag jetzt deine Zeiten ein/)
+ assert.deepEqual(knoepfe(angestossen), ['Zeiten bearbeiten'])
+ assert.match(angestossen, /data-view="zeiten" data-project="p1"/)
+
+ const manuell = await zeigen({ einbau: 'manuell', ticketWoid: '4711' }, { zeitenLeer: false })
+ assert.match(nurText(manuell), /Unser Team baut die Funktion ein \(Ticket #4711\)/)
+ assert.doesNotMatch(nurText(manuell), /nach dem nächsten Veröffentlichen/)
+
+ const ohneTicket = await zeigen({ einbau: 'manuell' }, { zeitenLeer: false })
+ assert.match(nurText(ohneTicket), /Unser Team baut die Funktion ein\./)
+ assert.doesNotMatch(ohneTicket, /Ticket #/)
+
+ const leer = await zeigen({ einbau: 'angestossen' }, { zeitenLeer: true })
+ assert.match(nurText(leer), /Trag jetzt deine Zeiten ein – erst dann kann das Team die Tabelle einbauen/)
+ const leerManuell = await zeigen({ einbau: 'manuell', ticketWoid: '1' }, { zeitenLeer: true })
+ assert.match(nurText(leerManuell), /Trag jetzt deine Zeiten ein/)
+
+ // Kauf: der Neubau steht noch aus (einbau leer): der Text nennt weder Veröffentlichen noch ein Ticket, das es nicht gibt
+ const kauf = await zeigen({ unlockedBy: 'kauf', einbau: null }, { zeitenLeer: false })
+ assert.match(nurText(kauf), /Unser Team baut die Funktion ein\./)
+})
+
+test('active: Kurzstatus aus zeitenStatus, keine Zusatzabfrage', async () => {
+ const status = { offen: true, kurz: 'Geöffnet', text: 'Jetzt geöffnet · schließt um 18 Uhr' }
+ const { html, aufrufe } = await render([projekt('p1', 'active', { einbau: 'angestossen' }, { zeitenStatus: status, zeitenLeer: false })], { zeiten: ANGEBOT(true) })
+ assert.deepEqual(aufrufe, ['/api/funktionen'], 'der Status kommt in der Liste, die Kachel fragt nichts nach')
+ const k = kachel(html)
+ assert.match(k, /fn-pill fn-pill-on">Aktiv)
+ assert.match(nurText(k), /Jetzt geöffnet · schließt um 18 Uhr/)
+ assert.deepEqual(knoepfe(k), ['Zeiten bearbeiten'])
+ // ohne gespeicherte Zeiten kein Status; stattdessen der Hinweis
+ const ohne = kachel((await render([projekt('p1', 'active', {}, { zeitenStatus: null, zeitenLeer: true })], { zeiten: ANGEBOT(true) })).html)
+ assert.doesNotMatch(ohne, /Jetzt geöffnet/)
+ assert.match(nurText(ohne), /Trag jetzt deine Zeiten ein/)
+ // Fremdtext nur maskiert
+ const bose = kachel((await render([projekt('p1', 'active', {}, { zeitenStatus: { offen: true, kurz: 'Geöffnet', text: ' ' }, zeitenLeer: false })], { zeiten: ANGEBOT(true) })).html)
+ assert.doesNotMatch(bose, / {
+ const wahl = async (liste, angebot = ANGEBOT(true)) => kachel((await render(liste, { zeiten: angebot })).html).match(/data-project="([^"]+)"/)?.[1]
+ assert.equal(await wahl([projekt('a'), projekt('b', 'active', {}, { zeitenLeer: false })]), 'b')
+ assert.equal(await wahl([projekt('a'), projekt('b', 'setup'), projekt('c', 'disabled')]), 'b')
+ assert.equal(await wahl([projekt('a'), projekt('b', 'disabled', { disabledReason: 'abo_ende', loeschungAm: '2999-01-01' }, { zeitenLeer: false })], ANGEBOT(false, 'kein_abo')), 'b')
+ assert.equal(await wahl([projekt('a'), projekt('b')]), 'a')
+ assert.match(nurText(kachel((await render([projekt('a'), projekt('b', 'active', {}, { zeitenLeer: false })], { zeiten: ANGEBOT(true) })).html)), /aktiv für 1 von 2 Websites/)
+ // ohne Website: ein Satz statt eines Knopfs
+ const keine = kachel((await render([], { zeiten: ANGEBOT(true) })).html)
+ assert.deepEqual(knoepfe(keine), [])
+ assert.match(nurText(keine), /Sobald deine Website angelegt ist/)
+})
+
+test('aboEndeAt: Behalten-Hinweis mit Datum', async () => {
+ const zeigen = async (feature, angebot = ANGEBOT(false, 'kein_abo'), state = 'active') => kachel((await render([projekt('p1', state, feature, { zeitenLeer: false })], { zeiten: angebot })).html)
+
+ const k = await zeigen({ aboEndeAt: '2026-10-01T10:00:00.000Z', einbau: 'angestossen' })
+ assert.match(nurText(k), /Dein Wartungs-Abo ist beendet – behalte die Funktion bis 31\.10\.2026 für einmalig 30 €/)
+ assert.deepEqual(knoepfe(k), ['Zeiten bearbeiten', 'Für 30 € behalten'])
+ assert.match(k, /Für 30 € behalten<\/button>/)
+
+ // Berliner Kalendertag, nicht UTC: 01.10. 23:30 UTC ist schon der 02.10. in Berlin ⇒ Frist 01.11.
+ assert.match(nurText(await zeigen({ aboEndeAt: '2026-10-01T23:30:00.000Z' })), /bis 01\.11\.2026/)
+ // Appwrite liefert +00:00
+ assert.match(nurText(await zeigen({ aboEndeAt: '2026-10-01T10:00:00.000+00:00' })), /bis 31\.10\.2026/)
+ // auch in der Einrichtung
+ assert.match(nurText(await zeigen({ aboEndeAt: '2026-10-01T10:00:00.000Z' }, ANGEBOT(false, 'kein_abo'), 'setup')), /behalte die Funktion bis 31\.10\.2026/)
+ // läuft das Abo wieder (inklusive true), gibt es nichts zu bezahlen: der Abgleich nimmt das Ende gleich zurück
+ const wieder = await zeigen({ aboEndeAt: '2026-10-01T10:00:00.000Z' }, ANGEBOT(true))
+ assert.doesNotMatch(wieder, /behalten|Für 30 €/)
+ assert.deepEqual(knoepfe(wieder), ['Zeiten bearbeiten'])
+ // Abo nicht lesbar: kein Bezahlknopf auf Verdacht
+ const unklar = await zeigen({ aboEndeAt: '2026-10-01T10:00:00.000Z' }, ANGEBOT(null, null))
+ assert.deepEqual(knoepfe(unklar), ['Zeiten bearbeiten'])
+ // ohne Datum kein Hinweis
+ assert.doesNotMatch(await zeigen({ aboEndeAt: null }), /Wartungs-Abo ist beendet/)
+ // ein unlesbares Datum erfindet kein Datum
+ const kaputt = await zeigen({ aboEndeAt: 'kein-datum' })
+ assert.doesNotMatch(nurText(kaputt), /bis NaN|Invalid|undefined/)
+})
+
+test('ordered: „Zahlung wird bestätigt …“', async () => {
+ const k = kachel((await render([projekt('p1', 'ordered', { unlockedBy: null })], { zeiten: ANGEBOT(false, 'kein_abo') })).html)
+ assert.match(k, /fn-pill fn-pill-setup">Zahlung offen)
+ assert.match(nurText(k), /Zahlung wird bestätigt …/)
+ assert.deepEqual(knoepfe(k), ['Status ansehen'])
+ assert.match(k, /data-view="zeiten" data-project="p1"/)
+})
+
+test('disabled abo_ende mit Daten: Knopf „Für 30 € wieder einschalten“', async () => {
+ const zeigen = async (feature, extra, angebot = ANGEBOT(false, 'kein_abo')) => kachel((await render([projekt('p1', 'disabled', feature, extra)], { zeiten: angebot })).html)
+
+ const k = await zeigen({ disabledReason: 'abo_ende', disabledAt: '2026-10-04T10:00:00.000Z', loeschungAm: '2999-01-01' }, { zeitenLeer: false })
+ assert.match(k, /fn-pill fn-pill-off">Abgeschaltet)
+ assert.match(nurText(k), /Öffnungszeiten live ist abgeschaltet, weil dein Wartungs-Abo beendet ist/)
+ assert.match(nurText(k), /Deine Eingaben bleiben bis 01\.01\.2999 gespeichert/)
+ assert.deepEqual(knoepfe(k), ['Für 30 € wieder einschalten'])
+ assert.match(k, /data-view="zeiten" data-project="p1" data-zeiten-weg="behalten"/)
+
+ // keine Daten mehr (gelöscht oder nie eingetragen): nichts zu behalten
+ const leer = await zeigen({ disabledReason: 'abo_ende', loeschungAm: '2999-01-01' }, { zeitenLeer: true })
+ assert.doesNotMatch(leer, /Für 30 €/)
+ assert.deepEqual(knoepfe(leer), ['Chat öffnen'])
+ // die Frist der Löschung ist vorbei
+ const zuSpaet = await zeigen({ disabledReason: 'abo_ende', loeschungAm: '2000-01-01' }, { zeitenLeer: false })
+ assert.doesNotMatch(zuSpaet, /Für 30 €/)
+ // Abschaltung durch das Team: Grundtext aus der Abschaltung, kein Behalten
+ const team = await zeigen({ disabledReason: 'team' }, { zeitenLeer: false })
+ assert.doesNotMatch(team, /Für 30 €/)
+ assert.match(nurText(team), /Öffnungszeiten live wurde für diese Website abgeschaltet\. Schreib uns im Chat, wenn du die Funktion wieder möchtest\./)
+ assert.deepEqual(knoepfe(team), ['Chat öffnen'])
+ // Altbestand ohne Grund
+ assert.deepEqual(knoepfe(await zeigen({}, { zeitenLeer: false })), ['Chat öffnen'])
+ // das Abo läuft wieder: der Abgleich schaltet selbst wieder ein, 30 € wären doppelt bezahlt
+ const wieder = await zeigen({ disabledReason: 'abo_ende', loeschungAm: '2999-01-01' }, { zeitenLeer: false }, ANGEBOT(true))
+ assert.doesNotMatch(wieder, /Für 30 €/)
+ assert.match(nurText(wieder), /Dein Wartungs-Abo läuft wieder/)
+})
+
+// ---------------------------------------------------------------------------------------------------------------- Klick
+test('Knopf: das Projekt (und beim Behalten der Weg) geht an #zeiten-body; nur Knöpfe der Zeiten-Kachel', async () => {
+ const gesetzt = []
+ const gehoert = {}
+ const artikel = { addEventListener: (typ, fn) => { gehoert[typ] = fn } }
+ const body = { dataset: {} }
+ const vorher = globalThis.document
+ globalThis.document = { getElementById: (id) => { gesetzt.push(id); return id === 'zeiten-body' ? body : null } }
+ try {
+ globalThis.fetch = async () => ({ ok: true, status: 200, json: async () => ({ projects: [projekt('p1', 'active', { aboEndeAt: '2026-10-01T10:00:00.000Z' }, { zeitenLeer: false })], zeiten: ANGEBOT(false, 'kein_abo') }) })
+ const root = { innerHTML: '', querySelector: (sel) => (sel === '[data-fn-tile="zeiten"]' ? artikel : null) }
+ await initFunktionen(root)
+ assert.equal(typeof gehoert.click, 'function', 'ein Klick-Handler an der Kachel')
+ const knopf = (dataset) => ({ closest: (sel) => (sel === 'button[data-view="zeiten"]' ? { dataset } : null) })
+
+ gehoert.click({ target: knopf({ view: 'zeiten', project: 'p1' }) })
+ assert.equal(body.dataset.wunschProjekt, 'p1')
+ assert.equal(body.dataset.wunschWeg, undefined, 'ohne Weg keiner')
+
+ gehoert.click({ target: knopf({ view: 'zeiten', project: 'p1', zeitenWeg: 'behalten' }) })
+ assert.equal(body.dataset.wunschWeg, 'behalten')
+ // der nächste Klick ohne Weg nimmt den alten zurück
+ gehoert.click({ target: knopf({ view: 'zeiten', project: 'p1' }) })
+ assert.equal(body.dataset.wunschWeg, undefined)
+ gehoert.click({ target: knopf({ view: 'zeiten', project: 'p1', zeitenWeg: 'behalten' }) })
+ assert.equal(body.dataset.wunschWeg, 'behalten')
+
+ // ein Klick auf etwas anderes in der Kachel (Text) legt nichts ab
+ body.dataset = {}
+ gehoert.click({ target: { closest: () => null } })
+ gehoert.click({ target: {} })
+ assert.deepEqual(body.dataset, {})
+ // ein unbekannter Weg wird nicht übernommen
+ gehoert.click({ target: knopf({ view: 'zeiten', project: 'p2', zeitenWeg: 'alles-frei' }) })
+ assert.equal(body.dataset.wunschProjekt, 'p2')
+ assert.equal(body.dataset.wunschWeg, undefined)
+ } finally {
+ if (vorher === undefined) delete globalThis.document
+ else globalThis.document = vorher
+ }
+})
+
+test('Projekt-IDs und Namen kommen nur maskiert in die Kachel', async () => {
+ const k = kachel((await render([projekt('">', 'off')], { zeiten: ANGEBOT(true) })).html)
+ assert.doesNotMatch(k, /