diff --git a/public/funktion-bestellen.js b/public/funktion-bestellen.js
new file mode 100644
index 0000000..0d9c151
--- /dev/null
+++ b/public/funktion-bestellen.js
@@ -0,0 +1,321 @@
+/* Bestellseite und Statuskarten des Terminplaners im Kundenbereich (TP2, Task C8). Das Modul heißt bewusst nicht termine*.js: es ist die
+ einzige Datei der Ansicht mit href-Links (Unterlagen, Rechnung) und wird relativ importiert (der Fragebogen zeigt nach dem letzten Schritt
+ renderBestellen, die Ansicht #termine zeigt statusKarte). Jeder Link geht durch sicherLink, jede Navigation nur auf eine https-Adresse.
+ Es gibt keine Texte des Registers im Client: Knopf, Haken, Belehrung, Preis und Unterlagen kommen als `bestellung` aus
+ GET /api/termine/einstellungen (Server: bestellungAnsicht), und der Knopf zeigt genau bestellung.consent.knopf, denselben Text, den der
+ Server im Nachweis speichert. Fremdtext kommt nur über textContent in die Seite (h aus termine-dom.js), nie über innerHTML.
+ Zusammenfassung: zusammenfassung() aus termine-view.js. Checkout: POST /api/funktionen/termine/checkout (Server: routes/funktionen.js). */
+
+import { fehlerKarte, h, request } from './termine-dom.js'
+import { FRAGEBOGEN_SCHRITTE, zusammenfassung } from './termine-view.js'
+
+const CHECKOUT = '/api/funktionen/termine/checkout'
+const RECHTLICHES = '/rechtliches/'
+const KUNDENANSICHT = '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 DANKE = 'Danke! Sobald die Zahlung da ist, legen wir los.'
+const SCHRITT_TITEL = new Map(FRAGEBOGEN_SCHRITTE)
+
+/* ---------------------------------------------------------------- Links --- */
+// Leerraum (auch Tabulator und Zeilenumbruch, die ein Browser aus Adressen entfernt), Steuerzeichen und Rückwärtsschrägstrich
+const UNZULAESSIG = /[\s\u0000-\u001f\u007f-\u009f\\]/
+
+/** Eine echte https-Adresse mit Rechnername, ohne Zugangsdaten und ohne Leerraum. */
+function istHttps(url) {
+ if (typeof url !== 'string' || UNZULAESSIG.test(url) || !/^https:\/\/[^/]/i.test(url)) return false
+ try {
+ const adresse = new URL(url)
+ return adresse.protocol === 'https:' && adresse.hostname !== '' && adresse.username === '' && adresse.password === ''
+ } catch {
+ return false
+ }
+}
+
+/**
+ * Adresse, die als Link gesetzt werden darf: eine https-Adresse oder ein Pfad unter /rechtliches/ (die Rechtsunterlagen des Portals), sonst ''.
+ * Abgewiesen: javascript:, data:, http:, //fremd.example, Leerraum und Steuerzeichen, Rückwärtsschrägstriche, Zugangsdaten in der Adresse,
+ * Pfade mit .. und alles, was nicht unter /rechtliches/ liegt. Gültige Adressen kommen unverändert zurück.
+ */
+export function sicherLink(url) {
+ if (typeof url !== 'string' || UNZULAESSIG.test(url)) return ''
+ if (istHttps(url)) return url
+ if (url.startsWith(RECHTLICHES) && url.length > RECHTLICHES.length && !/\.\.|%2e/i.test(url)) return url
+ return ''
+}
+
+/** Zur Zahlungsseite gehen: nur auf eine https-Adresse (true), alles andere bleibt ohne Wirkung (false). */
+function geheZu(url) {
+ if (!istHttps(url)) return false
+ window.location.href = url
+ return true
+}
+
+/* ----------------------------------------------------------- Körper der Anfragen --- */
+/**
+ * Körper der Bestellung (der Vertrag des Checkouts): { projectId, zustimmung: { version, haken } }. version ist die Fassung der Erklärungen,
+ * die die Seite zeigt (bestellung.consent.version); haken sind die IDs der angehakten Einträge mit haken: true in Registerreihenfolge, nie Texte.
+ * gewaehlt: Array oder Set von IDs; unbekannte IDs, IDs reiner Texte und Doppelte fallen weg.
+ */
+export function bestellKoerper({ projectId, bestellung, gewaehlt }) {
+ const gesetzt = new Set(Array.isArray(gewaehlt) || gewaehlt instanceof Set ? gewaehlt : [])
+ const eintraege = Array.isArray(bestellung?.consent?.items) ? bestellung.consent.items : []
+ const haken = []
+ for (const eintrag of eintraege) {
+ if (eintrag?.haken === true && gesetzt.has(eintrag.id) && !haken.includes(eintrag.id)) haken.push(eintrag.id)
+ }
+ return { projectId, zustimmung: { version: bestellung?.consent?.version, haken } }
+}
+
+/** Körper von „Zahlung fortsetzen“: die offene Sitzung weiterführen, ohne neue Zustimmung. */
+export const fortsetzenKoerper = (projectId) => ({ projectId, fortsetzen: true })
+
+/* -------------------------------------------------------------- kleine Bausteine --- */
+const text = (wert) => (typeof wert === 'string' ? wert : '')
+
+/** Datum in Berlin als TT.MM.JJJJ, bei fehlendem oder ungültigem 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' })
+}
+
+const istConsent = (wert) => wert !== null && typeof wert === 'object' && typeof wert.version === 'string' && Array.isArray(wert.items)
+const offeneListe = (wert) => (Array.isArray(wert) ? wert.filter((punkt) => punkt !== null && typeof punkt === 'object' && typeof punkt.text === 'string') : [])
+
+const chatKnopf = () => h('button', { type: 'button', class: 'kb-btn-outline', 'data-view': 'chat' }, 'Frage dazu? Chat öffnen')
+
+/* ---------------------------------------------------------------- Bestellseite --- */
+/**
+ * „Prüfen und bestellen“ in root zeichnen und die Seite zurückgeben. Reihenfolge: Zusammenfassung der Angaben (je Gruppe „Ändern“),
+ * offene Punkte (je Punkt „Zum Schritt“), Preis und Einrichtungsdauer, die vollständige Widerrufsbelehrung samt Muster-Formular als Text
+ * (ein scrollbarer Bereich, nie eingeklappt), die Unterlagen als Links, die Zustimmungen und zuletzt der Knopf.
+ * ctx = Kontext der Ansicht (projectId, aktuell(), neuLaden()), daten = Antwort von GET /api/termine/einstellungen im Zustand off
+ * (einstellung, fragebogen, branchen, offen, kundenansicht, bestellung), zumSchritt(key) springt in einen Schritt des Fragebogens.
+ * Der Knopf ist gesperrt, solange ein Haken fehlt, offene Punkte bleiben oder die Kundenansicht läuft. Ein Klick sperrt ihn, sendet
+ * bestellKoerper und geht zur Zahlungsseite. Bei 409 lädt die Seite die Daten neu und setzt die Haken zurück (bestellung_offen zeigt die
+ * Punkte aus der Antwort, fassung_veraltet die neue Fassung aus der Antwort, ein Abo-Code den Text der Antwort).
+ */
+export function renderBestellen(root, { ctx, daten, zumSchritt } = {}) {
+ return baueSeite(root, { ctx, daten, zumSchritt }, {})
+}
+
+function baueSeite(root, { ctx, daten, zumSchritt }, uebernahme) {
+ const roh = daten?.bestellung
+ if (!roh || !istConsent(roh.consent)) {
+ const karte = fehlerKarte('Die Bestellseite konnte gerade nicht geladen werden. Bitte lade die Seite neu.')
+ root.replaceChildren(karte)
+ return karte
+ }
+ // nach einer veralteten Fassung gilt die Fassung aus der Antwort der Bestellung, nicht die der Daten
+ const bestellung = istConsent(uebernahme.consent) ? { ...roh, consent: uebernahme.consent } : roh
+ const uebernommen = offeneListe(uebernahme.offen)
+ const offen = uebernommen.length > 0 ? uebernommen : offeneListe(daten.offen)
+ const eintraege = bestellung.consent.items.filter((eintrag) => eintrag !== null && typeof eintrag === 'object')
+ const hakenEintraege = eintraege.filter((eintrag) => eintrag.haken === true)
+ const kundenansicht = daten.kundenansicht === true
+ const gewaehlt = new Set()
+ let laeuft = false
+
+ const gehe = (schritt) => { if (typeof zumSchritt === 'function') zumSchritt(schritt) }
+
+ // 1 Zusammenfassung: je Gruppe die Angaben als Text und „Ändern“
+ const gruppen = zusammenfassung(daten.einstellung, daten.fragebogen, daten.branchen).map((gruppe) => h('div', { class: 'tm-bestellen-gruppe', 'data-schritt': gruppe.schritt },
+ h('div', { class: 'tm-bestellen-gruppe-kopf' },
+ h('h4', null, gruppe.titel),
+ h('button', {
+ type: 'button', class: 'kb-btn-outline tm-knopf', 'data-aktion': 'aendern', 'data-schritt': gruppe.schritt, 'aria-label': `Ändern: ${gruppe.titel}`, onclick: () => gehe(gruppe.schritt),
+ }, 'Ändern')),
+ gruppe.zeilen.length > 0 ? h('ul', null, gruppe.zeilen.map((zeile) => h('li', null, zeile))) : h('p', { class: 'tm-hinweis' }, 'Noch keine Angaben.')))
+
+ // 2 offene Punkte: „Zum Schritt“ nur dort, wo es einen Schritt des Fragebogens gibt (der Abo-Punkt gehört zu dieser Seite)
+ const offenBlock = offen.length === 0 ? null : h('div', { class: 'tm-bestellen-offen', role: 'note' },
+ h('h4', null, 'Das fehlt noch'),
+ h('p', { class: 'tm-hinweis' }, 'Erledige diese Punkte, dann kannst du bestellen.'),
+ h('ul', null, offen.map((punkt) => h('li', null,
+ h('span', null, punkt.text),
+ SCHRITT_TITEL.has(punkt.schritt)
+ ? h('button', {
+ type: 'button', class: 'kb-btn-outline tm-knopf', 'data-aktion': 'zum-schritt', 'data-schritt': punkt.schritt, 'aria-label': `Zum Schritt „${SCHRITT_TITEL.get(punkt.schritt)}“`, onclick: () => gehe(punkt.schritt),
+ }, 'Zum Schritt')
+ : null))))
+
+ // 3 Preis mit dem Hinweis des Servers und die Einrichtungsdauer
+ const preisBlock = h('div', { class: 'tm-bestellen-preis' },
+ h('h4', null, 'Preis und Einrichtung'),
+ h('p', { class: 'tm-bestellen-betrag' }, h('strong', null, text(bestellung.preis)), ' ', h('span', null, text(bestellung.preisHinweis))),
+ text(bestellung.einrichtung) ? h('p', null, `Einrichtung: ${bestellung.einrichtung}`) : null)
+
+ // 4 die vollständige Widerrufsbelehrung samt Muster-Formular: ein Textbereich zum Scrollen, kein Aufklappen
+ const belehrungBlock = text(bestellung.belehrung).trim() === '' ? null : 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' }, bestellung.belehrung))
+
+ // 5 Unterlagen: versionierte Adressen, nur sichere Links
+ const dokumente = (Array.isArray(bestellung.dokumente) ? bestellung.dokumente : []).filter((dokument) => text(dokument?.titel) !== '' && sicherLink(dokument?.url) !== '')
+ const unterlagenBlock = dokumente.length === 0 ? null : h('div', { class: 'tm-bestellen-unterlagen' },
+ h('h4', null, 'Unterlagen zu deiner Bestellung'),
+ h('ul', { class: 'tm-bestellen-dokumente' }, dokumente.map((dokument) => h('li', null,
+ h('a', { href: sicherLink(dokument.url), target: '_blank', rel: 'noopener noreferrer' }, dokument.titel)))))
+
+ // 6 Zustimmungen in Registerreihenfolge: Einträge mit haken: true sind Haken, die anderen reiner Text
+ const knopf = h('button', { type: 'button', class: 'kb-btn-primary tm-bestellen-knopf', 'data-aktion': 'bestellen', disabled: true, onclick: () => bestelle() }, text(bestellung.consent.knopf))
+ const meldungEl = h('p', { class: 'tm-fehler tm-bestellen-meldung', 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 h('label', { class: 'tm-haken' }, feld, h('span', null, text(eintrag.text)))
+ }
+ const zustimmungBlock = h('div', { class: 'tm-bestellen-zustimmung' },
+ h('h4', null, 'Deine Zustimmungen'),
+ h('div', { class: 'tm-bestellen-haken' }, eintraege.map((eintrag) => (eintrag.haken === true ? hakenZeile(eintrag) : h('p', { class: 'tm-bestellen-text' }, text(eintrag.text))))))
+
+ const seite = h('section', { class: 'kb-panel tm-bestellen' },
+ h('h3', null, 'Prüfen und bestellen'),
+ h('p', { class: 'tm-abschnitt-text tm-hinweis' }, 'Lies alles in Ruhe durch. Mit „Ändern“ kommst du zu dem Schritt zurück, in dem du die Angabe gemacht hast.'),
+ h('div', { class: 'tm-bestellen-zusammenfassung' }, gruppen),
+ offenBlock,
+ preisBlock,
+ belehrungBlock,
+ unterlagenBlock,
+ zustimmungBlock,
+ h('div', { class: 'tm-bestellen-aktion' }, meldungEl, knopf, hinweisEl))
+ root.replaceChildren(seite)
+
+ function zeigeMeldung(meldung) {
+ meldungEl.textContent = meldung
+ }
+
+ function aktualisiere() {
+ const hakenFehlen = hakenEintraege.some((eintrag) => !gewaehlt.has(eintrag.id))
+ knopf.disabled = laeuft || hakenFehlen || offen.length > 0 || kundenansicht
+ if (kundenansicht) hinweisEl.textContent = KUNDENANSICHT
+ else if (offen.length > 0) hinweisEl.textContent = 'Du kannst bestellen, sobald die offenen Punkte erledigt sind.'
+ else hinweisEl.textContent = hakenFehlen ? 'Setze alle Haken, um zu bestellen.' : ''
+ }
+
+ // 409: Daten neu laden, die Seite mit leeren Haken neu zeichnen und die Meldung des Servers zeigen. Ist die Bestellung inzwischen
+ // aufgegeben (die Daten haben keine bestellung mehr), bleibt nur die Meldung mit dem Hinweis, die Seite neu zu laden.
+ async function nachKonflikt(err) {
+ let neu = null
+ try {
+ neu = typeof ctx?.neuLaden === 'function' ? await ctx.neuLaden() : null
+ } catch {
+ neu = null
+ }
+ if (neu === null || typeof neu !== 'object') {
+ zeigeMeldung(err.message)
+ laeuft = false
+ aktualisiere()
+ return
+ }
+ if (typeof ctx.aktuell === 'function' && !ctx.aktuell()) return
+ if (!neu.bestellung) {
+ root.replaceChildren(h('div', { class: 'kb-panel tm-info', role: 'status' }, h('p', null, err.message), h('p', null, 'Lade die Seite neu, dann siehst du den aktuellen Stand.')))
+ return
+ }
+ const neueSeite = baueSeite(root, { ctx, daten: neu, zumSchritt }, {
+ offen: err.code === 'bestellung_offen' ? err.body?.offen : undefined,
+ consent: err.code === 'fassung_veraltet' ? err.body?.consent : undefined,
+ })
+ const neueMeldung = neueSeite.querySelector('[data-meldung="bestellen"]')
+ if (neueMeldung) {
+ neueMeldung.textContent = err.message
+ neueMeldung.scrollIntoView({ block: 'nearest' })
+ }
+ }
+
+ async function bestelle() {
+ if (knopf.disabled) return
+ laeuft = true
+ aktualisiere()
+ zeigeMeldung('')
+ try {
+ const antwort = await request(CHECKOUT, { method: 'POST', body: bestellKoerper({ projectId: ctx?.projectId, bestellung, gewaehlt }) })
+ if (geheZu(antwort?.url)) return // die Seite wird verlassen, der Knopf bleibt gesperrt
+ zeigeMeldung(ZAHLUNGSSEITE)
+ } catch (err) {
+ if (err?.status === 409) {
+ await nachKonflikt(err)
+ return
+ }
+ zeigeMeldung(err?.message || ZAHLUNGSSEITE)
+ }
+ laeuft = false
+ aktualisiere()
+ }
+
+ aktualisiere()
+ return seite
+}
+
+/* ---------------------------------------------------------------- Statuskarte --- */
+/**
+ * Karte für eine Bestellung im Zustand ordered („Zahlung offen“) oder setup („In Einrichtung“).
+ * projekt = Eintrag des Projekts aus GET /api/funktionen (projectId), bestellung = projekt.features.termine (state, orderedAt, ticketWoid,
+ * invoiceUrl), einrichtung = Text der Zeiteinschätzung (Register: setupEstimate), danke = Rückkehr von Stripe, kundenansicht = Sitzung in der
+ * Kundenansicht. weiter() wird gerufen, wenn „Zahlung fortsetzen“ mit 409 zustimmung_fehlt antwortet (keine offene Sitzung mit Nachweis): es
+ * lädt die Seite neu und zeigt wieder das Bestellformular. Fehlt weiter, steht die Meldung des Servers am Knopf.
+ */
+export function statusKarte({ projekt, bestellung, einrichtung, danke, kundenansicht } = {}, { weiter } = {}) {
+ const bestellt = bestellung?.state === 'ordered'
+ const datum = datumTag(bestellung?.orderedAt)
+ const ticket = bestellung?.ticketWoid
+ const hatTicket = ticket !== null && ticket !== undefined && String(ticket) !== ''
+ const rechnung = sicherLink(bestellung?.invoiceUrl)
+ const zeile = (name, wert) => h('div', null, h('dt', null, name), h('dd', null, wert))
+ const fakten = [
+ bestellt ? null : zeile('Ticketnummer', hatTicket ? `#${ticket}` : 'Wir legen dein Ticket gerade an.'),
+ datum ? zeile('Bestellt am', datum) : null,
+ !bestellt && text(einrichtung) ? zeile('Zeiteinschätzung', einrichtung) : null,
+ ].filter(Boolean)
+
+ const meldungEl = h('p', { class: 'tm-fehler', role: 'alert', 'data-meldung': 'fortsetzen' })
+ const fortsetzenKnopf = bestellt
+ ? h('button', { type: 'button', class: 'kb-btn-primary', 'data-aktion': 'fortsetzen', disabled: kundenansicht === true, onclick: () => fortsetzen() }, 'Zahlung fortsetzen')
+ : null
+
+ async function fortsetzen() {
+ if (fortsetzenKnopf.disabled) return
+ fortsetzenKnopf.disabled = true
+ meldungEl.textContent = ''
+ try {
+ const antwort = await request(CHECKOUT, { method: 'POST', body: fortsetzenKoerper(projekt?.projectId) })
+ if (geheZu(antwort?.url)) return // die Seite wird verlassen
+ meldungEl.textContent = ZAHLUNGSSEITE
+ } catch (err) {
+ if (err?.status === 409 && err.code === 'zustimmung_fehlt' && typeof weiter === 'function') {
+ try {
+ await weiter()
+ return // die Ansicht zeichnet sich neu
+ } catch (fehler) {
+ meldungEl.textContent = fehler?.message || ZAHLUNGSSEITE
+ }
+ } else {
+ meldungEl.textContent = err?.message || ZAHLUNGSSEITE
+ }
+ }
+ fortsetzenKnopf.disabled = false
+ }
+
+ return h('section', { class: 'kb-panel tm-statuskarte', 'data-state': bestellt ? 'ordered' : 'setup' },
+ danke ? h('p', { class: 'tm-danke', role: 'status', 'data-danke': true }, DANKE) : null,
+ h('span', { class: 'tm-pille tm-pille-wait' }, bestellt ? 'Zahlung offen' : 'In Einrichtung'),
+ h('h3', null, bestellt ? 'Deine Bestellung wartet auf die Zahlung' : 'Wir richten deinen Terminplaner ein'),
+ h('p', null, bestellt ? 'Sobald deine Zahlung bei uns ankommt, legen wir mit der Einrichtung los.' : 'Sobald die Einrichtung fertig ist, schalten wir deinen Terminplaner für dich frei.'),
+ fakten.length > 0 ? h('dl', { class: 'tm-fakten' }, fakten) : null,
+ bestellt
+ ? h('p', { class: 'tm-hinweis' }, 'Zahlst du per SEPA-Lastschrift, kann es einige Tage dauern, bis die Zahlung bei uns ankommt.')
+ : h('p', { class: 'tm-hinweis' }, 'Deine Einstellungen sind jetzt gesperrt, Änderungen laufen über den Chat.'),
+ meldungEl,
+ bestellt && kundenansicht === true ? h('p', { class: 'tm-hinweis' }, KUNDENANSICHT) : null,
+ h('div', { class: 'tm-aktionen' },
+ fortsetzenKnopf,
+ !bestellt && rechnung ? h('a', { class: 'kb-btn-outline', href: sicherLink(rechnung), target: '_blank', rel: 'noopener noreferrer' }, 'Rechnung ansehen') : null,
+ chatKnopf()))
+}
diff --git a/public/termine-dom.js b/public/termine-dom.js
index 5d5f5fd..6af48e3 100644
--- a/public/termine-dom.js
+++ b/public/termine-dom.js
@@ -34,6 +34,8 @@ export async function request(path, { method = 'GET', body } = {}) {
err.status = response.status
err.fehler = data.fehler || {}
err.code = data.code
+ // der ganze Body der Antwort (z. B. offen und consent der Bestellung in funktion-bestellen.js); immer ein Objekt
+ err.body = data !== null && typeof data === 'object' ? data : {}
throw err
}
return data
diff --git a/public/termine.css b/public/termine.css
index 699ff15..0bfca9f 100644
--- a/public/termine.css
+++ b/public/termine.css
@@ -188,6 +188,45 @@
.tm-status { margin: 0; }
.tm-abschnitt details.tm-details { margin-top: 12px; }
+/* ---------------------------------------- Bestellseite und Statuskarte (funktion-bestellen.js) --- */
+.tm-bestellen > * + * { margin-top: 18px; }
+.tm-bestellen h3 { margin-bottom: 0; }
+.tm-bestellen h4 { margin: 0 0 6px; font-size: 14.5px; }
+.tm-bestellen .tm-abschnitt-text { margin: 6px 0 0; font-size: 14px; line-height: 1.55; }
+.tm-bestellen-zusammenfassung { display: grid; gap: 10px; }
+.tm-bestellen-gruppe { padding: 12px 14px; border-radius: 12px; background: var(--kb-surface); border: 1px solid var(--kb-border); }
+.tm-bestellen-gruppe-kopf { display: flex; align-items: center; justify-content: space-between; gap: 8px 14px; flex-wrap: wrap; }
+.tm-bestellen-gruppe-kopf h4 { margin: 0; }
+.tm-bestellen-gruppe ul { margin: 8px 0 0; padding-left: 18px; font-size: 14px; line-height: 1.55; overflow-wrap: anywhere; }
+.tm-bestellen-gruppe .tm-hinweis { margin-top: 8px; }
+.tm-bestellen-offen { padding: 12px 16px; border-radius: 12px; background: rgba(251, 191, 36, 0.12); border: 1px solid rgba(251, 191, 36, 0.45); }
+.tm-bestellen-offen ul { display: grid; gap: 8px; margin: 10px 0 0; padding: 0; list-style: none; }
+.tm-bestellen-offen li { display: flex; align-items: center; justify-content: space-between; gap: 6px 14px; flex-wrap: wrap; font-size: 14px; line-height: 1.5; }
+.tm-bestellen-offen li > span { min-width: 0; overflow-wrap: anywhere; }
+.tm-bestellen-betrag { margin: 0 0 4px; font-size: 14px; }
+.tm-bestellen-betrag strong { font-size: 22px; }
+.tm-bestellen-preis p { margin: 0 0 4px; line-height: 1.5; }
+.tm-bestellen-belehrung .tm-hinweis { margin-bottom: 8px; }
+.tm-rechtstext {
+ max-height: 320px; overflow-y: auto; padding: 14px 16px; border-radius: 12px; border: 1px solid var(--kb-border-strong); background: rgba(255, 255, 255, 0.03);
+ font-size: 13.5px; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere;
+}
+.tm-rechtstext:focus-visible { outline: 3px solid #d4b4ff; outline-offset: 2px; }
+.tm-bestellen-dokumente { display: grid; gap: 6px; margin: 0; padding-left: 18px; font-size: 14px; }
+.tm-bestellen-dokumente a { color: var(--kb-lilac); overflow-wrap: anywhere; }
+.tm-bestellen-text { margin: 10px 0 0; font-size: 14px; line-height: 1.55; color: var(--kb-muted); }
+.tm-bestellen-aktion { display: grid; justify-items: start; gap: 8px; }
+.tm-bestellen-aktion .tm-fehler { font-size: 14px; line-height: 1.5; }
+.tm-bestellen-knopf { padding: 13px 22px; font-size: 15px; }
+.tm-statuskarte > * + * { margin-top: 12px; }
+.tm-statuskarte h3 { margin: 10px 0 0; }
+.tm-statuskarte p { margin: 0; line-height: 1.55; }
+.tm-statuskarte .tm-hinweis { font-size: 13.5px; }
+.tm-statuskarte .tm-fakten { margin: 0; }
+.tm-statuskarte .tm-aktionen { margin-top: 16px; }
+.tm-statuskarte a.kb-btn-outline { display: inline-flex; align-items: center; text-decoration: none; }
+.tm-danke { padding: 12px 16px; border-radius: 12px; background: rgba(74, 222, 128, 0.1); border: 1px solid rgba(74, 222, 128, 0.4); font-weight: 600; }
+
/* ----------------------------------------------------------------- Handy --- */
@media (max-width: 880px), (pointer: coarse) {
#view-termine button:not(.tm-link-knopf), #view-termine .kb-btn-outline, #view-termine .kb-btn-primary { min-height: 44px; }
@@ -218,5 +257,7 @@
.tm-kopf { flex-wrap: wrap; }
.tm-feed { padding: 12px; }
.tm-frage-knoepfe > * { flex: 1 1 auto; }
+ .tm-bestellen-aktion { justify-items: stretch; }
+ .tm-rechtstext { max-height: 260px; }
}
@media (prefers-reduced-motion: reduce) { #view-termine * { transition: none !important; animation: none !important; } }
diff --git a/test/termineFragebogenView.test.js b/test/termineFragebogenView.test.js
index b637d0d..fe6c57f 100644
--- a/test/termineFragebogenView.test.js
+++ b/test/termineFragebogenView.test.js
@@ -1,11 +1,13 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
-import { readFileSync } from 'node:fs'
+import { readFileSync, readdirSync } from 'node:fs'
import * as ansicht from '../public/termine-view.js'
import {
FRAGEBOGEN_SCHRITTE, LIMITS, MAX, buchungsartFuerAlle, buchungsartLage, ersterOffenerSchritt, ressourcenAusNamen, schrittStatus, zusammenfassung,
} from '../public/termine-view.js'
-import { BRANCHEN, SCHRITTE, SCHRITT_TITEL, leerFragebogen } from '../server/services/termine/fragebogen.js'
+import { BRANCHEN, SCHRITTE, SCHRITT_TITEL, bestellungAnsicht, leerFragebogen } from '../server/services/termine/fragebogen.js'
+import { featureConfig } from '../server/services/featureRegistry.js'
+import { bestellbarerStand } from './support/fragebogenStand.js'
import { normalisiereEinstellung } from '../server/services/termine/einstellungen.js'
import { ausVorlage } from '../server/services/termine/vorlagen.js'
import { installFakeDom } from './support/fakeDom.js'
@@ -13,6 +15,7 @@ import { installFakeDom } from './support/fakeDom.js'
import * as einstellungenTab from '../public/termine-tab-einstellungen.js'
import * as teamTab from '../public/termine-tab-team.js'
import * as huelle from '../public/termine.js'
+import * as bestellen from '../public/funktion-bestellen.js'
import { FENSTER_FEHLER } from '../public/termine-view.js'
import { h, request } from '../public/termine-dom.js'
import { neuerSpeicher } from '../public/termine-speichern.js'
@@ -837,3 +840,685 @@ test('baueCtx: Schnittstelle, Daten und Meldungen wie der Reiter-Kontext', mitDo
schlicht.hinweise()
schlicht.zumReiter('team')
}))
+
+// ------------------------------------------------------- request: ganzer Body des Fehlers (TP2, Task C8)
+test('request: eine Fehlerantwort behält den ganzen Body (err.body), z. B. offen und consent der Bestellung', mitDom({
+ antworten: [{ status: 409, body: { error: 'Nicht vollständig.', code: 'bestellung_offen', offen: [{ schritt: 'website', text: 'Impressum fehlt.' }] } }, { status: 500, body: 'kein Objekt' }],
+}, async () => {
+ await assert.rejects(request('/api/funktionen/termine/checkout', { method: 'POST', body: { projectId: 'p1' } }), (err) => {
+ assert.equal(err.status, 409)
+ assert.equal(err.code, 'bestellung_offen')
+ assert.equal(err.message, 'Nicht vollständig.')
+ assert.deepEqual(err.body.offen, [{ schritt: 'website', text: 'Impressum fehlt.' }])
+ return true
+ })
+ await assert.rejects(request('/x'), (err) => err.status === 500 && typeof err.body === 'object' && err.body !== null, 'auch ohne Objekt im Body ist err.body ein Objekt')
+}))
+
+// ------------------------------------------------------- funktion-bestellen.js: Bestellseite und Statuskarten (TP2, Task C8)
+// Die Datei ist bewusst kein termine*.js (einzige mit href-Links); ihre Regeln pruefen die statischen Tests unten.
+const BELEHRUNG = 'Widerrufsbelehrung\nDu hast das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.\n\nMuster-Widerrufsformular\n(Wenn Du den Vertrag widerrufen willst, fülle dieses Formular aus.)'
+const KNOPF_URL = 'https://checkout.stripe.test/c/pay/cs_test_1'
+
+const bestellungTest = async (aenderung = {}) => ({
+ ...(await bestellungAnsicht(featureConfig('termine'))),
+ belehrung: BELEHRUNG,
+ dokumente: [
+ { id: 'leistungsbeschreibung', titel: 'Leistungsbeschreibung', version: '2026-10-05', url: '/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.html' },
+ { id: 'avv', titel: 'Auftragsverarbeitungsvertrag (AVV)', version: '2026-10-05', url: 'https://webklar.example.invalid/avv-terminplaner.html' },
+ ],
+ ...aenderung,
+})
+/** Antwort von GET /api/termine/einstellungen in off, die die Bestellseite braucht (bestellbarer Entwurf, nichts offen). */
+const bestellDaten = async (aenderung = {}, bestellungAenderung = {}) => ({
+ state: 'off',
+ einstellung: bestellbarerStand({ now: JETZT }).einstellung,
+ fragebogen: bestellbarerStand({ now: JETZT }).fragebogen,
+ version: 1,
+ branchen: BRANCHEN.map(({ key, name, gesundheit }) => ({ key, name, gesundheit })),
+ kundenansicht: false,
+ abo: { ok: true },
+ offen: [],
+ bestellung: await bestellungTest(bestellungAenderung),
+ ...aenderung,
+})
+const registerHaken = () => featureConfig('termine').consent.items.filter((item) => item.haken).map((item) => item.id)
+
+/** Bestellseite in den Baum haengen. => { wurzel, ctx, schritte } (schritte: die Schluessel, zu denen zumSchritt gerufen wurde). */
+async function bestellSeite(daten) {
+ const wurzel = h('div')
+ document.body.append(wurzel)
+ const ctx = huelle.baueCtx({ daten, nurLesen: false }, { projekt: { projectId: 'p1' }, aktuell: () => true })
+ const schritte = []
+ bestellen.renderBestellen(wurzel, { ctx, daten, zumSchritt: (key) => schritte.push(key) })
+ return { wurzel, ctx, schritte }
+}
+const bestellKnopf = (fx, wurzel) => fx.finde(wurzel, '[data-aktion="bestellen"]')
+const hakenFelder = (fx, wurzel) => fx.alle(wurzel, 'input[type="checkbox"]')
+const alleAnhaken = async (fx, wurzel) => { for (const haken of hakenFelder(fx, wurzel)) if (!haken.checked) await fx.klick(haken) }
+const meldungVon = (fx, wurzel) => fx.text(fx.finde(wurzel, '[data-meldung="bestellen"]'))
+
+// ------------------------------------------------------- sicherLink, bestellKoerper, fortsetzenKoerper
+test('sicherLink: https-Adressen und Pfade unter /rechtliches/ gehen durch, alles andere wird zu ""', () => {
+ assert.equal(bestellen.sicherLink('https://x.de/a'), 'https://x.de/a')
+ assert.equal(bestellen.sicherLink('https://x.de'), 'https://x.de')
+ assert.equal(bestellen.sicherLink('/rechtliches/avv-terminplaner-v1.html'), '/rechtliches/avv-terminplaner-v1.html')
+ assert.equal(bestellen.sicherLink('/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.pdf'), '/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.pdf')
+ for (const schlecht of [
+ 'javascript:alert(1)', 'JavaScript:alert(1)', ' javascript:alert(1)', 'data:text/html,', 'vbscript:x', 'blob:https://x.de/a',
+ 'http://x.de/a', 'ftp://x.de/a', '//evil.example', '//evil.example/rechtliches/x.html', '/\\evil.example', '\\\\evil.example',
+ '/vertragsunterlagen/x.html', '/rechtliches', '/rechtliches/', '/Rechtliches/x.html', 'rechtliches/x.html', '/x/rechtliches/x.html', '../rechtliches/x.html',
+ '/rechtliches/../app.js', '/rechtliches/%2e%2e/app.js', '/rechtliches/%2E./app.js', 'https:', 'https://', 'https:///x', 'https://user:pw@x.de/a', 'https://user@x.de/a',
+ ' https://x.de/a', 'https://x.de/a ', '\thttps://x.de/a', 'https://x.de/a\n', 'https://x.de/ a', 'https://x.de/\u0000a', 'java\nscript:alert(1)', '/rechtliches/\nx.html', '/rechtliches/ x.html',
+ '', ' ', null, undefined, 42, {}, ['https://x.de/a'],
+ ]) assert.equal(bestellen.sicherLink(schlecht), '', `muss abgewiesen werden: ${JSON.stringify(schlecht)}`)
+})
+
+test('bestellKoerper: genau { projectId, zustimmung: { version, haken } } mit den angehakten IDs der Haken-Items, nie Texte', async () => {
+ const bestellung = await bestellungTest()
+ const koerper = bestellen.bestellKoerper({ projectId: 'p1', bestellung, gewaehlt: new Set(registerHaken()) })
+ assert.deepEqual(Object.keys(koerper), ['projectId', 'zustimmung'])
+ assert.deepEqual(Object.keys(koerper.zustimmung), ['version', 'haken'])
+ assert.equal(koerper.projectId, 'p1')
+ assert.equal(koerper.zustimmung.version, bestellung.consent.version)
+ assert.deepEqual(koerper.zustimmung.haken, registerHaken())
+ const json = JSON.stringify(koerper)
+ for (const item of bestellung.consent.items) assert.ok(!json.includes(item.text), 'kein Text des Registers im Körper')
+ assert.ok(!json.includes(bestellung.consent.knopf), 'auch der Knopftext nicht')
+ assert.equal(koerper.consent, undefined)
+})
+
+test('bestellKoerper: nur angehakte IDs von Items mit haken: true, in Registerreihenfolge, Fremdes und Doppeltes fällt weg', () => {
+ const bestellung = {
+ consent: {
+ version: 'v-test',
+ knopf: 'Bestellen',
+ items: [
+ { id: 'a', text: 'Hinweis ohne Haken', haken: false },
+ { id: 'b', text: 'Erster Haken', haken: true },
+ { id: 'c', text: 'Zweiter Haken', haken: true },
+ { id: 'd', text: 'Noch ein Hinweis', haken: false },
+ { id: 'e', text: 'Dritter Haken', haken: true },
+ ],
+ },
+ }
+ // Array und Set, andere Reihenfolge, eine ID eines reinen Textes (a), eine unbekannte (zz) und ein Doppelter (e)
+ assert.deepEqual(bestellen.bestellKoerper({ projectId: 'p', bestellung, gewaehlt: ['e', 'c', 'a', 'zz', 'e', 'b'] }).zustimmung.haken, ['b', 'c', 'e'])
+ assert.deepEqual(bestellen.bestellKoerper({ projectId: 'p', bestellung, gewaehlt: new Set(['c']) }).zustimmung, { version: 'v-test', haken: ['c'] })
+ assert.deepEqual(bestellen.bestellKoerper({ projectId: 'p', bestellung, gewaehlt: [] }).zustimmung.haken, [])
+ assert.deepEqual(bestellen.bestellKoerper({ projectId: 'p', bestellung, gewaehlt: undefined }).zustimmung.haken, [])
+})
+
+test('bestellKoerper: die gesendeten IDs sind Teilmenge der Register-IDs (Querprüfung gegen featureConfig("termine").consent.items)', async () => {
+ const register = featureConfig('termine').consent
+ const bestellung = await bestellungTest()
+ const alle = bestellen.bestellKoerper({ projectId: 'p1', bestellung, gewaehlt: register.items.map((item) => item.id) })
+ assert.equal(alle.zustimmung.version, register.version, 'die Fassung des Registers')
+ const registerIds = new Set(register.items.map((item) => item.id))
+ assert.ok(alle.zustimmung.haken.length > 0)
+ assert.ok(alle.zustimmung.haken.every((id) => registerIds.has(id)))
+ assert.deepEqual(alle.zustimmung.haken, register.items.filter((item) => item.haken).map((item) => item.id), 'alle Haken des Registers, in Registerreihenfolge')
+})
+
+test('fortsetzenKoerper: { projectId, fortsetzen: true } ohne zustimmung', () => {
+ const koerper = bestellen.fortsetzenKoerper('p1')
+ assert.deepEqual(koerper, { projectId: 'p1', fortsetzen: true })
+ assert.deepEqual(Object.keys(koerper), ['projectId', 'fortsetzen'])
+ assert.equal(koerper.zustimmung, undefined)
+})
+
+// ------------------------------------------------------- statische Regeln
+const BESTELLEN_DATEI = 'public/funktion-bestellen.js'
+const quelleBestellen = () => readFileSync(new URL(`../${BESTELLEN_DATEI}`, import.meta.url), 'utf8')
+const ohneKommentare = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '')
+
+test('funktion-bestellen.js statisch: Fremdtext nur als Text, keine Dialoge, kein Browserspeicher, Du-Form, kein Knopftext-Literal, kein details', () => {
+ const roh = quelleBestellen()
+ const code = ohneKommentare(roh)
+ assert.ok(code.length > 2000, 'die Datei ist gefüllt')
+ assert.doesNotMatch(code, /innerHTML|insertAdjacentHTML|outerHTML|document\.write/, 'Daten nie als HTML')
+ assert.doesNotMatch(code, /\bconfirm\(|\balert\(|\bprompt\(/, 'Rückfragen stehen in der Seite')
+ assert.doesNotMatch(code, /localStorage|sessionStorage|document\.cookie/, 'kein Browserspeicher')
+ assert.doesNotMatch(code, /\bSie\b|\bIhr(e[nmrs]?)?\b/, 'Portaltexte in der Du-Form (die Rechtstexte kommen vom Server)')
+ assert.doesNotMatch(roh, /zahlungspflichtig/i, 'der Knopftext kommt aus bestellung.consent.knopf, nie aus dem Client')
+ assert.doesNotMatch(code, /details|summary/i, 'die Widerrufsbelehrung ist nie eingeklappt')
+ assert.match(code, /bestellung\.consent\.knopf/, 'der Knopf zeigt den Registertext')
+})
+
+test('funktion-bestellen.js statisch: jedes href nur über sicherLink(, location.href nur nach Prüfung auf https:', () => {
+ const code = ohneKommentare(quelleBestellen())
+ const hrefs = code.match(/(?:\bhref:|setAttribute\('href',)[^\n]*/g) || []
+ assert.ok(hrefs.length >= 2, 'Unterlagen und Rechnung sind Links')
+ for (const zeile of hrefs) assert.match(zeile, /sicherLink\(/, `href nur über sicherLink: ${zeile}`)
+ const zuweisungen = [...code.matchAll(/location\.href\s*=[^=]/g)]
+ assert.equal(zuweisungen.length, 1, 'genau eine Stelle navigiert')
+ const davor = code.slice(Math.max(0, zuweisungen[0].index - 250), zuweisungen[0].index)
+ assert.match(davor, /istHttps\(|'https:'/, 'unmittelbar davor wird auf https: geprüft')
+ assert.doesNotMatch(code, /location\.(assign|replace)\(|window\.open\(/, 'keine andere Navigation')
+ const neueTabs = code.split('\n').filter((zeile) => zeile.includes("'_blank'"))
+ assert.ok(neueTabs.length >= 2)
+ for (const zeile of neueTabs) assert.match(zeile, /rel: 'noopener noreferrer'/, `Links in neuem Tab tragen rel: ${zeile}`)
+})
+
+test('funktion-bestellen.js statisch: kein termine*.js, relative Importe, von keinem absoluten Import geladen', () => {
+ const dateien = readdirSync(new URL('../public/', import.meta.url)).filter((f) => f.endsWith('.js'))
+ assert.ok(dateien.includes('funktion-bestellen.js'))
+ assert.doesNotMatch('funktion-bestellen.js', /^termine.*\.js$/, 'nicht in der Dateiliste der termine*.js-Regeln')
+ const code = ohneKommentare(quelleBestellen())
+ assert.deepEqual(code.match(/from '[^']+'/g), ["from './termine-dom.js'", "from './termine-view.js'"], 'nur relative Importe, nur diese zwei')
+ assert.doesNotMatch(code, /import\s*\(/, 'kein dynamischer Import')
+ for (const datei of dateien) {
+ assert.doesNotMatch(readFileSync(new URL(`../public/${datei}`, import.meta.url), 'utf8'), /from '\/funktion-bestellen\.js'|import\('\/funktion-bestellen\.js'\)/, `${datei}: kein absoluter Import`)
+ }
+ const fn = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8')
+ assert.deepEqual(fn.match(/from '\/[^']+'/g), ["from '/analyse-view.js'", "from '/geraete.js'"], 'funktionen.js behält genau diese beiden absoluten Importe')
+ assert.doesNotMatch(fn, /funktion-bestellen/)
+})
+
+test('termine.css: Klassen der Bestellseite, des Rechtstextes (scrollbar) und der Statuskarte', () => {
+ const css = readFileSync(new URL('../public/termine.css', import.meta.url), 'utf8')
+ for (const klasse of ['tm-bestellen', 'tm-rechtstext', 'tm-statuskarte']) assert.match(css, new RegExp(`\\.${klasse}[\\s{,.:\\[]`), `.${klasse} ist gestaltet`)
+ const rechtstext = css.match(/\.tm-rechtstext\s*\{[^}]*\}/)?.[0] ?? ''
+ assert.match(rechtstext, /max-height:/, 'begrenzte Höhe')
+ assert.match(rechtstext, /overflow-y:\s*auto/, 'scrollbar, nie eingeklappt')
+ assert.match(rechtstext, /white-space:\s*pre-wrap/, 'Absätze und Zeilen der Belehrung bleiben erhalten')
+})
+
+// ------------------------------------------------------- renderBestellen: Aufbau
+test('renderBestellen: Reihenfolge Zusammenfassung, offene Punkte, Preis, Belehrung, Unterlagen, Haken, Knopf', mitDom(undefined, async (fx) => {
+ const daten = await bestellDaten({ offen: [{ schritt: 'website', text: 'Bitte trage die Adresse deines Impressums ein.' }] })
+ const { wurzel } = await bestellSeite(daten)
+ const alle = fx.alle(wurzel, '*')
+ const stelle = (selektor) => {
+ const el = fx.finde(wurzel, selektor)
+ assert.ok(el, `${selektor} fehlt`)
+ return alle.indexOf(el)
+ }
+ const stellen = ['.tm-bestellen-zusammenfassung', '.tm-bestellen-offen', '.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), 'Dokumentreihenfolge')
+ assert.equal(new Set(stellen).size, stellen.length)
+ assert.equal(fx.alle(wurzel, '[data-aktion="bestellen"]').length, 1, 'genau ein Bestell-Knopf')
+ assert.equal(fx.alle(wurzel, 'button').at(-1), bestellKnopf(fx, wurzel), 'der Knopf steht zuletzt')
+}))
+
+test('renderBestellen: ohne offene Punkte gibt es den Block „offen“ nicht', mitDom(undefined, async (fx) => {
+ const { wurzel } = await bestellSeite(await bestellDaten())
+ assert.equal(fx.finde(wurzel, '.tm-bestellen-offen'), null)
+ const { wurzel: wurzel2 } = await bestellSeite(await bestellDaten({ offen: null }))
+ assert.equal(fx.finde(wurzel2, '.tm-bestellen-offen'), null, 'offen: null (kein off-Zustand) zählt wie leer')
+}))
+
+test('renderBestellen: Knopftext == bestellung.consent.knopf == Registertext, kein Text im Client', mitDom(undefined, async (fx) => {
+ const daten = await bestellDaten()
+ const { wurzel } = await bestellSeite(daten)
+ const knopf = bestellKnopf(fx, wurzel)
+ assert.equal(fx.text(knopf), daten.bestellung.consent.knopf)
+ assert.equal(fx.text(knopf), featureConfig('termine').consent.knopf)
+ // ein anderer Text im Register ist sofort der Text des Knopfes
+ const andere = await bestellDaten({}, { consent: { ...daten.bestellung.consent, knopf: 'Jetzt kaufen · 1 €' } })
+ const { wurzel: wurzel2 } = await bestellSeite(andere)
+ assert.equal(fx.text(bestellKnopf(fx, wurzel2)), 'Jetzt kaufen · 1 €')
+}))
+
+test('renderBestellen: die vollständige Widerrufsbelehrung steht als Text in einem scrollbaren Bereich vor dem Knopf, nie eingeklappt', mitDom(undefined, async (fx) => {
+ const { wurzel } = await bestellSeite(await bestellDaten())
+ const bereich = fx.finde(wurzel, '.tm-rechtstext')
+ assert.equal(bereich.textContent, BELEHRUNG, 'der ganze Text, unverändert (auch Zeilenumbrüche)')
+ assert.ok(bereich.textContent.includes('Muster-Widerrufsformular'))
+ assert.equal(bereich.getAttribute('tabindex'), '0', 'mit der Tastatur scrollbar')
+ assert.equal(bereich.hidden, false)
+ assert.equal(bereich.closest('details'), null)
+ assert.equal(fx.alle(wurzel, 'details').length, 0)
+ assert.equal(fx.alle(wurzel, '[hidden]').length, 0, 'nichts auf der Seite ist versteckt')
+ assert.equal(fx.alle(wurzel, '[aria-expanded]').length, 0, 'und nichts klappt auf oder zu')
+ const alle = fx.alle(wurzel, '*')
+ assert.ok(alle.indexOf(bereich) < alle.indexOf(bestellKnopf(fx, wurzel)), 'der Belehrungstext steht vor dem Knopf')
+ const hakenStellen = hakenFelder(fx, wurzel).map((haken) => alle.indexOf(haken))
+ assert.ok(hakenStellen.every((stelle) => stelle > alle.indexOf(bereich)), 'und vor den Haken')
+ assert.equal(fx.text(fx.finde(wurzel, '.tm-bestellen-belehrung h4')), 'Widerrufsbelehrung', 'mit Überschrift')
+}))
+
+test('renderBestellen: ohne Belehrungstext (belehrung: null) gibt es den Bereich nicht', mitDom(undefined, async (fx) => {
+ const { wurzel } = await bestellSeite(await bestellDaten({}, { belehrung: null }))
+ assert.equal(fx.finde(wurzel, '.tm-rechtstext'), null)
+ const { wurzel: leer } = await bestellSeite(await bestellDaten({}, { belehrung: '' }))
+ assert.equal(fx.finde(leer, '.tm-rechtstext'), null)
+}))
+
+test('renderBestellen: Unterlagen sind Links nur über sicherLink, unsichere Adressen werden ausgelassen', mitDom(undefined, async (fx) => {
+ const daten = await bestellDaten({}, {
+ dokumente: [
+ { id: 'leistungsbeschreibung', titel: 'Leistungsbeschreibung', version: 'v1', url: '/rechtliches/leistungsbeschreibung-terminplaner-v1.html' },
+ { id: 'avv', titel: 'Auftragsverarbeitungsvertrag (AVV)', version: 'v1', url: 'https://webklar.example.invalid/avv.html' },
+ { id: 'widerruf', titel: 'Widerrufsbelehrung', version: 'v1', url: 'javascript:alert(1)' },
+ { id: 'x', titel: 'Fremd', version: 'v1', url: '//evil.example/x.html' },
+ { id: 'y', titel: 'Unverschlüsselt', version: 'v1', url: 'http://webklar.example.invalid/y.html' },
+ ],
+ })
+ const { wurzel } = await bestellSeite(daten)
+ const links = fx.alle(wurzel, '.tm-bestellen-dokumente a')
+ assert.deepEqual(links.map((a) => [fx.text(a), a.getAttribute('href')]), [
+ ['Leistungsbeschreibung', '/rechtliches/leistungsbeschreibung-terminplaner-v1.html'],
+ ['Auftragsverarbeitungsvertrag (AVV)', 'https://webklar.example.invalid/avv.html'],
+ ])
+ for (const a of links) {
+ assert.equal(a.getAttribute('target'), '_blank')
+ assert.equal(a.getAttribute('rel'), 'noopener noreferrer')
+ }
+ for (const a of fx.alle(wurzel, 'a')) assert.notEqual(a.getAttribute('href'), 'javascript:alert(1)')
+ // ohne verwendbare Unterlagen entfällt der Block
+ const { wurzel: ohne } = await bestellSeite(await bestellDaten({}, { dokumente: [] }))
+ assert.equal(fx.finde(ohne, '.tm-bestellen-dokumente'), null)
+}))
+
+test('renderBestellen: Preis mit Hinweis aus dem Server und Einrichtungsdauer', mitDom(undefined, async (fx) => {
+ const daten = await bestellDaten()
+ const { wurzel } = await bestellSeite(daten)
+ const preis = fx.text(fx.finde(wurzel, '.tm-bestellen-preis'))
+ assert.ok(preis.includes(daten.bestellung.preis), 'der Preis')
+ assert.ok(preis.includes(daten.bestellung.preisHinweis), 'der Hinweis steht im Text vom Server')
+ assert.ok(preis.includes(daten.bestellung.einrichtung), 'die Einrichtungsdauer')
+}))
+
+test('renderBestellen: Zustimmungs-Haken in Registerreihenfolge mit dem Wortlaut des Registers, reine Texte ohne Haken', mitDom(undefined, async (fx) => {
+ const daten = await bestellDaten()
+ const { wurzel } = await bestellSeite(daten)
+ const felder = hakenFelder(fx, wurzel)
+ assert.deepEqual(felder.map((haken) => haken.dataset.haken), registerHaken(), 'IDs in Registerreihenfolge')
+ const items = featureConfig('termine').consent.items.filter((item) => item.haken)
+ felder.forEach((haken, i) => {
+ assert.equal(fx.text(haken.closest('label')), items[i].text, 'Wortlaut des Registers')
+ assert.equal(haken.checked, false, 'nichts ist vorab angehakt')
+ })
+ // gemischt: Items ohne haken sind reiner Text an ihrer Stelle
+ const consent = {
+ version: 'v-test',
+ knopf: 'Bestellen',
+ items: [
+ { id: 'a', text: 'Hinweis vorab', haken: false },
+ { id: 'b', text: 'Ich stimme dem ersten zu.', haken: true },
+ { id: 'c', text: 'Hinweis in der Mitte', haken: false },
+ { id: 'd', text: 'Ich stimme dem zweiten zu.', haken: true },
+ ],
+ }
+ const { wurzel: gemischt } = await bestellSeite(await bestellDaten({}, { consent }))
+ const bereich = fx.finde(gemischt, '.tm-bestellen-haken')
+ assert.deepEqual(fx.alle(bereich, 'input[type="checkbox"]').map((haken) => haken.dataset.haken), ['b', 'd'])
+ const zeilen = bereich.children.filter((el) => el.matches('p, label')).map((el) => [el.tagName, fx.text(el)])
+ assert.deepEqual(zeilen, [['P', 'Hinweis vorab'], ['LABEL', 'Ich stimme dem ersten zu.'], ['P', 'Hinweis in der Mitte'], ['LABEL', 'Ich stimme dem zweiten zu.']])
+}))
+
+test('renderBestellen: Kundentext und Registertext landen nur als Text im Baum', mitDom(undefined, async (fx) => {
+ const boese = '
'
+ const daten = await bestellDaten({
+ offen: [{ schritt: 'website', text: boese }],
+ einstellung: { ...bestellbarerStand({ now: JETZT }).einstellung, betrieb: { ...BETRIEB, name: boese } },
+ }, { belehrung: boese, consent: { version: 'v', knopf: boese, items: [{ id: 'a', text: boese, haken: true }] }, preis: boese, einrichtung: boese })
+ const { wurzel } = await bestellSeite(daten)
+ assert.equal(fx.alle(wurzel, 'img').length, 0)
+ assert.ok(fx.text(wurzel).includes(boese))
+}))
+
+test('renderBestellen: fehlt die Bestellung (kein off-Zustand), steht eine Fehlerkarte statt der Seite', mitDom(undefined, async (fx) => {
+ const { wurzel } = await bestellSeite(await bestellDaten({ bestellung: null }))
+ assert.equal(fx.finde(wurzel, '[data-aktion="bestellen"]'), null)
+ assert.match(fx.text(wurzel), /Bestellseite/)
+}))
+
+// ------------------------------------------------------- Zusammenfassung und offene Punkte
+test('renderBestellen: Zusammenfassung zeigt die acht Gruppen aus zusammenfassung(), „Ändern“ ruft zumSchritt(key)', mitDom(undefined, async (fx) => {
+ const daten = await bestellDaten()
+ const { wurzel, schritte } = await bestellSeite(daten)
+ const erwartet = zusammenfassung(daten.einstellung, daten.fragebogen, daten.branchen)
+ const gruppen = fx.alle(wurzel, '.tm-bestellen-gruppe')
+ assert.deepEqual(gruppen.map((g) => g.dataset.schritt), FRAGEBOGEN_SCHRITTE.map(([key]) => key), 'Reihenfolge der Schritte')
+ gruppen.forEach((gruppe, i) => {
+ assert.equal(fx.text(fx.finde(gruppe, 'h4')), erwartet[i].titel)
+ assert.deepEqual(fx.alle(gruppe, 'li').map((li) => fx.text(li)), erwartet[i].zeilen, 'Zeilen unverändert als Text')
+ })
+ assert.ok(erwartet.some((g) => g.zeilen.length > 0))
+ assert.ok(fx.text(gruppen[0]).includes(`Branche: ${daten.branchen.find((b) => b.key === 'salon').name}`), 'die Angaben des Kunden stehen da')
+ const aendern = fx.alle(wurzel, '[data-aktion="aendern"]')
+ assert.equal(aendern.length, 8, 'je Gruppe ein Knopf')
+ assert.ok(aendern.every((knopf) => fx.text(knopf) === 'Ändern'))
+ assert.match(aendern[2].getAttribute('aria-label'), /^Ändern: /)
+ await fx.klick(aendern[2])
+ await fx.klick(aendern[7])
+ assert.deepEqual(schritte, ['leistungen', 'website'])
+}))
+
+test('renderBestellen: eine Gruppe ohne Angaben sagt es und hat trotzdem „Ändern“; fehlendes zumSchritt ist kein Fehler', mitDom(undefined, async (fx) => {
+ const daten = await bestellDaten({ einstellung: null, fragebogen: null })
+ const wurzel = h('div')
+ document.body.append(wurzel)
+ const ctx = huelle.baueCtx({ daten, nurLesen: false }, { projekt: { projectId: 'p1' }, aktuell: () => true })
+ bestellen.renderBestellen(wurzel, { ctx, daten })
+ const gruppen = fx.alle(wurzel, '.tm-bestellen-gruppe')
+ assert.equal(gruppen.length, 8)
+ assert.match(fx.text(gruppen[0]), /Noch keine Angaben/)
+ await fx.klick(fx.finde(gruppen[0], '[data-aktion="aendern"]'))
+}))
+
+test('renderBestellen: offene Punkte mit „Zum Schritt“ zu ihrem Schritt, der Abo-Punkt (bestellen) ohne Knopf; Knopf gesperrt', mitDom(undefined, async (fx) => {
+ const offen = [
+ { schritt: 'branche', text: 'Bitte trage deine Anschrift ein.' },
+ { schritt: 'website', text: 'Bitte trage die Adresse deines Impressums ein.' },
+ { schritt: 'bestellen', text: 'Das Abo konnte gerade nicht geprüft werden.' },
+ ]
+ const { wurzel, schritte } = await bestellSeite(await bestellDaten({ offen }))
+ const punkte = fx.alle(wurzel, '.tm-bestellen-offen li')
+ assert.deepEqual(punkte.map((li) => fx.finde(li, 'span')?.textContent), offen.map((p) => p.text))
+ const knoepfe = fx.alle(wurzel, '[data-aktion="zum-schritt"]')
+ assert.deepEqual(knoepfe.map((k) => k.dataset.schritt), ['branche', 'website'], 'bestellen ist diese Seite')
+ assert.ok(knoepfe.every((k) => fx.text(k) === 'Zum Schritt'))
+ await fx.klick(knoepfe[1])
+ assert.deepEqual(schritte, ['website'])
+ await alleAnhaken(fx, wurzel)
+ assert.equal(bestellKnopf(fx, wurzel).disabled, true, 'mit offenen Punkten bleibt der Knopf gesperrt, auch mit allen Haken')
+ assert.match(fx.text(fx.finde(wurzel, '[data-hinweis="bestellen"]')), /offenen Punkte/)
+}))
+
+// ------------------------------------------------------- Knopf: gesperrt oder frei
+test('renderBestellen: Knopf gesperrt, bis alle Haken gesetzt sind; ein abgenommener Haken sperrt ihn wieder', mitDom(undefined, async (fx) => {
+ const { wurzel } = await bestellSeite(await bestellDaten())
+ const knopf = bestellKnopf(fx, wurzel)
+ const felder = hakenFelder(fx, wurzel)
+ assert.ok(felder.length >= 2)
+ assert.equal(knopf.disabled, true, 'am Anfang gesperrt')
+ for (const haken of felder.slice(0, -1)) {
+ await fx.klick(haken)
+ assert.equal(knopf.disabled, true, 'solange ein Haken fehlt')
+ }
+ await fx.klick(felder.at(-1))
+ assert.equal(knopf.disabled, false, 'alle Haken ⇒ frei')
+ await fx.klick(felder[0])
+ assert.equal(knopf.disabled, true, 'Haken weg ⇒ wieder gesperrt')
+ await fx.klick(felder[0])
+ assert.equal(knopf.disabled, false)
+ assert.equal(fx.fetchLog.length, 0, 'bis hierher keine Anfrage')
+}))
+
+test('renderBestellen: in der Kundenansicht bleibt der Knopf gesperrt, auch mit allen Haken, mit Hinweis und ohne Anfrage', mitDom(undefined, async (fx) => {
+ const { wurzel } = await bestellSeite(await bestellDaten({ kundenansicht: true }))
+ await alleAnhaken(fx, wurzel)
+ const knopf = bestellKnopf(fx, wurzel)
+ assert.equal(knopf.disabled, true)
+ assert.equal(fx.text(fx.finde(wurzel, '[data-hinweis="bestellen"]')), 'In der Kundenansicht kann nicht bestellt werden.')
+ await fx.klick(knopf)
+ assert.equal(fx.fetchLog.length, 0)
+}))
+
+// ------------------------------------------------------- Bestellen: Klick, Fehler, 409
+test('Bestellen: der Klick sperrt den Knopf, sendet bestellKoerper, navigiert nur auf eine https-Adresse; ein zweiter Klick sendet nichts', mitDom({ antworten: [{ body: { url: KNOPF_URL } }] }, async (fx) => {
+ const daten = await bestellDaten()
+ const { wurzel } = await bestellSeite(daten)
+ await alleAnhaken(fx, wurzel)
+ const knopf = bestellKnopf(fx, wurzel)
+ const erster = fx.klick(knopf)
+ assert.equal(knopf.disabled, true, 'sofort gesperrt, noch vor der Antwort')
+ const zweiter = fx.klick(knopf)
+ await Promise.all([erster, zweiter])
+ assert.equal(fx.fetchLog.length, 1, 'genau eine Anfrage')
+ assert.deepEqual(fx.fetchLog[0], {
+ method: 'POST',
+ url: '/api/funktionen/termine/checkout',
+ body: { projectId: 'p1', zustimmung: { version: daten.bestellung.consent.version, haken: registerHaken() } },
+ })
+ assert.equal(fx.window.location.href, KNOPF_URL)
+ assert.equal(knopf.disabled, true, 'die Seite wird verlassen, der Knopf bleibt gesperrt')
+}))
+
+test('Bestellen: eine Adresse, die nicht https ist, wird nie angesteuert; Meldung am Knopf, der Knopf ist wieder frei', mitDom(undefined, async (fx) => {
+ for (const url of ['http://checkout.stripe.test/x', 'javascript:alert(1)', '//evil.example/x', '/relativ', '', undefined, 42]) {
+ fx.antworten.push({ body: { url } })
+ const { wurzel } = await bestellSeite(await bestellDaten())
+ await alleAnhaken(fx, wurzel)
+ const knopf = bestellKnopf(fx, wurzel)
+ await fx.klick(knopf)
+ assert.equal(fx.window.location.href, 'http://localhost/', `keine Navigation für ${JSON.stringify(url)}`)
+ assert.match(meldungVon(fx, wurzel), /Zahlungsseite/)
+ assert.equal(knopf.disabled, false, 'wieder frei')
+ }
+}))
+
+test('Bestellen: ein Fehler (500, kein Netz) steht als Meldung am Knopf, Haken bleiben, der Knopf ist wieder frei', mitDom({
+ antworten: [{ status: 500, body: { error: 'Die Bestellung konnte gerade nicht gestartet werden. Bitte versuch es später noch einmal oder schreib uns im Chat.' } }, () => { throw new TypeError('Failed to fetch') }, { status: 400, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } }],
+}, async (fx) => {
+ const { wurzel } = await bestellSeite(await bestellDaten())
+ await alleAnhaken(fx, wurzel)
+ const knopf = bestellKnopf(fx, wurzel)
+ await fx.klick(knopf)
+ assert.equal(meldungVon(fx, wurzel), 'Die Bestellung konnte gerade nicht gestartet werden. Bitte versuch es später noch einmal oder schreib uns im Chat.')
+ assert.equal(knopf.disabled, false)
+ assert.ok(hakenFelder(fx, wurzel).every((haken) => haken.checked), 'die Haken bleiben')
+ await fx.klick(knopf)
+ assert.equal(meldungVon(fx, wurzel), 'Keine Verbindung. Bitte versuch es gleich noch einmal.')
+ await fx.klick(knopf)
+ assert.equal(meldungVon(fx, wurzel), 'Bitte stimme der Erklärung zu, um zu bestellen.')
+ assert.equal(knopf.disabled, false)
+ assert.equal(fx.fetchLog.length, 3, 'kein Nachladen bei Fehlern außer 409')
+ assert.equal(fx.window.location.href, 'http://localhost/')
+}))
+
+test('Bestellen 409 bestellung_offen: lädt die Daten neu, setzt die Haken zurück und zeigt die Punkte aus body.offen', mitDom(undefined, async (fx) => {
+ const daten = await bestellDaten()
+ const neu = await bestellDaten({ offen: [] })
+ const offen = [{ schritt: 'website', text: 'Bitte trage die Adresse deines Impressums ein.' }, { schritt: 'zeiten', text: 'Bitte wähle das Bundesland deines Betriebs.' }]
+ fx.antworten.push(
+ { status: 409, body: { error: 'Die Bestellung ist noch nicht vollständig. Bitte erledige zuerst die offenen Punkte.', code: 'bestellung_offen', offen } },
+ { body: neu },
+ )
+ const { wurzel, schritte } = await bestellSeite(daten)
+ await alleAnhaken(fx, wurzel)
+ await fx.klick(bestellKnopf(fx, wurzel))
+ assert.deepEqual(fx.fetchLog.map((a) => [a.method, a.url]), [['POST', '/api/funktionen/termine/checkout'], ['GET', '/api/termine/einstellungen?projectId=p1']])
+ assert.deepEqual(fx.alle(wurzel, '.tm-bestellen-offen li').map((li) => fx.finde(li, 'span').textContent), offen.map((p) => p.text))
+ assert.ok(hakenFelder(fx, wurzel).every((haken) => !haken.checked), 'Haken zurückgesetzt')
+ assert.equal(bestellKnopf(fx, wurzel).disabled, true)
+ assert.equal(meldungVon(fx, wurzel), 'Die Bestellung ist noch nicht vollständig. Bitte erledige zuerst die offenen Punkte.')
+ await fx.klick(fx.alle(wurzel, '[data-aktion="zum-schritt"]')[1])
+ assert.deepEqual(schritte, ['zeiten'], 'die neue Seite führt zu den Schritten')
+}))
+
+test('Bestellen 409 fassung_veraltet: zeigt die neue Fassung aus body.consent, Haken zurück, der nächste Körper trägt die neue Version', mitDom(undefined, async (fx) => {
+ const daten = await bestellDaten()
+ const neueFassung = {
+ version: '2026-11-01',
+ knopf: 'Neuer Knopf · 149 €',
+ items: [{ id: 'unterlagen', text: 'Neuer Wortlaut der Unterlagen.', haken: true }, { id: 'hinweis', text: 'Ein neuer Hinweis.', haken: false }, { id: 'beginn', text: 'Neuer Wortlaut des Beginns.', haken: true }],
+ }
+ fx.antworten.push(
+ { status: 409, body: { error: 'Die Erklärungen wurden inzwischen geändert. Bitte lies sie noch einmal und stimme neu zu.', code: 'fassung_veraltet', consent: neueFassung } },
+ { body: await bestellDaten() },
+ { body: { url: KNOPF_URL } },
+ )
+ const { wurzel } = await bestellSeite(daten)
+ await alleAnhaken(fx, wurzel)
+ await fx.klick(bestellKnopf(fx, wurzel))
+ assert.equal(meldungVon(fx, wurzel), 'Die Erklärungen wurden inzwischen geändert. Bitte lies sie noch einmal und stimme neu zu.')
+ assert.equal(fx.text(bestellKnopf(fx, wurzel)), 'Neuer Knopf · 149 €')
+ assert.deepEqual(hakenFelder(fx, wurzel).map((haken) => [haken.dataset.haken, fx.text(haken.closest('label')), haken.checked]), [
+ ['unterlagen', 'Neuer Wortlaut der Unterlagen.', false], ['beginn', 'Neuer Wortlaut des Beginns.', false],
+ ])
+ assert.ok(fx.text(wurzel).includes('Ein neuer Hinweis.'))
+ assert.equal(bestellKnopf(fx, wurzel).disabled, true)
+ await alleAnhaken(fx, wurzel)
+ await fx.klick(bestellKnopf(fx, wurzel))
+ assert.deepEqual(fx.fetchLog.at(-1).body, { projectId: 'p1', zustimmung: { version: '2026-11-01', haken: ['unterlagen', 'beginn'] } })
+ assert.equal(fx.window.location.href, KNOPF_URL)
+}))
+
+test('Bestellen 409 mit Abo-Code: der Text aus der Antwort steht am Knopf, Daten neu geladen, Haken zurück', mitDom(undefined, async (fx) => {
+ for (const [code, text] of [
+ ['kein_abo', 'Für den Terminplaner brauchst du ein laufendes Wartungs-Abo.'],
+ ['gekuendigt', 'Dein Wartungs-Abo ist gekündigt. Für den Terminplaner brauchst du ein laufendes Abo.'],
+ ['nicht_aktiv', 'Dein Wartungs-Abo ist gerade nicht aktiv (zum Beispiel wegen einer offenen Zahlung). Sobald es wieder aktiv ist, kannst du den Terminplaner bestellen.'],
+ ]) {
+ fx.fetchLog.length = 0
+ fx.antworten.push({ status: 409, body: { error: text, code, grund: code } }, { body: await bestellDaten({ abo: { ok: false, grund: code }, offen: [{ schritt: 'bestellen', text }] }) })
+ const { wurzel } = await bestellSeite(await bestellDaten())
+ await alleAnhaken(fx, wurzel)
+ await fx.klick(bestellKnopf(fx, wurzel))
+ assert.equal(meldungVon(fx, wurzel), text, code)
+ assert.equal(fx.fetchLog.length, 2, 'Daten neu geladen')
+ assert.ok(hakenFelder(fx, wurzel).every((haken) => !haken.checked))
+ assert.equal(bestellKnopf(fx, wurzel).disabled, true)
+ }
+}))
+
+test('Bestellen 409: schlägt das Neuladen fehl, bleibt die Seite stehen und die Meldung steht am Knopf', mitDom(undefined, async (fx) => {
+ fx.antworten.push({ status: 409, body: { error: 'Dein Wartungs-Abo ist gekündigt.', code: 'gekuendigt' } }, { status: 500, body: { error: 'Server kaputt.' } })
+ const { wurzel } = await bestellSeite(await bestellDaten())
+ await alleAnhaken(fx, wurzel)
+ const knopf = bestellKnopf(fx, wurzel)
+ await fx.klick(knopf)
+ assert.equal(bestellKnopf(fx, wurzel), knopf, 'dieselbe Seite')
+ assert.equal(meldungVon(fx, wurzel), 'Dein Wartungs-Abo ist gekündigt.')
+ assert.equal(knopf.disabled, false)
+}))
+
+test('Bestellen 409: ist die Bestellung inzwischen aufgegeben (keine bestellung mehr in den Daten), steht nur noch die Meldung mit Hinweis zum Neuladen', mitDom(undefined, async (fx) => {
+ fx.antworten.push({ status: 409, body: { error: 'Der Terminplaner ist für dieses Projekt schon bestellt.' } }, { body: await bestellDaten({ state: 'ordered', bestellung: null, offen: null }) })
+ const { wurzel } = await bestellSeite(await bestellDaten())
+ await alleAnhaken(fx, wurzel)
+ await fx.klick(bestellKnopf(fx, wurzel))
+ assert.equal(fx.finde(wurzel, '[data-aktion="bestellen"]'), null, 'die Bestellseite ist weg')
+ assert.match(fx.text(wurzel), /Der Terminplaner ist für dieses Projekt schon bestellt\./)
+ assert.match(fx.text(wurzel), /Lade die Seite neu/)
+}))
+
+test('Bestellen 409: eine inzwischen verlassene Ansicht (aktuell() falsch) wird nicht neu gezeichnet', mitDom(undefined, async (fx) => {
+ fx.antworten.push({ status: 409, body: { error: 'Nicht vollständig.', code: 'bestellung_offen', offen: [{ schritt: 'website', text: 'x' }] } }, { body: await bestellDaten() })
+ const daten = await bestellDaten()
+ const wurzel = h('div')
+ document.body.append(wurzel)
+ let aktuell = true
+ const ctx = huelle.baueCtx({ daten, nurLesen: false }, { projekt: { projectId: 'p1' }, aktuell: () => aktuell })
+ bestellen.renderBestellen(wurzel, { ctx, daten, zumSchritt() {} })
+ await alleAnhaken(fx, wurzel)
+ const knopf = bestellKnopf(fx, wurzel)
+ const klick = fx.klick(knopf)
+ aktuell = false
+ await klick
+ assert.equal(bestellKnopf(fx, wurzel), knopf, 'die alte Seite bleibt unberührt')
+}))
+
+// ------------------------------------------------------- statusKarte
+const statusProjekt = (aenderung = {}) => ({
+ projectId: 'p1',
+ projectName: 'Salon Muster',
+ features: { termine: { state: 'ordered', orderedAt: '2026-10-05T10:00:00.000Z', ticketWoid: null, invoiceUrl: null, ...aenderung } },
+})
+const EINRICHTUNG = 'meist innerhalb von 1–2 Wochen'
+function karte(aenderung = {}, optionen = {}, rueckruf = {}) {
+ const projekt = statusProjekt(aenderung)
+ const wurzel = h('div')
+ document.body.append(wurzel)
+ wurzel.append(bestellen.statusKarte({ projekt, bestellung: projekt.features.termine, einrichtung: EINRICHTUNG, danke: false, kundenansicht: false, ...optionen }, rueckruf))
+ return wurzel
+}
+
+test('statusKarte ordered: Pille „Zahlung offen“, Bestelldatum, SEPA-Hinweis, „Zahlung fortsetzen“ und Chat-Knopf', mitDom(undefined, (fx) => {
+ const wurzel = karte()
+ const el = fx.finde(wurzel, '.tm-statuskarte')
+ assert.ok(el)
+ assert.equal(fx.text(fx.finde(el, '.tm-pille')), 'Zahlung offen')
+ assert.match(fx.text(el), /Bestellt am\s*05\.10\.2026/)
+ assert.match(fx.text(el), /SEPA[^.]*einige Tage/)
+ assert.equal(fx.text(fx.finde(el, '[data-aktion="fortsetzen"]')), 'Zahlung fortsetzen')
+ assert.equal(fx.text(fx.finde(el, '[data-view="chat"]')).includes('Chat'), true)
+ assert.doesNotMatch(fx.text(el), /Ticketnummer|In Einrichtung|Danke!/)
+ assert.equal(fx.alle(el, 'a').length, 0, 'in ordered gibt es keine Rechnung')
+}))
+
+test('statusKarte ordered: „Zahlung fortsetzen“ sendet fortsetzenKoerper und navigiert zur offenen Sitzung', mitDom({ antworten: [{ body: { url: KNOPF_URL } }] }, async (fx) => {
+ const wurzel = karte()
+ const knopf = fx.finde(wurzel, '[data-aktion="fortsetzen"]')
+ const klick = fx.klick(knopf)
+ assert.equal(knopf.disabled, true, 'ein Klick sperrt ihn')
+ await klick
+ assert.deepEqual(fx.fetchLog, [{ method: 'POST', url: '/api/funktionen/termine/checkout', body: { projectId: 'p1', fortsetzen: true } }])
+ assert.equal(fx.window.location.href, KNOPF_URL)
+}))
+
+test('statusKarte ordered: 409 zustimmung_fehlt ⇒ weiter() lädt neu und zeigt das Bestellformular, keine Navigation', mitDom({
+ antworten: [{ status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } }],
+}, async (fx) => {
+ let weiter = 0
+ const wurzel = karte({}, {}, { weiter: async () => { weiter += 1 } })
+ await fx.klick(fx.finde(wurzel, '[data-aktion="fortsetzen"]'))
+ assert.equal(weiter, 1)
+ assert.equal(fx.window.location.href, 'http://localhost/')
+}))
+
+test('statusKarte ordered: ohne weiter() oder bei anderem Fehler steht die Meldung am Knopf, der Knopf ist wieder frei', mitDom({
+ antworten: [{ status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } }, { status: 409, body: { error: 'Der Terminplaner ist für dieses Projekt schon bestellt.' } }, { body: { url: 'http://unsicher.example/x' } }],
+}, async (fx) => {
+ let weiter = 0
+ const ohne = karte()
+ const knopf = fx.finde(ohne, '[data-aktion="fortsetzen"]')
+ await fx.klick(knopf)
+ assert.equal(fx.text(fx.finde(ohne, '[data-meldung="fortsetzen"]')), 'Bitte stimme der Erklärung zu, um zu bestellen.')
+ assert.equal(knopf.disabled, false)
+ const mit = karte({}, {}, { weiter: () => { weiter += 1 } })
+ await fx.klick(fx.finde(mit, '[data-aktion="fortsetzen"]'))
+ assert.equal(weiter, 0, 'weiter() nur bei zustimmung_fehlt')
+ assert.equal(fx.text(fx.finde(mit, '[data-meldung="fortsetzen"]')), 'Der Terminplaner ist für dieses Projekt schon bestellt.')
+ const unsicher = karte()
+ await fx.klick(fx.finde(unsicher, '[data-aktion="fortsetzen"]'))
+ assert.equal(fx.window.location.href, 'http://localhost/', 'keine http-Navigation')
+ assert.match(fx.text(fx.finde(unsicher, '[data-meldung="fortsetzen"]')), /Zahlungsseite/)
+}))
+
+test('statusKarte ordered: in der Kundenansicht ist „Zahlung fortsetzen“ gesperrt, mit Hinweis und ohne Anfrage', mitDom(undefined, async (fx) => {
+ const wurzel = karte({}, { kundenansicht: true })
+ const knopf = fx.finde(wurzel, '[data-aktion="fortsetzen"]')
+ assert.equal(knopf.disabled, true)
+ assert.match(fx.text(wurzel), /In der Kundenansicht kann nicht bestellt werden\./)
+ await fx.klick(knopf)
+ assert.equal(fx.fetchLog.length, 0)
+}))
+
+test('statusKarte setup: „In Einrichtung“, Ticketnummer, Bestelldatum, Rechnungslink, Zeiteinschätzung, gesperrte Einstellungen, kein „Zahlung fortsetzen“', mitDom(undefined, (fx) => {
+ const wurzel = karte({ state: 'setup', ticketWoid: 100342, invoiceUrl: 'https://invoice.stripe.test/i/in_1' })
+ const el = fx.finde(wurzel, '.tm-statuskarte')
+ assert.equal(fx.text(fx.finde(el, '.tm-pille')), 'In Einrichtung')
+ assert.match(fx.text(el), /Ticketnummer\s*#100342/)
+ assert.match(fx.text(el), /Bestellt am\s*05\.10\.2026/)
+ assert.match(fx.text(el), new RegExp(`Zeiteinschätzung\\s*${EINRICHTUNG}`))
+ assert.ok(fx.text(el).includes('Deine Einstellungen sind jetzt gesperrt, Änderungen laufen über den Chat.'))
+ const rechnung = fx.alle(el, 'a')
+ assert.deepEqual(rechnung.map((a) => [fx.text(a), a.getAttribute('href'), a.getAttribute('rel'), a.getAttribute('target')]), [['Rechnung ansehen', 'https://invoice.stripe.test/i/in_1', 'noopener noreferrer', '_blank']])
+ assert.equal(fx.finde(el, '[data-aktion="fortsetzen"]'), null)
+ assert.ok(fx.finde(el, '[data-view="chat"]'))
+ assert.doesNotMatch(fx.text(el), /SEPA|Zahlung offen/)
+}))
+
+test('statusKarte setup: ohne Ticket „Wir legen dein Ticket gerade an.“, ohne sichere Rechnungsadresse kein Link', mitDom(undefined, (fx) => {
+ const ohneTicket = fx.text(karte({ state: 'setup', ticketWoid: null }))
+ assert.ok(ohneTicket.includes('Wir legen dein Ticket gerade an.'))
+ assert.doesNotMatch(ohneTicket, /#/)
+ for (const invoiceUrl of ['javascript:alert(1)', 'http://invoice.stripe.test/x', '//evil.example/i', null, '']) {
+ assert.equal(fx.alle(karte({ state: 'setup', invoiceUrl }), 'a').length, 0, `kein Link für ${JSON.stringify(invoiceUrl)}`)
+ }
+}))
+
+test('statusKarte: „Danke!“ nach der Rückkehr von Stripe, in ordered und setup, sonst nicht', mitDom(undefined, (fx) => {
+ const DANKE = 'Danke! Sobald die Zahlung da ist, legen wir los.'
+ for (const state of ['ordered', 'setup']) {
+ const mit = karte({ state }, { danke: true })
+ assert.equal(fx.text(fx.finde(mit, '[data-danke]')), DANKE, state)
+ assert.equal(fx.finde(mit, '[data-danke]').getAttribute('role'), 'status')
+ assert.ok(!fx.text(karte({ state }, { danke: false })).includes('Danke!'))
+ }
+}))
+
+test('statusKarte: Texte vom Server (Ticketnummer, Datum, Einrichtung) nur als Text', mitDom(undefined, (fx) => {
+ const boese = '
'
+ const projekt = statusProjekt({ state: 'setup', ticketWoid: boese, orderedAt: boese })
+ const wurzel = h('div')
+ document.body.append(wurzel)
+ wurzel.append(bestellen.statusKarte({ projekt, bestellung: projekt.features.termine, einrichtung: boese, danke: false, kundenansicht: false }, {}))
+ assert.equal(fx.alle(wurzel, 'img').length, 0)
+ assert.ok(fx.text(wurzel).includes(boese))
+}))