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:
1 parent
747be432c5
commit
20583424a3
4 files changed
+1051
-2
No files matched your search
@@ -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()))
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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; } }
|
||||
@@ -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))
|
||||
}))
|
||||
Reference in new issue
Block a user