Öffnungszeiten: Kachel und Bestell-/Einschaltweg im Portal

Kachel „Öffnungszeiten live“ inline in funktionen.js (keine neuen Importe, keine Zusatzabfrage),
Ansicht #zeiten (zeiten.js, zeiten-bestellen.js, zeiten.css) mit Einschalten im Wartungs-Abo,
Bestellen für 30 € (Widerrufsbelehrung vor dem Knopf, Ruling R32), Behalten nach dem Abo-Ende,
Statuskarte je Zustand, Rückkehr von Stripe und Platz für den Editor (Aufgabe 29).
GET /api/funktionen liefert dafür zeiten.belehrung (Haken belehrung der Funktion).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 05:30:23 +00:00
1 parent f20f056c60
commit 7bb918390a
10 files changed
+1878 -8

No files matched your search

+5 -3
View File
@@ -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])'
+10
View File
@@ -23,6 +23,7 @@
<link rel="stylesheet" href="/chat-composer.css">
<link rel="stylesheet" href="/funktionen.css">
<link rel="stylesheet" href="/termine.css">
<link rel="stylesheet" href="/zeiten.css">
</head>
<body class="portal-page dashboard-page kb-page">
<div class="dot-background" aria-hidden="true">
@@ -195,6 +196,15 @@
<div id="termine-body"><p class="kb-empty">Lade…</p></div>
</section>
<!-- Öffnungszeiten live: Einschalten oder Bestellen, Status und Editor (zeiten.js, initZeiten); Unterseite von Funktionen. Kein aria-live am Bereich -->
<section class="kb-view" id="view-zeiten">
<header class="kb-head">
<h1>Öffnungszeiten live</h1>
<p>Deine Website zeigt immer den aktuellen Stand: geöffnet oder geschlossen, mit Feiertagen, Urlaub und Hinweisen.</p>
</header>
<div id="zeiten-body"><p class="kb-empty">Lade…</p></div>
</section>
<!-- Rechnungen -->
<section class="kb-view" id="view-invoices">
<header class="kb-head">
+113 -1
View File
@@ -92,6 +92,7 @@ const ICONS = {
news: '<path d="M12 17v5"/><path d="M9 10.8a2 2 0 0 1-1.1 1.8l-1.8.9A2 2 0 0 0 5 15.2V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.8a2 2 0 0 0-1.1-1.8l-1.8-.9A2 2 0 0 1 15 10.8V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/>',
calendar: '<rect x="3" y="5" width="18" height="16" rx="3"/><path d="M8 3v4M16 3v4M3 10h18"/>',
chart: '<path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/>',
clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
}
const svg = (name) => `<svg class="fn-ico" viewBox="0 0 24 24" aria-hidden="true">${ICONS[name]}</svg>`
@@ -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' } = {}) => `<button type="button" class="${klasse}" data-view="zeiten" data-project="${esc(target.projectId)}"${weg ? ` data-zeiten-weg="${weg}"` : ''}>${text}</button>`
const chat = '<button type="button" class="kb-btn-primary fn-tile-cta" data-view="chat">Chat öffnen</button>'
let pill = ['Aus', 'off']
let extra = ''
let action = ''
if (!target) {
extra = '<p class="fn-count">Sobald deine Website angelegt ist, kannst du Öffnungszeiten live einrichten.</p>'
} else if (f.state === 'active' || f.state === 'setup') {
const aktiv = f.state === 'active'
pill = aktiv ? ['Aktiv', 'on'] : ['In Einrichtung', 'setup']
const status = typeof target.zeitenStatus?.text === 'string' ? target.zeitenStatus.text : ''
if (aktiv && status) extra += `<p class="fn-count" data-zeiten-status>${esc(status)}</p>`
if (!aktiv) {
extra += f.einbau === 'angestossen'
? '<p>Hinweis- und Kontakt-Leiste erscheinen nach dem nächsten Veröffentlichen (meist in wenigen Minuten).</p>'
: `<p>Unser Team baut die Funktion ein${f.ticketWoid ? ` (Ticket #${esc(f.ticketWoid)})` : ''}.</p>`
}
if (target.zeitenLeer === true) extra += `<p>${aktiv ? 'Trag jetzt deine Zeiten ein.' : 'Trag jetzt deine Zeiten ein – erst dann kann das Team die Tabelle einbauen.'}</p>`
action = knopf('Zeiten bearbeiten')
// Wartungs-Abo beendet (aboEndeAt): fuer einmalig 30 € behalten; 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 += `<p class="fn-count">Dein Wartungs-Abo ist beendet – behalte die Funktion bis ${frist} für einmalig ${esc(preis)}.</p>`
action += knopf(`Für ${esc(preis)} behalten`, { weg: 'behalten', klasse: 'kb-btn-outline' })
}
} else if (f.state === 'ordered') {
pill = ['Zahlung offen', 'setup']
extra = '<p>Zahlung wird bestätigt …</p>'
action = knopf('Status ansehen')
} else if (f.state === 'disabled') {
pill = ['Abgeschaltet', 'off']
const aboEnde = f.disabledReason === 'abo_ende'
// Daten da: Plaene gespeichert (zeitenLeer false) und die Loeschfrist (loeschungAm, Berliner Tag) ist nicht vorbei
const daten = aboEnde && target.zeitenLeer === false && !(typeof f.loeschungAm === 'string' && f.loeschungAm < berlinIsoDay())
extra = `<p>${aboEnde ? 'Öffnungszeiten live ist abgeschaltet, weil dein Wartungs-Abo beendet ist.' : ZEITEN_ABGESCHALTET}</p>`
if (daten && zeitenTag(f.loeschungAm)) extra += `<p class="fn-count">Deine Eingaben bleiben bis ${zeitenTag(f.loeschungAm)} gespeichert.</p>`
if (daten && angebot.inklusive === true) extra += '<p class="fn-count">Dein Wartungs-Abo läuft wieder – wir schalten Öffnungszeiten live automatisch wieder ein (meist innerhalb eines Tages).</p>'
else if (daten && angebot.inklusive !== false) extra += `<p class="fn-count">${ZEITEN_NICHT_PRUEFBAR}</p>`
else action = daten ? knopf(`Für ${esc(preis)} wieder einschalten`, { weg: 'behalten' }) : chat
} else if (angebot.inklusive === true) {
extra = '<p>Im Wartungs-Abo enthalten.</p>'
action = knopf('Einschalten')
} else if (angebot.inklusive === false && angebot.grund === 'nicht_aktiv') {
extra = '<p class="fn-count">Im Wartungs-Abo enthalten – einschaltbar, sobald dein Abo aktiv ist.</p>'
} else if (angebot.inklusive === false) {
extra = `<p>${esc(preis)} einmalig</p>`
action = knopf(`Öffnungszeiten einrichten · ${esc(preis)}`)
} else {
extra = `<p class="fn-count">${ZEITEN_NICHT_PRUEFBAR}</p>`
}
const aktiveAnzahl = projects.filter((p) => zeitenOf(p).state === 'active').length
const count = projects.length > 1 && aktiveAnzahl ? `<p class="fn-count">aktiv für ${aktiveAnzahl} von ${projects.length} Websites</p>` : ''
return `
<article class="fn-tile" data-fn-tile="zeiten">
<div class="fn-tile-head">${svg('clock')}<span class="fn-pill fn-pill-${pill[1]}">${pill[0]}</span></div>
<h3>Öffnungszeiten live</h3>
<p>${ZEITEN_TEXT}</p>
${extra}
${count}
${action}
</article>`
}
const soonTile = (icon, title, text) => `
<article class="fn-tile fn-tile-soon">
<div class="fn-tile-head">${svg(icon)}<span class="fn-pill fn-pill-soon">Kommt bald</span></div>
@@ -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 = `
<div class="fn-tiles">
${newsTile(projects, newsOffer(features).price)}
${termine?.verfuegbar ? termineTile(projects, termine) : soonTile('calendar', 'Terminplaner', TERMINE_TEXT)}
${zeiten?.verfuegbar === true ? zeitenTile(projects, zeiten) : ''}
${analyseTile(projects)}
</div>`
root.querySelector('[data-fn-tile="news"] .fn-tile-cta')?.addEventListener('click', (ev) => { selection.projectId = ev.currentTarget.dataset.project })
@@ -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)
}
+377
View File
@@ -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' })
}
+35
View File
@@ -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; } }
+219
View File
@@ -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 <Datum> für einmalig 30 €“ mit dem Knopf zur Behalten-Seite.
Rueckkehr von Stripe: #zeiten?bestellt=1&projekt=<id> (Bestellung) und #zeiten?behalten=1&projekt=<id> (Behalten) waehlen das Projekt und zeigen
„Danke!“; #zeiten?projekt=<id> (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' })
}
+12 -2
View File
@@ -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,
}
}
+310
View File
@@ -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('</article>', von))
}
const nurText = (html) => html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
const knoepfe = (html) => [...html.matchAll(/<button\b[^>]*>([^<]*)<\/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), /<h3>Öffnungszeiten live<\/h3>/)
assert.match(kachel(html), /<svg class="fn-ico"/)
})
test('Abo: „Im Wartungs-Abo enthalten“ und Knopf Einschalten', async () => {
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, /<button type="button" class="kb-btn-primary fn-tile-cta" data-view="zeiten" data-project="p1">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|<button/)
})
test('ohne Abo: „30 € einmalig“', async () => {
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: '<b>31 €</b>' } })).html)
assert.doesNotMatch(bose, /<b>/)
// 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: '<img src=x onerror=1>' }, zeitenLeer: false })], { zeiten: ANGEBOT(true) })).html)
assert.doesNotMatch(bose, /<img/)
assert.match(bose, /&lt;img/)
})
test('mehrere Websites: Zielprojekt ist die aktive, dann die in Einrichtung, dann die abgeschaltete, sonst die erste; „aktiv für 1 von 2 Websites“', async () => {
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, /<button type="button" class="kb-btn-outline" data-view="zeiten" data-project="p1" data-zeiten-weg="behalten">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('"><script>x</script>', 'off')], { zeiten: ANGEBOT(true) })).html)
assert.doesNotMatch(k, /<script>/)
assert.match(k, /data-project="&quot;&gt;&lt;script&gt;x&lt;\/script&gt;"/)
})
// ---------------------------------------------------------------------------------------------------------------- Quelltext
test('funktionen.js: keine neuen Importe für die Zeiten (die Kachel steht inline), keine „24 Uhr“, Du-Form', () => {
assert.deepEqual(quelle.match(/from '\/[^']+'/g), ["from '/analyse-view.js'", "from '/geraete.js'"])
assert.deepEqual(quelle.match(/from '\.[^']+'/g), ["from './vertraege-link.js'"])
assert.doesNotMatch(quelle, /import\s*\(/)
assert.doesNotMatch(quelle, /from '[^']*zeiten/, 'kein Zeiten-Modul in funktionen.js')
const von = quelle.indexOf('Öffnungszeiten live')
assert.ok(von > 0)
assert.doesNotMatch(quelle.slice(von), /24 Uhr/)
assert.doesNotMatch(quelle, /\bSie\b/)
})
+791
View File
@@ -0,0 +1,791 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync, readdirSync } from 'node:fs'
import { installFakeDom } from './support/fakeDom.js'
import { config } from '../server/config.js'
import { featureConfig, featureHaken, freiweg, oeffentlicheAnsicht } from '../server/services/featureRegistry.js'
import { oeffentlicheConsent } from '../server/services/featureOrder.js'
import { h } from '../public/termine-dom.js'
import { initZeiten } from '../public/zeiten.js'
import { behaltenFrist, renderZeitenBestellen, tagText, zeitenStatusKarte } from '../public/zeiten-bestellen.js'
// Einschalt-, Bestell- und Behalten-Weg der Öffnungszeiten live im Portal (public/zeiten.js, public/zeiten-bestellen.js; Spec 3.1 bis 3.3,
// Rulings R20 und R32). Fake-DOM und Fake-fetch aus test/support/fakeDom.js: jeder Aufruf steht in fx.fetchLog, die Antworten kommen der Reihe nach
// aus fx.antworten. Die Texte der Register (Zustimmungen, Knopf, Belehrung) kommen aus dem echten Register des Servers.
const REGISTER = featureConfig('zeiten')
const BELEHRUNG = await featureHaken('zeiten').belehrung()
const STRIPE = 'https://checkout.stripe.test/c/pay/cs_test_1'
const KNOPF_BEZAHLT = REGISTER.consent.knopf
const HAKEN_BEZAHLT = REGISTER.consent.items.filter((item) => item.haken).map((item) => item.id)
const mitDom = (optionen, fn) => async () => {
const fx = installFakeDom(optionen)
try {
await fn(fx)
} finally {
fx.restore()
}
}
// ---------------------------------------------------------------------------------------------------------------- Fixtures
const zeitenFeature = (state = 'off', 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 = 'p1', state = 'off', feature = {}, extra = {}) => ({
projectId: id, projectName: `Website ${id}`, liveUrl: '', features: { news: { state: 'off' }, zeiten: zeitenFeature(state, feature) }, zeitenStatus: null, zeitenLeer: true, ...extra,
})
const angebot = (inklusive, grund = null, extra = {}) => ({
verfuegbar: true,
inklusive,
grund,
preis: REGISTER.priceLabel,
einschalten: { consent: oeffentlicheConsent(freiweg(REGISTER).consent), leistungsbeschreibung: { version: REGISTER.leistungsbeschreibung.version, url: REGISTER.leistungsbeschreibung.url } },
belehrung: BELEHRUNG,
...extra,
})
/** Antwort von GET /api/funktionen. */
const liste = (projects, zeiten, extra = {}) => ({
status: 200,
body: { projects, features: { zeiten: oeffentlicheAnsicht('zeiten') }, ustHinweis: config.ustHinweis, zeiten, ...extra },
})
const fehler = (status, error, code) => ({ status, body: { error, ...(code ? { code } : {}) } })
// ---------------------------------------------------------------------------------------------------------------- Hilfen
/** Die Ansicht in einen Behälter hängen und initZeiten laufen lassen. */
async function oeffne(fx, { projectId = 'p1', hash = '', dataset = {} } = {}) {
const root = h('div', { id: 'zeiten-body' })
document.body.append(root)
fx.window.location.hash = hash
for (const [name, wert] of Object.entries(dataset)) root.dataset[name] = wert
await initZeiten(root, { projectId })
return root
}
const knopf = (fx, root, aktion) => fx.finde(root, `[data-aktion="${aktion}"]`)
const haken = (fx, root) => fx.alle(root, 'input[type="checkbox"]')
const alleAnhaken = async (fx, root) => { for (const feld of haken(fx, root)) if (!feld.checked) await fx.klick(feld) }
const meldung = (fx, root) => fx.text(fx.finde(root, '[data-meldung="bestellen"]'))
const posts = (fx) => fx.fetchLog.filter((aufruf) => aufruf.method === 'POST')
const lesen = (pfad) => readFileSync(new URL(`../${pfad}`, import.meta.url), 'utf8')
// ---------------------------------------------------------------------------------------------------------------- Einschalten
test('Einschalten: Haken schaltet Knopf frei, POST /api/funktionen/zeiten/einschalten', mitDom({}, async (fx) => {
fx.antworten.push(
liste([projekt()], angebot(true)),
{ status: 200, body: { feature: { state: 'setup' }, einbau: 'angestossen' } },
liste([projekt('p1', 'setup', { einbau: 'angestossen' })], angebot(true)))
const root = await oeffne(fx)
const seite = fx.finde(root, '[data-variante="einschalten"]')
assert.ok(seite, 'die Einschaltseite')
assert.equal(fx.finde(seite, '.tm-rechtstext'), null, 'ohne Preis keine Widerrufsbelehrung: es gibt kein Widerrufsverlangen')
assert.doesNotMatch(fx.text(seite), /30 €|Zahlungspflichtig|einmalig|Widerruf/)
assert.match(fx.text(seite), /ohne Aufpreis enthalten/)
const link = fx.finde(seite, 'a')
assert.equal(link.getAttribute('href'), '/vertragsunterlagen/zeiten-leistungsbeschreibung.html')
assert.equal(link.getAttribute('rel'), 'noopener noreferrer')
const button = knopf(fx, root, 'einschalten')
assert.equal(fx.text(button), 'Einschalten', 'der Knopf trägt genau den Text des Registers (consentFrei)')
assert.equal(fx.text(button), REGISTER.consentFrei.knopf)
assert.equal(button.disabled, true, 'ohne Haken gesperrt')
assert.equal(haken(fx, root).length, 1)
assert.equal(fx.text(fx.finde(seite, '[data-hinweis="bestellen"]')), 'Setze den Haken, um einzuschalten.')
await fx.klick(button)
assert.equal(posts(fx).length, 0, 'ein gesperrter Knopf sendet nichts')
await fx.klick(haken(fx, root)[0])
assert.equal(button.disabled, false, 'der Haken gibt den Knopf frei')
await fx.klick(haken(fx, root)[0])
assert.equal(button.disabled, true, 'ohne Haken wieder gesperrt')
await fx.klick(haken(fx, root)[0])
await fx.klick(button)
assert.deepEqual(posts(fx), [{
method: 'POST',
url: '/api/funktionen/zeiten/einschalten',
body: { projectId: 'p1', zustimmung: { version: REGISTER.consentFrei.version, haken: ['leistung'] } },
}])
assert.deepEqual(fx.fetchLog.map((a) => `${a.method} ${a.url}`), ['GET /api/funktionen', 'POST /api/funktionen/zeiten/einschalten', 'GET /api/funktionen'], 'danach wird die Ansicht nach dem neuen Stand gezeichnet')
const karte = fx.finde(root, '.zt-status')
assert.equal(karte.getAttribute('data-state'), 'setup')
assert.match(fx.text(fx.finde(karte, '[data-danke]')), /Öffnungszeiten live ist eingeschaltet/)
assert.match(fx.text(karte), /nach dem nächsten Veröffentlichen/)
assert.equal(fx.finde(root, '[data-variante]'), null, 'die Einschaltseite ist weg')
}))
test('Einschalten: Fehler 503 und 400 bleiben am Knopf, der Knopf ist danach wieder frei; ein Klick zählt nur einmal', mitDom({}, async (fx) => {
fx.antworten.push(
liste([projekt()], angebot(true)),
fehler(503, 'Gerade nicht prüfbar – bitte versuch es gleich noch einmal.'),
{ status: 200, body: { feature: { state: 'setup' }, einbau: 'manuell' } },
liste([projekt('p1', 'setup', { einbau: 'manuell', ticketWoid: '4711' })], angebot(true)))
const root = await oeffne(fx)
await alleAnhaken(fx, root)
const button = knopf(fx, root, 'einschalten')
await fx.klick(button)
assert.equal(meldung(fx, root), 'Gerade nicht prüfbar – bitte versuch es gleich noch einmal.')
assert.equal(button.disabled, false, 'nach dem Fehler wieder frei (Haken bleiben)')
assert.equal(haken(fx, root)[0].checked, true)
await fx.klick(button)
assert.equal(posts(fx).length, 2)
assert.equal(fx.text(fx.finde(root, '.zt-status')).includes('#4711'), true, 'Ticketnummer in der Karte')
}))
test('Einschalten: 409 (Abo nicht mehr aktiv) zeichnet die Ansicht nach der neuen Liste mit der Meldung des Servers', mitDom({}, async (fx) => {
fx.antworten.push(
liste([projekt()], angebot(true)),
fehler(409, 'Dein Wartungs-Abo läuft nicht mehr. Du kannst Öffnungszeiten live für einmalig 30 € bestellen.', 'gekuendigt'),
liste([projekt()], angebot(false, 'gekuendigt')))
const root = await oeffne(fx)
await alleAnhaken(fx, root)
await fx.klick(knopf(fx, root, 'einschalten'))
assert.equal(fx.alle(root, '[data-variante]').length, 1)
assert.ok(fx.finde(root, '[data-variante="bestellen"]'), 'jetzt der bezahlte Weg')
assert.match(fx.text(fx.finde(root, '[data-meldung="ansicht"]')), /läuft nicht mehr/)
assert.equal(knopf(fx, root, 'bestellen').disabled, true, 'die Haken sind neu, der Knopf gesperrt')
assert.equal(haken(fx, root).every((feld) => !feld.checked), true)
}))
test('Einschalten: scheitert das Neuladen nach einem 409, steht die Meldung am Knopf', mitDom({}, async (fx) => {
fx.antworten.push(liste([projekt()], angebot(true)), fehler(409, 'Öffnungszeiten live ist für dieses Projekt schon bestellt oder eingeschaltet.'), fehler(500, 'Interner Serverfehler'))
const root = await oeffne(fx)
await alleAnhaken(fx, root)
await fx.klick(knopf(fx, root, 'einschalten'))
assert.match(meldung(fx, root), /schon bestellt oder eingeschaltet/)
assert.equal(knopf(fx, root, 'einschalten').disabled, false)
}))
// ---------------------------------------------------------------------------------------------------------------- Bestellen
test('Bestellen: POST /api/funktionen/zeiten/checkout, Weiterleitung zu Stripe', mitDom({}, async (fx) => {
fx.antworten.push(liste([projekt()], angebot(false, 'kein_abo')), { status: 200, body: { url: STRIPE } })
const root = await oeffne(fx)
const seite = fx.finde(root, '[data-variante="bestellen"]')
assert.ok(seite)
const alle = fx.alle(root, '*')
const stelle = (selektor) => {
const el = fx.finde(root, selektor)
assert.ok(el, `${selektor} fehlt`)
return alle.indexOf(el)
}
const stellen = ['.tm-bestellen-preis', '.tm-rechtstext', '.tm-bestellen-dokumente', '.tm-bestellen-haken input', '[data-aktion="bestellen"]'].map(stelle)
assert.deepEqual(stellen, [...stellen].sort((a, b) => a - b), 'Preis, Belehrung, Unterlagen, Haken, Knopf in dieser Reihenfolge')
assert.equal(new Set(stellen).size, stellen.length)
// Ruling R32: genau der Text des Nachweises, unverändert, in einem scrollbaren Bereich, nie eingeklappt
const bereich = fx.finde(root, '.tm-rechtstext')
assert.equal(bereich.textContent, BELEHRUNG)
assert.match(bereich.textContent, /Muster-Widerrufsformular/)
assert.equal(bereich.getAttribute('tabindex'), '0')
assert.equal(fx.alle(root, 'details').length, 0)
assert.equal(fx.alle(root, '[hidden]').length, 0)
assert.match(fx.text(fx.finde(root, '.tm-bestellen-betrag')), /^30 € einmalig, ohne Umsatzsteuer gemäß § 19 UStG$/)
assert.match(fx.text(fx.finde(root, '.tm-bestellen-preis')), /Einrichtung: meist innerhalb weniger Tage/)
assert.deepEqual(haken(fx, root).map((feld) => feld.dataset.haken), HAKEN_BEZAHLT, 'die zwei Haken des Registers: Leistungsbeschreibung und Beginn')
assert.deepEqual(fx.alle(root, '.tm-haken span').map((el) => fx.text(el)), REGISTER.consent.items.map((item) => item.text), 'die Texte aus dem Register')
const button = knopf(fx, root, 'bestellen')
assert.equal(fx.text(button), KNOPF_BEZAHLT)
assert.equal(fx.text(button), 'Zahlungspflichtig bestellen · 30 €')
assert.equal(button.disabled, true)
await fx.klick(haken(fx, root)[0])
assert.equal(button.disabled, true, 'ein Haken von zwei reicht nicht')
await fx.klick(haken(fx, root)[1])
assert.equal(button.disabled, false)
await fx.klick(button)
assert.deepEqual(posts(fx), [{
method: 'POST',
url: '/api/funktionen/zeiten/checkout',
body: { projectId: 'p1', zustimmung: { version: REGISTER.consent.version, haken: ['leistung', 'beginn'] } },
}])
assert.equal(fx.window.location.href, STRIPE, 'Weiterleitung zu Stripe')
assert.equal(button.disabled, true, 'die Seite wird verlassen, der Knopf bleibt gesperrt')
assert.equal(fx.fetchLog.length, 2, 'nach der Weiterleitung keine weitere Abfrage')
}))
test('Bestellen: ohne Abo, bei vorgemerkter Kündigung und ohne Grund erscheint die Bestellseite; mit Preis aus dem Angebot', mitDom({}, async (fx) => {
for (const grund of ['kein_abo', 'gekuendigt', null]) {
fx.antworten.push(liste([projekt()], angebot(false, grund)))
const root = await oeffne(fx)
assert.ok(fx.finde(root, '[data-variante="bestellen"]'), String(grund))
}
}))
test('Bestellen: eine Adresse, die nicht https ist, wird nie angesteuert', mitDom({}, async (fx) => {
for (const url of ['javascript:alert(1)', 'http://stripe.example/x', '/rechtliches/x.html', '//evil.example/x', '', undefined, 5]) {
fx.antworten.push(liste([projekt()], angebot(false, 'kein_abo')), { status: 200, body: { url } })
fx.window.location.href = 'http://localhost/'
const root = await oeffne(fx)
await alleAnhaken(fx, root)
await fx.klick(knopf(fx, root, 'bestellen'))
assert.equal(fx.window.location.href, 'http://localhost/', `Weiterleitung zu ${String(url)}`)
assert.match(meldung(fx, root), /Zahlungsseite konnte nicht geöffnet werden/)
assert.equal(knopf(fx, root, 'bestellen').disabled, false, 'der Kunde kann es noch einmal versuchen')
}
}))
test('Bestellen: ohne Belehrung oder ohne Zustimmung wird nie bestellt (R32), das Einschalten braucht beides nicht', mitDom({}, async (fx) => {
for (const belehrung of [null, '', ' \n', undefined]) {
fx.antworten.push(liste([projekt()], angebot(false, 'kein_abo', { belehrung })))
const root = await oeffne(fx)
assert.equal(knopf(fx, root, 'bestellen'), null, `Belehrung ${JSON.stringify(belehrung)}: kein Bestellknopf`)
assert.equal(haken(fx, root).length, 0)
assert.match(fx.text(root), /konnte gerade nicht geladen werden/)
}
// Zustimmung des Registers fehlt oder ist beschädigt
for (const features of [{}, { zeiten: { consent: null } }, { zeiten: { consent: { version: 'v', items: [] } } }]) {
fx.antworten.push(liste([projekt()], angebot(false, 'kein_abo'), { features }))
const root = await oeffne(fx)
assert.equal(knopf(fx, root, 'bestellen'), null, JSON.stringify(features))
}
// Einschalten ohne Belehrung: geht (kein Widerrufsverlangen)
fx.antworten.push(liste([projekt()], angebot(true, null, { belehrung: null })))
const root = await oeffne(fx)
assert.ok(knopf(fx, root, 'einschalten'))
}))
test('409 inklusive beim Bestellen ⇒ wechselt zur Einschalt-Ansicht', mitDom({}, async (fx) => {
const text = 'Im Wartungs-Abo bereits enthalten – einfach einschalten.'
fx.antworten.push(liste([projekt()], angebot(false, 'kein_abo')), fehler(409, text, 'inklusive'), liste([projekt()], angebot(true)))
const root = await oeffne(fx)
await alleAnhaken(fx, root)
await fx.klick(knopf(fx, root, 'bestellen'))
assert.deepEqual(fx.fetchLog.map((a) => `${a.method} ${a.url}`), ['GET /api/funktionen', 'POST /api/funktionen/zeiten/checkout', 'GET /api/funktionen'])
assert.equal(fx.finde(root, '[data-variante="bestellen"]'), null)
assert.ok(fx.finde(root, '[data-variante="einschalten"]'), 'die Einschaltseite')
assert.equal(fx.text(knopf(fx, root, 'einschalten')), 'Einschalten')
assert.equal(knopf(fx, root, 'einschalten').disabled, true, 'die Zustimmung des Gratisweges ist eine andere, der Haken muss neu gesetzt werden')
assert.equal(fx.text(fx.finde(root, '[data-meldung="ansicht"]')), text)
assert.equal(fx.finde(root, '.tm-rechtstext'), null)
}))
test('409 fassung_veraltet: die Seite zeigt nach dem Neuladen die neue Fassung mit leeren Haken', mitDom({}, async (fx) => {
const neu = oeffentlicheAnsicht('zeiten')
neu.consent = { ...neu.consent, version: '2099-01-01', items: neu.consent.items.map((item, i) => (i === 0 ? { ...item, text: 'Neuer Wortlaut des ersten Hakens.' } : item)) }
fx.antworten.push(
liste([projekt()], angebot(false, 'kein_abo')),
fehler(409, 'Die Erklärungen wurden inzwischen geändert. Bitte lies sie noch einmal und stimme neu zu.', 'fassung_veraltet'),
liste([projekt()], angebot(false, 'kein_abo'), { features: { zeiten: neu } }),
{ status: 200, body: { url: STRIPE } })
const root = await oeffne(fx)
await alleAnhaken(fx, root)
await fx.klick(knopf(fx, root, 'bestellen'))
assert.equal(fx.text(fx.alle(root, '.tm-haken span')[0]), 'Neuer Wortlaut des ersten Hakens.')
assert.equal(haken(fx, root).every((feld) => !feld.checked), true)
assert.match(fx.text(fx.finde(root, '[data-meldung="ansicht"]')), /inzwischen geändert/)
await alleAnhaken(fx, root)
await fx.klick(knopf(fx, root, 'bestellen'))
assert.equal(posts(fx).at(-1).body.zustimmung.version, '2099-01-01', 'gesendet wird die Fassung, die die Seite zeigt')
}))
test('Abo noch nicht aktiv (R20): Hinweis „sobald dein Abo aktiv ist“, kein Knopf, kein Haken; Abo nicht lesbar: Hinweis mit „Noch einmal versuchen“', mitDom({}, async (fx) => {
fx.antworten.push(liste([projekt()], angebot(false, 'nicht_aktiv')))
let root = await oeffne(fx)
assert.match(fx.text(root), /einschaltbar, sobald dein Abo aktiv ist/)
assert.equal(fx.alle(root, 'button').length, 0)
assert.equal(haken(fx, root).length, 0)
assert.doesNotMatch(fx.text(root), /30 €/)
fx.antworten.push(liste([projekt()], angebot(null, null)), liste([projekt()], angebot(true)))
root = await oeffne(fx)
assert.match(fx.text(root), /Gerade nicht prüfbar – bitte später erneut/)
assert.equal(haken(fx, root).length, 0)
const nochmal = fx.alle(root, 'button').find((b) => fx.text(b) === 'Noch einmal versuchen')
assert.ok(nochmal)
await fx.klick(nochmal)
assert.ok(knopf(fx, root, 'einschalten'), 'nach dem erneuten Versuch der Einschaltweg')
}))
test('Kundenansicht: Einschalten/Bestellen gesperrt mit Hinweis', mitDom({}, async (fx) => {
fx.window.KbView = { id: () => 'ansicht-1' }
fx.antworten.push(liste([projekt()], angebot(true)))
let root = await oeffne(fx)
await alleAnhaken(fx, root)
assert.equal(knopf(fx, root, 'einschalten').disabled, true, 'auch mit Haken')
assert.equal(fx.text(fx.finde(root, '[data-hinweis="bestellen"]')), 'In der Kundenansicht kann nicht eingeschaltet werden.')
await fx.klick(knopf(fx, root, 'einschalten'))
fx.antworten.push(liste([projekt()], angebot(false, 'kein_abo')))
root = await oeffne(fx)
await alleAnhaken(fx, root)
assert.equal(knopf(fx, root, 'bestellen').disabled, true)
assert.equal(fx.text(fx.finde(root, '[data-hinweis="bestellen"]')), 'In der Kundenansicht kann nicht bestellt werden.')
await fx.klick(knopf(fx, root, 'bestellen'))
// Behalten und Zahlung fortsetzen in der Karte
fx.antworten.push(liste([projekt('p1', 'active', { aboEndeAt: '2026-10-01T10:00:00.000Z' }, { zeitenLeer: false })], angebot(false, 'kein_abo')))
root = await oeffne(fx)
assert.equal(knopf(fx, root, 'behalten').disabled, true)
assert.match(fx.text(root), /In der Kundenansicht kann nicht bestellt werden/)
fx.antworten.push(liste([projekt('p1', 'ordered', { unlockedBy: null })], angebot(false, 'kein_abo')))
root = await oeffne(fx)
assert.equal(knopf(fx, root, 'fortsetzen').disabled, true)
assert.equal(posts(fx).length, 0, 'in der Kundenansicht geht keine Anfrage hinaus')
// ohne Kundenansicht ist nichts gesperrt
fx.window.KbView = { id: () => '' }
fx.antworten.push(liste([projekt()], angebot(true)))
root = await oeffne(fx)
await alleAnhaken(fx, root)
assert.equal(knopf(fx, root, 'einschalten').disabled, false)
}))
// ---------------------------------------------------------------------------------------------------------------- Rückkehr
test('Rückkehr #zeiten?bestellt=1 und ?behalten=1 zeigt Danke-Karte', mitDom({}, async (fx) => {
// Bestellung: der Webhook hat die Zahlung noch nicht gebucht (ordered)
fx.antworten.push(liste([projekt('p1'), projekt('p2', 'ordered', { unlockedBy: null })], angebot(false, 'kein_abo')))
let root = await oeffne(fx, { projectId: '', hash: '#zeiten?bestellt=1&projekt=p2' })
let karte = fx.finde(root, '.zt-status')
assert.equal(karte.getAttribute('data-state'), 'ordered')
assert.equal(fx.text(fx.finde(karte, '[data-danke="bestellt"]')), 'Danke! Sobald die Zahlung da ist, legen wir los.')
assert.equal(fx.window.location.hash, '#zeiten', 'bestellt=1 und projekt stehen nicht mehr in der Adresse')
assert.equal(fx.finde(root, '#zeiten-project').value, 'p2', 'das Projekt der Zahlung ist gewählt')
// nächster Besuch ohne Flag: kein Dank mehr
fx.antworten.push(liste([projekt('p1'), projekt('p2', 'ordered', { unlockedBy: null })], angebot(false, 'kein_abo')))
root = await oeffne(fx, { projectId: '' })
assert.equal(fx.finde(root, '[data-danke]'), null)
// Bestellung schon gebucht (setup)
fx.antworten.push(liste([projekt('p1', 'setup', { unlockedBy: 'kauf', einbau: 'angestossen' }, { zeitenLeer: false })], angebot(false, 'kein_abo')))
root = await oeffne(fx, { hash: '#zeiten?bestellt=1&projekt=p1' })
assert.equal(fx.text(fx.finde(root, '[data-danke="bestellt"]')), 'Danke! Deine Zahlung ist bei uns angekommen.')
// Behalten: die Zahlung ist unterwegs (aboEndeAt noch gesetzt) bzw. gebucht (aboEndeAt leer, unlockedBy kauf)
fx.antworten.push(liste([projekt('p1', 'active', { aboEndeAt: '2026-10-01T10:00:00.000Z' }, { zeitenLeer: false })], angebot(false, 'kein_abo')))
root = await oeffne(fx, { hash: '#zeiten?behalten=1&projekt=p1' })
assert.match(fx.text(fx.finde(root, '[data-danke="behalten"]')), /^Danke! Sobald die Zahlung da ist, behältst du Öffnungszeiten live\./)
fx.antworten.push(liste([projekt('p1', 'active', { unlockedBy: 'kauf' }, { zeitenLeer: false })], angebot(false, 'kein_abo')))
root = await oeffne(fx, { hash: '#zeiten?behalten=1&projekt=p1' })
assert.equal(fx.text(fx.finde(root, '[data-danke="behalten"]')), 'Danke! Du behältst Öffnungszeiten live.')
assert.equal(fx.window.location.hash, '#zeiten')
// Abbruch in Stripe (#zeiten?projekt=p2): das Projekt ist gewählt, kein Dank
fx.antworten.push(liste([projekt('p1'), projekt('p2')], angebot(false, 'kein_abo')))
root = await oeffne(fx, { projectId: '', hash: '#zeiten?projekt=p2' })
assert.equal(fx.finde(root, '[data-danke]'), null)
assert.equal(fx.finde(root, '#zeiten-project').value, 'p2')
// ein Projekt, das es nicht (mehr) gibt: kein Dank für ein anderes
fx.antworten.push(liste([projekt('p1', 'ordered', { unlockedBy: null })], angebot(false, 'kein_abo')))
root = await oeffne(fx, { projectId: '', hash: '#zeiten?bestellt=1&projekt=unbekannt' })
assert.equal(fx.finde(root, '[data-danke]'), null)
// andere Hashes werden nicht angefasst
fx.antworten.push(liste([projekt('p1')], angebot(true)))
await oeffne(fx, { hash: '#zeitenplan' })
assert.equal(fx.window.location.hash, '#zeitenplan')
}))
// ---------------------------------------------------------------------------------------------------------------- Behalten
const ABO_ENDE = { aboEndeAt: '2026-10-01T10:00:00.000Z', einbau: 'angestossen' }
test('Behalten: Hinweis mit Datum in der Karte, Behalten-Seite mit Belehrung, POST /api/funktionen/zeiten/behalten, „Zurück“ zeigt die Karte wieder', mitDom({}, async (fx) => {
fx.antworten.push(liste([projekt('p1', 'active', ABO_ENDE, { zeitenLeer: false })], angebot(false, 'kein_abo')), { status: 200, body: { url: STRIPE } })
const root = await oeffne(fx)
const karte = fx.finde(root, '.zt-status')
assert.equal(karte.getAttribute('data-state'), 'active')
assert.match(fx.text(fx.finde(karte, '[data-hinweis="behalten"]')), /Dein Wartungs-Abo ist beendet – behalte die Funktion bis 31\.10\.2026 für einmalig 30 €\./)
const editor = fx.finde(root, '.zt-editor')
assert.ok(editor, 'der Platz des Editors steht unter der Karte')
await fx.klick(knopf(fx, root, 'behalten'))
const seite = fx.finde(root, '[data-variante="behalten"]')
assert.ok(seite)
assert.equal(fx.finde(root, '.zt-status'), null, 'die Seite ersetzt die Karte')
assert.equal(editor.hidden, true, 'der Editor bleibt, nur versteckt (nichts geht verloren)')
assert.equal(fx.finde(root, '.tm-rechtstext').textContent, BELEHRUNG)
assert.match(fx.text(seite), /ohne neuen Einbau/)
assert.equal(fx.alle(root, '.tm-bestellen-preis h4').map((el) => fx.text(el))[0], 'Preis')
assert.doesNotMatch(fx.text(fx.finde(root, '.tm-bestellen-preis')), /Einrichtung:/, 'beim Behalten gibt es keinen neuen Einbau')
assert.equal(fx.text(knopf(fx, root, 'behalten')), KNOPF_BEZAHLT)
// zurück: die Karte ist wieder da, der Editor sichtbar
await fx.klick(knopf(fx, root, 'zurueck'))
assert.ok(fx.finde(root, '.zt-status'))
assert.equal(editor.hidden, false)
assert.equal(fx.finde(root, '.zt-editor'), editor, 'derselbe Editorplatz, nicht neu gebaut')
await fx.klick(knopf(fx, root, 'behalten'))
await alleAnhaken(fx, root)
await fx.klick(knopf(fx, root, 'behalten'))
assert.deepEqual(posts(fx), [{
method: 'POST',
url: '/api/funktionen/zeiten/behalten',
body: { projectId: 'p1', zustimmung: { version: REGISTER.consent.version, haken: ['leistung', 'beginn'] } },
}])
assert.equal(fx.window.location.href, STRIPE)
}))
test('Behalten: Frist ist der Berliner Tag des Abo-Endes plus 30 Tage; läuft das Abo wieder, gibt es keinen Behalten-Knopf', mitDom({}, async (fx) => {
assert.equal(behaltenFrist('2026-10-01T10:00:00.000Z'), '31.10.2026')
assert.equal(behaltenFrist('2026-10-01T23:30:00.000Z'), '01.11.2026', 'in Berlin schon der 2. Oktober')
assert.equal(behaltenFrist('2026-10-01T10:00:00.000+00:00'), '31.10.2026')
assert.equal(behaltenFrist('2026-03-01T10:00:00.000Z'), '31.03.2026')
assert.equal(behaltenFrist('kein-datum'), '')
assert.equal(behaltenFrist(null), '')
assert.equal(tagText('2026-11-04'), '04.11.2026')
assert.equal(tagText('4.11.2026'), '')
fx.antworten.push(liste([projekt('p1', 'active', ABO_ENDE, { zeitenLeer: false })], angebot(true)))
let root = await oeffne(fx)
assert.equal(knopf(fx, root, 'behalten'), null, 'das Abo läuft wieder: nichts zu bezahlen')
assert.doesNotMatch(fx.text(root), /behalte die Funktion/)
fx.antworten.push(liste([projekt('p1', 'active', ABO_ENDE, { zeitenLeer: false })], angebot(null, null)))
root = await oeffne(fx)
assert.equal(knopf(fx, root, 'behalten'), null, 'Abo nicht lesbar: kein Bezahlknopf auf Verdacht')
}))
test('Behalten aus der Kachel (dataset.wunschWeg): die Seite öffnet direkt, das Projekt der Kachel ist gewählt; ein Weg ohne Anlass wird ignoriert', mitDom({}, async (fx) => {
fx.antworten.push(liste([projekt('p1'), projekt('p2', 'active', ABO_ENDE, { zeitenLeer: false })], angebot(false, 'kein_abo')))
const root = await oeffne(fx, { projectId: '', dataset: { wunschProjekt: 'p2', wunschWeg: 'behalten' } })
assert.ok(fx.finde(root, '[data-variante="behalten"]'))
assert.equal(fx.finde(root, '#zeiten-project').value, 'p2')
assert.equal(root.dataset.wunschProjekt, undefined, 'die Wünsche werden einmal gelesen')
assert.equal(root.dataset.wunschWeg, undefined)
// kein Abo-Ende: kein Behalten, sondern die Karte
fx.antworten.push(liste([projekt('p1', 'active', {}, { zeitenLeer: false })], angebot(false, 'kein_abo')))
const ohne = await oeffne(fx, { dataset: { wunschWeg: 'behalten' } })
assert.equal(fx.finde(ohne, '[data-variante]'), null)
assert.ok(fx.finde(ohne, '.zt-status'))
}))
test('Behalten nach dem Abschalten (abo_ende) mit Daten: „Für 30 € wieder einschalten“; ohne Daten, nach der Löschfrist oder durch das Team nicht', mitDom({}, async (fx) => {
const abgeschaltet = (feature, extra) => projekt('p1', 'disabled', { disabledReason: 'abo_ende', disabledAt: '2026-10-04T10:00:00.000Z', ...feature }, extra)
fx.antworten.push(liste([abgeschaltet({ loeschungAm: '2999-01-01' }, { zeitenLeer: false })], angebot(false, 'kein_abo')), { status: 200, body: { url: STRIPE } })
let root = await oeffne(fx)
const karte = fx.finde(root, '.zt-status')
assert.equal(karte.getAttribute('data-state'), 'disabled')
assert.match(fx.text(karte), /Öffnungszeiten live ist abgeschaltet, weil dein Wartungs-Abo beendet ist/)
assert.match(fx.text(fx.finde(karte, '[data-hinweis="wieder-einschalten"]')), /Deine Eingaben bleiben bis 01\.01\.2999 gespeichert\. Für einmalig 30 € schaltest du die Funktion wieder ein\./)
assert.equal(fx.text(knopf(fx, root, 'behalten')), 'Für 30 € wieder einschalten')
assert.ok(fx.finde(root, '.zt-editor'), 'die Eingaben bleiben lesbar (Platz des Editors)')
await fx.klick(knopf(fx, root, 'behalten'))
assert.match(fx.text(fx.finde(root, '.zt-bestellen > p')), /abgeschaltet, deine Eingaben sind noch gespeichert\. Für einmalig 30 € schaltest du die Funktion wieder ein\./)
await alleAnhaken(fx, root)
await fx.klick(knopf(fx, root, 'behalten'))
assert.equal(posts(fx)[0].url, '/api/funktionen/zeiten/behalten')
for (const [name, p] of [
['keine Daten', abgeschaltet({ loeschungAm: '2999-01-01' }, { zeitenLeer: true })],
['Löschfrist vorbei', abgeschaltet({ loeschungAm: '2000-01-01' }, { zeitenLeer: false })],
['vom Team', projekt('p1', 'disabled', { disabledReason: 'team' }, { zeitenLeer: false })],
]) {
fx.antworten.push(liste([p], angebot(false, 'kein_abo')))
root = await oeffne(fx)
assert.equal(knopf(fx, root, 'behalten'), null, name)
}
fx.antworten.push(liste([projekt('p1', 'disabled', { disabledReason: 'team' }, { zeitenLeer: false })], angebot(false, 'kein_abo')))
root = await oeffne(fx)
assert.match(fx.text(root), /Öffnungszeiten live wurde für diese Website abgeschaltet\. Schreib uns im Chat, wenn du die Funktion wieder möchtest\./)
// das Abo läuft wieder: der Abgleich schaltet selbst ein
fx.antworten.push(liste([abgeschaltet({ loeschungAm: '2999-01-01' }, { zeitenLeer: false })], angebot(true)))
root = await oeffne(fx)
assert.equal(knopf(fx, root, 'behalten'), null)
assert.match(fx.text(root), /Dein Wartungs-Abo läuft wieder/)
}))
test('Behalten: 409 „Abo läuft wieder“ zeichnet die Ansicht nach der neuen Liste (kein Behalten mehr) mit der Meldung des Servers', mitDom({}, async (fx) => {
const text = 'Dein Wartungs-Abo läuft wieder – Öffnungszeiten live ist darin enthalten.'
fx.antworten.push(
liste([projekt('p1', 'active', ABO_ENDE, { zeitenLeer: false })], angebot(false, 'kein_abo')),
fehler(409, text),
liste([projekt('p1', 'active', {}, { zeitenLeer: false })], angebot(true)))
const root = await oeffne(fx, { dataset: { wunschWeg: 'behalten' } })
await alleAnhaken(fx, root)
await fx.klick(knopf(fx, root, 'behalten'))
assert.equal(fx.text(fx.finde(root, '[data-meldung="ansicht"]')), text)
assert.equal(fx.finde(root, '[data-variante]'), null)
assert.equal(knopf(fx, root, 'behalten'), null)
assert.ok(fx.finde(root, '.zt-status'))
}))
test('renderZeitenBestellen im Modus behalten: Abo läuft wieder ⇒ Hinweis statt Seite; Abo nicht lesbar ⇒ „Gerade nicht prüfbar“', mitDom({}, (fx) => {
const ctx = { projectId: 'p1', features: { zeiten: oeffentlicheAnsicht('zeiten') }, ustHinweis: config.ustHinweis, kundenansicht: false }
const wurzel = h('div')
renderZeitenBestellen(wurzel, { ctx, angebot: angebot(true), projekt: projekt('p1', 'active', ABO_ENDE), modus: 'behalten' })
assert.match(fx.text(wurzel), /Dein Wartungs-Abo läuft wieder/)
assert.equal(fx.alle(wurzel, 'input').length, 0)
renderZeitenBestellen(wurzel, { ctx, angebot: angebot(null), projekt: projekt('p1', 'active', ABO_ENDE), modus: 'behalten' })
assert.match(fx.text(wurzel), /Gerade nicht prüfbar – bitte später erneut/)
}))
// ---------------------------------------------------------------------------------------------------------------- Statuskarte
test('Statuskarte: Zahlung offen, In Einrichtung (je nach einbau), Aktiv, Abgeschaltet', mitDom({}, (fx) => {
const karte = (state, feature = {}, extra = {}, an = angebot(true)) => zeitenStatusKarte({ projekt: projekt('p1', state, feature, extra), feature: zeitenFeature(state, feature), angebot: an, kundenansicht: false })
const ordered = karte('ordered', { unlockedBy: null })
assert.equal(ordered.getAttribute('data-state'), 'ordered')
assert.match(fx.text(ordered), /Zahlung offen/)
assert.match(fx.text(ordered), /Zahlung wird bestätigt …/)
assert.match(fx.text(ordered), /SEPA/)
assert.ok(fx.finde(ordered, '[data-aktion="fortsetzen"]'))
const angestossen = karte('setup', { einbau: 'angestossen' }, { zeitenLeer: false })
assert.match(fx.text(angestossen), /In Einrichtung/)
assert.match(fx.text(angestossen), /Hinweis- und Kontakt-Leiste erscheinen nach dem nächsten Veröffentlichen deiner Website \(meist in wenigen Minuten\)\./)
assert.equal(fx.finde(angestossen, '[data-hinweis="zeiten-eintragen"]'), null)
assert.equal(fx.finde(angestossen, '[data-aktion="fortsetzen"]'), null)
const manuell = karte('setup', { einbau: 'manuell', ticketWoid: '4711', orderedAt: '2026-10-04T10:00:00.000Z' }, { zeitenLeer: true })
assert.match(fx.text(manuell), /Unser Team baut die Funktion ein\./)
assert.doesNotMatch(fx.text(manuell), /nach dem nächsten Veröffentlichen/)
assert.match(fx.text(manuell), /Ticketnummer#4711/)
assert.match(fx.text(manuell), /Bestellt am04\.10\.2026/)
assert.match(fx.text(fx.finde(manuell, '[data-hinweis="zeiten-eintragen"]')), /Trag jetzt deine Zeiten ein – erst dann kann das Team die Tabelle einbauen\./)
const rechnung = zeitenStatusKarte({ projekt: projekt('p1', 'setup'), feature: zeitenFeature('setup', { invoiceUrl: 'https://invoice.stripe.test/i/1' }), angebot: angebot(false, 'kein_abo') })
assert.equal(fx.finde(rechnung, 'a').getAttribute('href'), 'https://invoice.stripe.test/i/1')
const boese = zeitenStatusKarte({ projekt: projekt('p1', 'setup'), feature: zeitenFeature('setup', { invoiceUrl: 'javascript:alert(1)' }), angebot: angebot(false, 'kein_abo') })
assert.equal(fx.finde(boese, 'a'), null, 'unsichere Adressen werden nie Links')
const aktiv = karte('active', {}, { zeitenLeer: false })
assert.match(fx.text(aktiv), /Aktiv/)
assert.match(fx.text(aktiv), /Öffnungszeiten live ist aktiv/)
assert.equal(fx.finde(aktiv, '[data-hinweis="zeiten-eintragen"]'), null)
assert.match(fx.text(fx.finde(karte('active', {}, { zeitenLeer: true }), '[data-hinweis="zeiten-eintragen"]')), /Trag jetzt deine Zeiten ein\./)
const aus = karte('disabled', { disabledReason: 'team' }, { zeitenLeer: false })
assert.match(fx.text(aus), /Abgeschaltet/)
assert.match(fx.text(aus), /Schreib uns im Chat/)
assert.ok(fx.finde(aus, '[data-view="chat"]'))
}))
test('Statuskarte: Zahlung fortsetzen führt zur offenen Zahlungsseite; ohne offene Sitzung zeigt die Ansicht wieder die Bestellseite', mitDom({}, async (fx) => {
fx.antworten.push(liste([projekt('p1', 'ordered', { unlockedBy: null })], angebot(false, 'kein_abo')), { status: 200, body: { url: STRIPE } })
let root = await oeffne(fx)
await fx.klick(knopf(fx, root, 'fortsetzen'))
assert.deepEqual(posts(fx), [{ method: 'POST', url: '/api/funktionen/zeiten/checkout', body: { projectId: 'p1', fortsetzen: true } }])
assert.equal(fx.window.location.href, STRIPE)
// keine offene Zahlungsseite mehr (400 zustimmung_fehlt): die Bestellseite mit der Meldung des Servers; Bestellen legt eine neue Sitzung an
fx.window.location.href = 'http://localhost/'
fx.antworten.push(
liste([projekt('p1', 'ordered', { unlockedBy: null })], angebot(false, 'kein_abo')),
{ status: 400, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } },
{ status: 200, body: { url: STRIPE } })
root = await oeffne(fx)
await fx.klick(knopf(fx, root, 'fortsetzen'))
assert.ok(fx.finde(root, '[data-variante="bestellen"]'))
assert.match(fx.text(fx.finde(root, '[data-meldung="ansicht"]')), /stimme der Erklärung zu/)
await alleAnhaken(fx, root)
await fx.klick(knopf(fx, root, 'bestellen'))
assert.equal(fx.window.location.href, STRIPE)
// andere Fehler stehen am Knopf, der Knopf ist wieder frei
fx.antworten.push(liste([projekt('p1', 'ordered', { unlockedBy: null })], angebot(false, 'kein_abo')), fehler(500, 'Interner Serverfehler'))
root = await oeffne(fx)
await fx.klick(knopf(fx, root, 'fortsetzen'))
assert.equal(fx.text(fx.finde(root, '[data-meldung="karte"]')), 'Interner Serverfehler')
assert.equal(knopf(fx, root, 'fortsetzen').disabled, false)
}))
// ---------------------------------------------------------------------------------------------------------------- Ansicht
test('Projektauswahl: bei mehreren Websites eine Auswahl; der Wunsch der Kachel gilt einmal; ein Wechsel zeichnet das andere Projekt', mitDom({}, async (fx) => {
const projekte = [projekt('p1', 'active', {}, { zeitenLeer: false }), projekt('p2'), projekt('p3', 'setup')]
fx.antworten.push(liste(projekte, angebot(true)))
let root = await oeffne(fx, { projectId: '', dataset: { wunschProjekt: 'p2' } })
const auswahl = fx.finde(root, '#zeiten-project')
assert.deepEqual(fx.alle(auswahl, 'option').map((o) => o.getAttribute('value')), ['p1', 'p2', 'p3'])
assert.equal(auswahl.value, 'p2')
assert.ok(fx.finde(root, '[data-variante="einschalten"]'), 'p2 ist aus')
await fx.setze(auswahl, 'p3')
assert.equal(fx.finde(root, '[data-variante]'), null)
assert.equal(fx.finde(root, '.zt-status').getAttribute('data-state'), 'setup')
await fx.setze(auswahl, 'p1')
assert.equal(fx.finde(root, '.zt-status').getAttribute('data-state'), 'active')
// ohne Wunsch: die gemerkte Auswahl, bei neuer Sitzung aktiv vor Einrichtung vor aus
fx.antworten.push(liste([projekt('p1'), projekt('p2', 'setup'), projekt('p3', 'active', {}, { zeitenLeer: false })], angebot(true)))
root = await oeffne(fx, { projectId: '', dataset: {} })
assert.equal(fx.finde(root, '#zeiten-project').value, 'p1', 'die Auswahl von eben bleibt')
fx.antworten.push(liste([projekt('p9'), projekt('p8', 'setup'), projekt('p7', 'active', {}, { zeitenLeer: false })], angebot(true)))
root = await oeffne(fx, { projectId: '' })
assert.equal(fx.finde(root, '#zeiten-project').value, 'p7', 'ein unbekanntes Projekt: das aktive')
// genau eine Website: keine Auswahl
fx.antworten.push(liste([projekt('p1')], angebot(true)))
root = await oeffne(fx)
assert.equal(fx.finde(root, '#zeiten-project'), null)
}))
test('Ansicht: nicht verfügbar und keine Website', mitDom({}, async (fx) => {
fx.antworten.push(liste([projekt()], { verfuegbar: false }))
let root = await oeffne(fx)
assert.match(fx.text(root), /Öffnungszeiten live ist noch nicht verfügbar/)
assert.equal(fx.alle(root, 'input,button').length, 0)
fx.antworten.push(liste([projekt()], undefined))
root = await oeffne(fx)
assert.match(fx.text(root), /noch nicht verfügbar/)
fx.antworten.push(liste([], angebot(true)))
root = await oeffne(fx)
assert.match(fx.text(root), /Noch keine Website-Projekte zugewiesen/)
// ein Fehler der Liste kommt bei app.js an (zeigt ihn in der Ansicht)
fx.antworten.push(fehler(500, 'Die Funktionen konnten gerade nicht geladen werden. Bitte lade die Seite gleich noch einmal.'))
await assert.rejects(oeffne(fx), /konnten gerade nicht geladen werden/)
}))
test('Ansicht: der Editorplatz steht nur in setup, active und (mit Daten) disabled; nie in off und ordered', mitDom({}, async (fx) => {
const platz = async (p) => {
fx.antworten.push(liste([p], angebot(false, 'kein_abo')))
return fx.finde(await oeffne(fx), '.zt-editor')
}
assert.equal(await platz(projekt('p1', 'off')), null)
assert.equal(await platz(projekt('p1', 'ordered', { unlockedBy: null })), null)
assert.ok(await platz(projekt('p1', 'setup')))
assert.ok(await platz(projekt('p1', 'active')))
assert.ok(await platz(projekt('p1', 'disabled', { disabledReason: 'abo_ende' }, { zeitenLeer: false })))
assert.equal(await platz(projekt('p1', 'disabled', { disabledReason: 'team' }, { zeitenLeer: true })), null)
}))
test('app.js ruft initZeiten bei jedem Wechsel in die Ansicht: gleiches Projekt und gleicher Stand bauen den Editorplatz nicht neu auf, ein neuer Stand oder die Rückkehr von Stripe schon', mitDom({}, async (fx) => {
const aktiv = () => liste([projekt('p1', 'active', {}, { zeitenLeer: false })], angebot(true))
fx.antworten.push(aktiv())
const root = await oeffne(fx)
let editor = fx.finde(root, '.zt-editor')
const eingabe = h('input', { type: 'text' })
eingabe.value = 'ungespeichert'
editor.append(eingabe)
// zweiter Besuch: nur die Liste wird geholt, nichts neu gezeichnet
fx.antworten.push(aktiv())
await initZeiten(root, { projectId: 'p1' })
assert.equal(fx.finde(root, '.zt-editor'), editor, 'derselbe Editorplatz')
assert.equal(editor.firstChild, eingabe)
assert.equal(eingabe.value, 'ungespeichert')
assert.equal(fx.fetchLog.length, 2)
assert.equal(fx.fetchLog.every((a) => a.method === 'GET' && a.url === '/api/funktionen'), true, 'keine Zusatzabfrage')
// die Kachel schickt dasselbe Projekt: ebenfalls kein Neuaufbau
root.dataset.wunschProjekt = 'p1'
fx.antworten.push(aktiv())
await initZeiten(root)
assert.equal(fx.finde(root, '.zt-editor'), editor)
// die Abo-Lage hat sich geändert (Behalten gibt es nur ohne Abo): die Karte muss stimmen, also neu aufgebaut
fx.antworten.push(liste([projekt('p1', 'active', {}, { zeitenLeer: false })], angebot(false, 'kein_abo')))
await initZeiten(root)
assert.notEqual(fx.finde(root, '.zt-editor'), editor)
editor = fx.finde(root, '.zt-editor')
// „Für 30 € behalten“ aus der Kachel: Behalten-Seite; der Stand hat sich geändert (aboEndeAt), also neu aufgebaut
fx.antworten.push(liste([projekt('p1', 'active', ABO_ENDE, { zeitenLeer: false })], angebot(false, 'kein_abo')))
root.dataset.wunschWeg = 'behalten'
await initZeiten(root)
assert.notEqual(fx.finde(root, '.zt-editor'), editor)
assert.ok(fx.finde(root, '[data-variante="behalten"]'))
// gleicher neuer Stand noch einmal mit Weg: der Editor bleibt, die Seite öffnet
const editor2 = fx.finde(root, '.zt-editor')
await fx.klick(knopf(fx, root, 'zurueck'))
fx.antworten.push(liste([projekt('p1', 'active', ABO_ENDE, { zeitenLeer: false })], angebot(false, 'kein_abo')))
root.dataset.wunschWeg = 'behalten'
await initZeiten(root)
assert.equal(fx.finde(root, '.zt-editor'), editor2, 'der Editor bleibt')
assert.equal(editor2.hidden, true)
assert.ok(fx.finde(root, '[data-variante="behalten"]'))
// Rückkehr von Stripe: immer neu
fx.antworten.push(liste([projekt('p1', 'active', ABO_ENDE, { zeitenLeer: false })], angebot(false, 'kein_abo')))
fx.window.location.hash = '#zeiten?behalten=1&projekt=p1'
await initZeiten(root)
assert.notEqual(fx.finde(root, '.zt-editor'), editor2)
assert.ok(fx.finde(root, '[data-danke="behalten"]'))
}))
test('Fremdtext (Projektname, Meldung des Servers) kommt nur als Text in die Seite', mitDom({}, async (fx) => {
const bose = '<img src=x onerror=alert(1)>'
fx.antworten.push(
liste([{ ...projekt('p1'), projectName: bose }, projekt('p2')], angebot(true)),
fehler(409, bose),
liste([{ ...projekt('p1'), projectName: bose }, projekt('p2')], angebot(true)))
const root = await oeffne(fx)
assert.equal(fx.alle(root, 'img').length, 0)
assert.ok(fx.text(fx.finde(root, '#zeiten-project')).includes(bose))
await alleAnhaken(fx, root)
await fx.klick(knopf(fx, root, 'einschalten'))
assert.equal(fx.alle(root, 'img').length, 0)
assert.equal(fx.text(fx.finde(root, '[data-meldung="ansicht"]')), bose)
}))
test('veraltete Antwort: ein Wechsel der Website während des Neuladens überschreibt die neuere Ansicht nicht', mitDom({}, async (fx) => {
const echtes = globalThis.fetch
let freigeben
const wartet = new Promise((resolve) => { freigeben = resolve })
let listen = 0
// die zweite Liste (nach dem 409) kommt erst nach der Freigabe
globalThis.fetch = async (url, optionen) => {
if (String(url) === '/api/funktionen' && (optionen?.method ?? 'GET') === 'GET' && ++listen === 2) await wartet
return echtes(url, optionen)
}
fx.antworten.push(
liste([projekt('p1'), projekt('p2')], angebot(true)),
fehler(409, 'Stand geändert'),
liste([projekt('p1'), projekt('p2')], angebot(false, 'kein_abo')))
const root = await oeffne(fx, { projectId: 'p1' })
await alleAnhaken(fx, root)
const klick = fx.klick(knopf(fx, root, 'einschalten'))
await fx.ruhe()
await fx.setze(fx.finde(root, '#zeiten-project'), 'p2') // der Kunde wechselt die Website, während die Liste noch lädt
freigeben()
await klick
assert.ok(fx.finde(root, '[data-variante="einschalten"]'), 'die späte Antwort zeichnet nichts mehr')
assert.equal(fx.finde(root, '[data-meldung="ansicht"]'), null)
assert.equal(fx.finde(root, '#zeiten-project').value, 'p2')
}))
// ---------------------------------------------------------------------------------------------------------------- Quelltext
const ohneKommentare = (quelle) => quelle.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '')
test('Portal: Ansicht #zeiten ist in app.js und dashboard.html verdrahtet', () => {
const app = lesen('public/app.js')
assert.match(app, /VIEW_HASHES = \{[^}]*termine: 'termine', zeiten: 'zeiten'/)
assert.match(app, /APP_PARENT = \{ news: 'funktionen', analyse: 'funktionen', termine: 'funktionen', zeiten: 'funktionen'/)
assert.match(app, /FEATURE_LABELS = \{[^}]*termine: 'Terminplaner', zeiten: 'Öffnungszeiten'/)
assert.match(app, /zeiten: \(\) => import\('\/zeiten\.js'\)\.then\(\(m\) => m\.initZeiten\(document\.getElementById\('zeiten-body'\)\)\)/)
const html = lesen('public/dashboard.html')
assert.match(html, /<section class="kb-view" id="view-zeiten">[\s\S]*<h1>Öffnungszeiten live<\/h1>[\s\S]*<div id="zeiten-body">/)
assert.match(html, /<link rel="stylesheet" href="\/zeiten\.css">/)
assert.doesNotMatch(html, /id="view-zeiten"[^>]*aria-live/)
assert.doesNotMatch(html.slice(html.indexOf('id="zeiten-body"') - 80, html.indexOf('id="zeiten-body"') + 80), /aria-live/)
// die bestehenden Ansichten bleiben, wie sie waren
assert.match(app, /termine: \(\) => import\('\/termine\.js'\)\.then\(\(m\) => m\.initTermine\(document\.getElementById\('termine-body'\)\)\)/)
})
test('Quelltext: zeiten.js und zeiten-bestellen.js importieren nur relativ und nur das Nötige; kein HTML aus Daten, kein Speicher, Du-Form', () => {
const importe = (datei) => [...ohneKommentare(lesen(`public/${datei}`)).matchAll(/\bfrom\s+'([^']+)'/g)].map((m) => m[1])
assert.deepEqual(importe('zeiten.js'), ['./termine-dom.js', './zeiten-bestellen.js'])
assert.deepEqual(importe('zeiten-bestellen.js'), ['./funktion-bestellen.js', './termine-dom.js'])
const dateien = readdirSync(new URL('../public/', import.meta.url)).filter((f) => /^zeiten.*\.js$/.test(f))
for (const datei of ['zeiten.js', 'zeiten-bestellen.js']) {
assert.ok(dateien.includes(datei))
const quelle = ohneKommentare(lesen(`public/${datei}`))
assert.doesNotMatch(quelle, /innerHTML|insertAdjacentHTML|outerHTML|document\.write/, `${datei}: Daten nie als HTML`)
assert.doesNotMatch(quelle, /\bconfirm\(|\balert\(|\bprompt\(/, `${datei}: Rückfragen stehen in der Seite`)
assert.doesNotMatch(quelle, /\bSie\b|\bIhr(e[nmrs]?)?\b/, `${datei}: Portaltexte in der Du-Form`)
assert.doesNotMatch(quelle, /localStorage|sessionStorage|document\.cookie/, `${datei}: kein Browserspeicher`)
assert.doesNotMatch(quelle, /24 Uhr|\bbrutto\b|inklusive (Mehr|Umsatz)steuer/i, `${datei}: Wortwahl`)
assert.doesNotMatch(quelle, /import\s*\(/, `${datei}: kein dynamischer Import`)
}
// der einzige Link der Bestellseite ist die Leistungsbeschreibung (und die Rechnung in der Karte), jeder geht durch sicherLink bzw. unterlagenLink
const bestellen = ohneKommentare(lesen('public/zeiten-bestellen.js'))
assert.deepEqual(bestellen.match(/href: [^,}]+/g), ['href: link', 'href: rechnung'])
assert.match(bestellen, /const link = unterlagenLink\(/)
assert.match(bestellen, /const rechnung = sicherLink\(/)
// keine termine*.js und kein funktion-bestellen.js angefasst: sie sind gepinnt
assert.doesNotMatch(bestellen, /\btermine-view\b/)
})
test('zeiten.css: die Klassen der Ansicht sind gestaltet, die Bausteine der Bestellseite gibt es in termine.css', () => {
const css = lesen('public/zeiten.css')
for (const klasse of ['zt-kopf', 'zt-editor', 'zt-bestellen', 'zt-status', 'zt-hinweis']) assert.match(css, new RegExp(`\\.${klasse}[\\s{,.:\\[>]`), `.${klasse} ist gestaltet`)
assert.match(css, /#view-zeiten \.fn-project/)
assert.match(css, /prefers-reduced-motion/)
assert.match(css, /min-height: 44px/, 'Tippflächen am Handy')
const termine = lesen('public/termine.css')
for (const klasse of ['tm-bestellen', 'tm-rechtstext', 'tm-statuskarte', 'tm-haken', 'tm-pille', 'tm-box', 'tm-meldung', 'tm-danke', 'tm-fakten', 'tm-info', 'tm-aktionen']) assert.match(termine, new RegExp(`\\.${klasse}[\\s{,.:\\[]`), `.${klasse} steht in termine.css`)
// jede zt-Klasse, die die Module setzen, ist gestaltet (tm-Klassen sind teils reine Haken der Struktur)
const quelle = lesen('public/zeiten.js') + lesen('public/zeiten-bestellen.js')
const gesetzt = new Set([...quelle.matchAll(/class: '([^']+)'/g)].flatMap((m) => m[1].split(/\s+/)).filter((k) => /^zt-/.test(k)))
assert.ok(gesetzt.size >= 4)
for (const klasse of gesetzt) assert.ok(css.includes(`.${klasse}`), `.${klasse} hat keine Gestaltung`)
})
+6 -2
View File
@@ -526,9 +526,13 @@ test('Angebot: nicht verfügbar ⇒ genau { verfuegbar: false }, das Abo wird ni
})
test('Angebot: inklusive true/false/null je nach Abo-Lage, Preis und Texte aus dem Register', async () => {
const erwartet = (inklusive, grund) => ({ verfuegbar: true, inklusive, grund, preis: '30 €', einschalten: FREI() })
// Ruling R32: die Belehrung der Bestellseite ist genau der Text, den der Haken belehrung der Zeiten auch in den Nachweis schreibt
const { featureHaken } = await import('../server/services/featureRegistry.js')
const belehrung = await featureHaken('zeiten').belehrung()
assert.match(belehrung, /Muster-Widerrufsformular/)
const erwartet = (inklusive, grund) => ({ verfuegbar: true, inklusive, grund, preis: '30 €', einschalten: FREI(), belehrung })
assert.deepEqual(await angebot(), erwartet(true, null))
assert.deepEqual(Object.keys(await angebot()), ['verfuegbar', 'inklusive', 'grund', 'preis', 'einschalten'])
assert.deepEqual(Object.keys(await angebot()), ['verfuegbar', 'inklusive', 'grund', 'preis', 'einschalten', 'belehrung'])
aw.seed('subscriptions', hostingDoc)
assert.deepEqual(await angebot(), erwartet(false, 'kein_abo'), 'nur Hosting')