From 87d267e51a500a2874214c2e53c8bda6ec8a2396 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 23:10:54 +0000 Subject: [PATCH] Terminplaner TP2: Ansicht nach Zustand und Rueckkehr von Stripe Die Ansicht #termine folgt dem Zustand aus GET /api/termine/einstellungen: off mit laufendem Abo zeigt den Fragebogen samt Bestellseite, ohne Abo den Hinweis des Servers (termine.hinweis, R111) und bei kein_abo den Link zu Vertraege aus einer gemeinsamen Quelle (vertraege-link.js); ordered und setup zeigen die Statuskarte und die Reiter Leistungen, Team & Plaetze und Einstellungen nur zum Lesen (ohne Reiter Termine); active und disabled bleiben wie bisher. Rueckkehr von Stripe: #termine?bestellt=1&projekt= und #termine?projekt= waehlen das Projekt, "Danke!" nur mit bestellt=1, die Adresse wird einmal gelesen und bereinigt. R110: GET /einstellungen liefert bestellung und offen auch in ordered (ohne Abo-Punkt, abo bleibt null); scheitert "Zahlung fortsetzen", zeichnet die Huelle Liste und Daten neu (bei 409 zustimmung_fehlt mit dem Bestellformular, sonst nach dem neuen Zustand, die Meldung bleibt am Knopf, solange die Bestellung offen ist). Dazu: "null" als Text im lesbaren Reiter Leistungen entfernt, Export und Status der Einstellungen erst ab der Freischaltung. Co-Authored-By: Claude Sonnet 5.5 --- public/funktion-bestellen.js | 23 +- public/funktionen.js | 6 +- public/termine-tab-einstellungen.js | 2 + public/termine-tab-leistungen.js | 6 +- public/termine.js | 219 ++++++++-- public/vertraege-link.js | 5 + server/routes/termine.js | 9 +- test/funktionenTiles.test.js | 10 +- test/support/fakeDom.js | 4 +- test/termineFragebogenRoutes.test.js | 56 ++- test/termineFragebogenView.test.js | 575 ++++++++++++++++++++++++++- test/termineView.test.js | 27 +- test/ui/termine-portal.ui.mjs | 206 +++++++++- 13 files changed, 1077 insertions(+), 71 deletions(-) create mode 100644 public/vertraege-link.js diff --git a/public/funktion-bestellen.js b/public/funktion-bestellen.js index 0d9c151..8dfcad5 100644 --- a/public/funktion-bestellen.js +++ b/public/funktion-bestellen.js @@ -259,10 +259,14 @@ function baueSeite(root, { ctx, daten, zumSchritt }, uebernahme) { * 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. + * Kundenansicht, meldung = Text, der am Knopf „Zahlung fortsetzen“ steht (z. B. der Fehler eines Versuchs vor dem Neuzeichnen). + * weiter(fall) wird gerufen, wenn „Zahlung fortsetzen“ scheitert (Ruling R110): fall = { bestellen, meldung }, bestellen ist wahr bei 409 + * zustimmung_fehlt (keine offene Sitzung mit Nachweis: die Ansicht zeigt wieder das Bestellformular), meldung der Fehlertext der Antwort. + * weiter() lädt neu und zeichnet die Ansicht nach dem Zustand von neuem, damit keine veraltete Karte stehen bleibt (z. B. wenn die Bestellung + * inzwischen bezahlt ist und der Server 400 antwortet); wirft es, bleibt die Karte stehen und der Fehler steht am Knopf. Fehlt weiter, + * steht die Meldung des Servers am Knopf. */ -export function statusKarte({ projekt, bestellung, einrichtung, danke, kundenansicht } = {}, { weiter } = {}) { +export function statusKarte({ projekt, bestellung, einrichtung, danke, kundenansicht, meldung } = {}, { weiter } = {}) { const bestellt = bestellung?.state === 'ordered' const datum = datumTag(bestellung?.orderedAt) const ticket = bestellung?.ticketWoid @@ -275,7 +279,7 @@ export function statusKarte({ projekt, bestellung, einrichtung, danke, kundenans !bestellt && text(einrichtung) ? zeile('Zeiteinschätzung', einrichtung) : null, ].filter(Boolean) - const meldungEl = h('p', { class: 'tm-fehler', role: 'alert', 'data-meldung': 'fortsetzen' }) + const meldungEl = h('p', { class: 'tm-fehler', role: 'alert', 'data-meldung': 'fortsetzen' }, text(meldung) || null) const fortsetzenKnopf = bestellt ? h('button', { type: 'button', class: 'kb-btn-primary', 'data-aktion': 'fortsetzen', disabled: kundenansicht === true, onclick: () => fortsetzen() }, 'Zahlung fortsetzen') : null @@ -289,15 +293,18 @@ export function statusKarte({ projekt, bestellung, einrichtung, danke, kundenans 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') { + const nachricht = err?.message || ZAHLUNGSSEITE + const zustimmungFehlt = err?.status === 409 && err.code === 'zustimmung_fehlt' + if (typeof weiter === 'function') { try { - await weiter() + await weiter({ bestellen: zustimmungFehlt, meldung: nachricht }) return // die Ansicht zeichnet sich neu } catch (fehler) { - meldungEl.textContent = fehler?.message || ZAHLUNGSSEITE + // Neuladen gescheitert: bei fehlender Zustimmung ist das der Grund, der den Kunden betrifft, sonst bleibt der Fehler des Versuchs + meldungEl.textContent = zustimmungFehlt ? fehler?.message || ZAHLUNGSSEITE : nachricht } } else { - meldungEl.textContent = err?.message || ZAHLUNGSSEITE + meldungEl.textContent = nachricht } } fortsetzenKnopf.disabled = false diff --git a/public/funktionen.js b/public/funktionen.js index 91e0473..d4a67d6 100644 --- a/public/funktionen.js +++ b/public/funktionen.js @@ -5,6 +5,7 @@ import { bannerColor, BANNER_PREVIEW_TEXT, controllerLines, chartModel, barRows, countryName, flagEmoji, refLabel, DEVICE_LABELS, CONTACT_LABELS } from '/analyse-view.js' import { HEAT_DEVICES, deviceHtml, heatCaption } from '/geraete.js' +import { ZU_VERTRAEGE } from './vertraege-link.js' const PRICE = '30 €' // Rueckfall, wenn die Antwort von /api/funktionen kein features.news (Register) enthaelt const LEISTUNGSBESCHREIBUNG = '/vertragsunterlagen/funktionen-leistungsbeschreibung.html' @@ -112,8 +113,9 @@ function newsTile(projects, price = PRICE) { ` } -// Link in den Hinweisen ohne Wartungs-Abo (kein_abo): Analyse-Kachel, Analyse-Seite und Terminplaner-Kachel teilen ihn -const ZU_VERTRAEGE_LINK = '' +// Link in den Hinweisen ohne Wartungs-Abo (kein_abo): Analyse-Kachel, Analyse-Seite und Terminplaner-Kachel teilen ihn, und die Ansicht +// #termine (termine.js) nimmt Text, Ziel und Klasse aus derselben Quelle (vertraege-link.js) +const ZU_VERTRAEGE_LINK = `` /* Analyse: Kachel je nach Zustand der Websites (comingSoon = Schalter aus) */ const analyseOf = (project) => project.analyse || { comingSoon: true } diff --git a/public/termine-tab-einstellungen.js b/public/termine-tab-einstellungen.js index 851a20b..c7ebc38 100644 --- a/public/termine-tab-einstellungen.js +++ b/public/termine-tab-einstellungen.js @@ -341,6 +341,8 @@ function einbau(ctx) { /* ------------------------------------------------------------ Export, Status --- */ function exportUndStatus(ctx) { + // vor der Freischaltung (off, ordered, setup) gibt es weder Termine zum Exportieren noch ein „Aktiv“; null: der Aufrufer laesst den Abschnitt weg + if (['off', 'ordered', 'setup'].includes(ctx.daten.state)) return null const seit = aktivSeitText(ctx.daten.aktivSeit) const abgeschaltet = ctx.daten.state === 'disabled' return abschnitt('Export und Status', null, diff --git a/public/termine-tab-leistungen.js b/public/termine-tab-leistungen.js index 03dba47..6271d7e 100644 --- a/public/termine-tab-leistungen.js +++ b/public/termine-tab-leistungen.js @@ -92,7 +92,8 @@ export function render(panel, ctx) { }) aktualisiere() - wurzel.replaceChildren( + // neu.leiste ist null, solange die Funktion nur lesbar ist: replaceChildren wuerde sonst den Text "null" in die Seite schreiben + wurzel.replaceChildren(...[ sp.konflikt, h('div', { class: 'tm-box tm-box-info', role: 'note' }, h('span', null, h('strong', null, GESUNDHEIT_HINWEIS), ' Namen und Beschreibungen deiner Leistungen liest jeder, der auf deiner Website bucht. Schreib dort zum Beispiel keine Krankheiten oder Beschwerden hinein.')), @@ -100,7 +101,8 @@ export function render(panel, ctx) { neu.box, leiste, kopfFehler, - leistungen.length ? liste : h('p', { class: 'tm-leer' }, 'Noch keine Leistungen. Ohne Leistung kann niemand buchen.')) + leistungen.length ? liste : h('p', { class: 'tm-leer' }, 'Noch keine Leistungen. Ohne Leistung kann niemand buchen.'), + ].filter(Boolean)) } zeichne() } diff --git a/public/termine.js b/public/termine.js index 4b1dadd..7db98a9 100644 --- a/public/termine.js +++ b/public/termine.js @@ -1,10 +1,16 @@ /* Terminplaner im Kundenbereich (ES-Modul; app.js laedt es erst beim Oeffnen der Ansicht #termine). Das ist die Huelle: - initTermine(rootEl, { projectId }): Projektauswahl, Vorlagenauswahl beim ersten Oeffnen, Reiter-Geruest und die Meldung ueber den Reitern. + initTermine(rootEl, { projectId }): Projektauswahl und je nach Zustand der Funktion (GET /api/termine/einstellungen, state) die Ansicht: + off: ohne laufendes Wartungs-Abo (daten.abo.ok) der Hinweis wie in der Kachel (Text vom Server, GET /api/funktionen termine.hinweis; kein + Fragebogen), sonst der Fragebogen mit der Bestellseite (termine-fragebogen.js); ordered und setup: die Statuskarte (funktion-bestellen.js) + und darunter die Reiter Leistungen, Team & Plaetze und Einstellungen nur zum Lesen (der Reiter Termine entfaellt, der Server antwortet dort + mit 409); active: die Reiter, ohne Einstellung die Vorlagenauswahl; disabled: der Banner der Abschaltung (abschaltText) und die Reiter nur + zum Lesen. Rueckkehr von Stripe: #termine?bestellt=1&projekt= (Zahlung) und #termine?projekt= (Abbruch) waehlen das Projekt, + „Danke!“ gibt es nur mit bestellt=1; beides wird einmal gelesen und aus der Adresse genommen. Die Reiter stehen in REITER: jeder ist ein Modul mit render(panel, ctx) => void | Promise (termine-tab-termine.js, termine-tab-leistungen.js, termine-tab-team.js, termine-tab-einstellungen.js; die drei letzten speichern ueber termine-speichern.js). ctx = { s, projectId, daten, nurLesen, auswahl, aktuell(), api, meldung, hinweise, zumReiter, neuLaden } (baueCtx): Zustand und Pruefung "bin ich noch aktuell?" bekommen die Reiter nur ueber ctx, nie ueber Modulvariablen. daten ist die Antwort von GET /api/termine/einstellungen - (die Reiter, die speichern, halten daten.einstellung und daten.version aktuell), nurLesen gilt fuer abgeschaltete Funktionen, + (die Reiter, die speichern, halten daten.einstellung und daten.version aktuell), nurLesen gilt fuer alles ausser off und active, hinweise() zeichnet die Hinweisleiste ueber den Reitern neu (z. B. wenn Telefon und Anschrift nach dem Speichern nicht mehr fehlen). Server: server/routes/termine.js. Tage sind Berliner Kalendertage 'JJJJ-MM-TT'; gerechnet wird in termine-view.js. Helfer ohne Zustand: termine-dom.js. Styles: termine.css. */ @@ -14,19 +20,40 @@ import { render as renderTermine } from './termine-tab-termine.js' import { render as renderLeistungen } from './termine-tab-leistungen.js' import { render as renderTeam } from './termine-tab-team.js' import { render as renderEinstellungen } from './termine-tab-einstellungen.js' +import { renderFragebogen } from './termine-fragebogen.js' +import { renderBestellen, statusKarte } from './funktion-bestellen.js' +import { ZU_VERTRAEGE } from './vertraege-link.js' // Auswahl bleibt beim Wechsel zwischen Funktionen und Terminplaner erhalten (der Tag nicht: er beginnt immer bei heute) const auswahl = { projectId: '', reiter: 'termine', ansicht: 'tag', filter: 'alle' } let S = null // aktuelle Sitzung der Ansicht; eine alte Antwort darf eine neuere nicht ueberschreiben (S !== s) /* --------------------------------------------------------- Einstieg --- */ +/** + * Rueckkehr von Stripe aus der Adresse lesen und die Adresse bereinigen (wie initNews): #termine?bestellt=1&projekt= nach der Zahlung + * (success_url) und #termine?projekt= nach dem Abbruch (cancel_url). Gelesen wird einmal, danach steht nur noch #termine da. + */ +function leseRueckkehr() { + const hash = window.location.hash || '' + if (!hash.startsWith('#termine?')) return { bestellt: false, projekt: '' } + const query = new URLSearchParams(hash.slice(hash.indexOf('?') + 1)) + window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}#termine`) + return { bestellt: query.get('bestellt') === '1', projekt: query.get('projekt') || '' } +} + +const zustandVon = (projekt) => projekt?.features?.termine?.state || 'off' +const istBestellt = (zustand) => zustand === 'ordered' || zustand === 'setup' +// Entwurf: Funktionsdokument im Zustand Aus mit siteKey (wie die Kachel in funktionen.js) +const hatEntwurf = (projekt) => zustandVon(projekt) === 'off' && Boolean(projekt?.features?.termine?.siteKey) + export async function initTermine(root, { projectId } = {}) { if (!root) return - const wunsch = projectId || root.dataset.wunschProjekt || '' // die Kachel in Funktionen legt ihr Projekt hier ab + const rueckkehr = leseRueckkehr() + const wunsch = rueckkehr.projekt || projectId || root.dataset.wunschProjekt || '' // die Kachel in Funktionen legt ihr Projekt hier ab delete root.dataset.wunschProjekt if (wunsch) auswahl.projectId = wunsch const { projects = [], termine } = await request('/api/funktionen') - const s = { root, projects, projekt: null, daten: null, tag: heuteBerlin(), nurLesen: false, lauf: 0, buehne: null, meldungEl: null } + const s = { root, projects, termine: termine || null, projekt: null, daten: null, tag: heuteBerlin(), nurLesen: false, lauf: 0, buehne: null, meldungEl: null, danke: false } S = s if (!termine?.verfuegbar) { root.replaceChildren(infoKarte('Der Terminplaner ist noch nicht verfügbar', 'Sobald er für dich bereit ist, findest du ihn hier.')) @@ -36,10 +63,13 @@ export async function initTermine(root, { projectId } = {}) { root.replaceChildren(h('div', { class: 'kb-panel kb-empty' }, 'Noch keine Website-Projekte zugewiesen.')) return } - const zustand = (p) => p.features?.termine?.state + // Wahl wie bei der Kachel: das gewuenschte Projekt, dann aktiv, bestellt, abgeschaltet, Entwurf, das erste s.projekt = projects.find((p) => p.projectId === auswahl.projectId) - || projects.find((p) => zustand(p) === 'active') || projects.find((p) => zustand(p) === 'disabled') || projects[0] + || projects.find((p) => zustandVon(p) === 'active') || projects.find((p) => istBestellt(zustandVon(p))) + || projects.find((p) => zustandVon(p) === 'disabled') || projects.find(hatEntwurf) || projects[0] auswahl.projectId = s.projekt.projectId + // „Danke!“ nur nach der Zahlung (bestellt=1) und nur fuer das Projekt, fuer das bezahlt wurde + s.danke = rueckkehr.bestellt && (!rueckkehr.projekt || rueckkehr.projekt === s.projekt.projectId) s.buehne = h('div', { id: 'termine-buehne' }) root.replaceChildren(...(projects.length > 1 ? [projektwahl(s)] : []), s.buehne) await zeigeProjekt(s) @@ -55,6 +85,7 @@ function projektwahl(s) { auswahl.projectId = select.value s.projekt = s.projects.find((p) => p.projectId === select.value) s.tag = heuteBerlin() + s.danke = false // der Dank gilt dem Projekt, mit dem man von Stripe zurueckkam zeigeProjekt(s) }) return h('div', { class: 'fn-project kb-panel' }, h('label', { for: 'termine-project' }, 'Website'), select) @@ -62,6 +93,18 @@ function projektwahl(s) { const ladeDaten = (projectId) => request(`/api/termine/einstellungen?projectId=${encodeURIComponent(projectId)}`) +/** + * Liste der Funktionen neu holen (GET /api/funktionen): Projekte, das aktuelle Projekt darin, Preis, Einrichtungsdauer und Abo-Hinweis des + * Servers. Wirft bei einem Fehler und aendert dann nichts. + */ +async function ladeFunktionen(s) { + const antwort = await request('/api/funktionen') + const projects = Array.isArray(antwort.projects) ? antwort.projects : [] + s.projects = projects + s.termine = antwort.termine || null + s.projekt = projects.find((p) => p.projectId === s.projekt?.projectId) || s.projekt +} + /** * Hinweis fuer abgeschaltete Funktionen: Grund, Loeschdatum und (ausser vor der Einrichtung) der Export-Link, der einzige Link im Hinweis. * abgeschaltet: { grund, seit, loeschungAm } aus GET /einstellungen (fehlt bei einer aelteren Antwort: dann der bisherige Satz). @@ -73,19 +116,13 @@ function hinweisAbgeschaltet(projectId, abgeschaltet) { } /** - * Je nach Zustand der Funktion: nicht freigeschaltet, Vorlage waehlen oder die Reiter. Jeder Aufruf zaehlt s.lauf hoch: die Antwort + * Je nach Zustand der Funktion die Ansicht zeigen (siehe Kopf der Datei). Jeder Aufruf zaehlt s.lauf hoch: die Antwort * einer frueheren Website (langsam, dann zur anderen gewechselt) wird verworfen und erscheint nie unter der neuen. */ -async function zeigeProjekt(s) { +async function zeigeProjekt(s, optionen = {}) { const projekt = s.projekt const lauf = ++s.lauf const aktuell = () => S === s && s.lauf === lauf - const zustand = projekt.features?.termine?.state || 'off' - s.nurLesen = zustand !== 'active' - if (zustand !== 'active' && zustand !== 'disabled') { - s.buehne.replaceChildren(infoKarte('Der Terminplaner ist für diese Website noch nicht freigeschaltet', 'Wir schalten den Terminplaner für dich frei.', { chat: true })) - return - } s.buehne.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…')) let daten try { @@ -95,8 +132,39 @@ async function zeigeProjekt(s) { return } if (!aktuell()) return + await zeichneAnsicht(s, { daten, aktuell, ...optionen }) +} + +/** + * Ansicht nach einer gescheiterten Aktion der Statuskarte neu aufbauen (Ruling R110): Liste und Daten frisch holen und erst dann zeichnen. + * Scheitert das Laden, wirft die Funktion und die Karte bleibt mit ihrer Meldung stehen. optionen: bestellformular (ordered zeigt das + * Bestellformular statt der Karte), meldung ({ text, art }, nur im Zustand ordered noch von Belang). + */ +async function ladeNeu(s, optionen = {}) { + const lauf = s.lauf + await ladeFunktionen(s) + const daten = await ladeDaten(s.projekt.projectId) + if (S !== s || s.lauf !== lauf) return // inzwischen hat ein Wechsel der Website die Ansicht ersetzt + const nr = ++s.lauf + await zeichneAnsicht(s, { daten, aktuell: () => S === s && s.lauf === nr, ...optionen }) +} + +/** Die Ansicht zum Zustand aus den frisch geladenen Daten (daten.state; faellt auf den Eintrag der Liste zurueck). */ +async function zeichneAnsicht(s, { daten, aktuell, bestellformular = false, meldung = null }) { + const projekt = s.projekt s.daten = daten - if (daten.einstellung) zeigeReiter(s, { projekt, aktuell }) + const zustand = daten.state || zustandVon(projekt) + s.nurLesen = zustand !== 'active' && zustand !== 'off' + if (zustand === 'off') { + if (daten.abo?.ok === true) zeigeFragebogen(s, { projekt, aktuell }) + else await zeigeAboHinweis(s, { aktuell }) + return + } + if (istBestellt(zustand)) { + await zeigeBestellung(s, { zustand, aktuell, bestellformular, meldung }) + return + } + if (daten.einstellung) zeigeReiter(s, { projekt, aktuell, zustand }) else if (s.nurLesen) { s.buehne.replaceChildren( hinweisAbgeschaltet(projekt.projectId, daten.abgeschaltet), @@ -104,6 +172,82 @@ async function zeigeProjekt(s) { } else zeigeVorlagen(s, { projekt, aktuell }) } +const neueMeldung = () => h('div', { class: 'tm-meldung', role: 'status', 'aria-live': 'polite', tabindex: '-1' }) + +/* ----------------------------------------------- Zustand off: Hinweis, Fragebogen --- */ +/** + * Ohne laufendes Wartungs-Abo gibt es keinen Fragebogen, nur den Hinweis wie in der Kachel. Der Text kommt ausschliesslich vom Server + * (GET /api/funktionen, termine.hinweis = aboMeldung in server/services/featureOrder.js), nie aus einer Kopie im Browser; bei + * kein_abo kommt der Link zu den Vertraegen dazu (Quelle: vertraege-link.js). Fehlt der Text, weil sich das Abo seit dem Laden der Liste + * geaendert hat, wird die Liste noch einmal geholt. + */ +async function zeigeAboHinweis(s, { aktuell }) { + if (typeof s.termine?.hinweis !== 'string' || s.termine.hinweis === '') { + try { + await ladeFunktionen(s) + } catch { + // ohne Text bleibt der Hinweis bei der Ueberschrift (und dem Link bei kein_abo) + } + if (!aktuell()) return + } + const hinweis = typeof s.termine?.hinweis === 'string' ? s.termine.hinweis : '' + const link = s.termine?.bestellbar === false && s.termine.grund === 'kein_abo' + ? h('button', { type: 'button', class: ZU_VERTRAEGE.klasse, 'data-view': ZU_VERTRAEGE.view }, ZU_VERTRAEGE.text) + : null + s.buehne.replaceChildren(h('div', { class: 'kb-panel tm-info', 'data-hinweis': 'abo' }, + h('h3', null, 'Terminplaner einrichten'), + hinweis || link ? h('p', null, hinweis, hinweis && link ? ' ' : null, link) : null)) +} + +/** Der Fragebogen mit der Bestellseite danach (termine-fragebogen.js); die Meldung ueber ihm ist s.meldungEl (ctx.meldung). */ +function zeigeFragebogen(s, { projekt, aktuell }) { + s.meldungEl = neueMeldung() + const wurzel = h('div', { class: 'tm-fb-wurzel' }) + s.buehne.replaceChildren(s.meldungEl, wurzel) + renderFragebogen(wurzel, baueCtx(s, { projekt, aktuell })) +} + +/* ----------------------------------------------- Zustand ordered, setup --- */ +const GESPERRT = 'Deine Angaben sind seit der Bestellung gesperrt. Änderungen laufen über den Chat.' + +/** + * Statuskarte (Zahlung offen | In Einrichtung) und darunter die Reiter zum Lesen. Der Eintrag der Liste liefert Bestelldatum, Ticketnummer und + * Rechnung; stimmt sein Zustand nicht mit dem der Daten ueberein (die Liste ist aelter, z. B. nach der Zahlung), wird sie neu geholt. + * Die Karte meldet ueber weiter(), wenn „Zahlung fortsetzen“ scheitert: ladeNeu zeichnet die Ansicht dann neu (bei 409 zustimmung_fehlt + * mit dem Bestellformular statt der Karte). Das Bestellformular (daten.bestellung, R110) zeigt dieselbe Seite wie der Fragebogen. + */ +async function zeigeBestellung(s, { zustand, aktuell, bestellformular, meldung }) { + if (s.projekt.features?.termine?.state !== zustand) { + try { + await ladeFunktionen(s) + } catch { + // es gilt der Zustand der Daten; was nur die Liste kennt (Ticketnummer, Rechnung), fehlt dann in der Karte + } + if (!aktuell()) return + } + const projekt = s.projekt + const eintrag = { ...projekt.features?.termine, state: zustand } + const danke = s.danke + s.danke = false // nur beim ersten Zeichnen nach der Rueckkehr + const formular = bestellformular && zustand === 'ordered' && Boolean(s.daten.bestellung) + const kopf = (ctx) => { + if (formular) { + const seite = h('div', { class: 'tm-fb-bestellen' }) + renderBestellen(seite, { ctx, daten: s.daten, zumSchritt: () => ctx.meldung(GESPERRT, 'fehler') }) + return seite + } + return statusKarte({ + projekt, + bestellung: eintrag, + einrichtung: s.termine?.einrichtung, + danke, + kundenansicht: s.daten.kundenansicht === true, + meldung: zustand === 'ordered' && meldung ? meldung.text : '', // die Meldung gilt nur, solange die Bestellung noch offen ist + }, { weiter: (fall) => ladeNeu(s, { bestellformular: fall?.bestellen === true, meldung: fall?.meldung ? { text: fall.meldung } : null }) }) + } + zeigeReiter(s, { projekt, aktuell, zustand, kopf, meldung: formular && meldung ? { text: meldung.text, art: 'ok' } : null }) +} + /* ---------------------------------------------------- Vorlage wählen --- */ function zeigeVorlagen(s, { projekt, aktuell }) { const fehler = h('p', { class: 'tm-fehler', role: 'alert' }) @@ -201,19 +345,27 @@ export function baueCtx(s, { projekt, aktuell, hinweise = () => {}, zumReiter = return ctx } -function zeigeReiter(s, { projekt, aktuell }) { +/** + * Die Reiter (Hinweisleiste, Meldung, Reiterleiste, Panel). zustand steuert, was dazugehoert: disabled hat den Banner der Abschaltung, + * active den Hinweis auf fehlende Pflichtangaben, ordered und setup keinen der beiden und keinen Reiter „Termine“ (der Server antwortet + * dort mit 409). kopf(ctx) liefert ein Element, das ueber der Meldung steht (die Statuskarte), meldung ({ text, art }) wird nach dem + * ersten Zeichnen angezeigt. Ohne Einstellung gibt es keine Reiter, dann bleibt es beim Kopf. + */ +function zeigeReiter(s, { projekt, aktuell, zustand = s.nurLesen ? 'disabled' : 'active', kopf = null, meldung = null }) { const projectId = projekt.projectId const nurLesen = s.nurLesen - s.meldungEl = h('div', { class: 'tm-meldung', role: 'status', 'aria-live': 'polite', tabindex: '-1' }) + s.meldungEl = neueMeldung() const ctx = baueCtx(s, { projekt, aktuell, hinweise: zeichneHinweise, zumReiter: waehleReiter }) + const reiterListe = istBestellt(zustand) ? REITER.filter((r) => r.key !== 'termine') : REITER + const kopfEl = kopf ? kopf(ctx) : null // Hinweisleiste: abgeschaltet, oder Pflichtangaben fuer Gaeste fehlen (GET /einstellungen: fehlt). Sie folgt s.daten. const hinweisBox = h('div', { class: 'tm-hinweise' }) function zeichneHinweise() { const hinweise = [] - if (nurLesen) { + if (zustand === 'disabled') { hinweise.push(hinweisAbgeschaltet(projectId, s.daten.abgeschaltet)) - } else if ((s.daten.fehlt || []).length) { + } else if (!nurLesen && (s.daten.fehlt || []).length) { hinweise.push(h('div', { class: 'tm-box tm-box-warn', role: 'note' }, h('span', null, `Noch nicht buchbar: Es fehlt ${fehltText(s.daten.fehlt) || 'eine Angabe zu deinem Betrieb'}. Solange das fehlt, können Gäste über deine Website nicht buchen.`), h('button', { type: 'button', class: 'kb-btn-outline', onclick: () => waehleReiter('einstellungen') }, 'Zu den Einstellungen'))) @@ -222,24 +374,33 @@ function zeigeReiter(s, { projekt, aktuell }) { } zeichneHinweise() + if (!s.daten.einstellung) { + s.buehne.replaceChildren(...[hinweisBox, kopfEl, s.meldungEl].filter(Boolean)) + if (meldung) zeigeMeldung(s, meldung.text, meldung.art) + return + } + const panel = h('div', { class: 'tm-panel', role: 'tabpanel', id: 'tm-panel', tabindex: '0' }) - const knoepfe = REITER.map((r) => h('button', { + let aktiv = null // Schluessel des gezeigten Reiters + const knoepfe = reiterListe.map((r) => h('button', { type: 'button', role: 'tab', class: 'tm-reiter', id: `tm-tab-${r.key}`, 'aria-controls': 'tm-panel', 'data-reiter': r.key, onclick: () => waehleReiter(r.key), }, r.label)) const leiste = h('div', { class: 'tm-reiterleiste', role: 'tablist', 'aria-label': 'Bereiche des Terminplaners' }, knoepfe) leiste.addEventListener('keydown', (ev) => { - const i = REITER.findIndex((r) => r.key === auswahl.reiter) - const ziel = { ArrowRight: (i + 1) % REITER.length, ArrowLeft: (i + REITER.length - 1) % REITER.length, Home: 0, End: REITER.length - 1 }[ev.key] + const i = reiterListe.findIndex((r) => r.key === aktiv) + const ziel = { ArrowRight: (i + 1) % reiterListe.length, ArrowLeft: (i + reiterListe.length - 1) % reiterListe.length, Home: 0, End: reiterListe.length - 1 }[ev.key] if (ziel === undefined) return ev.preventDefault() - waehleReiter(REITER[ziel].key) + waehleReiter(reiterListe[ziel].key) knoepfe[ziel].focus() }) - function waehleReiter(key) { - const reiter = REITER.find((r) => r.key === key) || REITER[0] - auswahl.reiter = reiter.key + function waehleReiter(key, { merken = true } = {}) { + // ein Reiter, den es in diesem Zustand nicht gibt (Termine in ordered und setup), wird nicht gemerkt: die Auswahl gilt fuer die naechste Website weiter + const reiter = reiterListe.find((r) => r.key === key) || reiterListe[0] + if (merken && reiter.key === key) auswahl.reiter = reiter.key + aktiv = reiter.key for (const k of knoepfe) { const an = k.dataset.reiter === reiter.key k.setAttribute('aria-selected', String(an)) @@ -253,7 +414,7 @@ function zeigeReiter(s, { projekt, aktuell }) { .catch((err) => { if (aktuell()) panel.replaceChildren(fehlerKarte(err.message, () => waehleReiter(reiter.key))) }) } - s.buehne.replaceChildren(hinweisBox, s.meldungEl, leiste, panel) - waehleReiter(auswahl.reiter) + s.buehne.replaceChildren(...[hinweisBox, kopfEl, s.meldungEl, leiste, panel].filter(Boolean)) // replaceChildren(null) schriebe „null“ in die Seite + waehleReiter(auswahl.reiter, { merken: false }) + if (meldung) zeigeMeldung(s, meldung.text, meldung.art) } - diff --git a/public/vertraege-link.js b/public/vertraege-link.js new file mode 100644 index 0000000..b258dd0 --- /dev/null +++ b/public/vertraege-link.js @@ -0,0 +1,5 @@ +/* Der Link „Zu Verträge“ in den Hinweisen ohne Wartungs-Abo (Grund kein_abo): die einzige Quelle für Text, Ziel und Klasse. Gelesen von + funktionen.js (Kachel Analyse, Analyse-Seite, Kachel Terminplaner; baut daraus HTML) und von termine.js (Hinweis in der Ansicht #termine; + baut daraus ein Element). Reine Daten ohne Import: beide laden das Modul relativ, funktionen.js behält seine zwei absoluten Importe. + Die Hinweistexte selbst stehen nicht hier: sie kommen vom Server (aboMeldung in server/services/featureOrder.js). */ +export const ZU_VERTRAEGE = Object.freeze({ text: 'Zu Verträge', view: 'vertraege', klasse: 'kb-link' }) diff --git a/server/routes/termine.js b/server/routes/termine.js index aba52d0..40878c5 100644 --- a/server/routes/termine.js +++ b/server/routes/termine.js @@ -311,7 +311,9 @@ async function aboFuerVorschau(customerId) { * Einstellungen und Entwurf für das Portal. Liest in jedem Zustand und auch ohne Funktionsdokument (entwurf: true). * Neben der Einstellung liefert die Antwort den Entwurf des Fragebogens (branchen, fragebogen, kundenansicht, betriebVorschlag * ohne Einstellung) und in `off` die Sicht der Bestellung: abo (bestellbar() oder null bei Lesefehler), offen (Punkte der - * Bestellprüfung inklusive Abo-Punkt (h)) und bestellung (Texte und Preis des Registers). Einbau-Code und Kalender-Links + * Bestellprüfung inklusive Abo-Punkt (h)) und bestellung (Texte und Preis des Registers). In `ordered` liefert sie bestellung und + * offen ebenfalls, nur lesend (Ruling R110: „Zahlung fortsetzen“ ohne Nachweis zeigt dort wieder das Bestellformular); das Abo wird + * dort nicht gelesen (abo null), offen hat keinen Abo-Punkt (h). Einbau-Code und Kalender-Links * gibt es nur, wo die Funktion läuft oder lief (active, disabled; Kalender-Links nur in active). */ router.get('/einstellungen', requireSession, withTermine({ entwurf: true }), route(async (req, res) => { @@ -322,6 +324,7 @@ router.get('/einstellungen', requireSession, withTermine({ entwurf: true }), rou const customerId = getSessionCustomerId(req) const register = featureConfig('termine') const abo = state === 'off' ? await aboFuerVorschau(customerId) : null + const bestellbar = state === 'off' || state === 'ordered' // die Sicht der Bestellung (offen, bestellung) gibt es nur vor der Zahlung res.json({ state, abgeschaltet: state === 'disabled' ? abgeschaltetAnsicht(ctx.feature) : null, @@ -334,8 +337,8 @@ router.get('/einstellungen', requireSession, withTermine({ entwurf: true }), rou kundenansicht: isKundenansicht(req.session), ...(e ? {} : { betriebVorschlag: await betriebVorschlag(customerId, req.project) }), abo, - offen: state === 'off' ? bestellpruefung({ einstellung: e, fragebogen: ctx.fragebogen, feature: register, abo }) : null, - bestellung: state === 'off' ? await bestellungAnsicht(register) : null, + offen: bestellbar ? bestellpruefung({ einstellung: e, fragebogen: ctx.fragebogen, feature: register, abo, mitAbo: state === 'off' }) : null, + bestellung: bestellbar ? await bestellungAnsicht(register) : null, feeds: e && state === 'active' ? feedLinks(ctx) : {}, siteKey: ctx.feature.siteKey || null, einbau: laeuft ? { snippet: einbauSnippet(ctx) } : {}, diff --git a/test/funktionenTiles.test.js b/test/funktionenTiles.test.js index 8f7a10a..b559f7e 100644 --- a/test/funktionenTiles.test.js +++ b/test/funktionenTiles.test.js @@ -5,12 +5,13 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' -// public/funktionen.js wirklich laden (die absoluten Importe /analyse-view.js und /geraete.js werden auf die Dateien umgeleitet) +// public/funktionen.js wirklich laden (die absoluten Importe /analyse-view.js und /geraete.js und der relative ./vertraege-link.js werden auf die Dateien umgeleitet) const dir = mkdtempSync(join(tmpdir(), 'fn-tiles-')) const fileUrl = (name) => pathToFileURL(new URL(`../public/${name}`, import.meta.url).pathname).href const src = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8') .replace("'/analyse-view.js'", `'${fileUrl('analyse-view.js')}'`) .replace("'/geraete.js'", `'${fileUrl('geraete.js')}'`) + .replace("'./vertraege-link.js'", `'${fileUrl('vertraege-link.js')}'`) const file = join(dir, 'funktionen.mjs') writeFileSync(file, src) const { initFunktionen, initNews, initAnalyse, renderAnalyseOff } = await import(pathToFileURL(file).href) @@ -265,9 +266,12 @@ test('Terminplaner-Kachel: mit Abo (bestellbar true) steht der Knopf da und ein assert.doesNotMatch(kachel, /Serverhinweis|fn-count/) }) -test('Der Browser hat keine Kopie der Abo-Hinweise; der Link zu Verträge steht einmal im Quelltext (Analyse und Terminplaner teilen ihn)', async () => { +test('Der Browser hat keine Kopie der Abo-Hinweise; der Link zu Verträge hat eine einzige Quelle (vertraege-link.js: Analyse, Terminplaner-Kachel und Ansicht #termine teilen sie)', async () => { assert.doesNotMatch(src, /TERMINE_ABO_TEXTE|termineAboHinweis|KEIN_ABO_HINWEIS|ist gekündigt\. Für|gerade nicht aktiv|nicht geprüft werden/) - assert.equal(src.split('data-view="vertraege"').length - 1, 1) + const quelle = readFileSync(new URL('../public/vertraege-link.js', import.meta.url), 'utf8') + assert.equal(quelle.split("view: 'vertraege'").length - 1, 1, 'Ziel, Text und Klasse stehen genau einmal in vertraege-link.js') + assert.equal(src.split('data-view="vertraege"').length - 1, 0, 'funktionen.js baut den Link aus ZU_VERTRAEGE, ohne eigenes Ziel') + assert.equal(src.split('ZU_VERTRAEGE_LINK =').length - 1, 1, 'ein Baustein für alle Stellen') // Analyse-Kachel und -Seite (unveraendert) und Terminplaner-Kachel zeigen genau diesen Link const analyse = tile(await render([project('a', { eligible: false, reason: 'kein_abo', reasonText: 'Kein Abo' })]), 'analyse') assert.ok(analyse.includes(`

