Terminplaner: Einbau-Skript – Nachbesserungen
429 ohne Schleife (Meldung und Erneut-versuchen-Knopf, Formulare bleiben stehen), Schriftfarbe auf der Betriebsfarbe mit mindestens 4,5 : 1, AGB-Link aus der oeffentlichen Konfiguration (betrieb.agbUrl), Haken starten bei geaenderter Auswahl ohne Haken, Fehler ohne Feld nicht verschluckt, Konfiguration fail closed, ladeNeu fuer Neuladen und neu_laden, Tastatur und Fokus (Modifikatoren, Monatspfeile, Belehrungstext), Aufraeumen. UI-Testserver: Timer, offene Verbindungen, Hinweis auf sessionCookie. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5fef7e47f0
commit
4465ac7b94
9 files changed
+501
-88
No files matched your search
@@ -51,7 +51,7 @@ Setze die Variablen auf dem `div`, in der Seiten-CSS oder auf `:root`, z. B. `[d
|
||||
| `--wkt-schrift` | die Schrift der Seite (`inherit`) |
|
||||
| `--wkt-schrift-titel` (Überschriften) | wie `--wkt-schrift` |
|
||||
|
||||
Die Betriebsfarbe gilt nur, solange `--wkt-akzent` nicht gesetzt ist. Setzt Du `--wkt-akzent`, setze auch `--wkt-akzent-text`. Im Modus `knopf` kennt das Widget die Betriebsfarbe erst nach dem Klick: Der Knopf selbst nimmt `--wkt-akzent` der Seite oder `#1d4ed8`. Setze `--wkt-akzent` dort also immer.
|
||||
Die Betriebsfarbe gilt nur, solange `--wkt-akzent` nicht gesetzt ist; die Schrift darauf (Weiß, dunkles Grau oder Schwarz) wählt das Skript so, dass sie mindestens 4,5 : 1 erreicht. Setzt Du `--wkt-akzent`, setze auch `--wkt-akzent-text`. Im Modus `knopf` kennt das Widget die Betriebsfarbe erst nach dem Klick: Der Knopf selbst nimmt `--wkt-akzent` der Seite oder `#1d4ed8`. Setze `--wkt-akzent` dort also immer.
|
||||
|
||||
Kontrast (WCAG 2.1 AA): Text auf dem Hintergrund mindestens 4,5 : 1, Schrift auf dem Akzent mindestens 4,5 : 1, `--wkt-rand` gegen den Hintergrund mindestens 3 : 1 (sonst sind Eingabefelder schwer zu erkennen). Der Fokusrahmen nimmt die Textfarbe.
|
||||
|
||||
@@ -115,7 +115,7 @@ Bricht das Netz mitten in der Strecke ab, zeigt das Widget „Gerade nicht errei
|
||||
2. Person wählen („Egal“ zuerst). Nur wenn mehrere Personen infrage kommen und der Betrieb die Wahl erlaubt; bei Plätzen entfällt der Schritt.
|
||||
3. Tag (Monatsraster, nur Tage mit freien Zeiten sind wählbar) und Uhrzeit. Alle Daten und Uhrzeiten kommen vom Server in Berliner Zeit. Das Widget rechnet nie mit dem Datum oder der Zeitzone des Browsers.
|
||||
4. Angaben: Name, E-Mail, Telefon, Nachricht und Zusatzfelder nach den Einstellungen des Betriebs.
|
||||
5. Zusammenfassung mit Stornobedingungen, Widerrufsbelehrung (aufklappbar, bei Verbraucher-Betrieben), Datenschutz-Hinweis, den Haken (Storno, vorzeitiger Beginn, Erinnerung) und dem Knopf („Zahlungspflichtig buchen“, „Termin buchen“ oder „Termin anfragen“, kommt vom Server).
|
||||
5. Zusammenfassung mit Beschreibung der Leistung, Stornobedingungen (daneben der Link „AGB des Betriebs“, wenn im Portal eine https-Adresse hinterlegt ist), Widerrufsbelehrung (aufklappbar, bei Verbraucher-Betrieben), Datenschutz-Hinweis, den Haken (Storno, vorzeitiger Beginn, Erinnerung) und dem Knopf („Zahlungspflichtig buchen“, „Termin buchen“ oder „Termin anfragen“, kommt vom Server).
|
||||
6. „Fast geschafft“: Der Gast bestätigt den Termin über den Link in der E-Mail. Die Reservierung gilt 30 Minuten.
|
||||
|
||||
Im Fuß stehen „Vertrag widerrufen“ (nur wenn der Betrieb verbindliche Leistungen für Verbraucher anbietet), „Datenschutz“ (Link auf die Datenschutzerklärung des Betriebs) und klein „Terminbuchung über WEBklar“. Anrede (Sie oder Du) und Betriebsfarbe kommen aus den Einstellungen. Die rechtlich vorgeschriebenen Texte (Haken, Belehrung) stehen immer in der Sie-Form.
|
||||
@@ -149,7 +149,7 @@ Alle Pfade unter `https://project.webklar.com/api/public/termine/v1`. Antworten
|
||||
|
||||
| Methode und Pfad | Zweck |
|
||||
| --- | --- |
|
||||
| `GET /:siteKey/config` | Betrieb (Name, Telefon, Adresse, URLs), Anrede, Farbe, Leistungen mit Knopftext, Personen, Regeln, Zahlungsart, Stornotext, `widerruf`, Texte der Haken und der Widerrufsbelehrung, Datenschutz-Hinweis, `formToken`. Eine Minute gecacht; das Widget lädt sie nach 100 Minuten neu. |
|
||||
| `GET /:siteKey/config` | Betrieb (Name, Telefon, Adresse, URLs von Impressum, Datenschutz und AGB), Anrede, Farbe, Leistungen mit Knopftext, Personen, Regeln, Zahlungsart, Stornotext, `widerruf`, Texte der Haken und der Widerrufsbelehrung, Datenschutz-Hinweis, `formToken`. Eine Minute gecacht; das Widget lädt sie nach 100 Minuten neu. |
|
||||
| `GET /:siteKey/tage?leistung=&ressource=&monat=YYYY-MM` | Tage mit freien Zeiten plus Kalenderdaten des Monats (`monat`) |
|
||||
| `GET /:siteKey/zeiten?leistung=&ressource=&tag=YYYY-MM-DD` | freie Startzeiten, `datumLang`, `vorzeitigNoetig` (Haken für vorzeitigen Beginn an diesem Tag) |
|
||||
| `POST /:siteKey/buchungen` | Formular absenden. Antwort `201 { ok, email: "m***@gmail.com", minuten }`. Honeypot `kontakt_url` und Formular-Token (Fehlercode `neu_laden`) schützen vor Automaten. |
|
||||
|
||||
+182
-79
@@ -89,7 +89,8 @@
|
||||
'.wkt-belehrung-text{margin-top:.5rem;max-height:18rem;overflow:auto;font-size:.9rem}',
|
||||
'.wkt-belehrung-text p{white-space:pre-line}',
|
||||
'.wkt-datenschutz{font-size:.875rem}',
|
||||
'.wkt-datenschutz a,.wkt-fuss a,.wkt-ausfall a{color:inherit}',
|
||||
'.wkt-datenschutz a,.wkt-fuss a,.wkt-ausfall a,.wkt-box a{color:inherit}',
|
||||
'.wkt-box .wkt-agb{margin:.5rem 0 0}.wkt-agb a{display:inline-block;padding:.2rem 0;min-height:24px}',
|
||||
'.wkt-fuss{margin-top:1.5rem;padding-top:.75rem;border-top:1px solid #c9ced6;font-size:.875rem}',
|
||||
'.wkt-fuss:empty{display:none}',
|
||||
'.wkt-fuss-links{display:flex;flex-wrap:wrap;align-items:baseline;gap:.5rem 1.25rem;margin:0 0 .35rem}',
|
||||
@@ -170,18 +171,39 @@
|
||||
return ''
|
||||
}
|
||||
|
||||
// Vorgabe-Akzent aus der Betriebsfarbe (#rrggbb), wenn die Seite --wkt-akzent nicht selbst setzt; Textfarbe nach Helligkeit
|
||||
function setzeBetriebsfarbe (box, farbe) {
|
||||
if (!/^#[0-9a-fA-F]{6}$/.test(farbe || '')) return
|
||||
var cs = getComputedStyle(box)
|
||||
if (cs.getPropertyValue('--wkt-akzent').trim()) return
|
||||
// @farben-anfang (reine Funktionen, test/termineEmbedStatic.test.js fuehrt sie einzeln aus)
|
||||
// Relative Leuchtdichte nach WCAG 2.1 fuer '#rrggbb'.
|
||||
function luminanz (farbe) {
|
||||
var rgb = [1, 3, 5].map(function (i) {
|
||||
var c = parseInt(farbe.slice(i, i + 2), 16) / 255
|
||||
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
|
||||
})
|
||||
var l = 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]
|
||||
return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]
|
||||
}
|
||||
|
||||
// Kontrastverhaeltnis zweier Leuchtdichten (1 bis 21).
|
||||
function kontrast (a, b) {
|
||||
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)
|
||||
}
|
||||
|
||||
// Schrift auf der Betriebsfarbe: Weiss oder das dunkle Grau, je nachdem was mehr Kontrast gibt; erreicht keines 4,5 : 1
|
||||
// (mittlere Toene), nimmt sie Schwarz (mindestens 4,58 : 1 fuer jede Farbe).
|
||||
function textFarbeAuf (farbe) {
|
||||
var l = luminanz(farbe)
|
||||
var weiss = kontrast(l, 1)
|
||||
var dunkel = kontrast(l, luminanz('#111827'))
|
||||
var beste = Math.max(weiss, dunkel)
|
||||
if (beste < 4.5 && kontrast(l, 0) > beste) return '#000000'
|
||||
return weiss >= dunkel ? '#ffffff' : '#111827'
|
||||
}
|
||||
// @farben-ende
|
||||
|
||||
// Vorgabe-Akzent aus der Betriebsfarbe (#rrggbb), wenn die Seite --wkt-akzent nicht selbst setzt
|
||||
function setzeBetriebsfarbe (box, farbe) {
|
||||
if (!/^#[0-9a-fA-F]{6}$/.test(farbe || '')) return
|
||||
if (getComputedStyle(box).getPropertyValue('--wkt-akzent').trim()) return
|
||||
box.style.setProperty('--wkt-vorgabe-akzent', farbe)
|
||||
box.style.setProperty('--wkt-vorgabe-akzent-text', l > 0.4 ? '#111827' : '#ffffff')
|
||||
box.style.setProperty('--wkt-vorgabe-akzent-text', textFarbeAuf(farbe))
|
||||
}
|
||||
|
||||
function init (root) {
|
||||
@@ -271,12 +293,28 @@
|
||||
|
||||
// ------------------------------------------------------------ Konfiguration
|
||||
|
||||
function nichtLeer (v) { return istText(v) && v !== '' }
|
||||
|
||||
// Fail closed: fehlt etwas, das die Strecke zwingend braucht (Knopftext, Texte der Haken, Belehrung), gilt die Funktion als nicht aktiv
|
||||
function konfigGueltig (d) {
|
||||
if (!d || d.aktiv !== true || !Array.isArray(d.leistungen) || d.leistungen.length === 0 || !Array.isArray(d.ressourcen)) return false
|
||||
if (!d.betrieb || typeof d.betrieb !== 'object' || !TAGESZEILE.test(d.heute || '') || !nichtLeer(d.formToken)) return false
|
||||
if (!d.texte || typeof d.texte !== 'object' || !d.regeln || typeof d.regeln !== 'object' || !d.gastFelder || typeof d.gastFelder !== 'object') return false
|
||||
var leistungenOk = d.leistungen.every(function (l) {
|
||||
return l && typeof l === 'object' && nichtLeer(l.id) && istText(l.name) && nichtLeer(l.knopf) && Array.isArray(l.ressourcen)
|
||||
})
|
||||
var ressourcenOk = d.ressourcen.every(function (r) { return r && typeof r === 'object' && nichtLeer(r.id) && istText(r.name) })
|
||||
if (!leistungenOk || !ressourcenOk) return false
|
||||
if (d.storno && !nichtLeer(d.texte.storno)) return false
|
||||
if (!nichtLeer(d.texte.vorzeitig)) return false
|
||||
if (d.erinnerung && d.erinnerung.anbieten === true && !nichtLeer(d.texte.erinnerung)) return false
|
||||
if (d.widerruf === true && !nichtLeer(d.texte.widerrufsbelehrung)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function lade (neu) {
|
||||
return request('GET', '/config', undefined, neu).then(function (d) {
|
||||
var ok = d && d.aktiv === true && Array.isArray(d.leistungen) && d.leistungen.length > 0 && Array.isArray(d.ressourcen) &&
|
||||
d.betrieb && typeof d.betrieb === 'object' && TAGESZEILE.test(d.heute || '') && d.texte && typeof d.texte === 'object' &&
|
||||
d.regeln && typeof d.regeln === 'object' && d.gastFelder && typeof d.gastFelder === 'object'
|
||||
if (!ok) throw { inaktiv: true }
|
||||
if (!konfigGueltig(d)) throw { inaktiv: true }
|
||||
S.cfg = d
|
||||
S.cfgAt = performance.now()
|
||||
return d
|
||||
@@ -285,19 +323,27 @@
|
||||
|
||||
function configFrisch () { return !!S.cfg && performance.now() - S.cfgAt <= CONFIG_ALTER_MAX_MS }
|
||||
|
||||
// Alte Konfiguration (Formular-Token laeuft nach 2 h ab) vor jedem Schritt erneuern; false = Auswahl galt nicht mehr, Zustand zurueckgesetzt
|
||||
function sichereConfig () {
|
||||
if (configFrisch()) return Promise.resolve(true)
|
||||
// Konfiguration frisch vom Server holen (neues Formular-Token) und pruefen, dass Leistung und Person noch gelten.
|
||||
// true = weiter; false = die Auswahl galt nicht mehr, die Strecke beginnt von vorn. Fehler beim Laden werden weitergereicht.
|
||||
function ladeNeu () {
|
||||
return lade(true).then(function () {
|
||||
if (S.leistungId && !leistungMitId(S.leistungId)) {
|
||||
var m = T('Das Angebot hat sich geändert. Bitte wählen Sie neu.', 'Das Angebot hat sich geändert. Bitte wähle neu.')
|
||||
starte(false, m)
|
||||
var l = S.leistungId ? leistungMitId(S.leistungId) : null
|
||||
var ressourceOk = !l || !S.ressource || S.ressource === 'egal' || kandidaten(l).some(function (r) { return r.id === S.ressource })
|
||||
if (S.leistungId && (!l || !ressourceOk)) {
|
||||
starte(false, T('Das Angebot hat sich geändert. Bitte wählen Sie neu.', 'Das Angebot hat sich geändert. Bitte wähle neu.'))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}, function (err) {
|
||||
})
|
||||
}
|
||||
|
||||
// Alte Konfiguration (Formular-Token laeuft nach 2 h ab) vor jedem Schritt erneuern; false = abbrechen (Auswahl galt nicht mehr
|
||||
// oder Funktion nicht mehr aktiv). Ist der Server nicht erreichbar, geht es mit der alten Konfiguration weiter: der naechste Aufruf meldet es.
|
||||
function sichereConfig () {
|
||||
if (configFrisch()) return Promise.resolve(true)
|
||||
return ladeNeu().catch(function (err) {
|
||||
if (err && err.inaktiv) { zeigeAusweich(); return false }
|
||||
return true // nicht erreichbar: weiter mit der alten Konfiguration, der naechste Aufruf meldet es
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
@@ -433,15 +479,16 @@
|
||||
ui.fuss.appendChild(el('p', 'wkt-powered', 'Terminbuchung über WEBklar'))
|
||||
}
|
||||
|
||||
// Netz weg oder Server kaputt mitten in der Strecke
|
||||
function ausfall (wiederhole) {
|
||||
// Netz weg, Server kaputt oder Grenze erreicht (meldung) mitten in der Strecke: Meldung und "Erneut versuchen". Es folgt nie ein
|
||||
// selbststaendiger neuer Abruf, nur der Klick auf den Knopf versucht es noch einmal.
|
||||
function ausfall (wiederhole, meldung) {
|
||||
S.sendet = false
|
||||
ui.body.textContent = ''
|
||||
ui.titel = null
|
||||
var tel = S.cfg && istText(S.cfg.betrieb.telefon) ? S.cfg.betrieb.telefon : ''
|
||||
var p = el('p', 'wkt-fehlertext wkt-ausfall', T('Gerade nicht erreichbar. Bitte rufen Sie an: ', 'Gerade nicht erreichbar. Bitte ruf an: '))
|
||||
var p = el('p', 'wkt-fehlertext wkt-ausfall', meldung || T('Gerade nicht erreichbar. Bitte rufen Sie an: ', 'Gerade nicht erreichbar. Bitte ruf an: '))
|
||||
p.tabIndex = -1
|
||||
if (tel) {
|
||||
if (tel && !meldung) {
|
||||
var a = el('a', '', tel)
|
||||
a.href = 'tel:' + tel.replace(/[^0-9+]/g, '')
|
||||
p.appendChild(a)
|
||||
@@ -458,7 +505,7 @@
|
||||
if (modus === 'inline') {
|
||||
root.classList.remove('wkt-root')
|
||||
root.textContent = ''
|
||||
if (ausweich) root.appendChild(ausweich)
|
||||
if (ausweich) root.appendChild(ausweich.cloneNode(true)) // Kopie: ein zweiter Aufruf darf den Text nicht verlieren
|
||||
return
|
||||
}
|
||||
if (!ui.body) return
|
||||
@@ -483,6 +530,14 @@
|
||||
else gehe('leistung', { fokus: fokus, meldung: meldung })
|
||||
}
|
||||
|
||||
// Zustimmungen gelten nur fuer die Buchung, bei der sie gegeben wurden: bei neuer Leistung, neuem Tag oder neuer Uhrzeit beginnt die
|
||||
// Zusammenfassung wieder ohne Haken (R45)
|
||||
function hakenZurueck () {
|
||||
S.daten.storno = false
|
||||
S.daten.vorzeitig = false
|
||||
S.daten.erinnerung = false
|
||||
}
|
||||
|
||||
function waehleLeistung (lid, opt) {
|
||||
var l = leistungMitId(lid)
|
||||
if (S.leistungId !== lid) {
|
||||
@@ -490,6 +545,7 @@
|
||||
S.monat = ''
|
||||
S.tag = ''
|
||||
S.zeit = ''
|
||||
hakenZurueck()
|
||||
}
|
||||
S.leistungId = lid
|
||||
opt = opt || {}
|
||||
@@ -505,6 +561,7 @@
|
||||
function zeichneLeistung () {
|
||||
var c = S.cfg
|
||||
kopf(T('Welche Leistung möchten Sie buchen?', 'Welche Leistung möchtest du buchen?'))
|
||||
zeigeFehlerListe()
|
||||
var ul = el('ul', 'wkt-liste')
|
||||
c.leistungen.forEach(function (l) {
|
||||
var li = el('li')
|
||||
@@ -525,6 +582,7 @@
|
||||
function zeichnePerson () {
|
||||
var l = aktuelleLeistung()
|
||||
kopf(T('Bei wem möchten Sie den Termin?', 'Bei wem möchtest du den Termin?'))
|
||||
zeigeFehlerListe()
|
||||
var ul = el('ul', 'wkt-liste')
|
||||
var optionen = [{ id: 'egal', name: 'Egal', text: T('Wir weisen Ihnen eine freie Person zu.', 'Wir weisen dir eine freie Person zu.') }]
|
||||
kandidaten(l).forEach(function (r) { optionen.push({ id: r.id, name: r.name, text: '' }) })
|
||||
@@ -546,12 +604,11 @@
|
||||
zurueckZeile(S.vorLeistung ? null : 'leistung')
|
||||
}
|
||||
|
||||
function zurueckZeile (zielSchritt, weiter) {
|
||||
function zurueckZeile (zielSchritt) {
|
||||
if (!zielSchritt) return
|
||||
var nav = el('div', 'wkt-nav')
|
||||
if (zielSchritt) nav.appendChild(knopf('wkt-btn wkt-btn-sek', T('Zurück', 'Zurück'), function () { gehe(zielSchritt) }))
|
||||
if (weiter) nav.appendChild(weiter)
|
||||
if (nav.childNodes.length) add(nav)
|
||||
return nav
|
||||
nav.appendChild(knopf('wkt-btn wkt-btn-sek', 'Zurück', function () { gehe(zielSchritt) }))
|
||||
add(nav)
|
||||
}
|
||||
|
||||
function vorigerSchritt (von) {
|
||||
@@ -566,7 +623,7 @@
|
||||
var c = S.cfg
|
||||
var l = aktuelleLeistung()
|
||||
kopf(T('Wann passt es Ihnen?', 'Wann passt es dir?'))
|
||||
if (S.fehler.zeit || S.fehler.tag) add(el('p', 'wkt-fehlertext', S.fehler.zeit || S.fehler.tag))
|
||||
zeigeFehlerListe()
|
||||
add(el('p', 'wkt-hinweis', l.name + (dauerText(l.dauer) ? ' · ' + dauerText(l.dauer) : '')))
|
||||
|
||||
S.tageCache = {} // Tage immer frisch beim Betreten des Schritts (Leistung und Person koennen sich geaendert haben)
|
||||
@@ -643,6 +700,7 @@
|
||||
S.monat = m.monat
|
||||
monLabel.textContent = m.info.label
|
||||
var nr = monatsNummer(m.monat)
|
||||
var hatteFokus = document.activeElement
|
||||
zurueck.disabled = nr <= minNr
|
||||
vor.disabled = nr >= maxNr
|
||||
tbody.textContent = ''
|
||||
@@ -679,6 +737,12 @@
|
||||
spalte++
|
||||
}
|
||||
setzeFokusZelle(S.tag && S.tag.slice(0, 7) === m.monat ? S.tag : (erstFrei || m.monat + '-01'), false)
|
||||
// Ein Pfeilknopf, der gerade gesperrt wurde, darf den Fokus nicht verlieren: er geht an den anderen Pfeil, sonst ins Raster
|
||||
if ((hatteFokus === zurueck && zurueck.disabled) || (hatteFokus === vor && vor.disabled)) {
|
||||
var anderer = hatteFokus === zurueck ? vor : zurueck
|
||||
if (!anderer.disabled) anderer.focus()
|
||||
else tabelle.querySelector('td[tabindex="0"]').focus()
|
||||
}
|
||||
}
|
||||
|
||||
function zeigeMonat (monat, opt) {
|
||||
@@ -695,23 +759,25 @@
|
||||
return m
|
||||
}, function (err) {
|
||||
if (seq !== S.seq) return null
|
||||
behandleFehler(err, function () { zeichne(true) })
|
||||
fehlerAllgemein(err, function () { zeichne(true) })
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
||||
function wechsleMonat (monat, tagNr, fokussieren) {
|
||||
// Zu einem anderen Monat (innerhalb der Grenzen). tag: Tagesnummer oder Funktion (Kalenderdaten des Zielmonats) => Tagesnummer;
|
||||
// sie wird auf 1 bis Monatslaenge begrenzt. Die Laenge des Nachbarmonats kennt erst die Antwort des Servers.
|
||||
function wechsleMonat (monat, tag, fokussieren) {
|
||||
var nr = monatsNummer(monat)
|
||||
if (nr < minNr || nr > maxNr) return
|
||||
var seq = S.seq
|
||||
holeTage(monat).then(function (m) {
|
||||
if (seq !== S.seq) return
|
||||
baueRaster(m)
|
||||
var t = Math.min(tagNr, m.info.anzahlTage)
|
||||
setzeFokusZelle(m.monat + '-' + pad2(t), fokussieren)
|
||||
var t = typeof tag === 'function' ? tag(m.info) : tag
|
||||
setzeFokusZelle(m.monat + '-' + pad2(Math.max(1, Math.min(m.info.anzahlTage, t))), fokussieren)
|
||||
ansage(m.info.label)
|
||||
}, function (err) {
|
||||
if (seq === S.seq) behandleFehler(err, function () { zeichne(true) })
|
||||
if (seq === S.seq) fehlerAllgemein(err, function () { zeichne(true) })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -719,8 +785,11 @@
|
||||
vor.addEventListener('click', function () { if (aktuell) wechsleMonat(aktuell.info.nachher, 1, false) })
|
||||
|
||||
function waehleTag (tag) {
|
||||
if (S.tag !== tag) {
|
||||
S.zeit = ''
|
||||
hakenZurueck()
|
||||
}
|
||||
S.tag = tag
|
||||
S.zeit = ''
|
||||
var zellen = tabelle.querySelectorAll('td[data-tag]')
|
||||
for (var i = 0; i < zellen.length; i++) zellen[i].setAttribute('aria-selected', zellen[i].getAttribute('data-tag') === tag ? 'true' : 'false')
|
||||
setzeFokusZelle(tag, false)
|
||||
@@ -751,6 +820,7 @@
|
||||
d.zeiten.forEach(function (z) {
|
||||
if (!istText(z)) return
|
||||
var b = knopf('wkt-zeit', z, function () {
|
||||
if (S.zeit !== z) hakenZurueck()
|
||||
S.zeit = z
|
||||
S.datumLang = d.datumLang
|
||||
S.vorzeitig = d.vorzeitigNoetig === true
|
||||
@@ -762,11 +832,12 @@
|
||||
zeitenBox.appendChild(liste)
|
||||
ansage(d.zeiten.length + (d.zeiten.length === 1 ? ' freie Zeit am ' : ' freie Zeiten am ') + d.datumLang)
|
||||
}).catch(function (err) {
|
||||
if (nr === S.zeitSeq) behandleFehler(err, function () { zeichne(true) })
|
||||
if (nr === S.zeitSeq) fehlerAllgemein(err, function () { zeichne(true) })
|
||||
})
|
||||
}
|
||||
|
||||
function tastatur (e) {
|
||||
if (e.altKey || e.ctrlKey || e.metaKey) return // Browser- und Systemkuerzel (Alt+Pfeil = zurueck u. a.) nicht abfangen
|
||||
var td = e.target && e.target.closest ? e.target.closest('td[data-tag]') : null
|
||||
if (!td || !aktuell) return
|
||||
var info = aktuell.info
|
||||
@@ -800,20 +871,9 @@
|
||||
if (ziel >= 1 && ziel <= info.anzahlTage) {
|
||||
setzeFokusZelle(aktuell.monat + '-' + pad2(ziel), true)
|
||||
} else if (ziel < 1) {
|
||||
// in den Vormonat: dessen Laenge kennt erst die Antwort des Servers
|
||||
var vm = info.vorher
|
||||
if (monatsNummer(vm) < minNr) return
|
||||
var seq = S.seq
|
||||
holeTage(vm).then(function (m) {
|
||||
if (seq !== S.seq) return
|
||||
baueRaster(m)
|
||||
setzeFokusZelle(m.monat + '-' + pad2(Math.max(1, m.info.anzahlTage + ziel)), true)
|
||||
ansage(m.info.label)
|
||||
}, function (err) { if (seq === S.seq) behandleFehler(err, function () { zeichne(true) }) })
|
||||
wechsleMonat(info.vorher, function (vi) { return vi.anzahlTage + ziel }, true)
|
||||
} else {
|
||||
var nm = info.nachher
|
||||
if (monatsNummer(nm) > maxNr) return
|
||||
wechsleMonat(nm, ziel - info.anzahlTage, true)
|
||||
wechsleMonat(info.nachher, ziel - info.anzahlTage, true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -962,6 +1022,19 @@
|
||||
return uebrig
|
||||
}
|
||||
|
||||
// Meldungen ohne eigenes Feld (z. B. "_" oder ein Schluessel, den der Schritt nicht zeigt) in den Fehlerkasten: nie verschlucken
|
||||
function zeigeRest (box, texte) {
|
||||
texte.forEach(function (t) { box.appendChild(el('p', 'wkt-fehlertext', t)) })
|
||||
if (texte.length) ansage(texte.join(' '))
|
||||
}
|
||||
|
||||
// Meldungen des Servers an Schritten ohne eigene Felder (Leistung, Person, Termin)
|
||||
function zeigeFehlerListe () {
|
||||
var box = el('div', 'wkt-fehlerbox')
|
||||
zeigeRest(box, Object.keys(S.fehler).map(function (k) { return S.fehler[k] }))
|
||||
if (box.childNodes.length) add(box)
|
||||
}
|
||||
|
||||
// ---- Daten
|
||||
|
||||
function zeichneDaten () {
|
||||
@@ -1001,18 +1074,15 @@
|
||||
form.insertBefore(fehlerBox, form.firstChild)
|
||||
add(form)
|
||||
|
||||
var vorhanden = {}
|
||||
Object.keys(S.fehler).forEach(function (k) { if (refs[k]) vorhanden[k] = S.fehler[k] })
|
||||
if (Object.keys(vorhanden).length) zeigeFeldfehler(refs, vorhanden)
|
||||
if (Object.keys(S.fehler).length) zeigeRest(fehlerBox, zeigeFeldfehler(refs, S.fehler))
|
||||
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault()
|
||||
var f = pruefeDaten(g)
|
||||
if (Object.keys(f).length) {
|
||||
S.fehler = f
|
||||
var rest = zeigeFeldfehler(refs, f)
|
||||
fehlerBox.textContent = ''
|
||||
rest.forEach(function (t) { fehlerBox.appendChild(el('p', 'wkt-fehlertext', t)) })
|
||||
zeigeRest(fehlerBox, zeigeFeldfehler(refs, f))
|
||||
return
|
||||
}
|
||||
S.fehler = {}
|
||||
@@ -1067,6 +1137,7 @@
|
||||
|
||||
var dl = el('dl', 'wkt-zusammen')
|
||||
zeile(dl, 'Leistung', l.name + (dauerText(l.dauer) ? ' (' + dauerText(l.dauer) + ')' : ''))
|
||||
zeile(dl, 'Beschreibung', l.beschreibung)
|
||||
var kand = kandidaten(l)
|
||||
if (personSchritt(l) || kand.length === 1) {
|
||||
var istPlatz = kand.every(function (r) { return r.typ === 'platz' })
|
||||
@@ -1082,17 +1153,34 @@
|
||||
zeile(dl, 'E-Mail', dd.email.trim())
|
||||
add(dl)
|
||||
|
||||
if (c.storno && istText(c.storno.text)) {
|
||||
// Stornoregel im Klartext, daneben der Link auf die AGB des Betriebs (nur https, wenn hinterlegt)
|
||||
var agbUrl = httpsUrl(c.betrieb.agbUrl)
|
||||
if ((c.storno && istText(c.storno.text)) || agbUrl) {
|
||||
var sb = el('div', 'wkt-box')
|
||||
sb.appendChild(el('h3', '', T('Stornobedingungen', 'Stornobedingungen')))
|
||||
sb.appendChild(el('p', '', c.storno.text))
|
||||
if (c.storno && istText(c.storno.text)) {
|
||||
sb.appendChild(el('h3', '', 'Stornobedingungen'))
|
||||
sb.appendChild(el('p', '', c.storno.text))
|
||||
}
|
||||
if (agbUrl) {
|
||||
var pa = el('p', 'wkt-agb')
|
||||
var agb = el('a', '', 'AGB des Betriebs')
|
||||
agb.href = agbUrl
|
||||
agb.target = '_blank'
|
||||
agb.rel = 'noopener noreferrer'
|
||||
pa.appendChild(agb)
|
||||
sb.appendChild(pa)
|
||||
}
|
||||
add(sb)
|
||||
}
|
||||
|
||||
if (c.widerruf === true && istText(c.texte.widerrufsbelehrung)) {
|
||||
var det = el('details', 'wkt-belehrung')
|
||||
det.appendChild(el('summary', '', T('Widerrufsbelehrung und Muster-Widerrufsformular anzeigen', 'Widerrufsbelehrung und Muster-Widerrufsformular anzeigen')))
|
||||
det.appendChild(absaetze(c.texte.widerrufsbelehrung, 'wkt-belehrung-text'))
|
||||
det.appendChild(el('summary', '', 'Widerrufsbelehrung und Muster-Widerrufsformular anzeigen'))
|
||||
var text = absaetze(c.texte.widerrufsbelehrung, 'wkt-belehrung-text')
|
||||
text.tabIndex = 0 // der Text scrollt: auch mit der Tastatur erreichbar
|
||||
text.setAttribute('role', 'region')
|
||||
text.setAttribute('aria-label', 'Widerrufsbelehrung und Muster-Widerrufsformular')
|
||||
det.appendChild(text)
|
||||
add(det)
|
||||
}
|
||||
|
||||
@@ -1125,10 +1213,7 @@
|
||||
form.appendChild(nav)
|
||||
add(form)
|
||||
|
||||
var vorhanden = {}
|
||||
Object.keys(S.fehler).forEach(function (k) { if (refs[k]) vorhanden[k] = S.fehler[k] })
|
||||
if (Object.keys(vorhanden).length) zeigeFeldfehler(refs, vorhanden)
|
||||
if (S.fehler._) fehlerBox.appendChild(el('p', 'wkt-fehlertext', S.fehler._))
|
||||
if (Object.keys(S.fehler).length) zeigeRest(fehlerBox, zeigeFeldfehler(refs, S.fehler))
|
||||
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault()
|
||||
@@ -1185,11 +1270,12 @@
|
||||
function nachSendeFehler (err, senden) {
|
||||
if (err && err.code === 'neu_laden') {
|
||||
// Formular-Token abgelaufen oder ungueltig (R38): Konfiguration neu holen, dem Gast Bescheid geben
|
||||
lade(true).then(function () {
|
||||
gehe('pruefen', { fokus: false, meldung: T('Das Formular wurde erneuert. Bitte versuchen Sie es noch einmal.', 'Das Formular wurde erneuert. Bitte versuche es noch einmal.') })
|
||||
ladeNeu().then(function (weiter) {
|
||||
if (weiter) gehe('pruefen', { fokus: false, meldung: T('Das Formular wurde erneuert. Bitte versuchen Sie es noch einmal.', 'Das Formular wurde erneuert. Bitte versuche es noch einmal.') })
|
||||
}, function (e2) { fehlerAllgemein(e2, function () { zeichne(true) }) })
|
||||
return
|
||||
}
|
||||
mitMeldung429(err)
|
||||
if (err && !err.netz && err.fehler && Object.keys(err.fehler).length) {
|
||||
zeigeServerFehler(err.fehler)
|
||||
return
|
||||
@@ -1217,16 +1303,23 @@
|
||||
gehe(step, { fehler: fehler, fokus: true })
|
||||
}
|
||||
|
||||
function meldung429 (err) {
|
||||
return (err.fehler && err.fehler._) || T('Zu viele Anfragen. Bitte versuchen Sie es später noch einmal.', 'Zu viele Anfragen. Bitte versuche es später noch einmal.')
|
||||
}
|
||||
|
||||
// Fehler eines lesenden Abrufs (Konfiguration, Tage, Zeiten) oder Netzfehler: keine Wiederholung von selbst (429 wuerde sonst
|
||||
// in einer Schleife weiterzaehlen), nur Meldung und "Erneut versuchen".
|
||||
function fehlerAllgemein (err, wiederhole) {
|
||||
if (err && err.inaktiv) { zeigeAusweich(); return }
|
||||
if (err && err.status === 429) {
|
||||
gehe(S.step === 'ergebnis' ? 'pruefen' : S.step, { meldung: (err.fehler && err.fehler._) || T('Zu viele Anfragen. Bitte versuchen Sie es später noch einmal.', 'Zu viele Anfragen. Bitte versuche es später noch einmal.'), fokus: false })
|
||||
return
|
||||
}
|
||||
if (err && err.status === 429) { ausfall(wiederhole, meldung429(err)); return }
|
||||
ausfall(wiederhole)
|
||||
}
|
||||
|
||||
function behandleFehler (err, wiederhole) { fehlerAllgemein(err, wiederhole) }
|
||||
// 429 beim Absenden: die Meldung steht im Formular, die Ansicht bleibt wie sie ist
|
||||
function mitMeldung429 (err) {
|
||||
if (err && err.status === 429 && !(err.fehler && Object.keys(err.fehler).length)) err.fehler = { _: meldung429(err) }
|
||||
return err
|
||||
}
|
||||
|
||||
function zeichneErgebnis () {
|
||||
var e = S.ergebnis
|
||||
@@ -1267,7 +1360,7 @@
|
||||
var senden = el('button', 'wkt-btn', 'Widerruf bestätigen')
|
||||
senden.type = 'submit'
|
||||
var nav = el('div', 'wkt-nav')
|
||||
nav.appendChild(knopf('wkt-btn wkt-btn-sek', T('Zurück zur Terminbuchung', 'Zurück zur Terminbuchung'), function () { S.ansicht = 'buchung'; zeichne(true) }))
|
||||
nav.appendChild(zurueckKnopf())
|
||||
nav.appendChild(senden)
|
||||
form.appendChild(nav)
|
||||
add(form)
|
||||
@@ -1291,9 +1384,9 @@
|
||||
zeichne(true)
|
||||
}).catch(function (err) {
|
||||
S.sendet = false
|
||||
mitMeldung429(err)
|
||||
if (err && !err.netz && err.fehler && Object.keys(err.fehler).length) {
|
||||
var uebrig = zeigeFeldfehler(refs, err.fehler)
|
||||
uebrig.forEach(function (t) { fehlerBox.appendChild(el('p', 'wkt-fehlertext', t)) })
|
||||
zeigeRest(fehlerBox, zeigeFeldfehler(refs, err.fehler))
|
||||
senden.disabled = false
|
||||
return
|
||||
}
|
||||
@@ -1302,9 +1395,14 @@
|
||||
})
|
||||
}
|
||||
|
||||
// Aus der Widerrufsansicht zurueck zur Buchung (der Stand der Buchung bleibt)
|
||||
function zurueckKnopf () {
|
||||
return knopf('wkt-btn wkt-btn-sek', 'Zurück zur Terminbuchung', function () { S.ansicht = 'buchung'; zeichne(true) })
|
||||
}
|
||||
|
||||
function zurueckAnsicht () {
|
||||
var nav = el('div', 'wkt-nav')
|
||||
nav.appendChild(knopf('wkt-btn wkt-btn-sek', T('Zurück zur Terminbuchung', 'Zurück zur Terminbuchung'), function () { S.ansicht = 'buchung'; zeichne(true) }))
|
||||
nav.appendChild(zurueckKnopf())
|
||||
add(nav)
|
||||
}
|
||||
|
||||
@@ -1315,9 +1413,14 @@
|
||||
injectStyle()
|
||||
ausweich = document.createDocumentFragment()
|
||||
while (root.firstChild) ausweich.appendChild(root.firstChild)
|
||||
bauGeruest(root)
|
||||
starte(false)
|
||||
}).catch(function () { /* Fehler oder inaktiv: der Ausweichtext der Seite bleibt */ })
|
||||
try {
|
||||
bauGeruest(root)
|
||||
starte(false)
|
||||
} catch (e) {
|
||||
// Der Ausweichtext ist schon beiseite: scheitert der Aufbau, steht er wieder da (nie eine leere Stelle auf der Seite)
|
||||
zeigeAusweich()
|
||||
}
|
||||
}, function () { /* nicht erreichbar oder nicht aktiv: der Ausweichtext der Seite bleibt unberuehrt stehen */ })
|
||||
}
|
||||
|
||||
function schliesse () {
|
||||
|
||||
@@ -683,6 +683,7 @@ export function oeffentlicheEinstellung(einstellung, now) {
|
||||
adresse: e.betrieb.adresse,
|
||||
impressumUrl: e.betrieb.impressumUrl,
|
||||
datenschutzUrl: e.betrieb.datenschutzUrl,
|
||||
agbUrl: e.betrieb.agbUrl, // https-Adresse oder null (urlFeld); das Widget zeigt sie als Link neben den Stornobedingungen
|
||||
},
|
||||
anrede: e.anrede,
|
||||
farbe: e.farbe,
|
||||
|
||||
@@ -255,6 +255,7 @@ test('config: Betriebsdaten, Leistungen, Regeln, Texte und Formular-Token; keine
|
||||
adresse: 'Hauptstr. 1, 67722 Winnweiler',
|
||||
impressumUrl: 'https://salon.de/impressum',
|
||||
datenschutzUrl: 'https://salon.de/datenschutz',
|
||||
agbUrl: null,
|
||||
})
|
||||
assert.deepEqual(c.ressourcen, [{ id: R1, name: 'Anna', typ: 'person' }, { id: R2, name: 'Ben', typ: 'person' }])
|
||||
assert.deepEqual(c.regeln, { horizontTage: 90, absageBisStd: 24 })
|
||||
|
||||
@@ -46,7 +46,7 @@ export function sessionCookie(session) {
|
||||
|
||||
let baseUrl = null
|
||||
|
||||
/** App mit den Routern (Pfad -> Router) auf 127.0.0.1 starten; liefert { url, close }. */
|
||||
/** App mit den Routern (Pfad -> Router) auf 127.0.0.1 starten; liefert { url, server, close } (server: der http.Server, z. B. fuer closeAllConnections). */
|
||||
export async function startTestApp(routers = {}) {
|
||||
const app = express()
|
||||
app.set('trust proxy', 1)
|
||||
@@ -66,7 +66,7 @@ export async function startTestApp(routers = {}) {
|
||||
const server = http.createServer(app)
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
baseUrl = `http://127.0.0.1:${server.address().port}`
|
||||
return { url: baseUrl, close: () => new Promise((resolve) => server.close(resolve)) }
|
||||
return { url: baseUrl, server, close: () => new Promise((resolve) => server.close(resolve)) }
|
||||
}
|
||||
|
||||
/** Anfrage an die zuletzt gestartete App (beliebige Methode) -> { status, body, headers }; body ist geparstes JSON, sonst Text. */
|
||||
|
||||
@@ -914,8 +914,9 @@ test('oeffentlicheEinstellung: genau die vereinbarten Felder, ohne feedToken und
|
||||
assert.equal(oe.heute, '2026-10-02')
|
||||
assert.deepEqual(oe.betrieb, {
|
||||
name: 'Salon Müller', telefon: '06301 1234', adresse: 'Hauptstr. 1, 67722 Winnweiler',
|
||||
impressumUrl: 'https://salon.de/impressum', datenschutzUrl: 'https://salon.de/datenschutz',
|
||||
impressumUrl: 'https://salon.de/impressum', datenschutzUrl: 'https://salon.de/datenschutz', agbUrl: 'https://salon.de/agb',
|
||||
})
|
||||
assert.equal(oeffentlicheEinstellung(ausVorlage('salon', BETRIEB, NOW), NOW).betrieb.agbUrl, null, 'ohne AGB-Adresse null')
|
||||
assert.equal(oe.anrede, 'sie')
|
||||
assert.equal(oe.farbe, e.farbe)
|
||||
assert.deepEqual(oe.regeln, { horizontTage: e.regeln.horizontTage, absageBisStd: e.regeln.absageBisStd })
|
||||
@@ -937,7 +938,7 @@ test('oeffentlicheEinstellung: genau die vereinbarten Felder, ohne feedToken und
|
||||
assert.equal(leistung.verbraucher, true)
|
||||
|
||||
const json = JSON.stringify(oe)
|
||||
for (const geheim of ['feedToken', e.ressourcen[0].feedToken, 'info@salon.de', 'agb', 'customerId', 'projectId', 'featureId', 'pufferNach', 'preisCent', 'aufbewahrungTage', 'sonderoeffnungen', 'abwesenheiten', 'feiertage', 'vorOrtArten', 'vorlage', 'kleinunternehmer', 'vorlaufMin', 'maxOffeneJeEmail', 'raster']) {
|
||||
for (const geheim of ['feedToken', e.ressourcen[0].feedToken, 'info@salon.de', 'customerId', 'projectId', 'featureId', 'pufferNach', 'preisCent', 'aufbewahrungTage', 'sonderoeffnungen', 'abwesenheiten', 'feiertage', 'vorOrtArten', 'vorlage', 'kleinunternehmer', 'vorlaufMin', 'maxOffeneJeEmail', 'raster']) {
|
||||
assert.ok(!json.includes(geheim), `oeffentlich enthaelt ${geheim}`)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -80,6 +80,62 @@ test('Stile stehen einmal in <style id="wkt-style">, alle Klassen beginnen mit w
|
||||
assert.equal([...QUELLE.matchAll(/getElementById\(STYLE_ID\)/g)].length, 1)
|
||||
})
|
||||
|
||||
test('Ausweichtext wird nie verbraucht (nur Kopien zurueck auf die Seite), Link auf die AGB nur als https-Link', () => {
|
||||
assert.ok(!/appendChild\(ausweich\)/.test(OHNE_KOMMENTARE), 'der Ausweichtext darf nur als cloneNode(true) zurueckgesetzt werden')
|
||||
assert.ok(OHNE_KOMMENTARE.includes('ausweich.cloneNode(true)'))
|
||||
assert.ok(QUELLE.includes('agbUrl') && QUELLE.includes('AGB des Betriebs'))
|
||||
assert.match(OHNE_KOMMENTARE, /httpsUrl\(c\.betrieb\.agbUrl\)/)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Textfarbe auf der Betriebsfarbe (Kontrast)
|
||||
|
||||
/** Die reinen Farbfunktionen des Skripts (zwischen den Marken @farben-anfang und @farben-ende) einzeln ausfuehren. */
|
||||
function farbFunktionen() {
|
||||
const m = /\/\/ @farben-anfang[^\n]*\n([\s\S]*?)\/\/ @farben-ende/.exec(QUELLE)
|
||||
assert.ok(m, 'Marken @farben-anfang/@farben-ende fehlen')
|
||||
return vm.runInNewContext(`${m[1]}; ({ luminanz, kontrast, textFarbeAuf })`, {})
|
||||
}
|
||||
|
||||
// Unabhaengige Gegenrechnung (WCAG 2.1: relative Leuchtdichte und Kontrastverhaeltnis)
|
||||
function wcagKontrast(hexA, hexB) {
|
||||
const l = (hex) => {
|
||||
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255).map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4))
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
}
|
||||
const [x, y] = [l(hexA), l(hexB)]
|
||||
return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05)
|
||||
}
|
||||
|
||||
test('Textfarbe auf der Betriebsfarbe erreicht mindestens 4,5 : 1 (auch fuer mittlere Toene)', () => {
|
||||
const { textFarbeAuf } = farbFunktionen()
|
||||
// Mitteltoene, bei denen Weiss allein nur 2,5 bis 3,6 : 1 haette
|
||||
for (const farbe of ['#ea580c', '#16a34a', '#0ea5e9', '#14b8a6', '#ec4899']) {
|
||||
const text = textFarbeAuf(farbe)
|
||||
assert.ok(wcagKontrast(farbe, text) >= 4.5, `${farbe}: ${text} nur ${wcagKontrast(farbe, text).toFixed(2)} : 1`)
|
||||
assert.ok(wcagKontrast(farbe, text) >= wcagKontrast(farbe, '#ffffff') - 1e-9, `${farbe}: Weiss waere besser gewesen`)
|
||||
}
|
||||
// dunkle und helle Farben behalten Weiss bzw. das dunkle Grau
|
||||
assert.equal(textFarbeAuf('#0f766e'), '#ffffff')
|
||||
assert.equal(textFarbeAuf('#1d4ed8'), '#ffffff')
|
||||
assert.equal(textFarbeAuf('#fde68a'), '#111827')
|
||||
assert.equal(textFarbeAuf('#ffffff'), '#111827')
|
||||
assert.equal(textFarbeAuf('#000000'), '#ffffff')
|
||||
})
|
||||
|
||||
test('Textfarbe: kein Farbton faellt unter 4,5 : 1 (alle Grautoene und ein Raster ueber den Farbraum)', () => {
|
||||
const { textFarbeAuf, luminanz, kontrast } = farbFunktionen()
|
||||
const hex = (r, g, b) => `#${[r, g, b].map((c) => c.toString(16).padStart(2, '0')).join('')}`
|
||||
const proben = []
|
||||
for (let g = 0; g < 256; g += 1) proben.push(hex(g, g, g))
|
||||
for (let r = 0; r < 256; r += 15) for (let g = 0; g < 256; g += 15) for (let b = 0; b < 256; b += 15) proben.push(hex(r, g, b))
|
||||
for (const farbe of proben) {
|
||||
const text = textFarbeAuf(farbe)
|
||||
assert.ok(['#ffffff', '#111827', '#000000'].includes(text), `${farbe}: unerwartete Textfarbe ${text}`)
|
||||
assert.ok(wcagKontrast(farbe, text) >= 4.5, `${farbe}: ${text} nur ${wcagKontrast(farbe, text).toFixed(2)} : 1`)
|
||||
assert.ok(Math.abs(kontrast(luminanz(farbe), luminanz(text)) - wcagKontrast(farbe, text)) < 1e-9, `${farbe}: Skript und Gegenrechnung weichen ab`)
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Demo und Einbauanleitung
|
||||
|
||||
test('Demo-Seite: noindex, Ausweichtext, Skript per defer, beide Modi', () => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import {
|
||||
startTermineUiServer, salonEingabe,
|
||||
R_ANNA, S_DAMEN, S_HERREN, S_KENNEN,
|
||||
R_ANNA, S_DAMEN, S_HERREN,
|
||||
} from './termineTestServer.mjs'
|
||||
|
||||
const PW = process.env.PLAYWRIGHT_CORE || '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs'
|
||||
@@ -165,10 +165,17 @@ async function pruefungen() {
|
||||
assert(zus.replace(/\s+/g, ' ').includes(t), `Zusammenfassung ohne "${t}": ${zus}`)
|
||||
}
|
||||
assert((await wd.locator('.wkt-box').innerText()).includes('Danach berechnen wir 50 % des Preises.'), 'Stornoregel im Klartext fehlt')
|
||||
assert(zus.replace(/\s+/g, ' ').includes('Beschreibung Mit Beratung zur Frisur.'), 'Beschreibung der Leistung fehlt in der Zusammenfassung')
|
||||
const agb = wd.locator('.wkt-box a')
|
||||
eq([await agb.innerText(), await agb.getAttribute('href'), await agb.getAttribute('rel'), await agb.getAttribute('target')],
|
||||
['AGB des Betriebs', 'https://salon-mueller.example/agb', 'noopener noreferrer', '_blank'], 'Link auf die AGB neben den Stornobedingungen')
|
||||
const det = wd.locator('details.wkt-belehrung')
|
||||
assert(await det.count() === 1 && await det.evaluate((d) => !d.open), 'Widerrufsbelehrung muss aufklappbar und zu sein')
|
||||
await det.locator('summary').click()
|
||||
assert((await det.innerText()).includes('Widerrufsbelehrung') && (await det.innerText()).includes('Muster-Widerrufsformular'), 'Belehrung nicht lesbar')
|
||||
const belehrung = det.locator('.wkt-belehrung-text')
|
||||
eq([await belehrung.getAttribute('tabindex'), await belehrung.getAttribute('role'), await belehrung.getAttribute('aria-label')],
|
||||
['0', 'region', 'Widerrufsbelehrung und Muster-Widerrufsformular'], 'Belehrungstext per Tastatur scrollbar')
|
||||
await det.locator('summary').click()
|
||||
assert((await wd.locator('.wkt-datenschutz').innerText()).startsWith('Datenschutz. Wir verwenden Ihre Angaben'), 'Datenschutz-Hinweis')
|
||||
eq(await wd.locator('.wkt-datenschutz a').getAttribute('rel'), 'noopener noreferrer', 'rel am Datenschutz-Link')
|
||||
@@ -366,6 +373,8 @@ async function pruefungen() {
|
||||
const aktTag = () => page.evaluate(() => document.activeElement.dataset.tag)
|
||||
const monat = () => wd.locator('.wkt-monat').innerText()
|
||||
eq(await aktTag(), '2026-10-05', 'Start')
|
||||
await page.keyboard.press('Control+ArrowRight'); eq(await aktTag(), '2026-10-05', 'Strg+Pfeil gehoert dem Browser, nicht dem Raster')
|
||||
await page.keyboard.press('Meta+ArrowDown'); eq(await aktTag(), '2026-10-05', 'Meta+Pfeil gehoert dem System, nicht dem Raster')
|
||||
await page.keyboard.press('ArrowRight'); eq(await aktTag(), '2026-10-06', 'Pfeil rechts')
|
||||
await page.keyboard.press('ArrowDown'); eq(await aktTag(), '2026-10-13', 'Pfeil runter = +7')
|
||||
await page.keyboard.press('ArrowLeft'); eq(await aktTag(), '2026-10-12', 'Pfeil links')
|
||||
@@ -797,6 +806,238 @@ async function pruefungen() {
|
||||
eq(fremd, [], 'Anfragen an fremde Hosts')
|
||||
})
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- 13. Nachbesserungen (Fix-Runde 1)
|
||||
|
||||
const ACAO = { 'access-control-allow-origin': '*' }
|
||||
const TEXT_429 = 'Zu viele Anfragen. Bitte versuchen Sie es später noch einmal.'
|
||||
const json = (route, status, body) => route.fulfill({ status, contentType: 'application/json', headers: ACAO, body: JSON.stringify(body) })
|
||||
|
||||
await check('grenze_429_keine_schleife', async (page) => {
|
||||
// lesend (Tage): Meldung und Knopf, kein neuer Abruf von selbst
|
||||
let tage = 0
|
||||
await page.route(/\/tage\?/, (route) => { tage += 1; return json(route, 429, { fehler: { _: TEXT_429 } }) })
|
||||
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
|
||||
const wd = w(page)
|
||||
await karte(wd, 'Herrenschnitt').click()
|
||||
await karte(wd, 'Egal').click()
|
||||
const meldung = wd.locator('.wkt-ausfall')
|
||||
await meldung.waitFor()
|
||||
await page.waitForTimeout(1500)
|
||||
assert(tage >= 1 && tage <= 2, `bei 429 darf es hoechstens 2 Abrufe von /tage geben, es waren ${tage}`)
|
||||
eq(await meldung.innerText(), TEXT_429, 'Meldung der Grenze')
|
||||
assert(await wd.getByRole('button', { name: 'Erneut versuchen' }).isVisible(), 'Knopf "Erneut versuchen" fehlt')
|
||||
await shot(page, 'fehler-429-lesen')
|
||||
await page.unroute(/\/tage\?/)
|
||||
await wd.getByRole('button', { name: 'Erneut versuchen' }).click()
|
||||
await wd.locator('td.wkt-tag').first().waitFor()
|
||||
|
||||
// schreibend (Buchung): die Zusammenfassung bleibt mit der Meldung stehen
|
||||
await tag(wd, '2026-10-26').click()
|
||||
await zeit(wd, '10:00').click()
|
||||
await wd.locator('input[name=name]').fill('Max Mustermann')
|
||||
await wd.locator('input[name=email]').fill('max@example.com')
|
||||
await wd.getByRole('button', { name: 'Weiter' }).click()
|
||||
await wd.locator('.wkt-zusammen').waitFor()
|
||||
await wd.locator('input[name=storno]').check()
|
||||
let buchungen = 0
|
||||
await page.route(/\/buchungen$/, (route) => { buchungen += 1; return json(route, 429, { fehler: { _: TEXT_429 } }) })
|
||||
await wd.getByRole('button', { name: 'Zahlungspflichtig buchen' }).click()
|
||||
await wd.locator('.wkt-fehlerbox .wkt-fehlertext').waitFor()
|
||||
await page.waitForTimeout(800)
|
||||
eq(await wd.locator('.wkt-fehlerbox .wkt-fehlertext').innerText(), TEXT_429, 'Meldung in der Zusammenfassung')
|
||||
eq(await titel(wd).innerText(), 'Bitte prüfen Sie Ihre Buchung', 'bleibt in der Zusammenfassung')
|
||||
eq(buchungen, 1, 'genau ein Versuch')
|
||||
assert(await wd.locator('input[name=storno]').isChecked(), 'Haken bleibt')
|
||||
await page.unroute(/\/buchungen$/)
|
||||
|
||||
// Widerrufsformular (auch eine 429 ohne Text): bleibt in der Widerrufsansicht
|
||||
let widerrufe = 0
|
||||
await page.route(/\/widerruf$/, (route) => { widerrufe += 1; return json(route, 429, {}) })
|
||||
await wd.getByRole('button', { name: 'Vertrag widerrufen' }).click()
|
||||
await warteTitel(wd, 'Vertrag widerrufen')
|
||||
await wd.locator('input[name=name]').fill('Gerda Gast')
|
||||
await wd.locator('input[name=email]').fill('gerda@example.com')
|
||||
await wd.getByRole('button', { name: 'Widerruf bestätigen' }).click()
|
||||
await wd.locator('.wkt-fehlerbox .wkt-fehlertext').waitFor()
|
||||
await page.waitForTimeout(800)
|
||||
eq(await wd.locator('.wkt-fehlerbox .wkt-fehlertext').innerText(), TEXT_429, 'Meldung im Widerrufsformular')
|
||||
eq(await titel(wd).innerText(), 'Vertrag widerrufen', 'bleibt in der Widerrufsansicht')
|
||||
eq(widerrufe, 1, 'genau ein Versuch')
|
||||
assert(await wd.getByRole('button', { name: 'Widerruf bestätigen' }).isEnabled(), 'Knopf wieder frei')
|
||||
assert(await wd.locator('input[name=name]').inputValue() === 'Gerda Gast', 'Eingaben bleiben')
|
||||
await shot(page, 'fehler-429-widerruf')
|
||||
await wd.getByRole('button', { name: 'Zurück zur Terminbuchung' }).click()
|
||||
await wd.locator('.wkt-zusammen').waitFor()
|
||||
})
|
||||
|
||||
await check('betriebsfarbe_orange_schrift_lesbar', async (page) => {
|
||||
await oeffne(page, seitenHtml(eingebettet(`data-modus="inline" data-leistung="${S_HERREN}"`)))
|
||||
const wd = await bisZusammenfassung(page, { leistung: null, tag: '2026-10-26' })
|
||||
const farben = await wd.locator('.wkt-form button[type=submit]').evaluate((b) => [getComputedStyle(b).backgroundColor, getComputedStyle(b).color])
|
||||
eq(farben, ['rgb(234, 88, 12)', 'rgb(17, 24, 39)'], 'Orange bekommt dunkle Schrift (Weiss waere nur 3,6 : 1)')
|
||||
await shot(page, 'orange-pruefen')
|
||||
}, { eingabe: salonEingabe({ farbe: '#ea580c' }) })
|
||||
|
||||
await check('haken_beginnen_bei_aenderung_ohne_haken', async (page) => {
|
||||
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
|
||||
const wd = await bisZusammenfassung(page, { leistung: 'Herrenschnitt', tag: '2026-10-12', zeit: '10:00' })
|
||||
const haken = () => wd.locator('.wkt-haken input').evaluateAll((l) => l.map((i) => i.name + ':' + i.checked))
|
||||
const alleAn = async () => { for (const n of ['storno', 'vorzeitig', 'erinnerung']) await wd.locator(`input[name=${n}]`).check() }
|
||||
const zurueck = async (n) => { for (let i = 0; i < n; i += 1) await wd.getByRole('button', { name: 'Zurück' }).click() }
|
||||
const weiterBisZusammenfassung = async () => { await wd.getByRole('button', { name: 'Weiter' }).click(); await wd.locator('.wkt-zusammen').waitFor() }
|
||||
await alleAn()
|
||||
eq(await haken(), ['storno:true', 'vorzeitig:true', 'erinnerung:true'], 'alle drei angekreuzt')
|
||||
// dieselbe Uhrzeit noch einmal gewaehlt: nichts hat sich geaendert, die Haken bleiben
|
||||
await zurueck(2)
|
||||
await wd.locator('.wkt-zeit').first().waitFor()
|
||||
await zeit(wd, '10:00').click()
|
||||
await warteTitel(wd, 'Ihre Angaben')
|
||||
await weiterBisZusammenfassung()
|
||||
eq(await haken(), ['storno:true', 'vorzeitig:true', 'erinnerung:true'], 'gleiche Uhrzeit: Haken bleiben')
|
||||
// andere Uhrzeit
|
||||
await zurueck(2)
|
||||
await wd.locator('.wkt-zeit').first().waitFor()
|
||||
await zeit(wd, '10:15').click()
|
||||
await warteTitel(wd, 'Ihre Angaben')
|
||||
await weiterBisZusammenfassung()
|
||||
eq(await haken(), ['storno:false', 'vorzeitig:false', 'erinnerung:false'], 'andere Uhrzeit: ohne Haken')
|
||||
// anderer Tag
|
||||
await alleAn()
|
||||
await zurueck(2)
|
||||
await tag(wd, '2026-10-13').click()
|
||||
await zeit(wd, '10:15').click()
|
||||
await warteTitel(wd, 'Ihre Angaben')
|
||||
await weiterBisZusammenfassung()
|
||||
eq(await haken(), ['storno:false', 'vorzeitig:false', 'erinnerung:false'], 'anderer Tag: ohne Haken')
|
||||
// andere Leistung
|
||||
await alleAn()
|
||||
await zurueck(4)
|
||||
await warteTitel(wd, 'Welche Leistung')
|
||||
await karte(wd, 'Kinderschnitt').click()
|
||||
await karte(wd, 'Egal').click()
|
||||
await tag(wd, '2026-10-13').click()
|
||||
await zeit(wd, '10:15').click()
|
||||
await warteTitel(wd, 'Ihre Angaben')
|
||||
await weiterBisZusammenfassung()
|
||||
eq(await haken(), ['storno:false', 'vorzeitig:false', 'erinnerung:false'], 'andere Leistung: ohne Haken')
|
||||
})
|
||||
|
||||
await check('fehler_ohne_feld_nicht_verschluckt', async (page) => {
|
||||
let fehler = {}
|
||||
await page.route(/\/buchungen$/, (route) => json(route, 400, { fehler }))
|
||||
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
|
||||
const wd = await bisZusammenfassung(page, { leistung: 'Herrenschnitt', tag: '2026-10-26' })
|
||||
await wd.locator('input[name=storno]').check()
|
||||
eq(await wd.locator('input[name=vorzeitig]').count(), 0, 'Voraussetzung: der Haken wird nicht gezeigt')
|
||||
// 1) Schluessel ohne Feld in der Zusammenfassung: Fehlerkasten
|
||||
fehler = { vorzeitig: 'Bitte der Leistung vor Ablauf der Widerrufsfrist ausdrücklich zustimmen, sonst ist die Buchung nicht möglich.', irgendwas: 'Unbekannter Fehler vom Server.' }
|
||||
await wd.getByRole('button', { name: 'Zahlungspflichtig buchen' }).click()
|
||||
await wd.locator('.wkt-fehlerbox .wkt-fehlertext').first().waitFor()
|
||||
eq(await wd.locator('.wkt-fehlerbox .wkt-fehlertext').allInnerTexts(), Object.values(fehler), 'beide Meldungen sichtbar')
|
||||
eq(await titel(wd).innerText(), 'Bitte prüfen Sie Ihre Buchung', 'bleibt in der Zusammenfassung')
|
||||
// 2) Feld im Schritt "Angaben" plus Schluessel ohne Feld
|
||||
fehler = { name: 'Bitte einen Namen angeben.', foo: 'Weitere Meldung.' }
|
||||
await wd.getByRole('button', { name: 'Zahlungspflichtig buchen' }).click()
|
||||
await warteTitel(wd, 'Ihre Angaben')
|
||||
eq(await wd.locator('input[name=name]').getAttribute('aria-invalid'), 'true', 'Namensfeld markiert')
|
||||
eq(await wd.locator('.wkt-fehlerbox .wkt-fehlertext').allInnerTexts(), ['Weitere Meldung.'], 'Meldung ohne Feld im Kasten')
|
||||
// 3) Leistung: der Leistungsschritt zeigt die Meldung
|
||||
await wd.getByRole('button', { name: 'Weiter' }).click()
|
||||
await wd.locator('.wkt-zusammen').waitFor()
|
||||
fehler = { leistung: 'Bitte eine Leistung auswählen.' }
|
||||
await wd.getByRole('button', { name: 'Zahlungspflichtig buchen' }).click()
|
||||
await warteTitel(wd, 'Welche Leistung')
|
||||
eq(await wd.locator('.wkt-fehlerbox .wkt-fehlertext').allInnerTexts(), ['Bitte eine Leistung auswählen.'], 'Meldung im Leistungsschritt')
|
||||
})
|
||||
|
||||
await check('angebot_geaendert_beginnt_von_vorn', async (page) => {
|
||||
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
|
||||
const wd = await bisZusammenfassung(page, { leistung: 'Damenschnitt', tag: '2026-10-26' })
|
||||
await wd.locator('input[name=storno]').check()
|
||||
// der Betrieb nimmt die Leistung heraus; das Formular-Token ist inzwischen abgelaufen (neu_laden)
|
||||
const eingabe = salonEingabe()
|
||||
eingabe.leistungen = eingabe.leistungen.filter((l) => l.id !== S_DAMEN)
|
||||
await srv.speichere(eingabe)
|
||||
srv.schiebeUhr(3 * 60 * 60 * 1000)
|
||||
await wd.getByRole('button', { name: 'Zahlungspflichtig buchen' }).click()
|
||||
await wd.locator('.wkt-meldung').waitFor()
|
||||
eq(await wd.locator('.wkt-meldung').innerText(), 'Das Angebot hat sich geändert. Bitte wählen Sie neu.', 'Hinweis')
|
||||
eq(await titel(wd).innerText(), 'Welche Leistung möchten Sie buchen?', 'zurueck zur Leistungswahl')
|
||||
eq(await karte(wd, 'Damenschnitt').count(), 0, 'die entfernte Leistung ist weg')
|
||||
eq(srv.buchungen().length, 0, 'nichts gebucht')
|
||||
})
|
||||
|
||||
await check('konfiguration_unvollstaendig_ausweichtext', async (page, ctx) => {
|
||||
const varianten = {
|
||||
'Leistung ohne Knopftext': (j) => { delete j.leistungen[0].knopf },
|
||||
'ohne Text fuer den Storno-Haken': (j) => { delete j.texte.storno },
|
||||
'ohne Text fuer den Haken vorzeitiger Beginn': (j) => { delete j.texte.vorzeitig },
|
||||
'Widerruf ohne Belehrung': (j) => { delete j.texte.widerrufsbelehrung },
|
||||
'ohne Formular-Token': (j) => { delete j.formToken },
|
||||
}
|
||||
for (const [name, aendere] of [['unveraendert', () => {}], ...Object.entries(varianten)]) {
|
||||
const p = await ctx.newPage()
|
||||
p.setDefaultTimeout(8000)
|
||||
await p.route(/\/config$/, async (route) => {
|
||||
const antwort = await route.fetch()
|
||||
const daten = await antwort.json()
|
||||
aendere(daten)
|
||||
await route.fulfill({ response: antwort, json: daten })
|
||||
})
|
||||
await p.goto(srv.seite('unvollstaendig.html', seitenHtml(eingebettet('data-modus="inline"'))))
|
||||
if (name === 'unveraendert') {
|
||||
await karte(w(p), 'Herrenschnitt').waitFor()
|
||||
} else {
|
||||
await p.waitForLoadState('networkidle')
|
||||
await p.waitForTimeout(300)
|
||||
assert(await p.locator('#ausweich').isVisible(), `${name}: Ausweichtext muss stehen`)
|
||||
eq(await p.locator('.wkt-root').count(), 0, `${name}: kein Widget`)
|
||||
}
|
||||
await p.close()
|
||||
}
|
||||
})
|
||||
|
||||
await check('render_fehler_zeigt_ausweichtext_wieder', async (page) => {
|
||||
// Der Aufbau der Leistungsliste scheitert (<ul> laesst sich nicht erzeugen), nachdem der Ausweichtext schon beiseite ist
|
||||
const kaputt = await page.context().newPage()
|
||||
await kaputt.addInitScript(() => {
|
||||
const echt = document.createElement.bind(document)
|
||||
document.createElement = (tag, opt) => { if (String(tag).toLowerCase() === 'ul') throw new Error('Test: Aufbau scheitert'); return echt(tag, opt) }
|
||||
})
|
||||
const fehler = []
|
||||
kaputt.on('pageerror', (e) => fehler.push(e.message))
|
||||
await kaputt.goto(srv.seite('kaputt.html', seitenHtml(eingebettet('data-modus="inline"'))))
|
||||
await kaputt.waitForLoadState('networkidle')
|
||||
await kaputt.waitForTimeout(500)
|
||||
assert(await kaputt.locator('#ausweich').isVisible(), 'Ausweichtext muss nach dem Fehler wieder da sein')
|
||||
eq(await kaputt.locator('.wkt-root').count(), 0, 'kein halbes Widget')
|
||||
eq(fehler, [], 'der Fehler wird abgefangen')
|
||||
await kaputt.close()
|
||||
})
|
||||
|
||||
await check('monatspfeil_behaelt_fokus', async (page) => {
|
||||
await oeffne(page, seitenHtml(eingebettet(`data-modus="inline" data-leistung="${S_HERREN}"`)))
|
||||
const wd = w(page)
|
||||
await karte(wd, 'Egal').click()
|
||||
await wd.locator('td.wkt-tag').first().waitFor()
|
||||
const vor = wd.getByRole('button', { name: 'Nächster Monat' })
|
||||
const zur = wd.getByRole('button', { name: 'Vorheriger Monat' })
|
||||
const fokus = () => page.evaluate(() => { const a = document.activeElement; return { body: a === document.body, label: a.getAttribute('aria-label') || '', imWidget: !!a.closest('[data-webklar-termine]') } })
|
||||
const klickeBisGesperrt = async (knopf) => {
|
||||
for (let i = 0; i < 8 && await knopf.isEnabled(); i += 1) {
|
||||
const vorher = await wd.locator('.wkt-monat').innerText()
|
||||
await knopf.click()
|
||||
await page.waitForFunction((v) => document.querySelector('.wkt-monat').textContent !== v, vorher)
|
||||
}
|
||||
assert(await knopf.isDisabled(), 'Pfeil wurde nie gesperrt')
|
||||
}
|
||||
await klickeBisGesperrt(vor)
|
||||
eq(await fokus(), { body: false, label: 'Vorheriger Monat', imWidget: true }, 'Fokus nach dem gesperrten "Naechster Monat" beim anderen Pfeil')
|
||||
await klickeBisGesperrt(zur)
|
||||
eq(await fokus(), { body: false, label: 'Nächster Monat', imWidget: true }, 'Fokus nach dem gesperrten "Vorheriger Monat" beim anderen Pfeil')
|
||||
})
|
||||
|
||||
// Zum Schluss: Server gestoppt. Seite und Skript liefert ein eigener kleiner Server, die Schnittstelle ist weg (Verbindung abgelehnt).
|
||||
await check('server_gestoppt_ausweichtext', async (page) => {
|
||||
const skript = await readFile(WIDGET_JS, 'utf8')
|
||||
|
||||
@@ -12,6 +12,11 @@
|
||||
// damit Tage und Uhrzeiten in Screenshots gleich bleiben und Zwischenspeicher, Haltezeiten und Formular-Token trotzdem
|
||||
// ablaufen. srv.schiebeUhr(ms) verschiebt sie (z. B. +3 h: Formular-Token abgelaufen), srv.setzeUhr(date) setzt sie neu.
|
||||
//
|
||||
// Cookies fuer das Portal (Aufgaben 21a/21b): sessionCookie/customerCookie aus test/support/testApp.js und fixtures.js erst NACH dem
|
||||
// Start dieses Servers dynamisch importieren (testApp.js prueft beim Laden, dass die Umgebung auf die Nachbauten zeigt):
|
||||
// const srv = await startTermineUiServer()
|
||||
// const { sessionCookie } = await import('../support/testApp.js')
|
||||
//
|
||||
// Seitenherkunft: srv.url ist http://127.0.0.1:PORT, srv.pageOrigin ist http://localhost:PORT (derselbe Server, anderer Host).
|
||||
// Mit { crossOrigin: true } gilt pageOrigin als Herkunft des Portals (config.publicBaseUrl): Seiten von pageOrigin mit
|
||||
// data-api=srv.url sind dann echte Cross-Origin-Anfragen (Preflight fuer POST), die der Server erlaubt.
|
||||
@@ -64,6 +69,7 @@ export function salonEingabe(ueberschreibe = {}) {
|
||||
websiteUrl: 'https://salon-mueller.example',
|
||||
impressumUrl: 'https://salon-mueller.example/impressum',
|
||||
datenschutzUrl: 'https://salon-mueller.example/datenschutz',
|
||||
agbUrl: 'https://salon-mueller.example/agb',
|
||||
},
|
||||
anrede: 'sie',
|
||||
farbe: '#0f766e',
|
||||
@@ -259,7 +265,11 @@ export async function startTermineUiServer({ jetzt = JETZT, crossOrigin = false,
|
||||
async beendeApp() {
|
||||
if (srv.appBeendet) return
|
||||
srv.appBeendet = true
|
||||
await Promise.race([app.close(), new Promise((resolve) => setTimeout(resolve, 3000))])
|
||||
let frist = null
|
||||
const geschlossen = app.close()
|
||||
app.server.closeAllConnections?.() // offene Verbindungen des Browsers (Keep-Alive) halten close() sonst auf
|
||||
await Promise.race([geschlossen, new Promise((resolve) => { frist = setTimeout(resolve, 3000) })])
|
||||
clearTimeout(frist)
|
||||
},
|
||||
appBeendet: false,
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user