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:
1 parent
52456dac76
commit
87d267e51a
13 files changed
+1077
-71
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
@@ -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) } : {},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.abo, null)
|
||||
assert.notEqual(body.offen, null)
|
||||
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.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 () => {
|
||||
|
||||
@@ -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)
|
||||
}))
|
||||
@@ -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\)/)
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in new issue
Block a user