Funktionen: Bestellseite und Statuskarten

Terminplaner TP2 C8: public/funktion-bestellen.js mit sicherLink, bestellKoerper,
fortsetzenKoerper, renderBestellen (Zusammenfassung, offene Punkte, Preis,
vollstaendige Widerrufsbelehrung als scrollbarer Text, Unterlagen, Zustimmungs-Haken,
Knopf mit bestellung.consent.knopf) und statusKarte (ordered/setup, Danke).
request() in termine-dom.js behaelt den ganzen Body der Fehlerantwort (err.body),
damit 409 bestellung_offen und fassung_veraltet offen/consent zeigen koennen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 21:27:36 +00:00
1 parent 747be432c5
commit 20583424a3
4 files changed
+1051 -2

No files matched your search

+321
View File
@@ -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()))
}
+2
View File
@@ -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
+41
View File
@@ -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; } }
+687 -2
View File
@@ -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,<script>alert(1)</script>', '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 = '<img src=x onerror=alert(1)>'
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 = '<img src=x onerror=alert(1)>'
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))
}))