From 20583424a39e058ea0edd3991f90cb69129e5bb0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 21:27:36 +0000 Subject: [PATCH] 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 --- public/funktion-bestellen.js | 321 ++++++++++++++ public/termine-dom.js | 2 + public/termine.css | 41 ++ test/termineFragebogenView.test.js | 689 ++++++++++++++++++++++++++++- 4 files changed, 1051 insertions(+), 2 deletions(-) create mode 100644 public/funktion-bestellen.js 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)) +}))