nach dem Abbruch (cancel_url). Gelesen wird einmal, danach steht nur noch #termine da.
+ */
+function leseRueckkehr() {
+ const hash = window.location.hash || ''
+ if (!hash.startsWith('#termine?')) return { bestellt: false, projekt: '' }
+ const query = new URLSearchParams(hash.slice(hash.indexOf('?') + 1))
+ window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}#termine`)
+ return { bestellt: query.get('bestellt') === '1', projekt: query.get('projekt') || '' }
+}
+
+const zustandVon = (projekt) => projekt?.features?.termine?.state || 'off'
+const istBestellt = (zustand) => zustand === 'ordered' || zustand === 'setup'
+// Entwurf: Funktionsdokument im Zustand Aus mit siteKey (wie die Kachel in funktionen.js)
+const hatEntwurf = (projekt) => zustandVon(projekt) === 'off' && Boolean(projekt?.features?.termine?.siteKey)
+
export async function initTermine(root, { projectId } = {}) {
if (!root) return
- const wunsch = projectId || root.dataset.wunschProjekt || '' // die Kachel in Funktionen legt ihr Projekt hier ab
+ const rueckkehr = leseRueckkehr()
+ const wunsch = rueckkehr.projekt || projectId || root.dataset.wunschProjekt || '' // die Kachel in Funktionen legt ihr Projekt hier ab
delete root.dataset.wunschProjekt
if (wunsch) auswahl.projectId = wunsch
const { projects = [], termine } = await request('/api/funktionen')
- const s = { root, projects, projekt: null, daten: null, tag: heuteBerlin(), nurLesen: false, lauf: 0, buehne: null, meldungEl: null }
+ const s = { root, projects, termine: termine || null, projekt: null, daten: null, tag: heuteBerlin(), nurLesen: false, lauf: 0, buehne: null, meldungEl: null, danke: false }
S = s
if (!termine?.verfuegbar) {
root.replaceChildren(infoKarte('Der Terminplaner ist noch nicht verfügbar', 'Sobald er für dich bereit ist, findest du ihn hier.'))
@@ -36,10 +63,13 @@ export async function initTermine(root, { projectId } = {}) {
root.replaceChildren(h('div', { class: 'kb-panel kb-empty' }, 'Noch keine Website-Projekte zugewiesen.'))
return
}
- const zustand = (p) => p.features?.termine?.state
+ // Wahl wie bei der Kachel: das gewuenschte Projekt, dann aktiv, bestellt, abgeschaltet, Entwurf, das erste
s.projekt = projects.find((p) => p.projectId === auswahl.projectId)
- || projects.find((p) => zustand(p) === 'active') || projects.find((p) => zustand(p) === 'disabled') || projects[0]
+ || projects.find((p) => zustandVon(p) === 'active') || projects.find((p) => istBestellt(zustandVon(p)))
+ || projects.find((p) => zustandVon(p) === 'disabled') || projects.find(hatEntwurf) || projects[0]
auswahl.projectId = s.projekt.projectId
+ // „Danke!“ nur nach der Zahlung (bestellt=1) und nur fuer das Projekt, fuer das bezahlt wurde
+ s.danke = rueckkehr.bestellt && (!rueckkehr.projekt || rueckkehr.projekt === s.projekt.projectId)
s.buehne = h('div', { id: 'termine-buehne' })
root.replaceChildren(...(projects.length > 1 ? [projektwahl(s)] : []), s.buehne)
await zeigeProjekt(s)
@@ -55,6 +85,7 @@ function projektwahl(s) {
auswahl.projectId = select.value
s.projekt = s.projects.find((p) => p.projectId === select.value)
s.tag = heuteBerlin()
+ s.danke = false // der Dank gilt dem Projekt, mit dem man von Stripe zurueckkam
zeigeProjekt(s)
})
return h('div', { class: 'fn-project kb-panel' }, h('label', { for: 'termine-project' }, 'Website'), select)
@@ -62,6 +93,18 @@ function projektwahl(s) {
const ladeDaten = (projectId) => request(`/api/termine/einstellungen?projectId=${encodeURIComponent(projectId)}`)
+/**
+ * Liste der Funktionen neu holen (GET /api/funktionen): Projekte, das aktuelle Projekt darin, Preis, Einrichtungsdauer und Abo-Hinweis des
+ * Servers. Wirft bei einem Fehler und aendert dann nichts.
+ */
+async function ladeFunktionen(s) {
+ const antwort = await request('/api/funktionen')
+ const projects = Array.isArray(antwort.projects) ? antwort.projects : []
+ s.projects = projects
+ s.termine = antwort.termine || null
+ s.projekt = projects.find((p) => p.projectId === s.projekt?.projectId) || s.projekt
+}
+
/**
* Hinweis fuer abgeschaltete Funktionen: Grund, Loeschdatum und (ausser vor der Einrichtung) der Export-Link, der einzige Link im Hinweis.
* abgeschaltet: { grund, seit, loeschungAm } aus GET /einstellungen (fehlt bei einer aelteren Antwort: dann der bisherige Satz).
@@ -73,19 +116,13 @@ function hinweisAbgeschaltet(projectId, abgeschaltet) {
}
/**
- * Je nach Zustand der Funktion: nicht freigeschaltet, Vorlage waehlen oder die Reiter. Jeder Aufruf zaehlt s.lauf hoch: die Antwort
+ * Je nach Zustand der Funktion die Ansicht zeigen (siehe Kopf der Datei). Jeder Aufruf zaehlt s.lauf hoch: die Antwort
* einer frueheren Website (langsam, dann zur anderen gewechselt) wird verworfen und erscheint nie unter der neuen.
*/
-async function zeigeProjekt(s) {
+async function zeigeProjekt(s, optionen = {}) {
const projekt = s.projekt
const lauf = ++s.lauf
const aktuell = () => S === s && s.lauf === lauf
- const zustand = projekt.features?.termine?.state || 'off'
- s.nurLesen = zustand !== 'active'
- if (zustand !== 'active' && zustand !== 'disabled') {
- s.buehne.replaceChildren(infoKarte('Der Terminplaner ist für diese Website noch nicht freigeschaltet', 'Wir schalten den Terminplaner für dich frei.', { chat: true }))
- return
- }
s.buehne.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…'))
let daten
try {
@@ -95,8 +132,39 @@ async function zeigeProjekt(s) {
return
}
if (!aktuell()) return
+ await zeichneAnsicht(s, { daten, aktuell, ...optionen })
+}
+
+/**
+ * Ansicht nach einer gescheiterten Aktion der Statuskarte neu aufbauen (Ruling R110): Liste und Daten frisch holen und erst dann zeichnen.
+ * Scheitert das Laden, wirft die Funktion und die Karte bleibt mit ihrer Meldung stehen. optionen: bestellformular (ordered zeigt das
+ * Bestellformular statt der Karte), meldung ({ text, art }, nur im Zustand ordered noch von Belang).
+ */
+async function ladeNeu(s, optionen = {}) {
+ const lauf = s.lauf
+ await ladeFunktionen(s)
+ const daten = await ladeDaten(s.projekt.projectId)
+ if (S !== s || s.lauf !== lauf) return // inzwischen hat ein Wechsel der Website die Ansicht ersetzt
+ const nr = ++s.lauf
+ await zeichneAnsicht(s, { daten, aktuell: () => S === s && s.lauf === nr, ...optionen })
+}
+
+/** Die Ansicht zum Zustand aus den frisch geladenen Daten (daten.state; faellt auf den Eintrag der Liste zurueck). */
+async function zeichneAnsicht(s, { daten, aktuell, bestellformular = false, meldung = null }) {
+ const projekt = s.projekt
s.daten = daten
- if (daten.einstellung) zeigeReiter(s, { projekt, aktuell })
+ const zustand = daten.state || zustandVon(projekt)
+ s.nurLesen = zustand !== 'active' && zustand !== 'off'
+ if (zustand === 'off') {
+ if (daten.abo?.ok === true) zeigeFragebogen(s, { projekt, aktuell })
+ else await zeigeAboHinweis(s, { aktuell })
+ return
+ }
+ if (istBestellt(zustand)) {
+ await zeigeBestellung(s, { zustand, aktuell, bestellformular, meldung })
+ return
+ }
+ if (daten.einstellung) zeigeReiter(s, { projekt, aktuell, zustand })
else if (s.nurLesen) {
s.buehne.replaceChildren(
hinweisAbgeschaltet(projekt.projectId, daten.abgeschaltet),
@@ -104,6 +172,82 @@ async function zeigeProjekt(s) {
} else zeigeVorlagen(s, { projekt, aktuell })
}
+const neueMeldung = () => h('div', { class: 'tm-meldung', role: 'status', 'aria-live': 'polite', tabindex: '-1' })
+
+/* ----------------------------------------------- Zustand off: Hinweis, Fragebogen --- */
+/**
+ * Ohne laufendes Wartungs-Abo gibt es keinen Fragebogen, nur den Hinweis wie in der Kachel. Der Text kommt ausschliesslich vom Server
+ * (GET /api/funktionen, termine.hinweis = aboMeldung in server/services/featureOrder.js), nie aus einer Kopie im Browser; bei
+ * kein_abo kommt der Link zu den Vertraegen dazu (Quelle: vertraege-link.js). Fehlt der Text, weil sich das Abo seit dem Laden der Liste
+ * geaendert hat, wird die Liste noch einmal geholt.
+ */
+async function zeigeAboHinweis(s, { aktuell }) {
+ if (typeof s.termine?.hinweis !== 'string' || s.termine.hinweis === '') {
+ try {
+ await ladeFunktionen(s)
+ } catch {
+ // ohne Text bleibt der Hinweis bei der Ueberschrift (und dem Link bei kein_abo)
+ }
+ if (!aktuell()) return
+ }
+ const hinweis = typeof s.termine?.hinweis === 'string' ? s.termine.hinweis : ''
+ const link = s.termine?.bestellbar === false && s.termine.grund === 'kein_abo'
+ ? h('button', { type: 'button', class: ZU_VERTRAEGE.klasse, 'data-view': ZU_VERTRAEGE.view }, ZU_VERTRAEGE.text)
+ : null
+ s.buehne.replaceChildren(h('div', { class: 'kb-panel tm-info', 'data-hinweis': 'abo' },
+ h('h3', null, 'Terminplaner einrichten'),
+ hinweis || link ? h('p', null, hinweis, hinweis && link ? ' ' : null, link) : null))
+}
+
+/** Der Fragebogen mit der Bestellseite danach (termine-fragebogen.js); die Meldung ueber ihm ist s.meldungEl (ctx.meldung). */
+function zeigeFragebogen(s, { projekt, aktuell }) {
+ s.meldungEl = neueMeldung()
+ const wurzel = h('div', { class: 'tm-fb-wurzel' })
+ s.buehne.replaceChildren(s.meldungEl, wurzel)
+ renderFragebogen(wurzel, baueCtx(s, { projekt, aktuell }))
+}
+
+/* ----------------------------------------------- Zustand ordered, setup --- */
+const GESPERRT = 'Deine Angaben sind seit der Bestellung gesperrt. Änderungen laufen über den Chat.'
+
+/**
+ * Statuskarte (Zahlung offen | In Einrichtung) und darunter die Reiter zum Lesen. Der Eintrag der Liste liefert Bestelldatum, Ticketnummer und
+ * Rechnung; stimmt sein Zustand nicht mit dem der Daten ueberein (die Liste ist aelter, z. B. nach der Zahlung), wird sie neu geholt.
+ * Die Karte meldet ueber weiter(), wenn „Zahlung fortsetzen“ scheitert: ladeNeu zeichnet die Ansicht dann neu (bei 409 zustimmung_fehlt
+ * mit dem Bestellformular statt der Karte). Das Bestellformular (daten.bestellung, R110) zeigt dieselbe Seite wie der Fragebogen.
+ */
+async function zeigeBestellung(s, { zustand, aktuell, bestellformular, meldung }) {
+ if (s.projekt.features?.termine?.state !== zustand) {
+ try {
+ await ladeFunktionen(s)
+ } catch {
+ // es gilt der Zustand der Daten; was nur die Liste kennt (Ticketnummer, Rechnung), fehlt dann in der Karte
+ }
+ if (!aktuell()) return
+ }
+ const projekt = s.projekt
+ const eintrag = { ...projekt.features?.termine, state: zustand }
+ const danke = s.danke
+ s.danke = false // nur beim ersten Zeichnen nach der Rueckkehr
+ const formular = bestellformular && zustand === 'ordered' && Boolean(s.daten.bestellung)
+ const kopf = (ctx) => {
+ if (formular) {
+ const seite = h('div', { class: 'tm-fb-bestellen' })
+ renderBestellen(seite, { ctx, daten: s.daten, zumSchritt: () => ctx.meldung(GESPERRT, 'fehler') })
+ return seite
+ }
+ return statusKarte({
+ projekt,
+ bestellung: eintrag,
+ einrichtung: s.termine?.einrichtung,
+ danke,
+ kundenansicht: s.daten.kundenansicht === true,
+ meldung: zustand === 'ordered' && meldung ? meldung.text : '', // die Meldung gilt nur, solange die Bestellung noch offen ist
+ }, { weiter: (fall) => ladeNeu(s, { bestellformular: fall?.bestellen === true, meldung: fall?.meldung ? { text: fall.meldung } : null }) })
+ }
+ zeigeReiter(s, { projekt, aktuell, zustand, kopf, meldung: formular && meldung ? { text: meldung.text, art: 'ok' } : null })
+}
+
/* ---------------------------------------------------- Vorlage wählen --- */
function zeigeVorlagen(s, { projekt, aktuell }) {
const fehler = h('p', { class: 'tm-fehler', role: 'alert' })
@@ -201,19 +345,27 @@ export function baueCtx(s, { projekt, aktuell, hinweise = () => {}, zumReiter =
return ctx
}
-function zeigeReiter(s, { projekt, aktuell }) {
+/**
+ * Die Reiter (Hinweisleiste, Meldung, Reiterleiste, Panel). zustand steuert, was dazugehoert: disabled hat den Banner der Abschaltung,
+ * active den Hinweis auf fehlende Pflichtangaben, ordered und setup keinen der beiden und keinen Reiter „Termine“ (der Server antwortet
+ * dort mit 409). kopf(ctx) liefert ein Element, das ueber der Meldung steht (die Statuskarte), meldung ({ text, art }) wird nach dem
+ * ersten Zeichnen angezeigt. Ohne Einstellung gibt es keine Reiter, dann bleibt es beim Kopf.
+ */
+function zeigeReiter(s, { projekt, aktuell, zustand = s.nurLesen ? 'disabled' : 'active', kopf = null, meldung = null }) {
const projectId = projekt.projectId
const nurLesen = s.nurLesen
- s.meldungEl = h('div', { class: 'tm-meldung', role: 'status', 'aria-live': 'polite', tabindex: '-1' })
+ s.meldungEl = neueMeldung()
const ctx = baueCtx(s, { projekt, aktuell, hinweise: zeichneHinweise, zumReiter: waehleReiter })
+ const reiterListe = istBestellt(zustand) ? REITER.filter((r) => r.key !== 'termine') : REITER
+ const kopfEl = kopf ? kopf(ctx) : null
// Hinweisleiste: abgeschaltet, oder Pflichtangaben fuer Gaeste fehlen (GET /einstellungen: fehlt). Sie folgt s.daten.
const hinweisBox = h('div', { class: 'tm-hinweise' })
function zeichneHinweise() {
const hinweise = []
- if (nurLesen) {
+ if (zustand === 'disabled') {
hinweise.push(hinweisAbgeschaltet(projectId, s.daten.abgeschaltet))
- } else if ((s.daten.fehlt || []).length) {
+ } else if (!nurLesen && (s.daten.fehlt || []).length) {
hinweise.push(h('div', { class: 'tm-box tm-box-warn', role: 'note' },
h('span', null, `Noch nicht buchbar: Es fehlt ${fehltText(s.daten.fehlt) || 'eine Angabe zu deinem Betrieb'}. Solange das fehlt, können Gäste über deine Website nicht buchen.`),
h('button', { type: 'button', class: 'kb-btn-outline', onclick: () => waehleReiter('einstellungen') }, 'Zu den Einstellungen')))
@@ -222,24 +374,33 @@ function zeigeReiter(s, { projekt, aktuell }) {
}
zeichneHinweise()
+ if (!s.daten.einstellung) {
+ s.buehne.replaceChildren(...[hinweisBox, kopfEl, s.meldungEl].filter(Boolean))
+ if (meldung) zeigeMeldung(s, meldung.text, meldung.art)
+ return
+ }
+
const panel = h('div', { class: 'tm-panel', role: 'tabpanel', id: 'tm-panel', tabindex: '0' })
- const knoepfe = REITER.map((r) => h('button', {
+ let aktiv = null // Schluessel des gezeigten Reiters
+ const knoepfe = reiterListe.map((r) => h('button', {
type: 'button', role: 'tab', class: 'tm-reiter', id: `tm-tab-${r.key}`, 'aria-controls': 'tm-panel', 'data-reiter': r.key,
onclick: () => waehleReiter(r.key),
}, r.label))
const leiste = h('div', { class: 'tm-reiterleiste', role: 'tablist', 'aria-label': 'Bereiche des Terminplaners' }, knoepfe)
leiste.addEventListener('keydown', (ev) => {
- const i = REITER.findIndex((r) => r.key === auswahl.reiter)
- const ziel = { ArrowRight: (i + 1) % REITER.length, ArrowLeft: (i + REITER.length - 1) % REITER.length, Home: 0, End: REITER.length - 1 }[ev.key]
+ const i = reiterListe.findIndex((r) => r.key === aktiv)
+ const ziel = { ArrowRight: (i + 1) % reiterListe.length, ArrowLeft: (i + reiterListe.length - 1) % reiterListe.length, Home: 0, End: reiterListe.length - 1 }[ev.key]
if (ziel === undefined) return
ev.preventDefault()
- waehleReiter(REITER[ziel].key)
+ waehleReiter(reiterListe[ziel].key)
knoepfe[ziel].focus()
})
- function waehleReiter(key) {
- const reiter = REITER.find((r) => r.key === key) || REITER[0]
- auswahl.reiter = reiter.key
+ function waehleReiter(key, { merken = true } = {}) {
+ // ein Reiter, den es in diesem Zustand nicht gibt (Termine in ordered und setup), wird nicht gemerkt: die Auswahl gilt fuer die naechste Website weiter
+ const reiter = reiterListe.find((r) => r.key === key) || reiterListe[0]
+ if (merken && reiter.key === key) auswahl.reiter = reiter.key
+ aktiv = reiter.key
for (const k of knoepfe) {
const an = k.dataset.reiter === reiter.key
k.setAttribute('aria-selected', String(an))
@@ -253,7 +414,7 @@ function zeigeReiter(s, { projekt, aktuell }) {
.catch((err) => { if (aktuell()) panel.replaceChildren(fehlerKarte(err.message, () => waehleReiter(reiter.key))) })
}
- s.buehne.replaceChildren(hinweisBox, s.meldungEl, leiste, panel)
- waehleReiter(auswahl.reiter)
+ s.buehne.replaceChildren(...[hinweisBox, kopfEl, s.meldungEl, leiste, panel].filter(Boolean)) // replaceChildren(null) schriebe „null“ in die Seite
+ waehleReiter(auswahl.reiter, { merken: false })
+ if (meldung) zeigeMeldung(s, meldung.text, meldung.art)
}
-
diff --git a/public/vertraege-link.js b/public/vertraege-link.js
new file mode 100644
index 0000000..b258dd0
--- /dev/null
+++ b/public/vertraege-link.js
@@ -0,0 +1,5 @@
+/* Der Link „Zu Verträge“ in den Hinweisen ohne Wartungs-Abo (Grund kein_abo): die einzige Quelle für Text, Ziel und Klasse. Gelesen von
+ funktionen.js (Kachel Analyse, Analyse-Seite, Kachel Terminplaner; baut daraus HTML) und von termine.js (Hinweis in der Ansicht #termine;
+ baut daraus ein Element). Reine Daten ohne Import: beide laden das Modul relativ, funktionen.js behält seine zwei absoluten Importe.
+ Die Hinweistexte selbst stehen nicht hier: sie kommen vom Server (aboMeldung in server/services/featureOrder.js). */
+export const ZU_VERTRAEGE = Object.freeze({ text: 'Zu Verträge', view: 'vertraege', klasse: 'kb-link' })
diff --git a/server/routes/termine.js b/server/routes/termine.js
index aba52d0..40878c5 100644
--- a/server/routes/termine.js
+++ b/server/routes/termine.js
@@ -311,7 +311,9 @@ async function aboFuerVorschau(customerId) {
* Einstellungen und Entwurf für das Portal. Liest in jedem Zustand und auch ohne Funktionsdokument (entwurf: true).
* Neben der Einstellung liefert die Antwort den Entwurf des Fragebogens (branchen, fragebogen, kundenansicht, betriebVorschlag
* ohne Einstellung) und in `off` die Sicht der Bestellung: abo (bestellbar() oder null bei Lesefehler), offen (Punkte der
- * Bestellprüfung inklusive Abo-Punkt (h)) und bestellung (Texte und Preis des Registers). Einbau-Code und Kalender-Links
+ * Bestellprüfung inklusive Abo-Punkt (h)) und bestellung (Texte und Preis des Registers). In `ordered` liefert sie bestellung und
+ * offen ebenfalls, nur lesend (Ruling R110: „Zahlung fortsetzen“ ohne Nachweis zeigt dort wieder das Bestellformular); das Abo wird
+ * dort nicht gelesen (abo null), offen hat keinen Abo-Punkt (h). Einbau-Code und Kalender-Links
* gibt es nur, wo die Funktion läuft oder lief (active, disabled; Kalender-Links nur in active).
*/
router.get('/einstellungen', requireSession, withTermine({ entwurf: true }), route(async (req, res) => {
@@ -322,6 +324,7 @@ router.get('/einstellungen', requireSession, withTermine({ entwurf: true }), rou
const customerId = getSessionCustomerId(req)
const register = featureConfig('termine')
const abo = state === 'off' ? await aboFuerVorschau(customerId) : null
+ const bestellbar = state === 'off' || state === 'ordered' // die Sicht der Bestellung (offen, bestellung) gibt es nur vor der Zahlung
res.json({
state,
abgeschaltet: state === 'disabled' ? abgeschaltetAnsicht(ctx.feature) : null,
@@ -334,8 +337,8 @@ router.get('/einstellungen', requireSession, withTermine({ entwurf: true }), rou
kundenansicht: isKundenansicht(req.session),
...(e ? {} : { betriebVorschlag: await betriebVorschlag(customerId, req.project) }),
abo,
- offen: state === 'off' ? bestellpruefung({ einstellung: e, fragebogen: ctx.fragebogen, feature: register, abo }) : null,
- bestellung: state === 'off' ? await bestellungAnsicht(register) : null,
+ offen: bestellbar ? bestellpruefung({ einstellung: e, fragebogen: ctx.fragebogen, feature: register, abo, mitAbo: state === 'off' }) : null,
+ bestellung: bestellbar ? await bestellungAnsicht(register) : null,
feeds: e && state === 'active' ? feedLinks(ctx) : {},
siteKey: ctx.feature.siteKey || null,
einbau: laeuft ? { snippet: einbauSnippet(ctx) } : {},
diff --git a/test/funktionenTiles.test.js b/test/funktionenTiles.test.js
index 8f7a10a..b559f7e 100644
--- a/test/funktionenTiles.test.js
+++ b/test/funktionenTiles.test.js
@@ -5,12 +5,13 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
-// public/funktionen.js wirklich laden (die absoluten Importe /analyse-view.js und /geraete.js werden auf die Dateien umgeleitet)
+// public/funktionen.js wirklich laden (die absoluten Importe /analyse-view.js und /geraete.js und der relative ./vertraege-link.js werden auf die Dateien umgeleitet)
const dir = mkdtempSync(join(tmpdir(), 'fn-tiles-'))
const fileUrl = (name) => pathToFileURL(new URL(`../public/${name}`, import.meta.url).pathname).href
const src = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8')
.replace("'/analyse-view.js'", `'${fileUrl('analyse-view.js')}'`)
.replace("'/geraete.js'", `'${fileUrl('geraete.js')}'`)
+ .replace("'./vertraege-link.js'", `'${fileUrl('vertraege-link.js')}'`)
const file = join(dir, 'funktionen.mjs')
writeFileSync(file, src)
const { initFunktionen, initNews, initAnalyse, renderAnalyseOff } = await import(pathToFileURL(file).href)
@@ -265,9 +266,12 @@ test('Terminplaner-Kachel: mit Abo (bestellbar true) steht der Knopf da und ein
assert.doesNotMatch(kachel, /Serverhinweis|fn-count/)
})
-test('Der Browser hat keine Kopie der Abo-Hinweise; der Link zu Verträge steht einmal im Quelltext (Analyse und Terminplaner teilen ihn)', async () => {
+test('Der Browser hat keine Kopie der Abo-Hinweise; der Link zu Verträge hat eine einzige Quelle (vertraege-link.js: Analyse, Terminplaner-Kachel und Ansicht #termine teilen sie)', async () => {
assert.doesNotMatch(src, /TERMINE_ABO_TEXTE|termineAboHinweis|KEIN_ABO_HINWEIS|ist gekündigt\. Für|gerade nicht aktiv|nicht geprüft werden/)
- assert.equal(src.split('data-view="vertraege"').length - 1, 1)
+ const quelle = readFileSync(new URL('../public/vertraege-link.js', import.meta.url), 'utf8')
+ assert.equal(quelle.split("view: 'vertraege'").length - 1, 1, 'Ziel, Text und Klasse stehen genau einmal in vertraege-link.js')
+ assert.equal(src.split('data-view="vertraege"').length - 1, 0, 'funktionen.js baut den Link aus ZU_VERTRAEGE, ohne eigenes Ziel')
+ assert.equal(src.split('ZU_VERTRAEGE_LINK =').length - 1, 1, 'ein Baustein für alle Stellen')
// Analyse-Kachel und -Seite (unveraendert) und Terminplaner-Kachel zeigen genau diesen Link
const analyse = tile(await render([project('a', { eligible: false, reason: 'kein_abo', reasonText: 'Kein Abo' })]), 'analyse')
assert.ok(analyse.includes(`Im Wartungs-Abo enthalten. ${ZU_VERTRAEGE}
`), analyse)
diff --git a/test/support/fakeDom.js b/test/support/fakeDom.js
index 0d2c117..44d5b59 100644
--- a/test/support/fakeDom.js
+++ b/test/support/fakeDom.js
@@ -3,7 +3,7 @@
// const fx = installFakeDom({ antworten: [{ status: 400, body: { fehler: {...} } }] })
// try { ...Client-Code bauen, fx.klick(knopf), fx.fetchLog pruefen... } finally { fx.restore() }
//
-// installFakeDom stellt `document`, `window` (nur location und history.replaceState), `CSS.escape` und `fetch` auf globalThis, restore() stellt den
+// installFakeDom stellt `document`, `window` (nur location mit href, pathname, search, hash und history.replaceState), `CSS.escape` und `fetch` auf globalThis, restore() stellt den
// alten Zustand wieder her. `h` aus public/termine-dom.js liest `document` bei jedem Aufruf: der Client braucht keine Einhaengepunkte.
// Wurzeln, die `isConnected` brauchen (neuerSpeicher.nochHier), haengt der Test selbst an `document.body`.
//
@@ -479,7 +479,7 @@ const GLOBALE = ['document', 'window', 'CSS', 'fetch']
export function installFakeDom({ antworten = [] } = {}) {
const vorher = Object.fromEntries(GLOBALE.map((name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)]))
const document = neuesDokument()
- const location = { href: 'http://localhost/', hash: '' }
+ const location = { href: 'http://localhost/', pathname: '/dashboard.html', search: '', hash: '' }
const window = {
document,
location,
diff --git a/test/termineFragebogenRoutes.test.js b/test/termineFragebogenRoutes.test.js
index cb2cb86..7886e6a 100644
--- a/test/termineFragebogenRoutes.test.js
+++ b/test/termineFragebogenRoutes.test.js
@@ -322,8 +322,8 @@ test('einbau.snippet und feeds nur in active (feeds) und active/disabled (snippe
}
})
-test('abo, offen und bestellung gibt es nur in off; sonst null', async () => {
- for (const zustand of ['ordered', 'setup', 'active', 'disabled']) {
+test('abo gibt es nur in off; offen und bestellung nur in off und ordered (R110); in setup, active und disabled null', async () => {
+ for (const zustand of ['setup', 'active', 'disabled']) {
await deleteDocument('portalFeatures', FEATURE).catch(() => {})
await deleteDocument('termineConfig', FEATURE).catch(() => {})
setzeZustand(zustand)
@@ -332,11 +332,57 @@ test('abo, offen und bestellung gibt es nur in off; sonst null', async () => {
assert.equal(body.offen, null, zustand)
assert.equal(body.bestellung, null, zustand)
}
- setzeZustand('off')
+ for (const zustand of ['off', 'ordered']) {
+ await deleteDocument('portalFeatures', FEATURE).catch(() => {})
+ await deleteDocument('termineConfig', FEATURE).catch(() => {})
+ setzeZustand(zustand)
+ const { body } = await send('GET', einstellungenUrl(), { cookie })
+ assert.notEqual(body.offen, null, zustand)
+ assert.notEqual(body.bestellung, null, zustand)
+ assert.equal(body.abo === null, zustand === 'ordered', `${zustand}: das Abo liest die Route nur in off`)
+ }
+})
+
+// ---------------------------------------------------------------- ordered: bestellung und offen nur lesend (R110)
+// "Zahlung fortsetzen" ohne Nachweis (409 zustimmung_fehlt) zeigt im Zustand ordered wieder das Bestellformular; dafuer braucht der
+// Client bestellung und offen auch dort. Das Abo gehoert nicht dazu: es wird in ordered nicht gelesen, offen hat keinen Abo-Punkt (h).
+
+test('ordered: bestellung ist die Sicht des Registers wie in off, offen die Bestellprüfung ohne Abo-Punkt, abo null, nichts wird geschrieben', async () => {
+ const stand = setzeZustand('ordered')
+ aw.resetListCalls()
+ const vorher = groessen()
+ const { status, body } = await send('GET', einstellungenUrl(), { cookie })
+ assert.equal(status, 200)
+ assert.equal(body.state, 'ordered')
+ assert.deepEqual(body.bestellung, JSON.parse(JSON.stringify(await bestellungAnsicht(T))))
+ assert.equal(body.bestellung.consent.knopf, T.consent.knopf)
+ assert.deepEqual(body.offen, [], 'bestellbarer Entwurf: keine offenen Punkte, auch ohne Abo-Punkt')
+ assert.deepEqual(body.offen, bestellpruefung({ einstellung: stand.einstellung, fragebogen: stand.fragebogen, feature: T, mitAbo: false }))
+ assert.equal(body.abo, null)
+ assert.deepEqual(aw.listCalls('subscriptions'), [], 'in ordered wird das Abo nicht gelesen')
+ assert.deepEqual(groessen(), vorher, 'read-only: nichts angelegt, nichts protokolliert')
+})
+
+test('ordered: ein unvollständiger Entwurf zeigt seine offenen Punkte (nie den Abo-Punkt), ohne Einstellung den Punkt „Branche“', async () => {
+ setzeZustand('ordered', { stand: { ...bestellbarerStand({ now: JETZT }), fragebogen: leerFragebogen() } })
const { body } = await send('GET', einstellungenUrl(), { cookie })
- assert.notEqual(body.abo, null)
- assert.notEqual(body.offen, null)
+ assert.ok(body.offen.length > 0)
+ assert.ok(body.offen.some((p) => p.schritt === 'leistungen'))
+ assert.ok(body.offen.every((p) => p.schritt !== 'bestellen'), 'kein Abo-Punkt (h)')
+ await deleteDocument('termineConfig', FEATURE)
+ const ohne = await send('GET', einstellungenUrl(), { cookie })
+ assert.equal(ohne.status, 200)
+ assert.deepEqual(ohne.body.offen, [{ schritt: 'branche', text: 'Bitte starte den Fragebogen mit der Wahl deiner Branche.' }])
+ assert.notEqual(ohne.body.bestellung, null)
+})
+
+test('ordered in der Kundenansicht: bestellung und offen ebenfalls da, kundenansicht true', async () => {
+ setzeZustand('ordered')
+ const { status, body } = await send('GET', einstellungenUrl(), kv())
+ assert.equal(status, 200)
+ assert.equal(body.kundenansicht, true)
assert.notEqual(body.bestellung, null)
+ assert.deepEqual(body.offen, [])
})
test('E11 bleibt: disabled liefert das Feld abgeschaltet mit Grund, Zeitpunkt und Löschtag; Schreiben ist weiter 409 "abgeschaltet"', async () => {
diff --git a/test/termineFragebogenView.test.js b/test/termineFragebogenView.test.js
index 245fc5b..1e1e274 100644
--- a/test/termineFragebogenView.test.js
+++ b/test/termineFragebogenView.test.js
@@ -1443,35 +1443,72 @@ test('statusKarte ordered: „Zahlung fortsetzen“ sendet fortsetzenKoerper und
assert.equal(fx.window.location.href, KNOPF_URL)
}))
-test('statusKarte ordered: 409 zustimmung_fehlt ⇒ weiter() lädt neu und zeigt das Bestellformular, keine Navigation', mitDom({
+test('statusKarte ordered: 409 zustimmung_fehlt ⇒ weiter({ bestellen: true, meldung }) lädt neu und zeigt das Bestellformular, keine Navigation', mitDom({
antworten: [{ status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } }],
}, async (fx) => {
- let weiter = 0
- const wurzel = karte({}, {}, { weiter: async () => { weiter += 1 } })
+ const aufrufe = []
+ const wurzel = karte({}, {}, { weiter: async (fall) => { aufrufe.push(fall) } })
await fx.klick(fx.finde(wurzel, '[data-aktion="fortsetzen"]'))
- assert.equal(weiter, 1)
+ assert.deepEqual(aufrufe, [{ bestellen: true, meldung: 'Bitte stimme der Erklärung zu, um zu bestellen.' }])
assert.equal(fx.window.location.href, 'http://localhost/')
}))
-test('statusKarte ordered: ohne weiter() oder bei anderem Fehler steht die Meldung am Knopf, der Knopf ist wieder frei', mitDom({
- antworten: [{ status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } }, { status: 409, body: { error: 'Der Terminplaner ist für dieses Projekt schon bestellt.' } }, { body: { url: 'http://unsicher.example/x' } }],
+test('statusKarte ordered: jeder andere Fehler ruft weiter({ bestellen: false, meldung }) (R110), die Karte bleibt nie veraltet stehen', mitDom({
+ antworten: [
+ { status: 400, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } },
+ { status: 409, body: { error: 'Der Terminplaner ist für dieses Projekt schon bestellt.' } },
+ { status: 500, body: {} },
+ ],
}, async (fx) => {
- let weiter = 0
+ const aufrufe = []
+ // wie im Browser zeichnet sich die Ansicht nach weiter() neu: der Knopf der alten Karte bleibt gesperrt, jeder Fall hat seine Karte
+ for (let i = 0; i < 3; i += 1) await fx.klick(fx.finde(karte({}, {}, { weiter: (fall) => { aufrufe.push(fall) } }), '[data-aktion="fortsetzen"]'))
+ assert.deepEqual(aufrufe, [
+ { bestellen: false, meldung: 'Bitte stimme der Erklärung zu, um zu bestellen.' },
+ { bestellen: false, meldung: 'Der Terminplaner ist für dieses Projekt schon bestellt.' },
+ { bestellen: false, meldung: 'Fehler 500' },
+ ], 'auch bei 400 (Zustand inzwischen setup oder active) und 409 ohne zustimmung_fehlt')
+}))
+
+test('statusKarte ordered: scheitert weiter() (Neuladen nicht möglich), bleibt die Karte mit dem Fehler am Knopf; ohne weiter() steht die Meldung des Servers dort', mitDom({
+ antworten: [
+ { status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } },
+ { status: 409, body: { error: 'Der Terminplaner ist für dieses Projekt schon bestellt.' } },
+ { status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } },
+ { status: 409, body: { error: 'Der Terminplaner ist für dieses Projekt schon bestellt.' } },
+ { body: { url: 'http://unsicher.example/x' } },
+ ],
+}, async (fx) => {
+ const kaputt = () => { throw new Error('Keine Verbindung. Bitte versuch es gleich noch einmal.') }
+ const mit = karte({}, {}, { weiter: kaputt })
+ const meldungMit = fx.finde(mit, '[data-meldung="fortsetzen"]')
+ const knopfMit = fx.finde(mit, '[data-aktion="fortsetzen"]')
+ await fx.klick(knopfMit)
+ assert.equal(fx.text(meldungMit), 'Keine Verbindung. Bitte versuch es gleich noch einmal.', 'bei fehlender Zustimmung der Grund des Neuladens')
+ assert.equal(knopfMit.disabled, false)
+ await fx.klick(knopfMit)
+ assert.equal(fx.text(meldungMit), 'Der Terminplaner ist für dieses Projekt schon bestellt.', 'sonst der Fehler des Versuchs')
const ohne = karte()
const knopf = fx.finde(ohne, '[data-aktion="fortsetzen"]')
await fx.klick(knopf)
assert.equal(fx.text(fx.finde(ohne, '[data-meldung="fortsetzen"]')), 'Bitte stimme der Erklärung zu, um zu bestellen.')
assert.equal(knopf.disabled, false)
- const mit = karte({}, {}, { weiter: () => { weiter += 1 } })
- await fx.klick(fx.finde(mit, '[data-aktion="fortsetzen"]'))
- assert.equal(weiter, 0, 'weiter() nur bei zustimmung_fehlt')
- assert.equal(fx.text(fx.finde(mit, '[data-meldung="fortsetzen"]')), 'Der Terminplaner ist für dieses Projekt schon bestellt.')
+ const ohneZwei = karte()
+ await fx.klick(fx.finde(ohneZwei, '[data-aktion="fortsetzen"]'))
+ assert.equal(fx.text(fx.finde(ohneZwei, '[data-meldung="fortsetzen"]')), 'Der Terminplaner ist für dieses Projekt schon bestellt.')
const unsicher = karte()
await fx.klick(fx.finde(unsicher, '[data-aktion="fortsetzen"]'))
assert.equal(fx.window.location.href, 'http://localhost/', 'keine http-Navigation')
assert.match(fx.text(fx.finde(unsicher, '[data-meldung="fortsetzen"]')), /Zahlungsseite/)
}))
+test('statusKarte: meldung steht beim Zeichnen am Knopf „Zahlung fortsetzen“ (als Text)', mitDom(undefined, (fx) => {
+ const wurzel = karte({}, { meldung: 'Deine Zahlung läuft schon. x' })
+ assert.equal(fx.text(fx.finde(wurzel, '[data-meldung="fortsetzen"]')), 'Deine Zahlung läuft schon. x')
+ assert.equal(fx.alle(wurzel, 'b').length, 0)
+ assert.equal(fx.text(fx.finde(karte(), '[data-meldung="fortsetzen"]')), '')
+}))
+
test('statusKarte ordered: in der Kundenansicht ist „Zahlung fortsetzen“ gesperrt, mit Hinweis und ohne Anfrage', mitDom(undefined, async (fx) => {
const wurzel = karte({}, { kundenansicht: true })
const knopf = fx.finde(wurzel, '[data-aktion="fortsetzen"]')
@@ -2965,3 +3002,519 @@ test('termine.css: Klassen der Schritte 4 bis 8 (Person, Ausnahmen, Sammelknopf,
for (const klasse of ['tm-fb-person', 'tm-fb-ausnahmen', 'tm-fb-sammel', 'tm-fb-einbau']) assert.match(css, new RegExp(`\\.${klasse}[\\s{,.:\\[]`), `.${klasse} ist gestaltet`)
assert.match(css.match(/\.tm-fb-sammel\s*\{[^}]*\}/)?.[0] ?? '', /flex-wrap:\s*wrap/, 'die Knöpfe brechen am Handy um')
})
+
+// ------------------------------------------------------- Ansicht #termine nach Zustand und Rückkehr von Stripe (TP2, Task C10b)
+// initTermine in public/termine.js. Der Fake-fetch antwortet hier nach Adresse statt der Reihe nach: Antworten von GET /api/funktionen (dort
+// steht der Abo-Hinweis, R111) und GET /api/termine/einstellungen liefert der Test. Ein Array in der Tabelle sind aufeinanderfolgende Antworten
+// (die letzte gilt weiter). Der Zustand der Huelle (Auswahl des Projekts und des Reiters) bleibt zwischen den Tests im Modul: jeder Test nennt
+// sein Projekt (projectId), und kein Test verlangt einen bestimmten Reiter, ohne ihn zu waehlen.
+const AN_FUNKTIONEN = 'GET /api/funktionen'
+const AN_CHECKOUT = 'POST /api/funktionen/termine/checkout'
+const an = (projectId) => `GET /api/termine/einstellungen?projectId=${projectId}`
+const SERVER_HINWEIS = 'Dieser Satz steht nur beim Server, so wie der Server ihn schreibt.'
+const AUFRUFE = (fx) => fx.fetchLog.map((a) => `${a.method} ${a.url}`)
+
+function routen(fx, tabelle) {
+ globalThis.fetch = async (url, optionen = {}) => {
+ const aufruf = { method: optionen.method || 'GET', url: String(url), body: optionen.body ? JSON.parse(optionen.body) : undefined }
+ fx.fetchLog.push(aufruf)
+ let eintrag = tabelle[`${aufruf.method} ${aufruf.url}`]
+ if (eintrag === undefined && aufruf.method === 'GET' && aufruf.url.startsWith('/api/termine/buchungen')) eintrag = { body: { buchungen: [] } }
+ if (Array.isArray(eintrag)) eintrag = eintrag.length > 1 ? eintrag.shift() : eintrag[0]
+ if (typeof eintrag === 'function') eintrag = eintrag(aufruf)
+ const { status = 200, body = {} } = eintrag ?? { status: 404, body: { error: `nicht vorgesehen: ${aufruf.method} ${aufruf.url}` } }
+ return { ok: status >= 200 && status < 300, status, json: async () => structuredClone(body) }
+ }
+}
+
+/** Ansicht öffnen (Adresse mit hash, Tabelle der Antworten) und warten, bis auch die Reiter gezeichnet sind. => Wurzel der Ansicht. */
+async function oeffneAnsicht(fx, { projectId = 'p1', hash = '', tabelle }) {
+ routen(fx, tabelle)
+ fx.window.location.hash = hash
+ const wurzel = h('div')
+ document.body.append(wurzel)
+ await huelle.initTermine(wurzel, { projectId })
+ await fx.ruhe()
+ return wurzel
+}
+
+const projektEintrag = (id, state, extra = {}) => ({ projectId: id, projectName: `Website ${id}`, liveUrl: '', features: { news: { state: 'off' }, termine: { state, ...extra } } })
+const GEORDERT = { orderedAt: '2026-10-05T10:00:00.000Z', ticketWoid: null, invoiceUrl: null }
+const funktionenAntwort = (projekte, termine = {}) => ({
+ body: { projects: projekte, termine: { verfuegbar: true, bestellbar: true, grund: null, preis: '149 €', einrichtung: EINRICHTUNG, hinweis: null, ...termine } },
+})
+const ohneAbo = (grund, hinweis = SERVER_HINWEIS) => ({ bestellbar: false, grund, hinweis })
+const ABGESCHALTET = { grund: 'abo_ende', seit: '2026-10-04T10:00:00.000Z', loeschungAm: '2026-11-03' }
+
+/** Antwort von GET /api/termine/einstellungen im jeweiligen Zustand (off: Entwurf mit Abo, ordered: dazu die Sicht der Bestellung ohne Abo). */
+async function ansichtDaten(state, extra = {}) {
+ const basis = { ...fbDaten({ state }), abo: null, offen: null, bestellung: null, feeds: {}, anfragenOffen: 0, aktivSeit: null, abgeschaltet: null }
+ if (state === 'off') return { ...basis, ...fbDaten(), ...extra }
+ if (state === 'ordered') return { ...basis, offen: [], bestellung: await bestellungTest(), ...extra }
+ if (state === 'active') return { ...basis, aktivSeit: '2026-10-01T09:00:00.000Z', einbau: { snippet: '' }, ...extra }
+ if (state === 'disabled') return { ...basis, aktivSeit: '2026-10-01T09:00:00.000Z', abgeschaltet: ABGESCHALTET, ...extra }
+ return { ...basis, ...extra }
+}
+const reiterTexte = (fx, wurzel) => fx.alle(wurzel, '[role="tab"]').map((tab) => fx.text(tab))
+const meldungDerHuelle = (fx, wurzel) => fx.text(fx.finde(wurzel, '.tm-meldung'))
+/** Antwort-Tabelle für ein Projekt p1 im Zustand state mit den Daten daten. */
+async function einProjekt(state, { eintrag = {}, daten = {}, termine = {}, tabelle = {} } = {}) {
+ return {
+ [AN_FUNKTIONEN]: funktionenAntwort([projektEintrag('p1', state, eintrag)], termine),
+ [an('p1')]: { body: await ansichtDaten(state, daten) },
+ ...tabelle,
+ }
+}
+
+test('Ansicht off mit laufendem Abo: der Fragebogen (Fortschrittsleiste im Baum), weder Hinweis noch Reiter', mitDom(undefined, async (fx) => {
+ const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { fragebogen: fbBis(1) } }) })
+ assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt ol.tm-schritte'), 'renderFragebogen hat gezeichnet')
+ assert.equal(fbSchritt(fx, wurzel), 'wer', 'Start beim ersten offenen Schritt des Entwurfs')
+ assert.ok(fx.finde(wurzel, '[data-hinweis="abo"]') === null)
+ assert.ok(fx.finde(wurzel, '.tm-reiterleiste') === null)
+ assert.ok(fx.finde(wurzel, '.tm-statuskarte') === null)
+ assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1')])
+}))
+
+test('Ansicht off: ohne Entwurf die Branchenwahl; die Meldung der Hülle (ctx.meldung) steht über dem Fragebogen', mitDom(undefined, async (fx) => {
+ const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', {
+ daten: { einstellung: null, version: 0, fragebogen: leerFragebogen(), betriebVorschlag: BETRIEB_KONTO },
+ tabelle: {
+ 'POST /api/termine/einstellungen/vorlage': { status: 201, body: { einstellung: salon(), version: 1, fehlt: [], fragebogen: fbBis(0) } },
+ [an('p1')]: [{ body: fbStart() }, { body: fbDaten({ version: 1, fragebogen: fbBis(0) }) }],
+ 'PUT /api/termine/fragebogen': fbAntwort(1),
+ },
+ }) })
+ assert.equal(fbSchritt(fx, wurzel), 'branche')
+ assert.equal(meldungDerHuelle(fx, wurzel), '')
+ await fx.klick(fbRadio(fx, wurzel, 'salon'))
+ await fx.klick(fx.finde(wurzel, '[data-aktion="branche-starten"]'))
+ assert.equal(fbSchritt(fx, wurzel), 'wer')
+ assert.match(meldungDerHuelle(fx, wurzel), /^Die Branche „Friseur und Salon“ ist angelegt\./, 'ohne meldungEl bliebe die Meldung stumm')
+ assert.equal(fx.finde(wurzel, '.tm-meldung').getAttribute('role'), 'status')
+}))
+
+test('Ansicht off ohne laufendes Abo: der Hinweis vom Server (kein_abo mit Link zu Verträge), kein Fragebogen, keine weitere Abfrage', mitDom(undefined, async (fx) => {
+ const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { abo: { ok: false, grund: 'kein_abo' } }, termine: ohneAbo('kein_abo') }) })
+ const karteEl = fx.finde(wurzel, '[data-hinweis="abo"]')
+ assert.ok(karteEl)
+ assert.ok(fx.text(karteEl).includes(SERVER_HINWEIS), 'der Text des Servers, unverändert')
+ const link = fx.alle(karteEl, 'button')
+ assert.deepEqual(link.map((b) => [fx.text(b), b.getAttribute('data-view'), b.getAttribute('class'), b.getAttribute('type')]), [['Zu Verträge', 'vertraege', 'kb-link', 'button']])
+ assert.equal(fx.text(fx.finde(karteEl, 'p')), `${SERVER_HINWEIS} Zu Verträge`, 'Text, Leerzeichen, Link wie in der Kachel')
+ assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt') === null, 'kein Fragebogen')
+ assert.equal(fx.alle(wurzel, 'input').length, 0)
+ assert.ok(fx.finde(wurzel, '.tm-reiterleiste') === null)
+ assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1')])
+}))
+
+test('Ansicht off ohne Abo: gekündigt, nicht aktiv, nicht lesbar und fehlendes abo zeigen nur den Text des Servers, ohne Link', mitDom(undefined, async (fx) => {
+ const faelle = [
+ ['gekuendigt', { ok: false, grund: 'gekuendigt' }, ohneAbo('gekuendigt', 'Server: Dein Wartungs-Abo ist gekündigt.')],
+ ['nicht_aktiv', { ok: false, grund: 'nicht_aktiv' }, ohneAbo('nicht_aktiv', 'Server: Dein Wartungs-Abo ist gerade nicht aktiv.')],
+ ['Lesefehler (abo null)', null, { bestellbar: null, grund: null, hinweis: 'Server: Das Abo konnte gerade nicht geprüft werden.' }],
+ ['abo fehlt in der Antwort', undefined, ohneAbo('gekuendigt', 'Server: ohne abo.')],
+ ]
+ for (const [name, abo, termine] of faelle) {
+ const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { abo }, termine }) })
+ const karteEl = fx.finde(wurzel, '[data-hinweis="abo"]')
+ assert.ok(karteEl, name)
+ assert.equal(fx.text(fx.finde(karteEl, 'p')), termine.hinweis, name)
+ assert.equal(fx.alle(karteEl, 'button').length, 0, `${name}: kein Link`)
+ assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt') === null, name)
+ }
+}))
+
+test('Ansicht off ohne Abo: der Hinweis kommt maskiert in die Seite und der Browser kennt keinen eigenen Text', mitDom(undefined, async (fx) => {
+ const bose = '
& "Anführungszeichen"'
+ const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { abo: { ok: false, grund: 'gekuendigt' } }, termine: ohneAbo('gekuendigt', bose) }) })
+ assert.equal(fx.alle(wurzel, 'img').length, 0)
+ assert.equal(fx.text(fx.finde(wurzel, '[data-hinweis="abo"] p')), bose)
+}))
+
+test('Ansicht off ohne Abo: fehlt der Text in der Liste (das Abo hat sich seit dem Laden geändert), wird die Liste noch einmal geholt', mitDom(undefined, async (fx) => {
+ const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', {
+ daten: { abo: { ok: false, grund: 'kein_abo' } },
+ tabelle: { [AN_FUNKTIONEN]: [funktionenAntwort([projektEintrag('p1', 'off')]), funktionenAntwort([projektEintrag('p1', 'off')], ohneAbo('kein_abo'))] },
+ }) })
+ assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1'), AN_FUNKTIONEN])
+ assert.equal(fx.text(fx.finde(wurzel, '[data-hinweis="abo"] p')), `${SERVER_HINWEIS} Zu Verträge`)
+ // bleibt der Text auch dann aus, steht nur die Überschrift (kein erfundener Ersatztext)
+ const leer = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { abo: { ok: false, grund: 'gekuendigt' } } }) })
+ assert.ok(fx.finde(leer, '[data-hinweis="abo"] p') === null)
+ assert.equal(fx.text(fx.finde(leer, '[data-hinweis="abo"] h3')), 'Terminplaner einrichten')
+}))
+
+test('Ansicht off: in der Kundenansicht gilt dasselbe (Fragebogen mit Hinweis der Kundenansicht, ohne Abo der Hinweis)', mitDom(undefined, async (fx) => {
+ const mit = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { kundenansicht: true } }) })
+ assert.match(fx.text(fx.finde(mit, '.tm-box[role="note"]')), /Kundenansicht/)
+ const ohne = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { kundenansicht: true, abo: { ok: false, grund: 'gekuendigt' } }, termine: ohneAbo('gekuendigt') }) })
+ assert.ok(fx.finde(ohne, '[data-hinweis="abo"]'))
+ assert.ok(fx.finde(ohne, 'nav.tm-fortschritt') === null)
+}))
+
+test('Ansicht active ohne Einstellung: weiter die Vorlagenauswahl von TP1 (kein Fragebogen), mit Einstellung die vier Reiter', mitDom(undefined, async (fx) => {
+ const ohne = await oeffneAnsicht(fx, { tabelle: await einProjekt('active', { daten: { einstellung: null, version: 0, fragebogen: null } }) })
+ assert.equal(fx.alle(ohne, 'input[name="termine-vorlage"]').length, 1)
+ assert.ok(fx.text(ohne).includes('Womit möchtest du starten?'))
+ assert.ok(fx.finde(ohne, 'nav.tm-fortschritt') === null)
+ assert.ok(fx.finde(ohne, '.tm-reiterleiste') === null)
+ const mit = await oeffneAnsicht(fx, { tabelle: await einProjekt('active') })
+ assert.deepEqual(reiterTexte(fx, mit), ['Termine', 'Leistungen', 'Team & Plätze', 'Einstellungen'])
+ assert.ok(fx.finde(mit, '.tm-statuskarte') === null)
+ assert.ok(fx.finde(mit, '.tm-hinweise .tm-box-warn') === null, 'weder Abschaltung noch fehlende Angaben')
+}))
+
+test('Ansicht active: fehlende Pflichtangaben zeigen den Hinweis „Noch nicht buchbar“ weiter wie bisher', mitDom(undefined, async (fx) => {
+ const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('active', { daten: { fehlt: ['betrieb.telefon'] } }) })
+ assert.match(fx.text(fx.finde(wurzel, '.tm-hinweise .tm-box-warn')), /Noch nicht buchbar/)
+}))
+
+for (const [state, pille, ticket] of [['ordered', 'Zahlung offen', null], ['setup', 'In Einrichtung', 100342]]) {
+ test(`Ansicht ${state}: Statuskarte „${pille}“ und die Reiter Leistungen, Team & Plätze, Einstellungen nur zum Lesen, ohne Reiter „Termine“`, mitDom(undefined, async (fx) => {
+ const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt(state, {
+ eintrag: { ...GEORDERT, ticketWoid: ticket, invoiceUrl: state === 'setup' ? 'https://invoice.stripe.test/i/in_1' : null },
+ daten: { fehlt: ['betrieb.telefon'], aktivSeit: null },
+ }) })
+ const karteEl = fx.finde(wurzel, '.tm-statuskarte')
+ assert.ok(karteEl)
+ assert.equal(karteEl.getAttribute('data-state'), state)
+ assert.equal(fx.text(fx.finde(karteEl, '.tm-pille')), pille)
+ assert.match(fx.text(karteEl), /Bestellt am\s*05\.10\.2026/, 'Bestelldatum aus dem Eintrag der Liste')
+ if (state === 'setup') {
+ assert.match(fx.text(karteEl), /Ticketnummer\s*#100342/)
+ assert.match(fx.text(karteEl), new RegExp(`Zeiteinschätzung\\s*${EINRICHTUNG}`), 'Einrichtungsdauer aus GET /api/funktionen')
+ }
+ assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt') === null, 'kein Fragebogen')
+ assert.deepEqual(reiterTexte(fx, wurzel), ['Leistungen', 'Team & Plätze', 'Einstellungen'])
+ assert.ok(fx.finde(wurzel, '#tm-tab-termine') === null)
+ assert.ok(fx.finde(wurzel, '.tm-hinweise .tm-box-warn') === null, 'weder Banner der Abschaltung noch „Noch nicht buchbar“')
+ // jeder Reiter ist schreibgeschützt: Felder gesperrt, keine Knöpfe zum Ändern
+ for (const [key, ohneKnopf] of [['leistungen', '[data-werkzeug="leistung-neu"]'], ['team', '[data-werkzeug="ressource-neu"]'], ['einstellungen', 'button[type="submit"]']]) {
+ await fx.klick(fx.finde(wurzel, `#tm-tab-${key}`))
+ await fx.ruhe()
+ const panel = fx.finde(wurzel, '#tm-panel')
+ assert.ok(fx.finde(panel, ohneKnopf) === null, `${key}: nichts zum Ändern`)
+ assert.ok(fx.finde(panel, 'fieldset[disabled]') || key !== 'einstellungen', `${key}: gesperrt`)
+ assert.ok(!fx.text(panel).includes('null'), `${key}: kein „null“ als Text (replaceChildren mit null)`)
+ }
+ // „Export und Status“ des Reiters Einstellungen sagt vor der Freischaltung nicht „Aktiv“
+ assert.ok(fx.finde(fx.finde(wurzel, '#tm-panel'), '.tm-status') === null)
+ assert.equal(fx.alle(fx.finde(wurzel, '#tm-panel'), 'a').length, 0, 'noch nichts zum Exportieren')
+ assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1')], 'der Reiter „Termine“ wird nicht gelesen (der Server antwortete mit 409)')
+ }))
+}
+
+test('Ansicht ordered/setup: die Pfeiltasten wandern nur durch die drei Reiter, ein gemerkter Reiter „Termine“ fällt auf den ersten zurück und bleibt für andere Websites gemerkt', mitDom(undefined, async (fx) => {
+ const tabelle = {
+ [AN_FUNKTIONEN]: funktionenAntwort([projektEintrag('p1', 'active'), projektEintrag('p2', 'ordered', GEORDERT)]),
+ [an('p1')]: { body: await ansichtDaten('active') },
+ [an('p2')]: { body: await ansichtDaten('ordered') },
+ }
+ const wurzel = await oeffneAnsicht(fx, { projectId: 'p1', tabelle })
+ await fx.klick(fx.finde(wurzel, '#tm-tab-termine'))
+ await fx.setze(fx.finde(wurzel, '#termine-project'), 'p2')
+ await fx.ruhe()
+ assert.ok(fx.finde(wurzel, '.tm-statuskarte'))
+ assert.deepEqual(reiterTexte(fx, wurzel), ['Leistungen', 'Team & Plätze', 'Einstellungen'])
+ assert.equal(fx.finde(wurzel, '[role="tab"][aria-selected="true"]').dataset.reiter, 'leistungen', 'der erste Reiter, den es gibt')
+ await fx.sende(fx.finde(wurzel, '.tm-reiterleiste'), 'keydown', { key: 'ArrowLeft' })
+ assert.equal(fx.finde(wurzel, '[role="tab"][aria-selected="true"]').dataset.reiter, 'einstellungen', 'Pfeil links vom ersten Reiter springt zum letzten')
+ await fx.sende(fx.finde(wurzel, '.tm-reiterleiste'), 'keydown', { key: 'ArrowRight' })
+ assert.equal(fx.finde(wurzel, '[role="tab"][aria-selected="true"]').dataset.reiter, 'leistungen')
+ // zurück zur aktiven Website: der zuvor gewählte Reiter „Termine“ ist wieder da
+ await fx.setze(fx.finde(wurzel, '#termine-project'), 'p1')
+ await fx.ruhe()
+ assert.equal(fx.finde(wurzel, '[role="tab"][aria-selected="true"]').dataset.reiter, 'leistungen', 'die Wahl in p2 gilt ab jetzt')
+}))
+
+test('Ansicht ordered in der Kundenansicht: „Zahlung fortsetzen“ ist gesperrt', mitDom(undefined, async (fx) => {
+ const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('ordered', { eintrag: GEORDERT, daten: { kundenansicht: true } }) })
+ assert.equal(fx.finde(wurzel, '[data-aktion="fortsetzen"]').disabled, true)
+}))
+
+test('Ansicht ordered/setup ohne Einstellung: nur die Statuskarte, keine Reiter', mitDom(undefined, async (fx) => {
+ const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('setup', { eintrag: GEORDERT, daten: { einstellung: null, version: 0 } }) })
+ assert.ok(fx.finde(wurzel, '.tm-statuskarte'))
+ assert.ok(fx.finde(wurzel, '.tm-reiterleiste') === null)
+ assert.ok(!fx.text(wurzel).includes('null'))
+}))
+
+test('Ansicht disabled: Banner der Abschaltung (hinweisAbgeschaltet) mit Löschdatum aus abgeschaltet.loeschungAm und Export-Link, alle vier Reiter, unverändert (E11)', mitDom(undefined, async (fx) => {
+ const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('disabled') })
+ const text = ansicht.abschaltText(ABGESCHALTET)
+ const banner = fx.finde(wurzel, '.tm-hinweise .tm-box-warn')
+ assert.ok(banner)
+ assert.equal(fx.text(banner), `${text.davor}exportieren${text.danach}`)
+ assert.match(fx.text(banner), /03\.11\.2026|2026-11-03/, 'das Löschdatum aus loeschungAm')
+ assert.deepEqual(fx.alle(banner, 'a').map((a) => [fx.text(a), a.getAttribute('href')]), [['exportieren', '/api/termine/export.csv?projectId=p1']])
+ assert.deepEqual(reiterTexte(fx, wurzel), ['Termine', 'Leistungen', 'Team & Plätze', 'Einstellungen'])
+ assert.ok(fx.finde(wurzel, '.tm-statuskarte') === null)
+ assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt') === null)
+ await fx.klick(fx.finde(wurzel, '#tm-tab-einstellungen'))
+ await fx.ruhe()
+ assert.match(fx.text(fx.finde(wurzel, '.tm-status')), /Abgeschaltet/, 'Export und Status bleiben in disabled')
+}))
+
+test('Ansicht disabled ohne Einstellung: Banner und „Keine Termine eingerichtet“ wie bisher', mitDom(undefined, async (fx) => {
+ const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('disabled', { daten: { einstellung: null, version: 0 } }) })
+ assert.ok(fx.finde(wurzel, '.tm-box-warn[role="note"]'))
+ assert.ok(fx.text(wurzel).includes('Keine Termine eingerichtet'))
+ assert.ok(fx.finde(wurzel, '.tm-reiterleiste') === null)
+}))
+
+test('Ansicht: der Zustand der Daten gilt, auch wenn die Liste einen anderen kennt (Liste: off, Daten: active)', mitDom(undefined, async (fx) => {
+ const wurzel = await oeffneAnsicht(fx, { tabelle: { [AN_FUNKTIONEN]: funktionenAntwort([projektEintrag('p1', 'off')]), [an('p1')]: { body: await ansichtDaten('active') } } })
+ assert.deepEqual(reiterTexte(fx, wurzel), ['Termine', 'Leistungen', 'Team & Plätze', 'Einstellungen'])
+}))
+
+test('Ansicht ordered: kennt die Liste einen älteren Zustand als die Daten (nach der Zahlung), wird sie neu geholt und die Karte zeigt die Ticketnummer', mitDom(undefined, async (fx) => {
+ const wurzel = await oeffneAnsicht(fx, { tabelle: {
+ [AN_FUNKTIONEN]: [funktionenAntwort([projektEintrag('p1', 'ordered', GEORDERT)]), funktionenAntwort([projektEintrag('p1', 'setup', { ...GEORDERT, ticketWoid: 100342 })])],
+ [an('p1')]: { body: await ansichtDaten('setup') },
+ } })
+ assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1'), AN_FUNKTIONEN])
+ assert.equal(fx.text(fx.finde(wurzel, '.tm-statuskarte .tm-pille')), 'In Einrichtung')
+ assert.match(fx.text(fx.finde(wurzel, '.tm-statuskarte')), /Ticketnummer\s*#100342/)
+ // lässt sich die Liste nicht holen, gilt der Zustand der Daten, ohne Ticket
+ const ohne = await oeffneAnsicht(fx, { tabelle: {
+ [AN_FUNKTIONEN]: [funktionenAntwort([projektEintrag('p1', 'ordered', GEORDERT)]), { status: 500, body: {} }],
+ [an('p1')]: { body: await ansichtDaten('setup') },
+ } })
+ assert.equal(fx.text(fx.finde(ohne, '.tm-statuskarte .tm-pille')), 'In Einrichtung')
+ assert.match(fx.text(fx.finde(ohne, '.tm-statuskarte')), /Wir legen dein Ticket gerade an\./)
+ assert.ok(fx.finde(ohne, '[data-aktion="fortsetzen"]') === null, 'kein „Zahlung fortsetzen“ für eine bezahlte Bestellung')
+}))
+
+test('Ansicht: ein Fehler beim Laden zeigt die Fehlerkarte mit „Noch einmal versuchen“, das lädt neu', mitDom(undefined, async (fx) => {
+ const tabelle = await einProjekt('off', { tabelle: { [an('p1')]: [{ status: 500, body: { error: 'Der Server hat gerade Pause.' } }, { body: await ansichtDaten('off') }] } })
+ const wurzel = await oeffneAnsicht(fx, { tabelle })
+ assert.equal(fx.text(fx.finde(wurzel, '[role="alert"] p')), 'Der Server hat gerade Pause.')
+ await fx.klick(fx.finde(wurzel, '[role="alert"] button'))
+ await fx.ruhe()
+ assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt'))
+}))
+
+// ------------------------------------------------------- Rückkehr von Stripe
+const zweiProjekte = async (zustandP2 = 'ordered', extraP2 = GEORDERT) => ({
+ [AN_FUNKTIONEN]: funktionenAntwort([projektEintrag('p1', 'off'), projektEintrag('p2', zustandP2, extraP2)]),
+ [an('p1')]: { body: await ansichtDaten('off') },
+ [an('p2')]: { body: await ansichtDaten(zustandP2) },
+})
+/** Aufrufe von history.replaceState mitschreiben. */
+function merkeAdressen(fx) {
+ const adressen = []
+ const echt = fx.window.history.replaceState
+ fx.window.history.replaceState = (zustand, titel, url) => { adressen.push(url); echt(zustand, titel, url) }
+ return adressen
+}
+
+test('Rückkehr nach der Zahlung: #termine?bestellt=1&projekt=p2 wählt p2, zeigt „Danke!“, bereinigt die Adresse (einmal gelesen)', mitDom(undefined, async (fx) => {
+ const adressen = merkeAdressen(fx)
+ const wurzel = await oeffneAnsicht(fx, { projectId: 'p1', hash: '#termine?bestellt=1&projekt=p2', tabelle: await zweiProjekte() })
+ assert.equal(fx.finde(wurzel, '#termine-project').value, 'p2', 'das Projekt der Rückkehr (vor dem gemerkten p1)')
+ assert.equal(fx.text(fx.finde(wurzel, '[data-danke]')), 'Danke! Sobald die Zahlung da ist, legen wir los.')
+ assert.equal(fx.text(fx.finde(wurzel, '.tm-statuskarte .tm-pille')), 'Zahlung offen')
+ assert.deepEqual(adressen, ['/dashboard.html#termine'], 'Pfad und Query bleiben, der Hash bis auf #termine weg')
+ assert.equal(fx.window.location.hash, '#termine')
+ assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p2')], 'nur das Projekt der Rückkehr wird geladen')
+ // ein zweiter Aufruf (Hash ist bereinigt) kennt weder Rückkehr noch Danke, behält aber die Auswahl
+ const wieder = await oeffneAnsicht(fx, { projectId: '', hash: fx.window.location.hash, tabelle: await zweiProjekte() })
+ assert.ok(fx.finde(wieder, '[data-danke]') === null)
+ assert.equal(fx.finde(wieder, '#termine-project').value, 'p2')
+}))
+
+test('Rückkehr nach der Zahlung, wenn der Webhook schon durch ist (setup): „Danke!“ auch dann', mitDom(undefined, async (fx) => {
+ const wurzel = await oeffneAnsicht(fx, { projectId: 'p1', hash: '#termine?bestellt=1&projekt=p2', tabelle: await zweiProjekte('setup', { ...GEORDERT, ticketWoid: 100342 }) })
+ assert.ok(fx.finde(wurzel, '[data-danke]'))
+ assert.equal(fx.text(fx.finde(wurzel, '.tm-statuskarte .tm-pille')), 'In Einrichtung')
+}))
+
+test('Rückkehr nach dem Abbruch: #termine?projekt=p2 wählt p2 ohne „Danke!“, die Adresse ist bereinigt; „Zahlung fortsetzen“ ist da', mitDom(undefined, async (fx) => {
+ const adressen = merkeAdressen(fx)
+ const wurzel = await oeffneAnsicht(fx, { projectId: 'p1', hash: '#termine?projekt=p2', tabelle: await zweiProjekte() })
+ assert.equal(fx.finde(wurzel, '#termine-project').value, 'p2')
+ assert.ok(fx.finde(wurzel, '[data-danke]') === null)
+ assert.ok(!fx.text(wurzel).includes('Danke!'))
+ assert.ok(fx.finde(wurzel, '[data-aktion="fortsetzen"]'))
+ assert.deepEqual(adressen, ['/dashboard.html#termine'])
+ assert.equal(fx.window.location.hash, '#termine')
+}))
+
+test('Rückkehr: bestellt=1 ohne Projekt gilt dem gewählten Projekt; ein unbekanntes Projekt wird ignoriert (kein „Danke!“ für ein anderes)', mitDom(undefined, async (fx) => {
+ const ohneProjekt = await oeffneAnsicht(fx, { projectId: 'p2', hash: '#termine?bestellt=1', tabelle: await zweiProjekte() })
+ assert.ok(fx.finde(ohneProjekt, '[data-danke]'))
+ assert.equal(fx.window.location.hash, '#termine')
+ const unbekannt = await oeffneAnsicht(fx, { projectId: 'p2', hash: '#termine?bestellt=1&projekt=gibtsnicht', tabelle: await zweiProjekte() })
+ assert.ok(fx.finde(unbekannt, '[data-danke]') === null)
+ assert.equal(fx.finde(unbekannt, '#termine-project').value, 'p2', 'es bleibt bei der gewählten Website')
+ assert.equal(fx.window.location.hash, '#termine')
+}))
+
+test('Rückkehr: „Danke!“ nur für das Projekt der Zahlung und nur beim ersten Zeichnen, nicht nach einem Wechsel der Website', mitDom(undefined, async (fx) => {
+ const wurzel = await oeffneAnsicht(fx, { projectId: 'p1', hash: '#termine?bestellt=1&projekt=p2', tabelle: await zweiProjekte() })
+ assert.ok(fx.finde(wurzel, '[data-danke]'))
+ await fx.setze(fx.finde(wurzel, '#termine-project'), 'p1')
+ await fx.ruhe()
+ assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt'), 'p1 ist die Website im Zustand off')
+ await fx.setze(fx.finde(wurzel, '#termine-project'), 'p2')
+ await fx.ruhe()
+ assert.ok(fx.finde(wurzel, '.tm-statuskarte'))
+ assert.ok(fx.finde(wurzel, '[data-danke]') === null, 'der Dank war einmal')
+}))
+
+test('Rückkehr: bestellt=1 für ein Projekt, das nicht bestellt ist (active, off), zeigt kein „Danke!“; fremde Hashes werden nicht angefasst', mitDom(undefined, async (fx) => {
+ const adressen = merkeAdressen(fx)
+ const aktiv = await oeffneAnsicht(fx, { projectId: 'p2', hash: '#termine?bestellt=1&projekt=p2', tabelle: await zweiProjekte('active', {}) })
+ assert.ok(fx.finde(aktiv, '[data-danke]') === null)
+ assert.ok(!fx.text(aktiv).includes('Danke!'))
+ assert.equal(adressen.length, 1)
+ const fremd = await oeffneAnsicht(fx, { projectId: 'p2', hash: '#news?bestellt=1&projekt=p2', tabelle: await zweiProjekte() })
+ assert.ok(fx.finde(fremd, '[data-danke]') === null, 'die Rückkehr der News gehört nicht hierher')
+ assert.equal(adressen.length, 1, 'und ihr Hash bleibt stehen')
+ assert.equal(fx.window.location.hash, '#news?bestellt=1&projekt=p2')
+}))
+
+test('Rückkehr: Projekt-ID aus der Adresse landet nur als Wert in der Anfrage (codiert), nie im Baum', mitDom(undefined, async (fx) => {
+ const wurzel = await oeffneAnsicht(fx, { projectId: 'p1', hash: '#termine?bestellt=1&projekt=%3Cimg%20src%3Dx%3E', tabelle: await zweiProjekte() })
+ assert.equal(fx.alle(wurzel, 'img').length, 0)
+ assert.ok(!AUFRUFE(fx).some((a) => a.includes('
projektEintrag('p1', 'ordered', GEORDERT)
+const fortsetzenKnopf = (fx, wurzel) => fx.finde(wurzel, '[data-aktion="fortsetzen"]')
+
+test('R110: 409 zustimmung_fehlt ⇒ Liste und Daten werden neu geholt und das Bestellformular steht an der Stelle der Karte', mitDom(undefined, async (fx) => {
+ const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('ordered', {
+ eintrag: GEORDERT,
+ tabelle: { [AN_CHECKOUT]: { status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } } },
+ }) })
+ assert.ok(fx.finde(wurzel, '.tm-statuskarte'))
+ assert.ok(fx.finde(wurzel, '.tm-bestellen') === null)
+ await fx.klick(fortsetzenKnopf(fx, wurzel))
+ await fx.ruhe()
+ assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1'), AN_CHECKOUT, AN_FUNKTIONEN, an('p1')])
+ assert.deepEqual(fx.fetchLog[2].body, { projectId: 'p1', fortsetzen: true })
+ assert.ok(fx.finde(wurzel, '.tm-statuskarte') === null, 'die Karte ist weg')
+ const seite = fx.finde(wurzel, '.tm-bestellen')
+ assert.ok(seite, 'das Bestellformular')
+ assert.equal(fx.text(bestellKnopf(fx, wurzel)), (await bestellungTest()).consent.knopf, 'Knopftext aus der Antwort des Servers')
+ assert.equal(bestellKnopf(fx, wurzel).disabled, true, 'gesperrt, bis alle Haken stehen')
+ assert.equal(hakenFelder(fx, seite).length, registerHaken().length)
+ assert.equal(meldungDerHuelle(fx, wurzel), 'Bitte stimme der Erklärung zu, um zu bestellen.')
+ assert.deepEqual(reiterTexte(fx, wurzel), ['Leistungen', 'Team & Plätze', 'Einstellungen'], 'die Reiter bleiben, nur lesbar')
+ // „Ändern“ führt nicht in den (gesperrten) Fragebogen, sondern erklärt, warum nicht
+ await fx.klick(fx.finde(wurzel, '[data-aktion="aendern"]'))
+ assert.match(meldungDerHuelle(fx, wurzel), /seit der Bestellung gesperrt/)
+ // nach dem Setzen aller Haken geht die neue Zustimmung an den Checkout und es folgt die Zahlungsseite
+ routen(fx, { [AN_CHECKOUT]: { body: { url: KNOPF_URL } } })
+ await alleAnhaken(fx, seite)
+ assert.equal(bestellKnopf(fx, wurzel).disabled, false)
+ await fx.klick(bestellKnopf(fx, wurzel))
+ const bestellt = fx.fetchLog.at(-1)
+ assert.equal(bestellt.url, '/api/funktionen/termine/checkout')
+ assert.equal(bestellt.body.zustimmung.version, (await bestellungTest()).consent.version)
+ assert.equal(fx.window.location.href, KNOPF_URL)
+}))
+
+test('R110: 400 (die Bestellung ist inzwischen bezahlt, der Server kennt sie nicht mehr als offen) ⇒ die Ansicht folgt dem neuen Zustand, die Karte steht nicht veraltet da', mitDom(undefined, async (fx) => {
+ const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('ordered', {
+ eintrag: GEORDERT,
+ tabelle: {
+ [AN_CHECKOUT]: { status: 400, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } },
+ [AN_FUNKTIONEN]: [funktionenAntwort([ORDERED_P1()]), funktionenAntwort([projektEintrag('p1', 'setup', { ...GEORDERT, ticketWoid: 100342 })])],
+ [an('p1')]: [{ body: await ansichtDaten('ordered') }, { body: await ansichtDaten('setup') }],
+ },
+ }) })
+ const alteKarte = fx.finde(wurzel, '.tm-statuskarte')
+ await fx.klick(fortsetzenKnopf(fx, wurzel))
+ await fx.ruhe()
+ const karteEl = fx.finde(wurzel, '.tm-statuskarte')
+ assert.ok(karteEl !== alteKarte, 'neu gezeichnet')
+ assert.equal(fx.text(fx.finde(karteEl, '.tm-pille')), 'In Einrichtung')
+ assert.match(fx.text(karteEl), /Ticketnummer\s*#100342/)
+ assert.ok(fortsetzenKnopf(fx, wurzel) === null)
+ assert.ok(fx.finde(wurzel, '.tm-bestellen') === null, 'kein Bestellformular für eine bezahlte Bestellung')
+ assert.equal(meldungDerHuelle(fx, wurzel), '', 'der Fehler des alten Zustands steht nicht in der neuen Karte')
+ assert.ok(!fx.text(wurzel).includes('stimme der Erklärung'))
+}))
+
+test('R110: ist die Funktion nach dem Webhook aktiv, zeigt die Ansicht die vier Reiter statt der Karte', mitDom(undefined, async (fx) => {
+ const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('ordered', {
+ eintrag: GEORDERT,
+ tabelle: {
+ [AN_CHECKOUT]: { status: 400, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } },
+ [AN_FUNKTIONEN]: [funktionenAntwort([ORDERED_P1()]), funktionenAntwort([projektEintrag('p1', 'active')])],
+ [an('p1')]: [{ body: await ansichtDaten('ordered') }, { body: await ansichtDaten('active') }],
+ },
+ }) })
+ await fx.klick(fortsetzenKnopf(fx, wurzel))
+ await fx.ruhe()
+ assert.ok(fx.finde(wurzel, '.tm-statuskarte') === null)
+ assert.deepEqual(reiterTexte(fx, wurzel), ['Termine', 'Leistungen', 'Team & Plätze', 'Einstellungen'])
+ assert.equal(meldungDerHuelle(fx, wurzel), '')
+}))
+
+test('R110: ein anderer Fehler bei noch offener Bestellung (409, die Sitzung läuft schon) ⇒ die Karte wird neu gezeichnet und der Fehler steht am Knopf', mitDom(undefined, async (fx) => {
+ const FEHLER = 'Deine Zahlung läuft gerade oder ist schon da. Wir richten den Terminplaner ein, sobald sie bei uns ist.'
+ const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('ordered', {
+ eintrag: GEORDERT,
+ tabelle: { [AN_CHECKOUT]: { status: 409, body: { error: FEHLER } } },
+ }) })
+ const alteKarte = fx.finde(wurzel, '.tm-statuskarte')
+ await fx.klick(fortsetzenKnopf(fx, wurzel))
+ await fx.ruhe()
+ assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1'), AN_CHECKOUT, AN_FUNKTIONEN, an('p1')])
+ const karteEl = fx.finde(wurzel, '.tm-statuskarte')
+ assert.ok(karteEl !== alteKarte)
+ assert.equal(fx.text(fx.finde(karteEl, '[data-meldung="fortsetzen"]')), FEHLER)
+ assert.equal(fortsetzenKnopf(fx, wurzel).disabled, false, 'der Knopf ist frei')
+ assert.ok(fx.finde(wurzel, '.tm-bestellen') === null)
+}))
+
+test('R110: lässt sich die Ansicht nicht neu laden, bleibt die Karte stehen und der Fehler steht am Knopf (bei fehlender Zustimmung der Grund des Neuladens)', mitDom(undefined, async (fx) => {
+ const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('ordered', {
+ eintrag: GEORDERT,
+ tabelle: {
+ [AN_CHECKOUT]: [{ status: 409, body: { error: 'Deine Zahlung läuft gerade.' } }, { status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } }],
+ [AN_FUNKTIONEN]: [funktionenAntwort([ORDERED_P1()]), { status: 500, body: { error: 'Der Server hat gerade Pause.' } }],
+ },
+ }) })
+ const karteEl = fx.finde(wurzel, '.tm-statuskarte')
+ await fx.klick(fortsetzenKnopf(fx, wurzel))
+ await fx.ruhe()
+ assert.ok(fx.finde(wurzel, '.tm-statuskarte') === karteEl, 'dieselbe Karte')
+ assert.equal(fx.text(fx.finde(karteEl, '[data-meldung="fortsetzen"]')), 'Deine Zahlung läuft gerade.')
+ assert.equal(fortsetzenKnopf(fx, wurzel).disabled, false)
+ await fx.klick(fortsetzenKnopf(fx, wurzel))
+ await fx.ruhe()
+ assert.ok(fx.finde(wurzel, '.tm-statuskarte') === karteEl)
+ assert.equal(fx.text(fx.finde(karteEl, '[data-meldung="fortsetzen"]')), 'Der Server hat gerade Pause.')
+ assert.ok(fx.finde(wurzel, '.tm-bestellen') === null)
+}))
+
+test('R110: wechselt der Nutzer die Website, während neu geladen wird, zeichnet die späte Antwort nichts mehr', mitDom(undefined, async (fx) => {
+ let freigeben
+ const spaet = new Promise((fertig) => { freigeben = fertig })
+ const tabelle = { ...(await zweiProjekte()), [AN_CHECKOUT]: { status: 409, body: { error: 'Deine Zahlung läuft gerade.' } } }
+ const wurzel = await oeffneAnsicht(fx, { projectId: 'p2', tabelle })
+ // ab jetzt hängt das Laden der Daten von p2, bis der Test es freigibt
+ const antwort = globalThis.fetch
+ globalThis.fetch = async (url, optionen) => {
+ if (String(url) !== '/api/termine/einstellungen?projectId=p2') return antwort(url, optionen)
+ fx.fetchLog.push({ method: 'GET', url: String(url), body: undefined })
+ await spaet
+ return { ok: true, status: 200, json: async () => structuredClone(await ansichtDaten('setup')) }
+ }
+ const klick = fx.klick(fortsetzenKnopf(fx, wurzel))
+ await fx.ruhe()
+ await fx.setze(fx.finde(wurzel, '#termine-project'), 'p1')
+ await fx.ruhe()
+ assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt'), 'p1 steht da')
+ freigeben()
+ await klick
+ await fx.ruhe()
+ assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt'), 'die späte Antwort für p2 hat p1 nicht überschrieben')
+ assert.ok(fx.finde(wurzel, '.tm-statuskarte') === null)
+}))
diff --git a/test/termineView.test.js b/test/termineView.test.js
index 75b619e..0937957 100644
--- a/test/termineView.test.js
+++ b/test/termineView.test.js
@@ -312,6 +312,8 @@ test('Portal: Ansicht #termine ist verdrahtet, funktionen.js lädt keine Terminp
const fn = lies('public/funktionen.js')
assert.deepEqual(fn.match(/from '\/[^']+'/g), ["from '/analyse-view.js'", "from '/geraete.js'"])
assert.doesNotMatch(fn, /from '\/termine/)
+ // dazu genau ein relativer Import: die gemeinsame Quelle des Links „Zu Verträge“ (Reiner Datenmodul, kein Terminplaner-Modul; C10b)
+ assert.deepEqual(fn.match(/from '\.[^']+'/g), ["from './vertraege-link.js'"])
assert.doesNotMatch(fn, /import\s*\(/, 'auch kein dynamischer Import')
})
@@ -365,10 +367,33 @@ test('Ansicht-Module: Reiter sind Module mit render(panel, ctx), die Huelle kenn
}
})
+test('Ansicht nach Zustand (TP2 C10b): termine.js lädt Fragebogen, Statuskarte und die Quelle des Links relativ, kennt keinen Abo-Text und liest die Rückkehr von Stripe einmal', () => {
+ const ohneKommentare = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '')
+ const huelle = ohneKommentare(lies('public/termine.js'))
+ assert.ok(huelle.includes("import { renderFragebogen } from './termine-fragebogen.js'"))
+ assert.ok(huelle.includes("import { renderBestellen, statusKarte } from './funktion-bestellen.js'"))
+ assert.ok(huelle.includes("import { ZU_VERTRAEGE } from './vertraege-link.js'"))
+ assert.doesNotMatch(huelle, /from '\/|import\s*\(/, 'nur relative, statische Importe')
+ // R111: der Abo-Hinweis kommt vom Server (GET /api/funktionen, termine.hinweis), nie als Text aus dem Browser; R112: die alten Texte sind weg
+ assert.doesNotMatch(huelle, /Wartungs-Abo|gekündigt|nicht aktiv|nicht geprüft|brauchst du ein laufendes/, 'keine Kopie der Abo-Hinweise')
+ assert.doesNotMatch(huelle, /noch nicht freigeschaltet|Wir schalten den Terminplaner für dich frei/, 'die Texte der Ansicht vor C10b')
+ assert.match(huelle, /s\.termine\??\.hinweis/, 'der Text kommt aus der Antwort von /api/funktionen')
+ assert.match(huelle, /ZU_VERTRAEGE\.view/, 'der Link nimmt Ziel, Text und Klasse aus der gemeinsamen Quelle')
+ assert.doesNotMatch(huelle, /'vertraege'|"vertraege"|Zu Verträge/, 'und schreibt sie nicht noch einmal hin')
+ // Rückkehr von Stripe: die Adresse wird an genau einer Stelle gelesen und bereinigt
+ assert.equal(huelle.match(/history\.replaceState/g).length, 1)
+ assert.equal(huelle.match(/location\.hash/g).length, 1)
+ assert.match(huelle, /bestellt'\) === '1'/, 'danke nur mit bestellt=1')
+ // die Quelle des Links ist reine Daten ohne Import
+ const link = lies('public/vertraege-link.js')
+ assert.doesNotMatch(ohneKommentare(link), /\bimport\b/)
+ assert.match(link, /export const ZU_VERTRAEGE = Object\.freeze\(\{ text: 'Zu Verträge', view: 'vertraege', klasse: 'kb-link' \}\)/)
+})
+
test('Fragebogen (TP2 C9a): renderFragebogen wird nur über termine.js geladen, nie absolut, nie von einem Reiter oder von funktionen.js', () => {
const dateien = readdirSync(new URL('../public/', import.meta.url)).filter((f) => /\.(js|html)$/.test(f))
const ladende = dateien.filter((f) => /termine-fragebogen\.js/.test(lies(`public/${f}`)))
- assert.ok(ladende.every((f) => f === 'termine.js'), `nur die Huelle darf den Fragebogen laden: ${ladende.join(', ')}`)
+ assert.deepEqual(ladende, ['termine.js'], `nur die Huelle darf den Fragebogen laden (C10b verdrahtet ihn): ${ladende.join(', ')}`)
for (const f of dateien) assert.doesNotMatch(lies(`public/${f}`), /['"]\/termine-fragebogen\.js['"]/, `${f}: kein absoluter Import`)
assert.match(lies('public/termine-fragebogen.js'), /export function renderFragebogen\(root, ctx\)/)
})
diff --git a/test/ui/termine-portal.ui.mjs b/test/ui/termine-portal.ui.mjs
index 4dd2255..b9820f8 100644
--- a/test/ui/termine-portal.ui.mjs
+++ b/test/ui/termine-portal.ui.mjs
@@ -68,6 +68,21 @@ function portalHilfe(p, json) {
return json({})
}
+// Branchen und Bestellsicht, wie GET /api/termine/einstellungen sie liefert (Server: BRANCHEN, bestellungAnsicht); feste Texte der Fixture
+const FX_BRANCHEN = [
+ { key: 'salon', name: 'Friseur und Salon', gesundheit: false },
+ { key: 'kosmetik', name: 'Kosmetik und Wellness', gesundheit: false },
+ { key: 'gesundheit', name: 'Gesundheitsbereich', gesundheit: true },
+]
+const FX_BESTELLUNG = {
+ preis: '149 €',
+ preisHinweis: 'ohne Umsatzsteuer gemäß § 19 UStG',
+ einrichtung: 'meist innerhalb von 1–2 Wochen',
+ consent: { version: 'fx-1', knopf: 'Zahlungspflichtig bestellen · 149 €', items: [{ id: 'verfuegbar', text: 'Ich bestelle den Terminplaner verbindlich.', haken: true }] },
+ dokumente: [],
+ belehrung: 'Widerrufsbelehrung der Fixture. Du hast das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.',
+}
+
/** Fixture: Daten, die page.route statt der Schnittstellen /api/funktionen und /api/termine liefert. */
function neueFixture() {
const fx = {
@@ -82,14 +97,25 @@ function neueFixture() {
{ key: 'beratung', name: 'Beratung und Coaching', beschreibung: 'Gespräche vor Ort oder online.' },
],
fehlt: [],
+ fragebogen: { schema: 1, branche: 'salon', bestaetigt: [], bundeslandGewaehlt: false, preiseGeprueft: false, einbau: { modus: null, knopfText: '', stelle: '', gestaltung: '', bemerkung: '' } },
+ offen: [],
+ checkout: { status: 200, body: { url: 'https://checkout.stripe.test/c/pay/cs_test_fx' } }, // Antwort auf POST /api/funktionen/termine/checkout
anfragenOffen: 0,
buchungen: [],
aufrufe: [],
}
+ // GET /api/termine/einstellungen. off: Entwurf mit Abo (abo), offen und Bestellsicht; ordered: Bestellsicht und offen, aber kein abo (R110)
fx.daten = (state = fx.projekte[0].features.termine.state) => ({
state, aktivSeit: '2026-10-01T09:00:00.000Z', einstellung: fx.einstellung, version: fx.einstellung ? 3 : 0, vorlagen: fx.vorlagen, feeds: {}, siteKey: KEY,
einbau: { snippet: '' }, anfragenOffen: fx.buchungen.filter((b) => b.status === 'angefragt').length || fx.anfragenOffen, fehlt: fx.fehlt,
abgeschaltet: state === 'disabled' ? fx.abgeschaltet ?? { grund: null, seit: null, loeschungAm: null } : null,
+ branchen: FX_BRANCHEN,
+ fragebogen: fx.fragebogen,
+ kundenansicht: false,
+ ...(fx.einstellung ? {} : { betriebVorschlag: { name: 'Salon Müller', adresse: 'Hauptstr. 1, 67722 Winnweiler', email: 'info@salon-mueller.example', telefon: '', websiteUrl: '' } }),
+ abo: state === 'off' ? (fx.bestellbar === true ? { ok: true } : fx.bestellbar === false ? { ok: false, grund: fx.aboGrund } : null) : null,
+ offen: state === 'off' || state === 'ordered' ? fx.offen : null,
+ bestellung: state === 'off' || state === 'ordered' ? FX_BESTELLUNG : null,
})
return fx
}
@@ -152,10 +178,14 @@ async function fixtureAntwort(fx, route, req, url, json) {
: { verfuegbar: false }
return json({ projects: fx.projekte, termine })
}
+ if (p === '/api/funktionen/termine/checkout' && m === 'POST') {
+ fx.aufrufe.push(['checkout', req.postDataJSON()])
+ return json(fx.checkout.body, fx.checkout.status)
+ }
if (p === '/api/termine/einstellungen' && m === 'GET') {
const projekt = q.get('projectId')
if (fx.verzoegerung?.[projekt]) await new Promise((fertig) => { setTimeout(fertig, fx.verzoegerung[projekt]) })
- const daten = fx.daten()
+ const daten = fx.daten(fx.projekte.find((p) => p.projectId === projekt)?.features?.termine?.state)
if (fx.ohneEinstellungFuer?.includes(projekt)) Object.assign(daten, { einstellung: null, version: 0 })
return json(daten)
}
@@ -268,7 +298,7 @@ const formular = (page, name) => page.getByRole('form', { name })
const fehlerAm = (loc, pfad) => loc.locator(`[data-fehler="${pfad}"]`)
const warteAnsicht = async (page) => {
await page.locator('#view-termine.active').waitFor()
- await page.locator('#termine-body .tm-reiterleiste, #termine-body .tm-vorlagen, #termine-body .tm-info').first().waitFor()
+ await page.locator('#termine-body .tm-reiterleiste, #termine-body .tm-vorlagen, #termine-body .tm-info, #termine-body .tm-fragebogen, #termine-body .tm-statuskarte').first().waitFor()
}
// ---- Gaeste ueber die oeffentliche Schnittstelle buchen (echte Pruefungen): Link-Token aus der Mail
@@ -448,11 +478,12 @@ async function pruefungen() {
await warteAnsicht(page)
await page.locator('.tm-reiterleiste').waitFor()
eq(await page.locator('#termine-project').inputValue(), 'p2', 'Auswahl: die Website der Kachel')
- // andere Website ohne Terminplaner: Hinweis statt Reiter
+ // andere Website, Terminplaner nicht bestellt (Aus, das Abo läuft): der Fragebogen statt der Reiter (C10b ersetzt den Hinweis „noch nicht freigeschaltet“)
await page.locator('#termine-project').selectOption('p1')
- await page.getByText('noch nicht freigeschaltet').waitFor()
+ await page.locator('#termine-body .tm-fragebogen').waitFor()
eq(await page.locator('.tm-reiterleiste').count(), 0, 'keine Reiter')
- await knopf(page.locator('#termine-body'), 'Chat öffnen').waitFor()
+ assert(!(await textVon(page.locator('#termine-body'))).includes('noch nicht freigeschaltet'), 'der alte Text ist weg')
+ eq(await page.locator('#termine-body [data-view="chat"]').count(), 0, 'kein Chat-Knopf der alten Ansicht')
await shot(page, 'projektwahl')
await page.locator('#termine-project').selectOption('p2')
await page.locator('.tm-reiterleiste').waitFor()
@@ -473,6 +504,171 @@ async function pruefungen() {
})(),
})
+ // ------------------------------------------------------------ Ansicht nach Zustand (TP2 C10b)
+ /** Fixture mit einer Website im Zustand state (Eintrag der Liste wie GET /api/funktionen ihn liefert). */
+ const imZustand = (state, extra = {}) => {
+ const fx = neueFixture()
+ fx.projekte[0].features.termine = { state, ...extra }
+ return fx
+ }
+ const GEORDERT = { orderedAt: '2026-10-04T08:00:00.000Z', ticketWoid: null, invoiceUrl: null }
+
+ await check('fx_ansicht_aus_fragebogen_und_hinweis', async (page) => {
+ const fx = page.fx
+ // das Abo läuft: der Fragebogen mit Fortschrittsleiste, keine Reiter
+ await oeffne(page, '#termine')
+ await warteAnsicht(page)
+ await page.locator('#termine-body nav.tm-fortschritt').waitFor()
+ eq(await page.locator('.tm-reiterleiste').count(), 0, 'keine Reiter')
+ eq(await page.locator('[data-hinweis="abo"]').count(), 0, 'kein Hinweis')
+ eq(await page.locator('li.tm-schritt[aria-current="step"]').getAttribute('data-schritt'), 'branche', 'Start beim ersten offenen Schritt')
+ await shot(page, 'ansicht-fragebogen')
+ // ohne Abo: nur der Hinweis des Servers, bei kein_abo mit dem Link zu den Verträgen; kein Fragebogen
+ fx.bestellbar = false
+ for (const [grund, mitLink] of [['kein_abo', true], ['gekuendigt', false], ['nicht_aktiv', false], ['unbekannt', false]]) {
+ fx.aboGrund = grund === 'unbekannt' ? null : grund
+ fx.bestellbar = grund === 'unbekannt' ? null : false
+ await page.reload()
+ await page.locator('[data-hinweis="abo"]').waitFor()
+ const hinweis = page.locator('[data-hinweis="abo"]')
+ assert((await textVon(hinweis)).includes(FX_ABO_HINWEISE[grund]), `${grund}: Text des Servers in ${await textVon(hinweis)}`)
+ eq(await hinweis.locator('[data-view="vertraege"]').count(), mitLink ? 1 : 0, `${grund}: Link zu Verträge`)
+ eq(await page.locator('#termine-body nav.tm-fortschritt').count(), 0, `${grund}: kein Fragebogen`)
+ eq(await page.locator('#termine-body input').count(), 0, `${grund}: keine Felder`)
+ eq(await page.locator('.tm-reiterleiste').count(), 0, `${grund}: keine Reiter`)
+ if (grund === 'kein_abo') await shot(page, 'ansicht-ohne-abo')
+ }
+ // der Link führt zu den Verträgen
+ fx.bestellbar = false
+ fx.aboGrund = 'kein_abo'
+ await page.reload()
+ await knopf(page.locator('[data-hinweis="abo"]'), 'Zu Verträge').click()
+ await page.locator('#view-vertraege.active').waitFor()
+ }, { fixture: imZustand('off') })
+
+ for (const [state, pille, extra] of [
+ ['ordered', 'Zahlung offen', GEORDERT],
+ ['setup', 'In Einrichtung', { ...GEORDERT, ticketWoid: 100342, invoiceUrl: 'https://invoice.stripe.test/i/in_1' }],
+ ]) {
+ await check(`fx_ansicht_${state}`, async (page) => {
+ await oeffne(page, '#termine')
+ await warteAnsicht(page)
+ const karte = page.locator('.tm-statuskarte')
+ await karte.waitFor()
+ eq(await textVon(karte.locator('.tm-pille')), pille, 'Pille')
+ assert((await textVon(karte)).includes('Bestellt am'), 'Bestelldatum')
+ if (state === 'setup') assert((await textVon(karte)).includes('#100342'), 'Ticketnummer')
+ else eq(await karte.locator('[data-aktion="fortsetzen"]').count(), 1, 'Zahlung fortsetzen')
+ eq(await page.getByRole('tab').allInnerTexts(), ['Leistungen', 'Team & Plätze', 'Einstellungen'], 'drei Reiter, keiner für Termine')
+ eq(await page.locator('#tm-tab-termine').count(), 0, 'kein Reiter Termine')
+ eq(await page.locator('.tm-hinweise .tm-box').count(), 0, 'weder Banner der Abschaltung noch „Noch nicht buchbar“')
+ await shot(page, `ansicht-${state}`)
+ // schreibgeschützt: Leistungen ohne „+ Leistung anlegen“, Einstellungen gesperrt, ohne Speichern, ohne „Aktiv“-Status und Export
+ await page.locator('#tm-tab-leistungen').click()
+ await page.locator('#tm-panel .tm-tab').waitFor()
+ eq(await page.locator('[data-werkzeug="leistung-neu"]').count(), 0, 'Leistungen: nichts zum Anlegen')
+ assert(!(await textVon(page.locator('#tm-panel'))).split('\n').some((zeile) => zeile.trim() === 'null'), 'Leistungen: kein Text „null“')
+ await page.locator('#tm-tab-einstellungen').click()
+ await page.locator('#tm-panel .tm-tab').waitFor()
+ eq(await page.locator('#tm-panel fieldset[disabled]').count() > 0, true, 'Einstellungen gesperrt')
+ eq(await page.locator('#tm-panel button[type="submit"]').count(), 0, 'Einstellungen: kein Speichern')
+ eq(await page.locator('#tm-panel .tm-status').count(), 0, 'kein „Aktiv“-Status vor der Freischaltung')
+ eq(await page.locator('#tm-panel a[download]').count(), 0, 'kein Export vor der Freischaltung')
+ eq(page.fx.aufrufe.filter((a) => a[0] === 'liste').length, 0, 'der Reiter Termine wird nicht gelesen')
+ }, { fixture: imZustand(state, extra) })
+ }
+
+ await check('fx_ansicht_bestellt_390', async (page) => {
+ await oeffne(page, '#termine')
+ await warteAnsicht(page)
+ await page.locator('.tm-statuskarte').waitFor()
+ eq(await passtInBreite(page), true, 'kein Streifen rechts')
+ const kleine = await page.evaluate(() => [...document.querySelectorAll('#view-termine button')]
+ .filter((e) => e.offsetParent !== null && e.getBoundingClientRect().height < 43.5).map((e) => `${e.textContent.trim().slice(0, 24)} ${Math.round(e.getBoundingClientRect().height)}`))
+ eq(kleine, [], 'Knöpfe mindestens 44 px hoch')
+ await shot(page, '390-ansicht-bestellt')
+ }, { fixture: imZustand('ordered', GEORDERT), context: { viewport: { width: 390, height: 844 } } })
+
+ await check('fx_rueckkehr_zahlung', async (page) => {
+ await oeffne(page, '#termine?bestellt=1&projekt=p2')
+ await warteAnsicht(page)
+ await page.locator('.tm-statuskarte [data-danke]').waitFor()
+ eq(await textVon(page.locator('[data-danke]')), 'Danke! Sobald die Zahlung da ist, legen wir los.', 'Danke')
+ eq(await page.locator('#termine-project').inputValue(), 'p2', 'Website der Zahlung')
+ eq(await page.evaluate(() => window.location.hash), '#termine', 'Adresse bereinigt')
+ await shot(page, 'rueckkehr-zahlung')
+ // neu geladen: kein Danke mehr, die Auswahl bleibt nicht (Seite neu), aber die Adresse ist sauber
+ await page.reload()
+ await warteAnsicht(page)
+ eq(await page.locator('[data-danke]').count(), 0, 'kein Danke nach dem Neuladen')
+ }, {
+ fixture: (() => {
+ const fx = neueFixture()
+ fx.projekte = [
+ { projectId: 'p1', projectName: 'Alte Website', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'active' } } },
+ { projectId: 'p2', projectName: 'Salon Müller', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'ordered', ...GEORDERT } } },
+ ]
+ return fx
+ })(),
+ })
+
+ await check('fx_rueckkehr_abbruch', async (page) => {
+ await oeffne(page, '#termine?projekt=p2')
+ await warteAnsicht(page)
+ await page.locator('.tm-statuskarte').waitFor()
+ eq(await page.locator('[data-danke]').count(), 0, 'kein Danke nach dem Abbruch')
+ eq(await page.locator('#termine-project').inputValue(), 'p2', 'Website der Bestellung')
+ eq(await page.locator('.tm-statuskarte [data-aktion="fortsetzen"]').count(), 1, 'Zahlung fortsetzen')
+ eq(await page.evaluate(() => window.location.hash), '#termine', 'Adresse bereinigt')
+ }, {
+ fixture: (() => {
+ const fx = neueFixture()
+ fx.projekte = [
+ { projectId: 'p1', projectName: 'Alte Website', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'active' } } },
+ { projectId: 'p2', projectName: 'Salon Müller', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'ordered', ...GEORDERT } } },
+ ]
+ return fx
+ })(),
+ })
+
+ await check('fx_zahlung_fortsetzen_zustimmung_fehlt', async (page) => {
+ const fx = page.fx
+ await oeffne(page, '#termine')
+ await warteAnsicht(page)
+ await page.locator('.tm-statuskarte').waitFor()
+ await knopf(page.locator('.tm-statuskarte'), 'Zahlung fortsetzen').click()
+ // die Karte weicht dem Bestellformular (R110), die Reiter bleiben
+ await page.locator('.tm-bestellen').waitFor()
+ eq(await page.locator('.tm-statuskarte').count(), 0, 'keine Karte mehr')
+ eq(fx.aufrufe.filter((a) => a[0] === 'checkout').map((a) => a[1]), [{ projectId: 'p1', fortsetzen: true }], 'Zahlung fortsetzen ohne Zustimmung')
+ assert((await textVon(meldung(page))).includes('Bitte stimme der Erklärung zu'), 'Meldung der Hülle')
+ const bestellen = page.locator('[data-aktion="bestellen"]')
+ eq(await bestellen.isDisabled(), true, 'gesperrt bis zum Haken')
+ await page.locator('.tm-bestellen input[type="checkbox"]').check()
+ eq(await bestellen.isDisabled(), false, 'frei nach dem Haken')
+ eq(await page.getByRole('tab').allInnerTexts(), ['Leistungen', 'Team & Plätze', 'Einstellungen'], 'Reiter bleiben')
+ await shot(page, 'zahlung-fortsetzen-bestellformular')
+ }, {
+ fixture: Object.assign(imZustand('ordered', GEORDERT), { checkout: { status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } } }),
+ })
+
+ await check('fx_zahlung_fortsetzen_inzwischen_bezahlt', async (page) => {
+ const fx = page.fx
+ await oeffne(page, '#termine')
+ await warteAnsicht(page)
+ await page.locator('.tm-statuskarte').waitFor()
+ eq(await textVon(page.locator('.tm-statuskarte .tm-pille')), 'Zahlung offen', 'zuerst offen')
+ // inzwischen kam die Zahlung an (Webhook): der Server kennt keine offene Bestellung mehr (400)
+ fx.projekte[0].features.termine = { state: 'setup', ...GEORDERT, ticketWoid: 100343 }
+ await knopf(page.locator('.tm-statuskarte'), 'Zahlung fortsetzen').click()
+ await page.locator('.tm-statuskarte .tm-pille', { hasText: 'In Einrichtung' }).waitFor()
+ assert((await textVon(page.locator('.tm-statuskarte'))).includes('#100343'), 'Ticketnummer der frischen Liste')
+ eq(await page.locator('[data-aktion="fortsetzen"]').count(), 0, 'kein Knopf mehr')
+ eq(await page.locator('.tm-bestellen').count(), 0, 'kein Bestellformular')
+ }, {
+ fixture: Object.assign(imZustand('ordered', GEORDERT), { checkout: { status: 400, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } } }),
+ })
+
// ------------------------------------------------------------ Reiter-Geruest
await check('fx_reiter_geruest', async (page) => {
await oeffne(page, '#termine')