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=<id>
und #termine?projekt=<id> 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 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 23:10:54 +00:00
1 parent 52456dac76
commit 87d267e51a
13 files changed
+1077 -71

No files matched your search

+15 -8
View File
@@ -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
+4 -2
View File
@@ -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) {
</article>`
}
// Link in den Hinweisen ohne Wartungs-Abo (kein_abo): Analyse-Kachel, Analyse-Seite und Terminplaner-Kachel teilen ihn
const ZU_VERTRAEGE_LINK = '<button type="button" class="kb-link" data-view="vertraege">Zu Verträge</button>'
// 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 = `<button type="button" class="${ZU_VERTRAEGE.klasse}" data-view="${ZU_VERTRAEGE.view}">${ZU_VERTRAEGE.text}</button>`
/* Analyse: Kachel je nach Zustand der Websites (comingSoon = Schalter aus) */
const analyseOf = (project) => project.analyse || { comingSoon: true }
+2
View File
@@ -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,
+4 -2
View File
@@ -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()
}
+190 -29
View File
@@ -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=<id> (Zahlung) und #termine?projekt=<id> (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=<id> nach der Zahlung
* (success_url) und #termine?projekt=<id> 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)
}
+5
View File
@@ -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' })
+6 -3
View File
@@ -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) } : {},
+7 -3
View File
@@ -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(`<p class="fn-count">Im Wartungs-Abo enthalten. ${ZU_VERTRAEGE}</p>`), analyse)
+2 -2
View File
@@ -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,
+51 -5
View File
@@ -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 () => {
+564 -11
View File
@@ -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. <b>x</b>' })
assert.equal(fx.text(fx.finde(wurzel, '[data-meldung="fortsetzen"]')), 'Deine Zahlung läuft schon. <b>x</b>')
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: '<script></script>' }, ...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 = '<img src=x onerror=alert(1)> & "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('<img')))
}))
// ------------------------------------------------------- „Zahlung fortsetzen“ scheitert: Ansicht neu aufbauen (Ruling R110)
const ORDERED_P1 = () => 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)
}))
+26 -1
View File
@@ -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\)/)
})
+201 -5
View File
@@ -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')