From 4465ac7b94bfd7aa52f201f3905410a32dbb940c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 22:41:05 +0000 Subject: [PATCH] =?UTF-8?q?Terminplaner:=20Einbau-Skript=20=E2=80=93=20Nac?= =?UTF-8?q?hbesserungen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/funktionen/termine-einbau.md | 6 +- public/embed/termine.js | 261 ++++++++++++++++------- server/services/termine/einstellungen.js | 1 + test/publicTermine.test.js | 1 + test/support/testApp.js | 4 +- test/termineEinstellungen.test.js | 5 +- test/termineEmbedStatic.test.js | 56 +++++ test/ui/termine-widget.ui.mjs | 243 ++++++++++++++++++++- test/ui/termineTestServer.mjs | 12 +- 9 files changed, 501 insertions(+), 88 deletions(-) diff --git a/docs/funktionen/termine-einbau.md b/docs/funktionen/termine-einbau.md index 8a6a182..9cbce96 100644 --- a/docs/funktionen/termine-einbau.md +++ b/docs/funktionen/termine-einbau.md @@ -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. | diff --git a/public/embed/termine.js b/public/embed/termine.js index eecd4d9..c0fb06c 100644 --- a/public/embed/termine.js +++ b/public/embed/termine.js @@ -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 () { diff --git a/server/services/termine/einstellungen.js b/server/services/termine/einstellungen.js index 1e28382..2190880 100644 --- a/server/services/termine/einstellungen.js +++ b/server/services/termine/einstellungen.js @@ -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, diff --git a/test/publicTermine.test.js b/test/publicTermine.test.js index 16b3143..fffbe85 100644 --- a/test/publicTermine.test.js +++ b/test/publicTermine.test.js @@ -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 }) diff --git a/test/support/testApp.js b/test/support/testApp.js index 12fda08..92a7b3b 100644 --- a/test/support/testApp.js +++ b/test/support/testApp.js @@ -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. */ diff --git a/test/termineEinstellungen.test.js b/test/termineEinstellungen.test.js index 13fb369..9186c72 100644 --- a/test/termineEinstellungen.test.js +++ b/test/termineEinstellungen.test.js @@ -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}`) } }) diff --git a/test/termineEmbedStatic.test.js b/test/termineEmbedStatic.test.js index 7aacbb9..54f347d 100644 --- a/test/termineEmbedStatic.test.js +++ b/test/termineEmbedStatic.test.js @@ -80,6 +80,62 @@ test('Stile stehen einmal in