Im Wartungs-Abo enthalten. ${ZU_VERTRAEGE}

`), analyse) diff --git a/test/support/fakeDom.js b/test/support/fakeDom.js index 0d2c117..44d5b59 100644 --- a/test/support/fakeDom.js +++ b/test/support/fakeDom.js @@ -3,7 +3,7 @@ // const fx = installFakeDom({ antworten: [{ status: 400, body: { fehler: {...} } }] }) // try { ...Client-Code bauen, fx.klick(knopf), fx.fetchLog pruefen... } finally { fx.restore() } // -// installFakeDom stellt `document`, `window` (nur location und history.replaceState), `CSS.escape` und `fetch` auf globalThis, restore() stellt den +// installFakeDom stellt `document`, `window` (nur location mit href, pathname, search, hash und history.replaceState), `CSS.escape` und `fetch` auf globalThis, restore() stellt den // alten Zustand wieder her. `h` aus public/termine-dom.js liest `document` bei jedem Aufruf: der Client braucht keine Einhaengepunkte. // Wurzeln, die `isConnected` brauchen (neuerSpeicher.nochHier), haengt der Test selbst an `document.body`. // @@ -479,7 +479,7 @@ const GLOBALE = ['document', 'window', 'CSS', 'fetch'] export function installFakeDom({ antworten = [] } = {}) { const vorher = Object.fromEntries(GLOBALE.map((name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)])) const document = neuesDokument() - const location = { href: 'http://localhost/', hash: '' } + const location = { href: 'http://localhost/', pathname: '/dashboard.html', search: '', hash: '' } const window = { document, location, diff --git a/test/termineFragebogenRoutes.test.js b/test/termineFragebogenRoutes.test.js index cb2cb86..7886e6a 100644 --- a/test/termineFragebogenRoutes.test.js +++ b/test/termineFragebogenRoutes.test.js @@ -322,8 +322,8 @@ test('einbau.snippet und feeds nur in active (feeds) und active/disabled (snippe } }) -test('abo, offen und bestellung gibt es nur in off; sonst null', async () => { - for (const zustand of ['ordered', 'setup', 'active', 'disabled']) { +test('abo gibt es nur in off; offen und bestellung nur in off und ordered (R110); in setup, active und disabled null', async () => { + for (const zustand of ['setup', 'active', 'disabled']) { await deleteDocument('portalFeatures', FEATURE).catch(() => {}) await deleteDocument('termineConfig', FEATURE).catch(() => {}) setzeZustand(zustand) @@ -332,11 +332,57 @@ test('abo, offen und bestellung gibt es nur in off; sonst null', async () => { assert.equal(body.offen, null, zustand) assert.equal(body.bestellung, null, zustand) } - setzeZustand('off') + for (const zustand of ['off', 'ordered']) { + await deleteDocument('portalFeatures', FEATURE).catch(() => {}) + await deleteDocument('termineConfig', FEATURE).catch(() => {}) + setzeZustand(zustand) + const { body } = await send('GET', einstellungenUrl(), { cookie }) + assert.notEqual(body.offen, null, zustand) + assert.notEqual(body.bestellung, null, zustand) + assert.equal(body.abo === null, zustand === 'ordered', `${zustand}: das Abo liest die Route nur in off`) + } +}) + +// ---------------------------------------------------------------- ordered: bestellung und offen nur lesend (R110) +// "Zahlung fortsetzen" ohne Nachweis (409 zustimmung_fehlt) zeigt im Zustand ordered wieder das Bestellformular; dafuer braucht der +// Client bestellung und offen auch dort. Das Abo gehoert nicht dazu: es wird in ordered nicht gelesen, offen hat keinen Abo-Punkt (h). + +test('ordered: bestellung ist die Sicht des Registers wie in off, offen die Bestellprüfung ohne Abo-Punkt, abo null, nichts wird geschrieben', async () => { + const stand = setzeZustand('ordered') + aw.resetListCalls() + const vorher = groessen() + const { status, body } = await send('GET', einstellungenUrl(), { cookie }) + assert.equal(status, 200) + assert.equal(body.state, 'ordered') + assert.deepEqual(body.bestellung, JSON.parse(JSON.stringify(await bestellungAnsicht(T)))) + assert.equal(body.bestellung.consent.knopf, T.consent.knopf) + assert.deepEqual(body.offen, [], 'bestellbarer Entwurf: keine offenen Punkte, auch ohne Abo-Punkt') + assert.deepEqual(body.offen, bestellpruefung({ einstellung: stand.einstellung, fragebogen: stand.fragebogen, feature: T, mitAbo: false })) + assert.equal(body.abo, null) + assert.deepEqual(aw.listCalls('subscriptions'), [], 'in ordered wird das Abo nicht gelesen') + assert.deepEqual(groessen(), vorher, 'read-only: nichts angelegt, nichts protokolliert') +}) + +test('ordered: ein unvollständiger Entwurf zeigt seine offenen Punkte (nie den Abo-Punkt), ohne Einstellung den Punkt „Branche“', async () => { + setzeZustand('ordered', { stand: { ...bestellbarerStand({ now: JETZT }), fragebogen: leerFragebogen() } }) const { body } = await send('GET', einstellungenUrl(), { cookie }) - assert.notEqual(body.abo, null) - assert.notEqual(body.offen, null) + assert.ok(body.offen.length > 0) + assert.ok(body.offen.some((p) => p.schritt === 'leistungen')) + assert.ok(body.offen.every((p) => p.schritt !== 'bestellen'), 'kein Abo-Punkt (h)') + await deleteDocument('termineConfig', FEATURE) + const ohne = await send('GET', einstellungenUrl(), { cookie }) + assert.equal(ohne.status, 200) + assert.deepEqual(ohne.body.offen, [{ schritt: 'branche', text: 'Bitte starte den Fragebogen mit der Wahl deiner Branche.' }]) + assert.notEqual(ohne.body.bestellung, null) +}) + +test('ordered in der Kundenansicht: bestellung und offen ebenfalls da, kundenansicht true', async () => { + setzeZustand('ordered') + const { status, body } = await send('GET', einstellungenUrl(), kv()) + assert.equal(status, 200) + assert.equal(body.kundenansicht, true) assert.notEqual(body.bestellung, null) + assert.deepEqual(body.offen, []) }) test('E11 bleibt: disabled liefert das Feld abgeschaltet mit Grund, Zeitpunkt und Löschtag; Schreiben ist weiter 409 "abgeschaltet"', async () => { diff --git a/test/termineFragebogenView.test.js b/test/termineFragebogenView.test.js index 245fc5b..1e1e274 100644 --- a/test/termineFragebogenView.test.js +++ b/test/termineFragebogenView.test.js @@ -1443,35 +1443,72 @@ test('statusKarte ordered: „Zahlung fortsetzen“ sendet fortsetzenKoerper und 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({ +test('statusKarte ordered: 409 zustimmung_fehlt ⇒ weiter({ bestellen: true, meldung }) 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 } }) + const aufrufe = [] + const wurzel = karte({}, {}, { weiter: async (fall) => { aufrufe.push(fall) } }) await fx.klick(fx.finde(wurzel, '[data-aktion="fortsetzen"]')) - assert.equal(weiter, 1) + assert.deepEqual(aufrufe, [{ bestellen: true, meldung: 'Bitte stimme der Erklärung zu, um zu bestellen.' }]) 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' } }], +test('statusKarte ordered: jeder andere Fehler ruft weiter({ bestellen: false, meldung }) (R110), die Karte bleibt nie veraltet stehen', mitDom({ + antworten: [ + { status: 400, 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.' } }, + { status: 500, body: {} }, + ], }, async (fx) => { - let weiter = 0 + const aufrufe = [] + // wie im Browser zeichnet sich die Ansicht nach weiter() neu: der Knopf der alten Karte bleibt gesperrt, jeder Fall hat seine Karte + for (let i = 0; i < 3; i += 1) await fx.klick(fx.finde(karte({}, {}, { weiter: (fall) => { aufrufe.push(fall) } }), '[data-aktion="fortsetzen"]')) + assert.deepEqual(aufrufe, [ + { bestellen: false, meldung: 'Bitte stimme der Erklärung zu, um zu bestellen.' }, + { bestellen: false, meldung: 'Der Terminplaner ist für dieses Projekt schon bestellt.' }, + { bestellen: false, meldung: 'Fehler 500' }, + ], 'auch bei 400 (Zustand inzwischen setup oder active) und 409 ohne zustimmung_fehlt') +})) + +test('statusKarte ordered: scheitert weiter() (Neuladen nicht möglich), bleibt die Karte mit dem Fehler am Knopf; ohne weiter() steht die Meldung des Servers dort', 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.' } }, + { 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) => { + const kaputt = () => { throw new Error('Keine Verbindung. Bitte versuch es gleich noch einmal.') } + const mit = karte({}, {}, { weiter: kaputt }) + const meldungMit = fx.finde(mit, '[data-meldung="fortsetzen"]') + const knopfMit = fx.finde(mit, '[data-aktion="fortsetzen"]') + await fx.klick(knopfMit) + assert.equal(fx.text(meldungMit), 'Keine Verbindung. Bitte versuch es gleich noch einmal.', 'bei fehlender Zustimmung der Grund des Neuladens') + assert.equal(knopfMit.disabled, false) + await fx.klick(knopfMit) + assert.equal(fx.text(meldungMit), 'Der Terminplaner ist für dieses Projekt schon bestellt.', 'sonst der Fehler des Versuchs') 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 ohneZwei = karte() + await fx.klick(fx.finde(ohneZwei, '[data-aktion="fortsetzen"]')) + assert.equal(fx.text(fx.finde(ohneZwei, '[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: meldung steht beim Zeichnen am Knopf „Zahlung fortsetzen“ (als Text)', mitDom(undefined, (fx) => { + const wurzel = karte({}, { meldung: 'Deine Zahlung läuft schon. x' }) + assert.equal(fx.text(fx.finde(wurzel, '[data-meldung="fortsetzen"]')), 'Deine Zahlung läuft schon. x') + assert.equal(fx.alle(wurzel, 'b').length, 0) + assert.equal(fx.text(fx.finde(karte(), '[data-meldung="fortsetzen"]')), '') +})) + 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"]') @@ -2965,3 +3002,519 @@ test('termine.css: Klassen der Schritte 4 bis 8 (Person, Ausnahmen, Sammelknopf, for (const klasse of ['tm-fb-person', 'tm-fb-ausnahmen', 'tm-fb-sammel', 'tm-fb-einbau']) assert.match(css, new RegExp(`\\.${klasse}[\\s{,.:\\[]`), `.${klasse} ist gestaltet`) assert.match(css.match(/\.tm-fb-sammel\s*\{[^}]*\}/)?.[0] ?? '', /flex-wrap:\s*wrap/, 'die Knöpfe brechen am Handy um') }) + +// ------------------------------------------------------- Ansicht #termine nach Zustand und Rückkehr von Stripe (TP2, Task C10b) +// initTermine in public/termine.js. Der Fake-fetch antwortet hier nach Adresse statt der Reihe nach: Antworten von GET /api/funktionen (dort +// steht der Abo-Hinweis, R111) und GET /api/termine/einstellungen liefert der Test. Ein Array in der Tabelle sind aufeinanderfolgende Antworten +// (die letzte gilt weiter). Der Zustand der Huelle (Auswahl des Projekts und des Reiters) bleibt zwischen den Tests im Modul: jeder Test nennt +// sein Projekt (projectId), und kein Test verlangt einen bestimmten Reiter, ohne ihn zu waehlen. +const AN_FUNKTIONEN = 'GET /api/funktionen' +const AN_CHECKOUT = 'POST /api/funktionen/termine/checkout' +const an = (projectId) => `GET /api/termine/einstellungen?projectId=${projectId}` +const SERVER_HINWEIS = 'Dieser Satz steht nur beim Server, so wie der Server ihn schreibt.' +const AUFRUFE = (fx) => fx.fetchLog.map((a) => `${a.method} ${a.url}`) + +function routen(fx, tabelle) { + globalThis.fetch = async (url, optionen = {}) => { + const aufruf = { method: optionen.method || 'GET', url: String(url), body: optionen.body ? JSON.parse(optionen.body) : undefined } + fx.fetchLog.push(aufruf) + let eintrag = tabelle[`${aufruf.method} ${aufruf.url}`] + if (eintrag === undefined && aufruf.method === 'GET' && aufruf.url.startsWith('/api/termine/buchungen')) eintrag = { body: { buchungen: [] } } + if (Array.isArray(eintrag)) eintrag = eintrag.length > 1 ? eintrag.shift() : eintrag[0] + if (typeof eintrag === 'function') eintrag = eintrag(aufruf) + const { status = 200, body = {} } = eintrag ?? { status: 404, body: { error: `nicht vorgesehen: ${aufruf.method} ${aufruf.url}` } } + return { ok: status >= 200 && status < 300, status, json: async () => structuredClone(body) } + } +} + +/** Ansicht öffnen (Adresse mit hash, Tabelle der Antworten) und warten, bis auch die Reiter gezeichnet sind. => Wurzel der Ansicht. */ +async function oeffneAnsicht(fx, { projectId = 'p1', hash = '', tabelle }) { + routen(fx, tabelle) + fx.window.location.hash = hash + const wurzel = h('div') + document.body.append(wurzel) + await huelle.initTermine(wurzel, { projectId }) + await fx.ruhe() + return wurzel +} + +const projektEintrag = (id, state, extra = {}) => ({ projectId: id, projectName: `Website ${id}`, liveUrl: '', features: { news: { state: 'off' }, termine: { state, ...extra } } }) +const GEORDERT = { orderedAt: '2026-10-05T10:00:00.000Z', ticketWoid: null, invoiceUrl: null } +const funktionenAntwort = (projekte, termine = {}) => ({ + body: { projects: projekte, termine: { verfuegbar: true, bestellbar: true, grund: null, preis: '149 €', einrichtung: EINRICHTUNG, hinweis: null, ...termine } }, +}) +const ohneAbo = (grund, hinweis = SERVER_HINWEIS) => ({ bestellbar: false, grund, hinweis }) +const ABGESCHALTET = { grund: 'abo_ende', seit: '2026-10-04T10:00:00.000Z', loeschungAm: '2026-11-03' } + +/** Antwort von GET /api/termine/einstellungen im jeweiligen Zustand (off: Entwurf mit Abo, ordered: dazu die Sicht der Bestellung ohne Abo). */ +async function ansichtDaten(state, extra = {}) { + const basis = { ...fbDaten({ state }), abo: null, offen: null, bestellung: null, feeds: {}, anfragenOffen: 0, aktivSeit: null, abgeschaltet: null } + if (state === 'off') return { ...basis, ...fbDaten(), ...extra } + if (state === 'ordered') return { ...basis, offen: [], bestellung: await bestellungTest(), ...extra } + if (state === 'active') return { ...basis, aktivSeit: '2026-10-01T09:00:00.000Z', einbau: { snippet: '' }, ...extra } + if (state === 'disabled') return { ...basis, aktivSeit: '2026-10-01T09:00:00.000Z', abgeschaltet: ABGESCHALTET, ...extra } + return { ...basis, ...extra } +} +const reiterTexte = (fx, wurzel) => fx.alle(wurzel, '[role="tab"]').map((tab) => fx.text(tab)) +const meldungDerHuelle = (fx, wurzel) => fx.text(fx.finde(wurzel, '.tm-meldung')) +/** Antwort-Tabelle für ein Projekt p1 im Zustand state mit den Daten daten. */ +async function einProjekt(state, { eintrag = {}, daten = {}, termine = {}, tabelle = {} } = {}) { + return { + [AN_FUNKTIONEN]: funktionenAntwort([projektEintrag('p1', state, eintrag)], termine), + [an('p1')]: { body: await ansichtDaten(state, daten) }, + ...tabelle, + } +} + +test('Ansicht off mit laufendem Abo: der Fragebogen (Fortschrittsleiste im Baum), weder Hinweis noch Reiter', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { fragebogen: fbBis(1) } }) }) + assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt ol.tm-schritte'), 'renderFragebogen hat gezeichnet') + assert.equal(fbSchritt(fx, wurzel), 'wer', 'Start beim ersten offenen Schritt des Entwurfs') + assert.ok(fx.finde(wurzel, '[data-hinweis="abo"]') === null) + assert.ok(fx.finde(wurzel, '.tm-reiterleiste') === null) + assert.ok(fx.finde(wurzel, '.tm-statuskarte') === null) + assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1')]) +})) + +test('Ansicht off: ohne Entwurf die Branchenwahl; die Meldung der Hülle (ctx.meldung) steht über dem Fragebogen', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { + daten: { einstellung: null, version: 0, fragebogen: leerFragebogen(), betriebVorschlag: BETRIEB_KONTO }, + tabelle: { + 'POST /api/termine/einstellungen/vorlage': { status: 201, body: { einstellung: salon(), version: 1, fehlt: [], fragebogen: fbBis(0) } }, + [an('p1')]: [{ body: fbStart() }, { body: fbDaten({ version: 1, fragebogen: fbBis(0) }) }], + 'PUT /api/termine/fragebogen': fbAntwort(1), + }, + }) }) + assert.equal(fbSchritt(fx, wurzel), 'branche') + assert.equal(meldungDerHuelle(fx, wurzel), '') + await fx.klick(fbRadio(fx, wurzel, 'salon')) + await fx.klick(fx.finde(wurzel, '[data-aktion="branche-starten"]')) + assert.equal(fbSchritt(fx, wurzel), 'wer') + assert.match(meldungDerHuelle(fx, wurzel), /^Die Branche „Friseur und Salon“ ist angelegt\./, 'ohne meldungEl bliebe die Meldung stumm') + assert.equal(fx.finde(wurzel, '.tm-meldung').getAttribute('role'), 'status') +})) + +test('Ansicht off ohne laufendes Abo: der Hinweis vom Server (kein_abo mit Link zu Verträge), kein Fragebogen, keine weitere Abfrage', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { abo: { ok: false, grund: 'kein_abo' } }, termine: ohneAbo('kein_abo') }) }) + const karteEl = fx.finde(wurzel, '[data-hinweis="abo"]') + assert.ok(karteEl) + assert.ok(fx.text(karteEl).includes(SERVER_HINWEIS), 'der Text des Servers, unverändert') + const link = fx.alle(karteEl, 'button') + assert.deepEqual(link.map((b) => [fx.text(b), b.getAttribute('data-view'), b.getAttribute('class'), b.getAttribute('type')]), [['Zu Verträge', 'vertraege', 'kb-link', 'button']]) + assert.equal(fx.text(fx.finde(karteEl, 'p')), `${SERVER_HINWEIS} Zu Verträge`, 'Text, Leerzeichen, Link wie in der Kachel') + assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt') === null, 'kein Fragebogen') + assert.equal(fx.alle(wurzel, 'input').length, 0) + assert.ok(fx.finde(wurzel, '.tm-reiterleiste') === null) + assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1')]) +})) + +test('Ansicht off ohne Abo: gekündigt, nicht aktiv, nicht lesbar und fehlendes abo zeigen nur den Text des Servers, ohne Link', mitDom(undefined, async (fx) => { + const faelle = [ + ['gekuendigt', { ok: false, grund: 'gekuendigt' }, ohneAbo('gekuendigt', 'Server: Dein Wartungs-Abo ist gekündigt.')], + ['nicht_aktiv', { ok: false, grund: 'nicht_aktiv' }, ohneAbo('nicht_aktiv', 'Server: Dein Wartungs-Abo ist gerade nicht aktiv.')], + ['Lesefehler (abo null)', null, { bestellbar: null, grund: null, hinweis: 'Server: Das Abo konnte gerade nicht geprüft werden.' }], + ['abo fehlt in der Antwort', undefined, ohneAbo('gekuendigt', 'Server: ohne abo.')], + ] + for (const [name, abo, termine] of faelle) { + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { abo }, termine }) }) + const karteEl = fx.finde(wurzel, '[data-hinweis="abo"]') + assert.ok(karteEl, name) + assert.equal(fx.text(fx.finde(karteEl, 'p')), termine.hinweis, name) + assert.equal(fx.alle(karteEl, 'button').length, 0, `${name}: kein Link`) + assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt') === null, name) + } +})) + +test('Ansicht off ohne Abo: der Hinweis kommt maskiert in die Seite und der Browser kennt keinen eigenen Text', mitDom(undefined, async (fx) => { + const bose = ' & "Anführungszeichen"' + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { abo: { ok: false, grund: 'gekuendigt' } }, termine: ohneAbo('gekuendigt', bose) }) }) + assert.equal(fx.alle(wurzel, 'img').length, 0) + assert.equal(fx.text(fx.finde(wurzel, '[data-hinweis="abo"] p')), bose) +})) + +test('Ansicht off ohne Abo: fehlt der Text in der Liste (das Abo hat sich seit dem Laden geändert), wird die Liste noch einmal geholt', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { + daten: { abo: { ok: false, grund: 'kein_abo' } }, + tabelle: { [AN_FUNKTIONEN]: [funktionenAntwort([projektEintrag('p1', 'off')]), funktionenAntwort([projektEintrag('p1', 'off')], ohneAbo('kein_abo'))] }, + }) }) + assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1'), AN_FUNKTIONEN]) + assert.equal(fx.text(fx.finde(wurzel, '[data-hinweis="abo"] p')), `${SERVER_HINWEIS} Zu Verträge`) + // bleibt der Text auch dann aus, steht nur die Überschrift (kein erfundener Ersatztext) + const leer = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { abo: { ok: false, grund: 'gekuendigt' } } }) }) + assert.ok(fx.finde(leer, '[data-hinweis="abo"] p') === null) + assert.equal(fx.text(fx.finde(leer, '[data-hinweis="abo"] h3')), 'Terminplaner einrichten') +})) + +test('Ansicht off: in der Kundenansicht gilt dasselbe (Fragebogen mit Hinweis der Kundenansicht, ohne Abo der Hinweis)', mitDom(undefined, async (fx) => { + const mit = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { kundenansicht: true } }) }) + assert.match(fx.text(fx.finde(mit, '.tm-box[role="note"]')), /Kundenansicht/) + const ohne = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { kundenansicht: true, abo: { ok: false, grund: 'gekuendigt' } }, termine: ohneAbo('gekuendigt') }) }) + assert.ok(fx.finde(ohne, '[data-hinweis="abo"]')) + assert.ok(fx.finde(ohne, 'nav.tm-fortschritt') === null) +})) + +test('Ansicht active ohne Einstellung: weiter die Vorlagenauswahl von TP1 (kein Fragebogen), mit Einstellung die vier Reiter', mitDom(undefined, async (fx) => { + const ohne = await oeffneAnsicht(fx, { tabelle: await einProjekt('active', { daten: { einstellung: null, version: 0, fragebogen: null } }) }) + assert.equal(fx.alle(ohne, 'input[name="termine-vorlage"]').length, 1) + assert.ok(fx.text(ohne).includes('Womit möchtest du starten?')) + assert.ok(fx.finde(ohne, 'nav.tm-fortschritt') === null) + assert.ok(fx.finde(ohne, '.tm-reiterleiste') === null) + const mit = await oeffneAnsicht(fx, { tabelle: await einProjekt('active') }) + assert.deepEqual(reiterTexte(fx, mit), ['Termine', 'Leistungen', 'Team & Plätze', 'Einstellungen']) + assert.ok(fx.finde(mit, '.tm-statuskarte') === null) + assert.ok(fx.finde(mit, '.tm-hinweise .tm-box-warn') === null, 'weder Abschaltung noch fehlende Angaben') +})) + +test('Ansicht active: fehlende Pflichtangaben zeigen den Hinweis „Noch nicht buchbar“ weiter wie bisher', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('active', { daten: { fehlt: ['betrieb.telefon'] } }) }) + assert.match(fx.text(fx.finde(wurzel, '.tm-hinweise .tm-box-warn')), /Noch nicht buchbar/) +})) + +for (const [state, pille, ticket] of [['ordered', 'Zahlung offen', null], ['setup', 'In Einrichtung', 100342]]) { + test(`Ansicht ${state}: Statuskarte „${pille}“ und die Reiter Leistungen, Team & Plätze, Einstellungen nur zum Lesen, ohne Reiter „Termine“`, mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt(state, { + eintrag: { ...GEORDERT, ticketWoid: ticket, invoiceUrl: state === 'setup' ? 'https://invoice.stripe.test/i/in_1' : null }, + daten: { fehlt: ['betrieb.telefon'], aktivSeit: null }, + }) }) + const karteEl = fx.finde(wurzel, '.tm-statuskarte') + assert.ok(karteEl) + assert.equal(karteEl.getAttribute('data-state'), state) + assert.equal(fx.text(fx.finde(karteEl, '.tm-pille')), pille) + assert.match(fx.text(karteEl), /Bestellt am\s*05\.10\.2026/, 'Bestelldatum aus dem Eintrag der Liste') + if (state === 'setup') { + assert.match(fx.text(karteEl), /Ticketnummer\s*#100342/) + assert.match(fx.text(karteEl), new RegExp(`Zeiteinschätzung\\s*${EINRICHTUNG}`), 'Einrichtungsdauer aus GET /api/funktionen') + } + assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt') === null, 'kein Fragebogen') + assert.deepEqual(reiterTexte(fx, wurzel), ['Leistungen', 'Team & Plätze', 'Einstellungen']) + assert.ok(fx.finde(wurzel, '#tm-tab-termine') === null) + assert.ok(fx.finde(wurzel, '.tm-hinweise .tm-box-warn') === null, 'weder Banner der Abschaltung noch „Noch nicht buchbar“') + // jeder Reiter ist schreibgeschützt: Felder gesperrt, keine Knöpfe zum Ändern + for (const [key, ohneKnopf] of [['leistungen', '[data-werkzeug="leistung-neu"]'], ['team', '[data-werkzeug="ressource-neu"]'], ['einstellungen', 'button[type="submit"]']]) { + await fx.klick(fx.finde(wurzel, `#tm-tab-${key}`)) + await fx.ruhe() + const panel = fx.finde(wurzel, '#tm-panel') + assert.ok(fx.finde(panel, ohneKnopf) === null, `${key}: nichts zum Ändern`) + assert.ok(fx.finde(panel, 'fieldset[disabled]') || key !== 'einstellungen', `${key}: gesperrt`) + assert.ok(!fx.text(panel).includes('null'), `${key}: kein „null“ als Text (replaceChildren mit null)`) + } + // „Export und Status“ des Reiters Einstellungen sagt vor der Freischaltung nicht „Aktiv“ + assert.ok(fx.finde(fx.finde(wurzel, '#tm-panel'), '.tm-status') === null) + assert.equal(fx.alle(fx.finde(wurzel, '#tm-panel'), 'a').length, 0, 'noch nichts zum Exportieren') + assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1')], 'der Reiter „Termine“ wird nicht gelesen (der Server antwortete mit 409)') + })) +} + +test('Ansicht ordered/setup: die Pfeiltasten wandern nur durch die drei Reiter, ein gemerkter Reiter „Termine“ fällt auf den ersten zurück und bleibt für andere Websites gemerkt', mitDom(undefined, async (fx) => { + const tabelle = { + [AN_FUNKTIONEN]: funktionenAntwort([projektEintrag('p1', 'active'), projektEintrag('p2', 'ordered', GEORDERT)]), + [an('p1')]: { body: await ansichtDaten('active') }, + [an('p2')]: { body: await ansichtDaten('ordered') }, + } + const wurzel = await oeffneAnsicht(fx, { projectId: 'p1', tabelle }) + await fx.klick(fx.finde(wurzel, '#tm-tab-termine')) + await fx.setze(fx.finde(wurzel, '#termine-project'), 'p2') + await fx.ruhe() + assert.ok(fx.finde(wurzel, '.tm-statuskarte')) + assert.deepEqual(reiterTexte(fx, wurzel), ['Leistungen', 'Team & Plätze', 'Einstellungen']) + assert.equal(fx.finde(wurzel, '[role="tab"][aria-selected="true"]').dataset.reiter, 'leistungen', 'der erste Reiter, den es gibt') + await fx.sende(fx.finde(wurzel, '.tm-reiterleiste'), 'keydown', { key: 'ArrowLeft' }) + assert.equal(fx.finde(wurzel, '[role="tab"][aria-selected="true"]').dataset.reiter, 'einstellungen', 'Pfeil links vom ersten Reiter springt zum letzten') + await fx.sende(fx.finde(wurzel, '.tm-reiterleiste'), 'keydown', { key: 'ArrowRight' }) + assert.equal(fx.finde(wurzel, '[role="tab"][aria-selected="true"]').dataset.reiter, 'leistungen') + // zurück zur aktiven Website: der zuvor gewählte Reiter „Termine“ ist wieder da + await fx.setze(fx.finde(wurzel, '#termine-project'), 'p1') + await fx.ruhe() + assert.equal(fx.finde(wurzel, '[role="tab"][aria-selected="true"]').dataset.reiter, 'leistungen', 'die Wahl in p2 gilt ab jetzt') +})) + +test('Ansicht ordered in der Kundenansicht: „Zahlung fortsetzen“ ist gesperrt', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('ordered', { eintrag: GEORDERT, daten: { kundenansicht: true } }) }) + assert.equal(fx.finde(wurzel, '[data-aktion="fortsetzen"]').disabled, true) +})) + +test('Ansicht ordered/setup ohne Einstellung: nur die Statuskarte, keine Reiter', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('setup', { eintrag: GEORDERT, daten: { einstellung: null, version: 0 } }) }) + assert.ok(fx.finde(wurzel, '.tm-statuskarte')) + assert.ok(fx.finde(wurzel, '.tm-reiterleiste') === null) + assert.ok(!fx.text(wurzel).includes('null')) +})) + +test('Ansicht disabled: Banner der Abschaltung (hinweisAbgeschaltet) mit Löschdatum aus abgeschaltet.loeschungAm und Export-Link, alle vier Reiter, unverändert (E11)', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('disabled') }) + const text = ansicht.abschaltText(ABGESCHALTET) + const banner = fx.finde(wurzel, '.tm-hinweise .tm-box-warn') + assert.ok(banner) + assert.equal(fx.text(banner), `${text.davor}exportieren${text.danach}`) + assert.match(fx.text(banner), /03\.11\.2026|2026-11-03/, 'das Löschdatum aus loeschungAm') + assert.deepEqual(fx.alle(banner, 'a').map((a) => [fx.text(a), a.getAttribute('href')]), [['exportieren', '/api/termine/export.csv?projectId=p1']]) + assert.deepEqual(reiterTexte(fx, wurzel), ['Termine', 'Leistungen', 'Team & Plätze', 'Einstellungen']) + assert.ok(fx.finde(wurzel, '.tm-statuskarte') === null) + assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt') === null) + await fx.klick(fx.finde(wurzel, '#tm-tab-einstellungen')) + await fx.ruhe() + assert.match(fx.text(fx.finde(wurzel, '.tm-status')), /Abgeschaltet/, 'Export und Status bleiben in disabled') +})) + +test('Ansicht disabled ohne Einstellung: Banner und „Keine Termine eingerichtet“ wie bisher', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('disabled', { daten: { einstellung: null, version: 0 } }) }) + assert.ok(fx.finde(wurzel, '.tm-box-warn[role="note"]')) + assert.ok(fx.text(wurzel).includes('Keine Termine eingerichtet')) + assert.ok(fx.finde(wurzel, '.tm-reiterleiste') === null) +})) + +test('Ansicht: der Zustand der Daten gilt, auch wenn die Liste einen anderen kennt (Liste: off, Daten: active)', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: { [AN_FUNKTIONEN]: funktionenAntwort([projektEintrag('p1', 'off')]), [an('p1')]: { body: await ansichtDaten('active') } } }) + assert.deepEqual(reiterTexte(fx, wurzel), ['Termine', 'Leistungen', 'Team & Plätze', 'Einstellungen']) +})) + +test('Ansicht ordered: kennt die Liste einen älteren Zustand als die Daten (nach der Zahlung), wird sie neu geholt und die Karte zeigt die Ticketnummer', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: { + [AN_FUNKTIONEN]: [funktionenAntwort([projektEintrag('p1', 'ordered', GEORDERT)]), funktionenAntwort([projektEintrag('p1', 'setup', { ...GEORDERT, ticketWoid: 100342 })])], + [an('p1')]: { body: await ansichtDaten('setup') }, + } }) + assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1'), AN_FUNKTIONEN]) + assert.equal(fx.text(fx.finde(wurzel, '.tm-statuskarte .tm-pille')), 'In Einrichtung') + assert.match(fx.text(fx.finde(wurzel, '.tm-statuskarte')), /Ticketnummer\s*#100342/) + // lässt sich die Liste nicht holen, gilt der Zustand der Daten, ohne Ticket + const ohne = await oeffneAnsicht(fx, { tabelle: { + [AN_FUNKTIONEN]: [funktionenAntwort([projektEintrag('p1', 'ordered', GEORDERT)]), { status: 500, body: {} }], + [an('p1')]: { body: await ansichtDaten('setup') }, + } }) + assert.equal(fx.text(fx.finde(ohne, '.tm-statuskarte .tm-pille')), 'In Einrichtung') + assert.match(fx.text(fx.finde(ohne, '.tm-statuskarte')), /Wir legen dein Ticket gerade an\./) + assert.ok(fx.finde(ohne, '[data-aktion="fortsetzen"]') === null, 'kein „Zahlung fortsetzen“ für eine bezahlte Bestellung') +})) + +test('Ansicht: ein Fehler beim Laden zeigt die Fehlerkarte mit „Noch einmal versuchen“, das lädt neu', mitDom(undefined, async (fx) => { + const tabelle = await einProjekt('off', { tabelle: { [an('p1')]: [{ status: 500, body: { error: 'Der Server hat gerade Pause.' } }, { body: await ansichtDaten('off') }] } }) + const wurzel = await oeffneAnsicht(fx, { tabelle }) + assert.equal(fx.text(fx.finde(wurzel, '[role="alert"] p')), 'Der Server hat gerade Pause.') + await fx.klick(fx.finde(wurzel, '[role="alert"] button')) + await fx.ruhe() + assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt')) +})) + +// ------------------------------------------------------- Rückkehr von Stripe +const zweiProjekte = async (zustandP2 = 'ordered', extraP2 = GEORDERT) => ({ + [AN_FUNKTIONEN]: funktionenAntwort([projektEintrag('p1', 'off'), projektEintrag('p2', zustandP2, extraP2)]), + [an('p1')]: { body: await ansichtDaten('off') }, + [an('p2')]: { body: await ansichtDaten(zustandP2) }, +}) +/** Aufrufe von history.replaceState mitschreiben. */ +function merkeAdressen(fx) { + const adressen = [] + const echt = fx.window.history.replaceState + fx.window.history.replaceState = (zustand, titel, url) => { adressen.push(url); echt(zustand, titel, url) } + return adressen +} + +test('Rückkehr nach der Zahlung: #termine?bestellt=1&projekt=p2 wählt p2, zeigt „Danke!“, bereinigt die Adresse (einmal gelesen)', mitDom(undefined, async (fx) => { + const adressen = merkeAdressen(fx) + const wurzel = await oeffneAnsicht(fx, { projectId: 'p1', hash: '#termine?bestellt=1&projekt=p2', tabelle: await zweiProjekte() }) + assert.equal(fx.finde(wurzel, '#termine-project').value, 'p2', 'das Projekt der Rückkehr (vor dem gemerkten p1)') + assert.equal(fx.text(fx.finde(wurzel, '[data-danke]')), 'Danke! Sobald die Zahlung da ist, legen wir los.') + assert.equal(fx.text(fx.finde(wurzel, '.tm-statuskarte .tm-pille')), 'Zahlung offen') + assert.deepEqual(adressen, ['/dashboard.html#termine'], 'Pfad und Query bleiben, der Hash bis auf #termine weg') + assert.equal(fx.window.location.hash, '#termine') + assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p2')], 'nur das Projekt der Rückkehr wird geladen') + // ein zweiter Aufruf (Hash ist bereinigt) kennt weder Rückkehr noch Danke, behält aber die Auswahl + const wieder = await oeffneAnsicht(fx, { projectId: '', hash: fx.window.location.hash, tabelle: await zweiProjekte() }) + assert.ok(fx.finde(wieder, '[data-danke]') === null) + assert.equal(fx.finde(wieder, '#termine-project').value, 'p2') +})) + +test('Rückkehr nach der Zahlung, wenn der Webhook schon durch ist (setup): „Danke!“ auch dann', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { projectId: 'p1', hash: '#termine?bestellt=1&projekt=p2', tabelle: await zweiProjekte('setup', { ...GEORDERT, ticketWoid: 100342 }) }) + assert.ok(fx.finde(wurzel, '[data-danke]')) + assert.equal(fx.text(fx.finde(wurzel, '.tm-statuskarte .tm-pille')), 'In Einrichtung') +})) + +test('Rückkehr nach dem Abbruch: #termine?projekt=p2 wählt p2 ohne „Danke!“, die Adresse ist bereinigt; „Zahlung fortsetzen“ ist da', mitDom(undefined, async (fx) => { + const adressen = merkeAdressen(fx) + const wurzel = await oeffneAnsicht(fx, { projectId: 'p1', hash: '#termine?projekt=p2', tabelle: await zweiProjekte() }) + assert.equal(fx.finde(wurzel, '#termine-project').value, 'p2') + assert.ok(fx.finde(wurzel, '[data-danke]') === null) + assert.ok(!fx.text(wurzel).includes('Danke!')) + assert.ok(fx.finde(wurzel, '[data-aktion="fortsetzen"]')) + assert.deepEqual(adressen, ['/dashboard.html#termine']) + assert.equal(fx.window.location.hash, '#termine') +})) + +test('Rückkehr: bestellt=1 ohne Projekt gilt dem gewählten Projekt; ein unbekanntes Projekt wird ignoriert (kein „Danke!“ für ein anderes)', mitDom(undefined, async (fx) => { + const ohneProjekt = await oeffneAnsicht(fx, { projectId: 'p2', hash: '#termine?bestellt=1', tabelle: await zweiProjekte() }) + assert.ok(fx.finde(ohneProjekt, '[data-danke]')) + assert.equal(fx.window.location.hash, '#termine') + const unbekannt = await oeffneAnsicht(fx, { projectId: 'p2', hash: '#termine?bestellt=1&projekt=gibtsnicht', tabelle: await zweiProjekte() }) + assert.ok(fx.finde(unbekannt, '[data-danke]') === null) + assert.equal(fx.finde(unbekannt, '#termine-project').value, 'p2', 'es bleibt bei der gewählten Website') + assert.equal(fx.window.location.hash, '#termine') +})) + +test('Rückkehr: „Danke!“ nur für das Projekt der Zahlung und nur beim ersten Zeichnen, nicht nach einem Wechsel der Website', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { projectId: 'p1', hash: '#termine?bestellt=1&projekt=p2', tabelle: await zweiProjekte() }) + assert.ok(fx.finde(wurzel, '[data-danke]')) + await fx.setze(fx.finde(wurzel, '#termine-project'), 'p1') + await fx.ruhe() + assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt'), 'p1 ist die Website im Zustand off') + await fx.setze(fx.finde(wurzel, '#termine-project'), 'p2') + await fx.ruhe() + assert.ok(fx.finde(wurzel, '.tm-statuskarte')) + assert.ok(fx.finde(wurzel, '[data-danke]') === null, 'der Dank war einmal') +})) + +test('Rückkehr: bestellt=1 für ein Projekt, das nicht bestellt ist (active, off), zeigt kein „Danke!“; fremde Hashes werden nicht angefasst', mitDom(undefined, async (fx) => { + const adressen = merkeAdressen(fx) + const aktiv = await oeffneAnsicht(fx, { projectId: 'p2', hash: '#termine?bestellt=1&projekt=p2', tabelle: await zweiProjekte('active', {}) }) + assert.ok(fx.finde(aktiv, '[data-danke]') === null) + assert.ok(!fx.text(aktiv).includes('Danke!')) + assert.equal(adressen.length, 1) + const fremd = await oeffneAnsicht(fx, { projectId: 'p2', hash: '#news?bestellt=1&projekt=p2', tabelle: await zweiProjekte() }) + assert.ok(fx.finde(fremd, '[data-danke]') === null, 'die Rückkehr der News gehört nicht hierher') + assert.equal(adressen.length, 1, 'und ihr Hash bleibt stehen') + assert.equal(fx.window.location.hash, '#news?bestellt=1&projekt=p2') +})) + +test('Rückkehr: Projekt-ID aus der Adresse landet nur als Wert in der Anfrage (codiert), nie im Baum', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { projectId: 'p1', hash: '#termine?bestellt=1&projekt=%3Cimg%20src%3Dx%3E', tabelle: await zweiProjekte() }) + assert.equal(fx.alle(wurzel, 'img').length, 0) + assert.ok(!AUFRUFE(fx).some((a) => a.includes(' projektEintrag('p1', 'ordered', GEORDERT) +const fortsetzenKnopf = (fx, wurzel) => fx.finde(wurzel, '[data-aktion="fortsetzen"]') + +test('R110: 409 zustimmung_fehlt ⇒ Liste und Daten werden neu geholt und das Bestellformular steht an der Stelle der Karte', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('ordered', { + eintrag: GEORDERT, + tabelle: { [AN_CHECKOUT]: { status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } } }, + }) }) + assert.ok(fx.finde(wurzel, '.tm-statuskarte')) + assert.ok(fx.finde(wurzel, '.tm-bestellen') === null) + await fx.klick(fortsetzenKnopf(fx, wurzel)) + await fx.ruhe() + assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1'), AN_CHECKOUT, AN_FUNKTIONEN, an('p1')]) + assert.deepEqual(fx.fetchLog[2].body, { projectId: 'p1', fortsetzen: true }) + assert.ok(fx.finde(wurzel, '.tm-statuskarte') === null, 'die Karte ist weg') + const seite = fx.finde(wurzel, '.tm-bestellen') + assert.ok(seite, 'das Bestellformular') + assert.equal(fx.text(bestellKnopf(fx, wurzel)), (await bestellungTest()).consent.knopf, 'Knopftext aus der Antwort des Servers') + assert.equal(bestellKnopf(fx, wurzel).disabled, true, 'gesperrt, bis alle Haken stehen') + assert.equal(hakenFelder(fx, seite).length, registerHaken().length) + assert.equal(meldungDerHuelle(fx, wurzel), 'Bitte stimme der Erklärung zu, um zu bestellen.') + assert.deepEqual(reiterTexte(fx, wurzel), ['Leistungen', 'Team & Plätze', 'Einstellungen'], 'die Reiter bleiben, nur lesbar') + // „Ändern“ führt nicht in den (gesperrten) Fragebogen, sondern erklärt, warum nicht + await fx.klick(fx.finde(wurzel, '[data-aktion="aendern"]')) + assert.match(meldungDerHuelle(fx, wurzel), /seit der Bestellung gesperrt/) + // nach dem Setzen aller Haken geht die neue Zustimmung an den Checkout und es folgt die Zahlungsseite + routen(fx, { [AN_CHECKOUT]: { body: { url: KNOPF_URL } } }) + await alleAnhaken(fx, seite) + assert.equal(bestellKnopf(fx, wurzel).disabled, false) + await fx.klick(bestellKnopf(fx, wurzel)) + const bestellt = fx.fetchLog.at(-1) + assert.equal(bestellt.url, '/api/funktionen/termine/checkout') + assert.equal(bestellt.body.zustimmung.version, (await bestellungTest()).consent.version) + assert.equal(fx.window.location.href, KNOPF_URL) +})) + +test('R110: 400 (die Bestellung ist inzwischen bezahlt, der Server kennt sie nicht mehr als offen) ⇒ die Ansicht folgt dem neuen Zustand, die Karte steht nicht veraltet da', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('ordered', { + eintrag: GEORDERT, + tabelle: { + [AN_CHECKOUT]: { status: 400, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } }, + [AN_FUNKTIONEN]: [funktionenAntwort([ORDERED_P1()]), funktionenAntwort([projektEintrag('p1', 'setup', { ...GEORDERT, ticketWoid: 100342 })])], + [an('p1')]: [{ body: await ansichtDaten('ordered') }, { body: await ansichtDaten('setup') }], + }, + }) }) + const alteKarte = fx.finde(wurzel, '.tm-statuskarte') + await fx.klick(fortsetzenKnopf(fx, wurzel)) + await fx.ruhe() + const karteEl = fx.finde(wurzel, '.tm-statuskarte') + assert.ok(karteEl !== alteKarte, 'neu gezeichnet') + assert.equal(fx.text(fx.finde(karteEl, '.tm-pille')), 'In Einrichtung') + assert.match(fx.text(karteEl), /Ticketnummer\s*#100342/) + assert.ok(fortsetzenKnopf(fx, wurzel) === null) + assert.ok(fx.finde(wurzel, '.tm-bestellen') === null, 'kein Bestellformular für eine bezahlte Bestellung') + assert.equal(meldungDerHuelle(fx, wurzel), '', 'der Fehler des alten Zustands steht nicht in der neuen Karte') + assert.ok(!fx.text(wurzel).includes('stimme der Erklärung')) +})) + +test('R110: ist die Funktion nach dem Webhook aktiv, zeigt die Ansicht die vier Reiter statt der Karte', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('ordered', { + eintrag: GEORDERT, + tabelle: { + [AN_CHECKOUT]: { status: 400, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } }, + [AN_FUNKTIONEN]: [funktionenAntwort([ORDERED_P1()]), funktionenAntwort([projektEintrag('p1', 'active')])], + [an('p1')]: [{ body: await ansichtDaten('ordered') }, { body: await ansichtDaten('active') }], + }, + }) }) + await fx.klick(fortsetzenKnopf(fx, wurzel)) + await fx.ruhe() + assert.ok(fx.finde(wurzel, '.tm-statuskarte') === null) + assert.deepEqual(reiterTexte(fx, wurzel), ['Termine', 'Leistungen', 'Team & Plätze', 'Einstellungen']) + assert.equal(meldungDerHuelle(fx, wurzel), '') +})) + +test('R110: ein anderer Fehler bei noch offener Bestellung (409, die Sitzung läuft schon) ⇒ die Karte wird neu gezeichnet und der Fehler steht am Knopf', mitDom(undefined, async (fx) => { + const FEHLER = 'Deine Zahlung läuft gerade oder ist schon da. Wir richten den Terminplaner ein, sobald sie bei uns ist.' + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('ordered', { + eintrag: GEORDERT, + tabelle: { [AN_CHECKOUT]: { status: 409, body: { error: FEHLER } } }, + }) }) + const alteKarte = fx.finde(wurzel, '.tm-statuskarte') + await fx.klick(fortsetzenKnopf(fx, wurzel)) + await fx.ruhe() + assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1'), AN_CHECKOUT, AN_FUNKTIONEN, an('p1')]) + const karteEl = fx.finde(wurzel, '.tm-statuskarte') + assert.ok(karteEl !== alteKarte) + assert.equal(fx.text(fx.finde(karteEl, '[data-meldung="fortsetzen"]')), FEHLER) + assert.equal(fortsetzenKnopf(fx, wurzel).disabled, false, 'der Knopf ist frei') + assert.ok(fx.finde(wurzel, '.tm-bestellen') === null) +})) + +test('R110: lässt sich die Ansicht nicht neu laden, bleibt die Karte stehen und der Fehler steht am Knopf (bei fehlender Zustimmung der Grund des Neuladens)', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('ordered', { + eintrag: GEORDERT, + tabelle: { + [AN_CHECKOUT]: [{ status: 409, body: { error: 'Deine Zahlung läuft gerade.' } }, { status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } }], + [AN_FUNKTIONEN]: [funktionenAntwort([ORDERED_P1()]), { status: 500, body: { error: 'Der Server hat gerade Pause.' } }], + }, + }) }) + const karteEl = fx.finde(wurzel, '.tm-statuskarte') + await fx.klick(fortsetzenKnopf(fx, wurzel)) + await fx.ruhe() + assert.ok(fx.finde(wurzel, '.tm-statuskarte') === karteEl, 'dieselbe Karte') + assert.equal(fx.text(fx.finde(karteEl, '[data-meldung="fortsetzen"]')), 'Deine Zahlung läuft gerade.') + assert.equal(fortsetzenKnopf(fx, wurzel).disabled, false) + await fx.klick(fortsetzenKnopf(fx, wurzel)) + await fx.ruhe() + assert.ok(fx.finde(wurzel, '.tm-statuskarte') === karteEl) + assert.equal(fx.text(fx.finde(karteEl, '[data-meldung="fortsetzen"]')), 'Der Server hat gerade Pause.') + assert.ok(fx.finde(wurzel, '.tm-bestellen') === null) +})) + +test('R110: wechselt der Nutzer die Website, während neu geladen wird, zeichnet die späte Antwort nichts mehr', mitDom(undefined, async (fx) => { + let freigeben + const spaet = new Promise((fertig) => { freigeben = fertig }) + const tabelle = { ...(await zweiProjekte()), [AN_CHECKOUT]: { status: 409, body: { error: 'Deine Zahlung läuft gerade.' } } } + const wurzel = await oeffneAnsicht(fx, { projectId: 'p2', tabelle }) + // ab jetzt hängt das Laden der Daten von p2, bis der Test es freigibt + const antwort = globalThis.fetch + globalThis.fetch = async (url, optionen) => { + if (String(url) !== '/api/termine/einstellungen?projectId=p2') return antwort(url, optionen) + fx.fetchLog.push({ method: 'GET', url: String(url), body: undefined }) + await spaet + return { ok: true, status: 200, json: async () => structuredClone(await ansichtDaten('setup')) } + } + const klick = fx.klick(fortsetzenKnopf(fx, wurzel)) + await fx.ruhe() + await fx.setze(fx.finde(wurzel, '#termine-project'), 'p1') + await fx.ruhe() + assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt'), 'p1 steht da') + freigeben() + await klick + await fx.ruhe() + assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt'), 'die späte Antwort für p2 hat p1 nicht überschrieben') + assert.ok(fx.finde(wurzel, '.tm-statuskarte') === null) +})) diff --git a/test/termineView.test.js b/test/termineView.test.js index 75b619e..0937957 100644 --- a/test/termineView.test.js +++ b/test/termineView.test.js @@ -312,6 +312,8 @@ test('Portal: Ansicht #termine ist verdrahtet, funktionen.js lädt keine Terminp const fn = lies('public/funktionen.js') assert.deepEqual(fn.match(/from '\/[^']+'/g), ["from '/analyse-view.js'", "from '/geraete.js'"]) assert.doesNotMatch(fn, /from '\/termine/) + // dazu genau ein relativer Import: die gemeinsame Quelle des Links „Zu Verträge“ (Reiner Datenmodul, kein Terminplaner-Modul; C10b) + assert.deepEqual(fn.match(/from '\.[^']+'/g), ["from './vertraege-link.js'"]) assert.doesNotMatch(fn, /import\s*\(/, 'auch kein dynamischer Import') }) @@ -365,10 +367,33 @@ test('Ansicht-Module: Reiter sind Module mit render(panel, ctx), die Huelle kenn } }) +test('Ansicht nach Zustand (TP2 C10b): termine.js lädt Fragebogen, Statuskarte und die Quelle des Links relativ, kennt keinen Abo-Text und liest die Rückkehr von Stripe einmal', () => { + const ohneKommentare = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '') + const huelle = ohneKommentare(lies('public/termine.js')) + assert.ok(huelle.includes("import { renderFragebogen } from './termine-fragebogen.js'")) + assert.ok(huelle.includes("import { renderBestellen, statusKarte } from './funktion-bestellen.js'")) + assert.ok(huelle.includes("import { ZU_VERTRAEGE } from './vertraege-link.js'")) + assert.doesNotMatch(huelle, /from '\/|import\s*\(/, 'nur relative, statische Importe') + // R111: der Abo-Hinweis kommt vom Server (GET /api/funktionen, termine.hinweis), nie als Text aus dem Browser; R112: die alten Texte sind weg + assert.doesNotMatch(huelle, /Wartungs-Abo|gekündigt|nicht aktiv|nicht geprüft|brauchst du ein laufendes/, 'keine Kopie der Abo-Hinweise') + assert.doesNotMatch(huelle, /noch nicht freigeschaltet|Wir schalten den Terminplaner für dich frei/, 'die Texte der Ansicht vor C10b') + assert.match(huelle, /s\.termine\??\.hinweis/, 'der Text kommt aus der Antwort von /api/funktionen') + assert.match(huelle, /ZU_VERTRAEGE\.view/, 'der Link nimmt Ziel, Text und Klasse aus der gemeinsamen Quelle') + assert.doesNotMatch(huelle, /'vertraege'|"vertraege"|Zu Verträge/, 'und schreibt sie nicht noch einmal hin') + // Rückkehr von Stripe: die Adresse wird an genau einer Stelle gelesen und bereinigt + assert.equal(huelle.match(/history\.replaceState/g).length, 1) + assert.equal(huelle.match(/location\.hash/g).length, 1) + assert.match(huelle, /bestellt'\) === '1'/, 'danke nur mit bestellt=1') + // die Quelle des Links ist reine Daten ohne Import + const link = lies('public/vertraege-link.js') + assert.doesNotMatch(ohneKommentare(link), /\bimport\b/) + assert.match(link, /export const ZU_VERTRAEGE = Object\.freeze\(\{ text: 'Zu Verträge', view: 'vertraege', klasse: 'kb-link' \}\)/) +}) + test('Fragebogen (TP2 C9a): renderFragebogen wird nur über termine.js geladen, nie absolut, nie von einem Reiter oder von funktionen.js', () => { const dateien = readdirSync(new URL('../public/', import.meta.url)).filter((f) => /\.(js|html)$/.test(f)) const ladende = dateien.filter((f) => /termine-fragebogen\.js/.test(lies(`public/${f}`))) - assert.ok(ladende.every((f) => f === 'termine.js'), `nur die Huelle darf den Fragebogen laden: ${ladende.join(', ')}`) + assert.deepEqual(ladende, ['termine.js'], `nur die Huelle darf den Fragebogen laden (C10b verdrahtet ihn): ${ladende.join(', ')}`) for (const f of dateien) assert.doesNotMatch(lies(`public/${f}`), /['"]\/termine-fragebogen\.js['"]/, `${f}: kein absoluter Import`) assert.match(lies('public/termine-fragebogen.js'), /export function renderFragebogen\(root, ctx\)/) }) diff --git a/test/ui/termine-portal.ui.mjs b/test/ui/termine-portal.ui.mjs index 4dd2255..b9820f8 100644 --- a/test/ui/termine-portal.ui.mjs +++ b/test/ui/termine-portal.ui.mjs @@ -68,6 +68,21 @@ function portalHilfe(p, json) { return json({}) } +// Branchen und Bestellsicht, wie GET /api/termine/einstellungen sie liefert (Server: BRANCHEN, bestellungAnsicht); feste Texte der Fixture +const FX_BRANCHEN = [ + { key: 'salon', name: 'Friseur und Salon', gesundheit: false }, + { key: 'kosmetik', name: 'Kosmetik und Wellness', gesundheit: false }, + { key: 'gesundheit', name: 'Gesundheitsbereich', gesundheit: true }, +] +const FX_BESTELLUNG = { + preis: '149 €', + preisHinweis: 'ohne Umsatzsteuer gemäß § 19 UStG', + einrichtung: 'meist innerhalb von 1–2 Wochen', + consent: { version: 'fx-1', knopf: 'Zahlungspflichtig bestellen · 149 €', items: [{ id: 'verfuegbar', text: 'Ich bestelle den Terminplaner verbindlich.', haken: true }] }, + dokumente: [], + belehrung: 'Widerrufsbelehrung der Fixture. Du hast das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.', +} + /** Fixture: Daten, die page.route statt der Schnittstellen /api/funktionen und /api/termine liefert. */ function neueFixture() { const fx = { @@ -82,14 +97,25 @@ function neueFixture() { { key: 'beratung', name: 'Beratung und Coaching', beschreibung: 'Gespräche vor Ort oder online.' }, ], fehlt: [], + fragebogen: { schema: 1, branche: 'salon', bestaetigt: [], bundeslandGewaehlt: false, preiseGeprueft: false, einbau: { modus: null, knopfText: '', stelle: '', gestaltung: '', bemerkung: '' } }, + offen: [], + checkout: { status: 200, body: { url: 'https://checkout.stripe.test/c/pay/cs_test_fx' } }, // Antwort auf POST /api/funktionen/termine/checkout anfragenOffen: 0, buchungen: [], aufrufe: [], } + // GET /api/termine/einstellungen. off: Entwurf mit Abo (abo), offen und Bestellsicht; ordered: Bestellsicht und offen, aber kein abo (R110) fx.daten = (state = fx.projekte[0].features.termine.state) => ({ state, aktivSeit: '2026-10-01T09:00:00.000Z', einstellung: fx.einstellung, version: fx.einstellung ? 3 : 0, vorlagen: fx.vorlagen, feeds: {}, siteKey: KEY, einbau: { snippet: '' }, anfragenOffen: fx.buchungen.filter((b) => b.status === 'angefragt').length || fx.anfragenOffen, fehlt: fx.fehlt, abgeschaltet: state === 'disabled' ? fx.abgeschaltet ?? { grund: null, seit: null, loeschungAm: null } : null, + branchen: FX_BRANCHEN, + fragebogen: fx.fragebogen, + kundenansicht: false, + ...(fx.einstellung ? {} : { betriebVorschlag: { name: 'Salon Müller', adresse: 'Hauptstr. 1, 67722 Winnweiler', email: 'info@salon-mueller.example', telefon: '', websiteUrl: '' } }), + abo: state === 'off' ? (fx.bestellbar === true ? { ok: true } : fx.bestellbar === false ? { ok: false, grund: fx.aboGrund } : null) : null, + offen: state === 'off' || state === 'ordered' ? fx.offen : null, + bestellung: state === 'off' || state === 'ordered' ? FX_BESTELLUNG : null, }) return fx } @@ -152,10 +178,14 @@ async function fixtureAntwort(fx, route, req, url, json) { : { verfuegbar: false } return json({ projects: fx.projekte, termine }) } + if (p === '/api/funktionen/termine/checkout' && m === 'POST') { + fx.aufrufe.push(['checkout', req.postDataJSON()]) + return json(fx.checkout.body, fx.checkout.status) + } if (p === '/api/termine/einstellungen' && m === 'GET') { const projekt = q.get('projectId') if (fx.verzoegerung?.[projekt]) await new Promise((fertig) => { setTimeout(fertig, fx.verzoegerung[projekt]) }) - const daten = fx.daten() + const daten = fx.daten(fx.projekte.find((p) => p.projectId === projekt)?.features?.termine?.state) if (fx.ohneEinstellungFuer?.includes(projekt)) Object.assign(daten, { einstellung: null, version: 0 }) return json(daten) } @@ -268,7 +298,7 @@ const formular = (page, name) => page.getByRole('form', { name }) const fehlerAm = (loc, pfad) => loc.locator(`[data-fehler="${pfad}"]`) const warteAnsicht = async (page) => { await page.locator('#view-termine.active').waitFor() - await page.locator('#termine-body .tm-reiterleiste, #termine-body .tm-vorlagen, #termine-body .tm-info').first().waitFor() + await page.locator('#termine-body .tm-reiterleiste, #termine-body .tm-vorlagen, #termine-body .tm-info, #termine-body .tm-fragebogen, #termine-body .tm-statuskarte').first().waitFor() } // ---- Gaeste ueber die oeffentliche Schnittstelle buchen (echte Pruefungen): Link-Token aus der Mail @@ -448,11 +478,12 @@ async function pruefungen() { await warteAnsicht(page) await page.locator('.tm-reiterleiste').waitFor() eq(await page.locator('#termine-project').inputValue(), 'p2', 'Auswahl: die Website der Kachel') - // andere Website ohne Terminplaner: Hinweis statt Reiter + // andere Website, Terminplaner nicht bestellt (Aus, das Abo läuft): der Fragebogen statt der Reiter (C10b ersetzt den Hinweis „noch nicht freigeschaltet“) await page.locator('#termine-project').selectOption('p1') - await page.getByText('noch nicht freigeschaltet').waitFor() + await page.locator('#termine-body .tm-fragebogen').waitFor() eq(await page.locator('.tm-reiterleiste').count(), 0, 'keine Reiter') - await knopf(page.locator('#termine-body'), 'Chat öffnen').waitFor() + assert(!(await textVon(page.locator('#termine-body'))).includes('noch nicht freigeschaltet'), 'der alte Text ist weg') + eq(await page.locator('#termine-body [data-view="chat"]').count(), 0, 'kein Chat-Knopf der alten Ansicht') await shot(page, 'projektwahl') await page.locator('#termine-project').selectOption('p2') await page.locator('.tm-reiterleiste').waitFor() @@ -473,6 +504,171 @@ async function pruefungen() { })(), }) + // ------------------------------------------------------------ Ansicht nach Zustand (TP2 C10b) + /** Fixture mit einer Website im Zustand state (Eintrag der Liste wie GET /api/funktionen ihn liefert). */ + const imZustand = (state, extra = {}) => { + const fx = neueFixture() + fx.projekte[0].features.termine = { state, ...extra } + return fx + } + const GEORDERT = { orderedAt: '2026-10-04T08:00:00.000Z', ticketWoid: null, invoiceUrl: null } + + await check('fx_ansicht_aus_fragebogen_und_hinweis', async (page) => { + const fx = page.fx + // das Abo läuft: der Fragebogen mit Fortschrittsleiste, keine Reiter + await oeffne(page, '#termine') + await warteAnsicht(page) + await page.locator('#termine-body nav.tm-fortschritt').waitFor() + eq(await page.locator('.tm-reiterleiste').count(), 0, 'keine Reiter') + eq(await page.locator('[data-hinweis="abo"]').count(), 0, 'kein Hinweis') + eq(await page.locator('li.tm-schritt[aria-current="step"]').getAttribute('data-schritt'), 'branche', 'Start beim ersten offenen Schritt') + await shot(page, 'ansicht-fragebogen') + // ohne Abo: nur der Hinweis des Servers, bei kein_abo mit dem Link zu den Verträgen; kein Fragebogen + fx.bestellbar = false + for (const [grund, mitLink] of [['kein_abo', true], ['gekuendigt', false], ['nicht_aktiv', false], ['unbekannt', false]]) { + fx.aboGrund = grund === 'unbekannt' ? null : grund + fx.bestellbar = grund === 'unbekannt' ? null : false + await page.reload() + await page.locator('[data-hinweis="abo"]').waitFor() + const hinweis = page.locator('[data-hinweis="abo"]') + assert((await textVon(hinweis)).includes(FX_ABO_HINWEISE[grund]), `${grund}: Text des Servers in ${await textVon(hinweis)}`) + eq(await hinweis.locator('[data-view="vertraege"]').count(), mitLink ? 1 : 0, `${grund}: Link zu Verträge`) + eq(await page.locator('#termine-body nav.tm-fortschritt').count(), 0, `${grund}: kein Fragebogen`) + eq(await page.locator('#termine-body input').count(), 0, `${grund}: keine Felder`) + eq(await page.locator('.tm-reiterleiste').count(), 0, `${grund}: keine Reiter`) + if (grund === 'kein_abo') await shot(page, 'ansicht-ohne-abo') + } + // der Link führt zu den Verträgen + fx.bestellbar = false + fx.aboGrund = 'kein_abo' + await page.reload() + await knopf(page.locator('[data-hinweis="abo"]'), 'Zu Verträge').click() + await page.locator('#view-vertraege.active').waitFor() + }, { fixture: imZustand('off') }) + + for (const [state, pille, extra] of [ + ['ordered', 'Zahlung offen', GEORDERT], + ['setup', 'In Einrichtung', { ...GEORDERT, ticketWoid: 100342, invoiceUrl: 'https://invoice.stripe.test/i/in_1' }], + ]) { + await check(`fx_ansicht_${state}`, async (page) => { + await oeffne(page, '#termine') + await warteAnsicht(page) + const karte = page.locator('.tm-statuskarte') + await karte.waitFor() + eq(await textVon(karte.locator('.tm-pille')), pille, 'Pille') + assert((await textVon(karte)).includes('Bestellt am'), 'Bestelldatum') + if (state === 'setup') assert((await textVon(karte)).includes('#100342'), 'Ticketnummer') + else eq(await karte.locator('[data-aktion="fortsetzen"]').count(), 1, 'Zahlung fortsetzen') + eq(await page.getByRole('tab').allInnerTexts(), ['Leistungen', 'Team & Plätze', 'Einstellungen'], 'drei Reiter, keiner für Termine') + eq(await page.locator('#tm-tab-termine').count(), 0, 'kein Reiter Termine') + eq(await page.locator('.tm-hinweise .tm-box').count(), 0, 'weder Banner der Abschaltung noch „Noch nicht buchbar“') + await shot(page, `ansicht-${state}`) + // schreibgeschützt: Leistungen ohne „+ Leistung anlegen“, Einstellungen gesperrt, ohne Speichern, ohne „Aktiv“-Status und Export + await page.locator('#tm-tab-leistungen').click() + await page.locator('#tm-panel .tm-tab').waitFor() + eq(await page.locator('[data-werkzeug="leistung-neu"]').count(), 0, 'Leistungen: nichts zum Anlegen') + assert(!(await textVon(page.locator('#tm-panel'))).split('\n').some((zeile) => zeile.trim() === 'null'), 'Leistungen: kein Text „null“') + await page.locator('#tm-tab-einstellungen').click() + await page.locator('#tm-panel .tm-tab').waitFor() + eq(await page.locator('#tm-panel fieldset[disabled]').count() > 0, true, 'Einstellungen gesperrt') + eq(await page.locator('#tm-panel button[type="submit"]').count(), 0, 'Einstellungen: kein Speichern') + eq(await page.locator('#tm-panel .tm-status').count(), 0, 'kein „Aktiv“-Status vor der Freischaltung') + eq(await page.locator('#tm-panel a[download]').count(), 0, 'kein Export vor der Freischaltung') + eq(page.fx.aufrufe.filter((a) => a[0] === 'liste').length, 0, 'der Reiter Termine wird nicht gelesen') + }, { fixture: imZustand(state, extra) }) + } + + await check('fx_ansicht_bestellt_390', async (page) => { + await oeffne(page, '#termine') + await warteAnsicht(page) + await page.locator('.tm-statuskarte').waitFor() + eq(await passtInBreite(page), true, 'kein Streifen rechts') + const kleine = await page.evaluate(() => [...document.querySelectorAll('#view-termine button')] + .filter((e) => e.offsetParent !== null && e.getBoundingClientRect().height < 43.5).map((e) => `${e.textContent.trim().slice(0, 24)} ${Math.round(e.getBoundingClientRect().height)}`)) + eq(kleine, [], 'Knöpfe mindestens 44 px hoch') + await shot(page, '390-ansicht-bestellt') + }, { fixture: imZustand('ordered', GEORDERT), context: { viewport: { width: 390, height: 844 } } }) + + await check('fx_rueckkehr_zahlung', async (page) => { + await oeffne(page, '#termine?bestellt=1&projekt=p2') + await warteAnsicht(page) + await page.locator('.tm-statuskarte [data-danke]').waitFor() + eq(await textVon(page.locator('[data-danke]')), 'Danke! Sobald die Zahlung da ist, legen wir los.', 'Danke') + eq(await page.locator('#termine-project').inputValue(), 'p2', 'Website der Zahlung') + eq(await page.evaluate(() => window.location.hash), '#termine', 'Adresse bereinigt') + await shot(page, 'rueckkehr-zahlung') + // neu geladen: kein Danke mehr, die Auswahl bleibt nicht (Seite neu), aber die Adresse ist sauber + await page.reload() + await warteAnsicht(page) + eq(await page.locator('[data-danke]').count(), 0, 'kein Danke nach dem Neuladen') + }, { + fixture: (() => { + const fx = neueFixture() + fx.projekte = [ + { projectId: 'p1', projectName: 'Alte Website', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'active' } } }, + { projectId: 'p2', projectName: 'Salon Müller', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'ordered', ...GEORDERT } } }, + ] + return fx + })(), + }) + + await check('fx_rueckkehr_abbruch', async (page) => { + await oeffne(page, '#termine?projekt=p2') + await warteAnsicht(page) + await page.locator('.tm-statuskarte').waitFor() + eq(await page.locator('[data-danke]').count(), 0, 'kein Danke nach dem Abbruch') + eq(await page.locator('#termine-project').inputValue(), 'p2', 'Website der Bestellung') + eq(await page.locator('.tm-statuskarte [data-aktion="fortsetzen"]').count(), 1, 'Zahlung fortsetzen') + eq(await page.evaluate(() => window.location.hash), '#termine', 'Adresse bereinigt') + }, { + fixture: (() => { + const fx = neueFixture() + fx.projekte = [ + { projectId: 'p1', projectName: 'Alte Website', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'active' } } }, + { projectId: 'p2', projectName: 'Salon Müller', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'ordered', ...GEORDERT } } }, + ] + return fx + })(), + }) + + await check('fx_zahlung_fortsetzen_zustimmung_fehlt', async (page) => { + const fx = page.fx + await oeffne(page, '#termine') + await warteAnsicht(page) + await page.locator('.tm-statuskarte').waitFor() + await knopf(page.locator('.tm-statuskarte'), 'Zahlung fortsetzen').click() + // die Karte weicht dem Bestellformular (R110), die Reiter bleiben + await page.locator('.tm-bestellen').waitFor() + eq(await page.locator('.tm-statuskarte').count(), 0, 'keine Karte mehr') + eq(fx.aufrufe.filter((a) => a[0] === 'checkout').map((a) => a[1]), [{ projectId: 'p1', fortsetzen: true }], 'Zahlung fortsetzen ohne Zustimmung') + assert((await textVon(meldung(page))).includes('Bitte stimme der Erklärung zu'), 'Meldung der Hülle') + const bestellen = page.locator('[data-aktion="bestellen"]') + eq(await bestellen.isDisabled(), true, 'gesperrt bis zum Haken') + await page.locator('.tm-bestellen input[type="checkbox"]').check() + eq(await bestellen.isDisabled(), false, 'frei nach dem Haken') + eq(await page.getByRole('tab').allInnerTexts(), ['Leistungen', 'Team & Plätze', 'Einstellungen'], 'Reiter bleiben') + await shot(page, 'zahlung-fortsetzen-bestellformular') + }, { + fixture: Object.assign(imZustand('ordered', GEORDERT), { checkout: { status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } } }), + }) + + await check('fx_zahlung_fortsetzen_inzwischen_bezahlt', async (page) => { + const fx = page.fx + await oeffne(page, '#termine') + await warteAnsicht(page) + await page.locator('.tm-statuskarte').waitFor() + eq(await textVon(page.locator('.tm-statuskarte .tm-pille')), 'Zahlung offen', 'zuerst offen') + // inzwischen kam die Zahlung an (Webhook): der Server kennt keine offene Bestellung mehr (400) + fx.projekte[0].features.termine = { state: 'setup', ...GEORDERT, ticketWoid: 100343 } + await knopf(page.locator('.tm-statuskarte'), 'Zahlung fortsetzen').click() + await page.locator('.tm-statuskarte .tm-pille', { hasText: 'In Einrichtung' }).waitFor() + assert((await textVon(page.locator('.tm-statuskarte'))).includes('#100343'), 'Ticketnummer der frischen Liste') + eq(await page.locator('[data-aktion="fortsetzen"]').count(), 0, 'kein Knopf mehr') + eq(await page.locator('.tm-bestellen').count(), 0, 'kein Bestellformular') + }, { + fixture: Object.assign(imZustand('ordered', GEORDERT), { checkout: { status: 400, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } } }), + }) + // ------------------------------------------------------------ Reiter-Geruest await check('fx_reiter_geruest', async (page) => { await oeffne(page, '#termine')