From 5fef7e47f0e414d8a4fa9f8fc13bcde725e96472 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 22:18:01 +0000 Subject: [PATCH] Terminplaner: Einbau-Skript, Demo und Einbauanleitung public/embed/termine.js: Buchungs-Widget fuer Kundenwebsites (inline und Knopf mit dialog, Monatsraster nach APG, Haken-Texte und Knopf aus der Konfiguration, Honigtopf kontakt_url, Widerrufsfunktion, Konfiguration nach 100 min neu, Ausweichtext bei Fehlern). Rechnet nie mit Datum oder Zeitzone des Browsers. Dazu Demo-Seite, Einbauanleitung fuer Mitarbeiter, statischer Test und Playwright-Skript mit wiederverwendbarem UI-Testserver (test/ui/termineTestServer.mjs). Co-Authored-By: Claude Sonnet 5.5 --- docs/funktionen/termine-einbau.md | 164 ++++ public/embed/termine-demo.html | 37 + public/embed/termine.js | 1392 +++++++++++++++++++++++++++++ test/termineEmbedStatic.test.js | 111 +++ test/ui/termine-widget.ui.mjs | 852 ++++++++++++++++++ test/ui/termineTestServer.mjs | 269 ++++++ 6 files changed, 2825 insertions(+) create mode 100644 docs/funktionen/termine-einbau.md create mode 100644 public/embed/termine-demo.html create mode 100644 public/embed/termine.js create mode 100644 test/termineEmbedStatic.test.js create mode 100644 test/ui/termine-widget.ui.mjs create mode 100644 test/ui/termineTestServer.mjs diff --git a/docs/funktionen/termine-einbau.md b/docs/funktionen/termine-einbau.md new file mode 100644 index 0000000..8a6a182 --- /dev/null +++ b/docs/funktionen/termine-einbau.md @@ -0,0 +1,164 @@ +# Terminplaner auf der Kundenwebsite einbauen + +Anleitung für das WEBklar-Team. Das Widget lässt Gäste auf der Website des Kunden Termine buchen. Leistungen, Personen und Öffnungszeiten pflegt der Kunde im Portal (Tab „Funktionen“, Kachel „Terminplaner“). Den `siteKey` findest Du im Ticketsystem beim Projekt (16 Zeichen, a–z und 2–7). Ohne aktive Funktion, ohne gespeicherte Einstellung und ohne Telefonnummer des Betriebs (bei Verbrauchern auch ohne Anschrift) ist nichts buchbar: Das Widget zeigt dann den Ausweichtext der Seite. + +## 1. Das Snippet + +```html +
+

Termine telefonisch unter 06301 1234.

+
+ +``` + +Das Skript steht einmal pro Seite, vor ``. Es hat keine Abhängigkeiten, setzt keine Cookies, speichert nichts im Browser und lädt nichts von Dritten. Texte der Kunden setzt es nur als Text ein, nie als HTML. Es wird 5 Minuten gecacht und darf von jeder Domain geladen werden. + +## 2. Variablen (Attribute am `div`) + +| Attribut | Pflicht | Bedeutung | +| --- | --- | --- | +| `data-webklar-termine` | ja | der `siteKey` des Projekts | +| `data-api` | nein | Herkunft des Portals, Standard `https://project.webklar.com`. Ohne Angabe nimmt das Skript die Herkunft seiner eigenen Adresse. Das klappt nur bei einem normalen ` + + diff --git a/public/embed/termine.js b/public/embed/termine.js new file mode 100644 index 0000000..eecd4d9 --- /dev/null +++ b/public/embed/termine.js @@ -0,0 +1,1392 @@ +/*! WEBklar Terminplaner: Einbau-Skript fuer Kundenwebsites. + *
Ausweichtext
+ * data-modus: inline (Strecke in der Seite) | knopf (Knopf mit data-knopf="Text", oeffnet ein , laedt erst beim Klick) + * data-leistung="s_xxxxxxxx" waehlt eine Leistung vor. + * Keine Abhaengigkeiten, keine Cookies, kein Storage, keine Drittanfragen. Alle Texte der Kunden nur ueber textContent. + * Das Widget rechnet nie mit Datum oder Zeitzone des Browsers: Kalender, Wochentage, Uhrzeiten und Datumstexte kommen vom Server. */ +(function () { + 'use strict' + var SELF = document.currentScript + var STYLE_ID = 'wkt-style' + var API_PFAD = '/api/public/termine/v1/' + var CONFIG_ALTER_MAX_MS = 100 * 60 * 1000 // vor dem Ablauf des Formular-Tokens (2 h) neu laden + var TIMEOUT_MS = 20000 + var PREFIX = 'wkt' + Math.random().toString(36).slice(2, 6) + '-' + var zaehler = 0 + var WOCHE = [['Mo', 'Montag'], ['Di', 'Dienstag'], ['Mi', 'Mittwoch'], ['Do', 'Donnerstag'], ['Fr', 'Freitag'], ['Sa', 'Samstag'], ['So', 'Sonntag']] + var SCHRITTE = ['leistung', 'person', 'termin', 'daten', 'pruefen'] + var MONATSZEILE = /^\d{4}-(0[1-9]|1[0-2])$/ + var TAGESZEILE = /^\d{4}-\d{2}-\d{2}$/ + var EMAIL_FORM = /^[^\s@,;<>"]+@[^\s@,;<>"]+\.[^\s@,;<>"]+$/ + var TELEFON_ZEICHEN = /^[0-9 +()-]+$/ + + var CSS = [ + '@layer wkt{', + '.wkt-root,.wkt-knopf-box{--wkt-i-akzent:var(--wkt-akzent,var(--wkt-vorgabe-akzent,#1d4ed8));--wkt-i-akzent-text:var(--wkt-akzent-text,var(--wkt-vorgabe-akzent-text,#fff));', + '--wkt-i-bg:var(--wkt-hintergrund,#fff);--wkt-i-text:var(--wkt-text,#1f2937);--wkt-i-rand:var(--wkt-rand,#858f9e);--wkt-i-radius:var(--wkt-radius,10px);', + '--wkt-i-weich:#f3f4f6;--wkt-i-fehler:#b91c1c;--wkt-i-schrift:var(--wkt-schrift,inherit);--wkt-i-titel:var(--wkt-schrift-titel,var(--wkt-schrift,inherit))}', + '@supports (color:color-mix(in srgb,red,blue)){.wkt-root{--wkt-i-weich:color-mix(in srgb,var(--wkt-i-text) 6%,var(--wkt-i-bg))}}', + '.wkt-root{container:wkt/inline-size;display:block;position:relative;box-sizing:border-box;padding:20px;border:1px solid #c9ced6;border-radius:var(--wkt-i-radius);', + 'background:var(--wkt-i-bg);color:var(--wkt-i-text);font-family:var(--wkt-i-schrift);font-size:16px;line-height:1.5;text-align:left}', + '.wkt-root *,.wkt-root *::before,.wkt-root *::after{box-sizing:border-box}', + '.wkt-vh{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0}', + '.wkt-titel{font-family:var(--wkt-i-titel);font-size:1.35rem;line-height:1.25;font-weight:700;margin:0 0 .75rem;color:inherit}', + '.wkt-titel:focus{outline:none}.wkt-titel:focus-visible{outline:3px solid var(--wkt-i-text);outline-offset:3px}', + '.wkt-pfad{margin:0 0 .25rem;font-size:.875rem;opacity:.8}', + '.wkt-root p{margin:0 0 .75rem}', + '.wkt-laden{opacity:.8}', + '.wkt-btn,.wkt-knopf{font:inherit;font-weight:600;min-height:44px;padding:.55rem 1.25rem;border-radius:var(--wkt-i-radius);border:2px solid var(--wkt-i-akzent);background:var(--wkt-i-akzent);color:var(--wkt-i-akzent-text);cursor:pointer;line-height:1.25}', + '.wkt-btn:hover,.wkt-knopf:hover{filter:brightness(1.08)}', + '.wkt-btn-sek{background:transparent;color:inherit;border-color:var(--wkt-i-rand)}', + '.wkt-btn[disabled]{opacity:.6;cursor:default}', + '.wkt-nav{display:flex;flex-wrap:wrap;gap:.75rem;justify-content:space-between;align-items:center;margin-top:1.25rem}', + '.wkt-nav>:only-child{margin-left:auto}', + '.wkt-root :focus-visible,.wkt-knopf:focus-visible{outline:3px solid var(--wkt-i-text);outline-offset:2px}', + '.wkt-liste{list-style:none;margin:0 0 1rem;padding:0;display:grid;gap:.6rem}', + '.wkt-liste>li{margin:0;padding:0}', + '.wkt-karte{display:grid;gap:.15rem;width:100%;text-align:left;font:inherit;color:inherit;background:var(--wkt-i-bg);border:1px solid var(--wkt-i-rand);border-radius:var(--wkt-i-radius);padding:.8rem 1rem;min-height:44px;cursor:pointer}', + '.wkt-karte:hover{border-color:var(--wkt-i-akzent);background:var(--wkt-i-weich)}', + '.wkt-karte-name{font-weight:700;font-size:1.05rem}.wkt-karte-meta{font-size:.9rem;opacity:.85}.wkt-karte-text{font-size:.9rem}', + '.wkt-zahlung{font-size:.9rem;opacity:.9;margin:0}', + '.wkt-kal-kopf{display:flex;align-items:center;justify-content:space-between;gap:.5rem;margin-bottom:.5rem}', + '.wkt-monat{font-family:var(--wkt-i-titel);font-size:1.1rem;margin:0;font-weight:700;text-align:center;flex:1}', + '.wkt-pfeil{font:inherit;font-size:1.4rem;line-height:1;min-width:44px;min-height:44px;border-radius:var(--wkt-i-radius);border:1px solid var(--wkt-i-rand);background:var(--wkt-i-bg);color:inherit;cursor:pointer}', + '.wkt-pfeil[disabled]{opacity:.35;cursor:default}', + '.wkt-raster{width:100%;border-collapse:separate;border-spacing:3px;table-layout:fixed}', + '.wkt-raster th{font-size:.8rem;font-weight:600;padding:.25rem 0;text-align:center;opacity:.85}', + '.wkt-tag,.wkt-leer{height:44px;padding:0;text-align:center;vertical-align:middle;border-radius:calc(var(--wkt-i-radius) * .7)}', + '.wkt-tag{border:1px solid transparent;opacity:.5;cursor:default}', + '.wkt-tag[aria-disabled="false"]{border-color:var(--wkt-i-akzent);font-weight:700;opacity:1;cursor:pointer}', + '.wkt-tag[aria-disabled="false"]:hover{background:var(--wkt-i-weich)}', + '.wkt-tag[aria-disabled="false"][aria-selected="true"],.wkt-tag[aria-disabled="false"][aria-selected="true"]:hover{background:var(--wkt-i-akzent);color:var(--wkt-i-akzent-text);border-color:var(--wkt-i-akzent);opacity:1}', + '.wkt-zeiten{margin-top:1rem}', + '.wkt-zeiten-titel{font-size:1rem;margin:0 0 .5rem;font-weight:700}', + '.wkt-zeiten-liste{display:grid;grid-template-columns:repeat(auto-fill,minmax(5.5rem,1fr));gap:.5rem}', + '.wkt-zeit{font:inherit;font-weight:600;min-height:44px;border-radius:var(--wkt-i-radius);border:1px solid var(--wkt-i-akzent);background:var(--wkt-i-bg);color:inherit;cursor:pointer}', + '.wkt-zeit:hover{background:var(--wkt-i-akzent);color:var(--wkt-i-akzent-text)}', + '.wkt-form{margin:0}', + '.wkt-pflichthinweis{font-size:.875rem;opacity:.85}', + '.wkt-feld{margin:0 0 1rem}', + '.wkt-label{display:block;font-weight:600;margin-bottom:.25rem}', + '.wkt-stern{color:var(--wkt-i-fehler)}', + '.wkt-hinweis{font-size:.875rem;margin:0 0 .35rem;opacity:.9}', + '.wkt-eingabe{display:block;width:100%;min-height:44px;font:inherit;color:inherit;background:var(--wkt-i-bg);border:1px solid var(--wkt-i-rand);border-radius:calc(var(--wkt-i-radius) * .7);padding:.5rem .7rem}', + 'textarea.wkt-eingabe{min-height:6rem;resize:vertical}', + '.wkt-eingabe[aria-invalid="true"]{border-color:var(--wkt-i-fehler);border-width:2px}', + '.wkt-feldfehler,.wkt-fehlertext{color:var(--wkt-i-fehler);font-weight:600;font-size:.9rem;margin:.3rem 0 0}', + '.wkt-fehlertext{margin:0 0 .75rem}', + '.wkt-meldung{margin:0 0 .75rem;padding:.6rem .8rem;border-left:4px solid var(--wkt-i-fehler);background:var(--wkt-i-weich);font-weight:600}', + '.wkt-haken{display:flex;gap:.65rem;align-items:flex-start;margin:0 0 .75rem}', + '.wkt-haken input{flex:none;width:1.5rem;height:1.5rem;margin:.1rem 0 0;accent-color:var(--wkt-i-akzent)}', + '.wkt-haken>div{flex:1;min-width:0}.wkt-haken label{cursor:pointer}', + '.wkt-zusammen{margin:0 0 1rem;display:grid;grid-template-columns:max-content 1fr;gap:.35rem 1rem}', + '.wkt-zusammen dt{font-weight:600}.wkt-zusammen dd{margin:0;overflow-wrap:anywhere}', + '.wkt-box{margin:0 0 1rem;padding:.75rem 1rem;border-radius:var(--wkt-i-radius);background:var(--wkt-i-weich)}', + '.wkt-box h3{font-size:1rem;margin:0 0 .25rem}', + '.wkt-box p{white-space:pre-line}', + '.wkt-belehrung{margin:0 0 1rem;border:1px solid var(--wkt-i-rand);border-radius:var(--wkt-i-radius);padding:.5rem 1rem}', + '.wkt-belehrung summary{cursor:pointer;font-weight:600;min-height:24px;padding:.2rem 0}', + '.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-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}', + '.wkt-link{font:inherit;color:inherit;background:none;border:0;padding:.2rem 0;min-height:24px;line-height:inherit;text-decoration:underline;cursor:pointer}', + '.wkt-fuss a{display:inline-block;padding:.2rem 0;min-height:24px}', + '.wkt-powered{margin:0;opacity:.75;font-size:.8rem}', + '.wkt-ergebnis-text{font-size:1.05rem}', + '.wkt-knopf-box{display:block}', + 'dialog.wkt-dialog{position:fixed;inset:auto;left:50%;top:50%;transform:translate(-50%,-50%);margin:0;padding:0;border:0;overflow:auto;width:min(36rem,calc(100vw - 1.5rem));max-height:calc(100vh - 1.5rem);max-height:calc(100dvh - 1.5rem);border-radius:var(--wkt-radius,10px);background:var(--wkt-hintergrund,#fff);color:var(--wkt-text,#1f2937);box-shadow:0 20px 50px rgba(0,0,0,.35)}', + 'dialog.wkt-dialog::backdrop{background:rgba(15,23,42,.55)}', + '.wkt-dialog .wkt-root{border:0;border-radius:0}', + '.wkt-schliessen{position:absolute;top:.5rem;right:.5rem;z-index:1;font:inherit;font-size:1.5rem;line-height:1;min-width:44px;min-height:44px;border-radius:var(--wkt-i-radius);border:1px solid var(--wkt-i-rand);background:var(--wkt-i-bg);color:inherit;cursor:pointer}', + '.wkt-dialog .wkt-pfad,.wkt-dialog .wkt-titel{padding-right:3rem}', + '@container wkt (max-width:30rem){', + '.wkt-nav{flex-direction:column-reverse;align-items:stretch}.wkt-nav .wkt-btn{width:100%}', + '.wkt-raster{border-spacing:2px}.wkt-tag,.wkt-leer{height:40px}', + '.wkt-zeiten-liste{grid-template-columns:repeat(3,minmax(0,1fr))}', + '.wkt-zusammen{grid-template-columns:1fr;gap:0}.wkt-zusammen dd{margin-bottom:.5rem}', + '}', + '@media (max-width:30rem){.wkt-root{padding:16px}}', + '@media (forced-colors:active){.wkt-tag[aria-selected="true"]{outline:2px solid Highlight}}', + '}' + ].join('\n') + + function injectStyle () { + if (document.getElementById(STYLE_ID)) return + var st = document.createElement('style') + st.id = STYLE_ID + st.textContent = CSS + document.head.appendChild(st) + } + + function el (tag, cls, text) { + var e = document.createElement(tag) + if (cls) e.className = cls + if (text != null) e.textContent = text + return e + } + + function knopf (cls, text, onClick) { + var b = el('button', cls, text) + b.type = 'button' + if (onClick) b.addEventListener('click', onClick) + return b + } + + function pad2 (n) { return n < 10 ? '0' + n : String(n) } + + // 'YYYY-MM' -> laufende Monatsnummer (reine Ganzzahlrechnung auf der Zeichenkette des Servers) + function monatsNummer (monat) { return parseInt(monat.slice(0, 4), 10) * 12 + parseInt(monat.slice(5, 7), 10) - 1 } + + function dauerText (min) { + if (!(min > 0)) return '' + var h = Math.floor(min / 60) + var m = min % 60 + if (h === 0) return m + ' Minuten' + var s = h + (h === 1 ? ' Stunde' : ' Stunden') + return m ? s + ' ' + m + ' Minuten' : s + } + + function httpsUrl (u) { + if (typeof u !== 'string') return '' + try { + var p = new URL(u) + return p.protocol === 'https:' ? p.href : '' + } catch (e) { return '' } + } + + function istText (v) { return typeof v === 'string' } + + function apiBasis (root) { + var a = String(root.getAttribute('data-api') || '').replace(/\/+$/, '') + if (a) return a + var s = SELF && SELF.src ? SELF : document.querySelector('script[src*="/embed/termine.js"]') + if (s && s.src) { + try { return new URL(s.src).origin } catch (e) { return '' } + } + 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 + 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] + box.style.setProperty('--wkt-vorgabe-akzent', farbe) + box.style.setProperty('--wkt-vorgabe-akzent-text', l > 0.4 ? '#111827' : '#ffffff') + } + + function init (root) { + var key = root.getAttribute('data-webklar-termine') + if (!key || root.getAttribute('data-wkt-init')) return + root.setAttribute('data-wkt-init', '1') + var api = apiBasis(root) + var modus = root.getAttribute('data-modus') === 'knopf' ? 'knopf' : 'inline' + var vorgabeLeistung = root.getAttribute('data-leistung') || '' + var knopfText = root.getAttribute('data-knopf') || 'Termin buchen' + var id = PREFIX + (++zaehler) + '-' + var ausweich = null // Ausweichtext der Seite (Kindknoten des Containers), solange das Widget ihn ersetzt + var dlg = null + var opener = null + + var S = null // Zustand, siehe neuerZustand() + var ui = { box: null, live: null, body: null, fuss: null, titel: null } + + function neuerZustand () { + return { + cfg: S ? S.cfg : null, + cfgAt: S ? S.cfgAt : 0, + step: '', + ansicht: 'buchung', + vorLeistung: '', + leistungId: '', + ressource: '', + monat: '', + tag: '', + zeit: '', + datumLang: '', + vorzeitig: false, + daten: { name: '', email: '', telefon: '', nachricht: '', zusatz: {}, erinnerung: false, storno: false, vorzeitig: false }, + honig: '', + fehler: {}, + meldung: '', + ergebnis: null, + widerrufErgebnis: null, + widerrufDaten: null, + sendet: false, + tot: false, + fokusGesetzt: false, + seq: 0, + zeitSeq: 0, + tageCache: {} + } + } + S = neuerZustand() + + function fid (name) { return id + String(name).replace(/[^a-z0-9_-]/gi, '-') } + function T (sie, du) { return S.cfg && S.cfg.anrede === 'du' ? du : sie } + + // ------------------------------------------------------------ Anfragen + + function request (method, pfad, body, frisch) { + var opts = { method: method, credentials: 'omit', headers: { Accept: 'application/json' } } + if (frisch) opts.cache = 'reload' // Konfiguration erneuern: nicht aus dem Browser-Cache (dort steht das alte Formular-Token) + if (body !== undefined) { + opts.headers['Content-Type'] = 'application/json' + opts.body = JSON.stringify(body) + } + var ctrl = typeof AbortController === 'function' ? new AbortController() : null + var timer = null + if (ctrl) { + opts.signal = ctrl.signal + timer = setTimeout(function () { ctrl.abort() }, TIMEOUT_MS) + } + function fertig () { if (timer) clearTimeout(timer) } + return fetch(api + API_PFAD + encodeURIComponent(key) + pfad, opts).then(function (r) { + return r.json().then(function (d) { return { status: r.status, ok: r.ok, body: d } }, function () { return { status: r.status, ok: r.ok, body: null } }) + }, function () { + throw { netz: true } + }).then(function (r) { + fertig() + if (r.ok && r.body !== null && typeof r.body === 'object') return r.body + var fehler = {} + var b = r.body + if (b && typeof b.fehler === 'object' && b.fehler) { + Object.keys(b.fehler).forEach(function (k) { if (istText(b.fehler[k])) fehler[k] = b.fehler[k] }) + } + throw { status: r.status, fehler: fehler, code: b && istText(b.code) ? b.code : '', netz: r.status >= 500 || r.status === 403 || r.status === 404 || r.status === 0 } + }, function (err) { + fertig() + throw err + }) + } + + // ------------------------------------------------------------ Konfiguration + + 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 } + S.cfg = d + S.cfgAt = performance.now() + return d + }) + } + + 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) + 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) + return false + } + return true + }, function (err) { + if (err && err.inaktiv) { zeigeAusweich(); return false } + return true // nicht erreichbar: weiter mit der alten Konfiguration, der naechste Aufruf meldet es + }) + } + + function leistungMitId (lid) { + var l = S.cfg ? S.cfg.leistungen : [] + for (var i = 0; i < l.length; i++) if (l[i].id === lid) return l[i] + return null + } + function ressourceMitId (rid) { + var r = S.cfg.ressourcen + for (var i = 0; i < r.length; i++) if (r[i].id === rid) return r[i] + return null + } + function aktuelleLeistung () { return leistungMitId(S.leistungId) } + function kandidaten (l) { + return (l.ressourcen || []).map(ressourceMitId).filter(Boolean) + } + // Person waehlen: mehr als eine Person infrage, vom Betrieb erlaubt, und nicht nur Plaetze + function personSchritt (l) { + var k = kandidaten(l) + return !!S.cfg.personWaehlbar && k.length > 1 && k.some(function (r) { return r.typ !== 'platz' }) + } + function standardRessource (l) { + var k = kandidaten(l) + return k.length === 1 ? k[0].id : 'egal' + } + + // ------------------------------------------------------------ Geruest + + function bauGeruest (box) { + box.classList.add('wkt-root') + var live = el('div', 'wkt-vh') + live.setAttribute('aria-live', 'polite') + live.setAttribute('aria-atomic', 'true') + var body = el('div', 'wkt-body') + var fuss = el('div', 'wkt-fuss') + box.appendChild(live) + box.appendChild(body) + box.appendChild(fuss) + ui.box = box + ui.live = live + ui.body = body + ui.fuss = fuss + } + + function ansage (text) { + if (ui.live) ui.live.textContent = text + } + + function add (node) { ui.body.appendChild(node); return node } + + function titel (text) { + var h = el('h2', 'wkt-titel', text) + h.tabIndex = -1 + h.id = fid('titel') + ui.titel = h + return h + } + + function schrittListe () { + var l = aktuelleLeistung() + var s = [] + if (!S.vorLeistung) s.push('leistung') + var person = l ? personSchritt(l) : S.cfg.leistungen.some(personSchritt) + if (person) s.push('person') + s.push('termin', 'daten', 'pruefen') + return s + } + + function pfad () { + var s = schrittListe() + var i = s.indexOf(S.step) + if (i < 0) return null + return el('p', 'wkt-pfad', T('Schritt ', 'Schritt ') + (i + 1) + T(' von ', ' von ') + s.length) + } + + function kopf (text) { + var p = pfad() + if (p) add(p) + add(titel(text)) + if (S.meldung) { + add(el('p', 'wkt-meldung', S.meldung)) + ansage(S.meldung) + } + } + + function zeichne (fokus) { + S.seq++ + S.fokusGesetzt = false + ui.body.textContent = '' + ui.titel = null + var f = { + leistung: zeichneLeistung, person: zeichnePerson, termin: zeichneTermin, daten: zeichneDaten, + pruefen: zeichnePruefen, ergebnis: zeichneErgebnis + } + if (S.ansicht === 'widerruf') zeichneWiderruf() + else f[S.step]() + zeichneFuss() + if (fokus && ui.titel && !S.fokusGesetzt) ui.titel.focus() + } + + function gehe (step, opt) { + opt = opt || {} + S.step = step + S.ansicht = 'buchung' + S.fehler = opt.fehler || {} + S.meldung = opt.meldung || '' + zeichne(opt.fokus !== false) + } + + function zeichneFuss () { + var c = S.cfg + ui.fuss.textContent = '' + var links = el('p', 'wkt-fuss-links') + if (c.widerruf === true) { + links.appendChild(knopf('wkt-link', 'Vertrag widerrufen', function () { + S.ansicht = 'widerruf' + S.widerrufErgebnis = null + S.fehler = {} + S.meldung = '' + zeichne(true) + })) + } + var ds = httpsUrl(c.betrieb.datenschutzUrl) + if (ds) { + var a = el('a', '', 'Datenschutz') + a.href = ds + a.target = '_blank' + a.rel = 'noopener noreferrer' + links.appendChild(a) + } + if (links.childNodes.length) ui.fuss.appendChild(links) + ui.fuss.appendChild(el('p', 'wkt-powered', 'Terminbuchung über WEBklar')) + } + + // Netz weg oder Server kaputt mitten in der Strecke + function ausfall (wiederhole) { + 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: ')) + p.tabIndex = -1 + if (tel) { + var a = el('a', '', tel) + a.href = 'tel:' + tel.replace(/[^0-9+]/g, '') + p.appendChild(a) + } + add(p) + add(knopf('wkt-btn wkt-btn-sek', T('Erneut versuchen', 'Erneut versuchen'), function () { wiederhole() })) + ansage(p.textContent) + p.focus() + } + + // Funktion nicht aktiv oder Konfiguration unbrauchbar: der Ausweichtext der Seite bleibt (inline) bzw. erscheint (Knopf) + function zeigeAusweich () { + S.tot = true + if (modus === 'inline') { + root.classList.remove('wkt-root') + root.textContent = '' + if (ausweich) root.appendChild(ausweich) + return + } + if (!ui.body) return + ui.body.textContent = '' + ui.fuss.textContent = '' + ui.titel = null + if (ausweich) add(ausweich.cloneNode(true)) + else add(el('p', '', 'Gerade nicht erreichbar.')) + } + + // ------------------------------------------------------------ Schritte + + function starte (fokus, meldung) { + var cfg = S.cfg + var at = S.cfgAt + S = neuerZustand() + S.cfg = cfg + S.cfgAt = at + S.vorLeistung = vorgabeLeistung && leistungMitId(vorgabeLeistung) ? vorgabeLeistung : '' + setzeBetriebsfarbe(ui.box, cfg.farbe) + if (S.vorLeistung) waehleLeistung(S.vorLeistung, { fokus: fokus, meldung: meldung }) + else gehe('leistung', { fokus: fokus, meldung: meldung }) + } + + function waehleLeistung (lid, opt) { + var l = leistungMitId(lid) + if (S.leistungId !== lid) { + S.ressource = '' + S.monat = '' + S.tag = '' + S.zeit = '' + } + S.leistungId = lid + opt = opt || {} + if (personSchritt(l)) { + if (!S.ressource) S.ressource = 'egal' + gehe('person', opt) + } else { + S.ressource = standardRessource(l) + gehe('termin', opt) + } + } + + function zeichneLeistung () { + var c = S.cfg + kopf(T('Welche Leistung möchten Sie buchen?', 'Welche Leistung möchtest du buchen?')) + var ul = el('ul', 'wkt-liste') + c.leistungen.forEach(function (l) { + var li = el('li') + var b = el('button', 'wkt-karte') + b.type = 'button' + b.appendChild(el('span', 'wkt-karte-name', l.name)) + var meta = [dauerText(l.dauer), l.preis].filter(Boolean).join(' · ') + if (meta) b.appendChild(el('span', 'wkt-karte-meta', meta)) + if (l.beschreibung) b.appendChild(el('span', 'wkt-karte-text', l.beschreibung)) + b.addEventListener('click', function () { waehleLeistung(l.id) }) + li.appendChild(b) + ul.appendChild(li) + }) + add(ul) + if (c.zahlung && istText(c.zahlung.text) && c.zahlung.text) add(el('p', 'wkt-zahlung', c.zahlung.text)) + } + + function zeichnePerson () { + var l = aktuelleLeistung() + kopf(T('Bei wem möchten Sie den Termin?', 'Bei wem möchtest du den Termin?')) + 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: '' }) }) + optionen.forEach(function (o) { + var li = el('li') + var b = el('button', 'wkt-karte') + b.type = 'button' + b.appendChild(el('span', 'wkt-karte-name', o.name)) + if (o.text) b.appendChild(el('span', 'wkt-karte-text', o.text)) + b.addEventListener('click', function () { + if (S.ressource !== o.id) { S.monat = ''; S.tag = ''; S.zeit = '' } + S.ressource = o.id + gehe('termin') + }) + li.appendChild(b) + ul.appendChild(li) + }) + add(ul) + zurueckZeile(S.vorLeistung ? null : 'leistung') + } + + function zurueckZeile (zielSchritt, weiter) { + 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 + } + + function vorigerSchritt (von) { + var s = schrittListe() + var i = s.indexOf(von) + return i > 0 ? s[i - 1] : null + } + + // ---- Termin: Monatsraster und Uhrzeiten + + function zeichneTermin () { + 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)) + 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) + var startMonat = c.heute.slice(0, 7) + var minNr = monatsNummer(startMonat) + var maxNr = minNr + Math.max(1, Math.ceil((c.regeln.horizontTage || 60) / 28)) + + var kal = add(el('div', 'wkt-kal')) + var kopfZeile = el('div', 'wkt-kal-kopf') + var zurueck = knopf('wkt-pfeil', '‹') + zurueck.setAttribute('aria-label', 'Vorheriger Monat') + var monLabel = el('h3', 'wkt-monat') + monLabel.id = fid('monat') + var vor = knopf('wkt-pfeil', '›') + vor.setAttribute('aria-label', 'Nächster Monat') + kopfZeile.appendChild(zurueck) + kopfZeile.appendChild(monLabel) + kopfZeile.appendChild(vor) + kal.appendChild(kopfZeile) + + var tabelle = el('table', 'wkt-raster') + tabelle.setAttribute('role', 'grid') + tabelle.setAttribute('aria-labelledby', monLabel.id) + var thead = el('thead') + var kopfTr = el('tr') + kopfTr.setAttribute('role', 'row') + WOCHE.forEach(function (w) { + var th = el('th', '', w[0]) + th.setAttribute('role', 'columnheader') + th.scope = 'col' + th.abbr = w[1] + th.setAttribute('aria-label', w[1]) + kopfTr.appendChild(th) + }) + thead.appendChild(kopfTr) + var tbody = el('tbody') + tabelle.appendChild(thead) + tabelle.appendChild(tbody) + kal.appendChild(tabelle) + + var zeitenBox = add(el('div', 'wkt-zeiten')) + zurueckZeile(vorigerSchritt('termin')) + + var aktuell = null // { monat, info, frei } + + function holeTage (monat) { + if (S.tageCache[monat]) return Promise.resolve(S.tageCache[monat]) + var q = '/tage?leistung=' + encodeURIComponent(S.leistungId) + '&ressource=' + encodeURIComponent(S.ressource) + '&monat=' + encodeURIComponent(monat) + return request('GET', q).then(function (d) { + var i = d && d.monat + var ok = d && Array.isArray(d.tage) && i && istText(i.label) && i.ersterWochentag >= 1 && i.ersterWochentag <= 7 && + i.anzahlTage >= 28 && i.anzahlTage <= 31 && MONATSZEILE.test(i.vorher || '') && MONATSZEILE.test(i.nachher || '') + if (!ok) throw { netz: true } + var frei = {} + d.tage.forEach(function (t) { if (istText(t)) frei[t] = true }) + S.tageCache[monat] = { monat: monat, info: i, frei: frei, anzahl: d.tage.length } + return S.tageCache[monat] + }) + } + + function zelle (tag) { return tabelle.querySelector('td[data-tag="' + tag + '"]') } + + function setzeFokusZelle (tag, fokussieren) { + var alt = tabelle.querySelector('td[tabindex="0"]') + if (alt) alt.tabIndex = -1 + var z = zelle(tag) + if (!z) return + z.tabIndex = 0 + if (fokussieren) z.focus() + } + + function baueRaster (m) { + aktuell = m + S.monat = m.monat + monLabel.textContent = m.info.label + var nr = monatsNummer(m.monat) + zurueck.disabled = nr <= minNr + vor.disabled = nr >= maxNr + tbody.textContent = '' + var tr = null + var spalte = 0 + function neueZeile () { + tr = el('tr') + tr.setAttribute('role', 'row') + tbody.appendChild(tr) + spalte = 0 + } + neueZeile() + for (var i = 1; i < m.info.ersterWochentag; i++) { + var leer = el('td', 'wkt-leer') + leer.setAttribute('role', 'gridcell') + leer.setAttribute('aria-hidden', 'true') + tr.appendChild(leer) + spalte++ + } + var erstFrei = '' + for (var d = 1; d <= m.info.anzahlTage; d++) { + if (spalte === 7) neueZeile() + var tag = m.monat + '-' + pad2(d) + var frei = !!m.frei[tag] + if (frei && !erstFrei) erstFrei = tag + var td = el('td', 'wkt-tag', String(d)) + td.setAttribute('role', 'gridcell') + td.setAttribute('data-tag', tag) + td.tabIndex = -1 + td.setAttribute('aria-selected', S.tag === tag ? 'true' : 'false') + td.setAttribute('aria-disabled', frei ? 'false' : 'true') + td.setAttribute('aria-label', d + '. ' + m.info.label + (frei ? '' : ', keine freien Zeiten')) + tr.appendChild(td) + spalte++ + } + setzeFokusZelle(S.tag && S.tag.slice(0, 7) === m.monat ? S.tag : (erstFrei || m.monat + '-01'), false) + } + + function zeigeMonat (monat, opt) { + opt = opt || {} + var seq = S.seq + return holeTage(monat).then(function (m) { + if (seq !== S.seq) return null + if (opt.auto && m.anzahl === 0 && opt.versuch < 3 && monatsNummer(m.info.nachher) <= maxNr) { + return zeigeMonat(m.info.nachher, { auto: true, versuch: opt.versuch + 1 }) + } + baueRaster(m) + if (m.anzahl === 0) ansage(T('In diesem Monat gibt es keine freien Termine.', 'In diesem Monat gibt es keine freien Termine.')) + else ansage(m.info.label) + return m + }, function (err) { + if (seq !== S.seq) return null + behandleFehler(err, function () { zeichne(true) }) + return null + }) + } + + function wechsleMonat (monat, tagNr, 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) + ansage(m.info.label) + }, function (err) { + if (seq === S.seq) behandleFehler(err, function () { zeichne(true) }) + }) + } + + zurueck.addEventListener('click', function () { if (aktuell) wechsleMonat(aktuell.info.vorher, 1, false) }) + vor.addEventListener('click', function () { if (aktuell) wechsleMonat(aktuell.info.nachher, 1, false) }) + + function waehleTag (tag) { + 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) + holeZeiten(tag) + } + + function holeZeiten (tag) { + var nr = ++S.zeitSeq + zeitenBox.textContent = '' + zeitenBox.appendChild(el('p', 'wkt-laden', T('Zeiten werden geladen …', 'Zeiten werden geladen …'))) + var q = '/zeiten?leistung=' + encodeURIComponent(S.leistungId) + '&ressource=' + encodeURIComponent(S.ressource) + '&tag=' + encodeURIComponent(tag) + request('GET', q).then(function (d) { + var ok = d && Array.isArray(d.zeiten) && istText(d.datumLang) + if (!ok) throw { netz: true } + if (nr !== S.zeitSeq) return + zeitenBox.textContent = '' + var h = el('h3', 'wkt-zeiten-titel', T('Freie Zeiten am ', 'Freie Zeiten am ') + d.datumLang) + h.id = fid('zeiten') + zeitenBox.appendChild(h) + if (d.zeiten.length === 0) { + zeitenBox.appendChild(el('p', '', T('An diesem Tag ist nichts mehr frei. Bitte wählen Sie einen anderen Tag.', 'An diesem Tag ist nichts mehr frei. Bitte wähle einen anderen Tag.'))) + ansage(T('Keine freien Zeiten am ', 'Keine freien Zeiten am ') + d.datumLang) + return + } + var liste = el('div', 'wkt-zeiten-liste') + liste.setAttribute('role', 'group') + liste.setAttribute('aria-labelledby', h.id) + d.zeiten.forEach(function (z) { + if (!istText(z)) return + var b = knopf('wkt-zeit', z, function () { + S.zeit = z + S.datumLang = d.datumLang + S.vorzeitig = d.vorzeitigNoetig === true + sichereConfig().then(function (weiter) { if (weiter) gehe('daten') }) + }) + b.setAttribute('aria-label', z + ' Uhr, ' + d.datumLang) + liste.appendChild(b) + }) + 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) }) + }) + } + + function tastatur (e) { + var td = e.target && e.target.closest ? e.target.closest('td[data-tag]') : null + if (!td || !aktuell) return + var info = aktuell.info + var tag = td.getAttribute('data-tag') + var d = parseInt(tag.slice(8), 10) + var woche = (info.ersterWochentag - 1 + d - 1) % 7 // 0 = Montag + var ziel = null + switch (e.key) { + case 'ArrowLeft': ziel = d - 1; break + case 'ArrowRight': ziel = d + 1; break + case 'ArrowUp': ziel = d - 7; break + case 'ArrowDown': ziel = d + 7; break + case 'Home': ziel = Math.max(1, d - woche); break + case 'End': ziel = Math.min(info.anzahlTage, d + (6 - woche)); break + case 'PageUp': + e.preventDefault() + wechsleMonat(info.vorher, d, true) + return + case 'PageDown': + e.preventDefault() + wechsleMonat(info.nachher, d, true) + return + case 'Enter': + case ' ': + e.preventDefault() + if (td.getAttribute('aria-disabled') !== 'true') waehleTag(tag) + return + default: return + } + e.preventDefault() + 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) }) }) + } else { + var nm = info.nachher + if (monatsNummer(nm) > maxNr) return + wechsleMonat(nm, ziel - info.anzahlTage, true) + } + } + + tabelle.addEventListener('keydown', tastatur) + tabelle.addEventListener('click', function (e) { + var td = e.target && e.target.closest ? e.target.closest('td[data-tag]') : null + if (!td) return + if (td.getAttribute('aria-disabled') === 'true') { setzeFokusZelle(td.getAttribute('data-tag'), true); return } + waehleTag(td.getAttribute('data-tag')) + }) + + zeigeMonat(S.monat || startMonat, { auto: !S.monat, versuch: 0 }).then(function (m) { + if (m && S.tag && S.tag.slice(0, 7) === m.monat) holeZeiten(S.tag) + }) + } + + // ---- Formularfelder + + function honigfeld () { + var wrap = el('div', 'wkt-hp') + wrap.setAttribute('aria-hidden', 'true') + wrap.style.cssText = 'position:absolute;left:-10000px;top:auto;width:1px;height:1px;overflow:hidden' + var lab = el('label', '', 'Bitte leer lassen') + var inp = document.createElement('input') + inp.type = 'text' + inp.name = 'kontakt_url' + inp.value = S.honig + inp.setAttribute('autocomplete', 'off') + inp.setAttribute('tabindex', '-1') + inp.addEventListener('input', function () { S.honig = inp.value }) + lab.appendChild(inp) + wrap.appendChild(lab) + return wrap + } + + // opt: { name, label, typ (text|email|tel|textarea|auswahl), wert, pflicht, hinweis, autocomplete, maxlength, optionen, onInput }; refs sammelt { name: { input, fehler } } + function feld (refs, opt) { + var wrap = el('div', 'wkt-feld') + var fieldId = fid('f-' + opt.name) + var lab = el('label', 'wkt-label', opt.label) + lab.htmlFor = fieldId + if (opt.pflicht) { + var st = el('span', 'wkt-stern', ' *') + st.setAttribute('aria-hidden', 'true') + lab.appendChild(st) + } else { + lab.appendChild(document.createTextNode(T(' (freiwillig)', ' (freiwillig)'))) + } + wrap.appendChild(lab) + var beschreibung = [] + if (opt.hinweis) { + var h = el('p', 'wkt-hinweis', opt.hinweis) + h.id = fieldId + '-h' + beschreibung.push(h.id) + wrap.appendChild(h) + } + var inp + if (opt.typ === 'textarea') { + inp = document.createElement('textarea') + inp.rows = 4 + } else if (opt.typ === 'auswahl') { + inp = document.createElement('select') + var leer = document.createElement('option') + leer.value = '' + leer.textContent = T('Bitte wählen', 'Bitte wähle') + inp.appendChild(leer) + ;(opt.optionen || []).forEach(function (o) { + var op = document.createElement('option') + op.value = o + op.textContent = o + inp.appendChild(op) + }) + } else { + inp = document.createElement('input') + inp.type = opt.typ || 'text' + } + inp.id = fieldId + inp.name = opt.name + inp.className = 'wkt-eingabe' + inp.value = opt.wert || '' + if (opt.pflicht) { inp.required = true } + if (opt.autocomplete) inp.setAttribute('autocomplete', opt.autocomplete) + if (opt.maxlength) inp.maxLength = opt.maxlength + if (opt.typ === 'tel') inp.setAttribute('inputmode', 'tel') + var fehler = el('p', 'wkt-feldfehler') + fehler.id = fieldId + '-e' + fehler.hidden = true + inp.addEventListener('input', function () { opt.onInput(inp.value) }) + inp.addEventListener('change', function () { opt.onInput(inp.value) }) + wrap.appendChild(inp) + wrap.appendChild(fehler) + refs[opt.name] = { input: inp, fehler: fehler, beschreibung: beschreibung } + return wrap + } + + function haken (refs, name, text, wert, pflicht, onChange) { + var wrap = el('div', 'wkt-haken') + var cb = document.createElement('input') + cb.type = 'checkbox' + cb.id = fid('h-' + name) + cb.name = name + cb.checked = !!wert + if (pflicht) cb.required = true + var lab = el('label', '', text) + lab.htmlFor = cb.id + var fehler = el('p', 'wkt-feldfehler') + fehler.id = cb.id + '-e' + fehler.hidden = true + cb.addEventListener('change', function () { onChange(cb.checked) }) + wrap.appendChild(cb) + var spalte = el('div') + spalte.appendChild(lab) + spalte.appendChild(fehler) + wrap.appendChild(spalte) + refs[name] = { input: cb, fehler: fehler, beschreibung: [] } + return wrap + } + + // Fehlertexte an die Felder schreiben; liefert die Meldungen ohne Feld + function zeigeFeldfehler (refs, fehler) { + var uebrig = [] + var erster = null + Object.keys(refs).forEach(function (k) { + var r = refs[k] + var text = fehler[k] + if (text) { + r.fehler.textContent = text + r.fehler.hidden = false + r.input.setAttribute('aria-invalid', 'true') + r.input.setAttribute('aria-describedby', r.beschreibung.concat([r.fehler.id]).join(' ')) + if (!erster) erster = r.input + } else { + r.fehler.textContent = '' + r.fehler.hidden = true + r.input.removeAttribute('aria-invalid') + if (r.beschreibung.length) r.input.setAttribute('aria-describedby', r.beschreibung.join(' ')) + else r.input.removeAttribute('aria-describedby') + } + }) + Object.keys(fehler).forEach(function (k) { if (!refs[k]) uebrig.push(fehler[k]) }) + if (erster) { + S.fokusGesetzt = true + erster.focus() + ansage(T('Bitte prüfen Sie Ihre Eingaben: ', 'Bitte prüfe deine Eingaben: ') + Object.keys(fehler).map(function (k) { return fehler[k] }).join(' ')) + } + return uebrig + } + + // ---- Daten + + function zeichneDaten () { + var g = S.cfg.gastFelder + var dd = S.daten + kopf(T('Ihre Angaben', 'Deine Angaben')) + var form = el('form', 'wkt-form') + form.noValidate = true + var refs = {} + form.appendChild(el('p', 'wkt-pflichthinweis', T('Mit * markierte Felder sind Pflichtfelder.', 'Mit * markierte Felder sind Pflichtfelder.'))) + form.appendChild(feld(refs, { name: 'name', label: T('Ihr Name', 'Dein Name'), typ: 'text', wert: dd.name, pflicht: true, autocomplete: 'name', maxlength: 100, onInput: function (v) { dd.name = v } })) + form.appendChild(feld(refs, { name: 'email', label: T('Ihre E-Mail-Adresse', 'Deine E-Mail-Adresse'), typ: 'email', wert: dd.email, pflicht: true, autocomplete: 'email', maxlength: 254, onInput: function (v) { dd.email = v } })) + if (g.telefon && g.telefon !== 'aus') { + form.appendChild(feld(refs, { name: 'telefon', label: T('Telefon', 'Telefon'), typ: 'tel', wert: dd.telefon, pflicht: g.telefon === 'pflicht', autocomplete: 'tel', maxlength: 40, onInput: function (v) { dd.telefon = v } })) + } + ;(Array.isArray(g.zusatz) ? g.zusatz : []).forEach(function (z) { + if (!z || !istText(z.key) || !istText(z.label)) return + form.appendChild(feld(refs, { + name: 'zusatz.' + z.key, label: z.label, typ: z.typ === 'auswahl' ? 'auswahl' : 'text', optionen: Array.isArray(z.optionen) ? z.optionen.filter(istText) : [], + wert: dd.zusatz[z.key], pflicht: z.pflicht === true, maxlength: 200, onInput: function (v) { dd.zusatz[z.key] = v } + })) + }) + if (g.nachricht && g.nachricht !== 'aus') { + form.appendChild(feld(refs, { + name: 'nachricht', label: T('Nachricht', 'Nachricht'), typ: 'textarea', wert: dd.nachricht, pflicht: g.nachricht === 'pflicht', maxlength: 500, + hinweis: istText(S.cfg.texte.gesundheit) ? S.cfg.texte.gesundheit : '', onInput: function (v) { dd.nachricht = v } + })) + } + form.appendChild(honigfeld()) + var weiter = el('button', 'wkt-btn', T('Weiter', 'Weiter')) + weiter.type = 'submit' + var nav = el('div', 'wkt-nav') + nav.appendChild(knopf('wkt-btn wkt-btn-sek', T('Zurück', 'Zurück'), function () { gehe('termin') })) + nav.appendChild(weiter) + form.appendChild(nav) + var fehlerBox = el('div', 'wkt-fehlerbox') + 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) + + 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)) }) + return + } + S.fehler = {} + sichereConfig().then(function (weiterGehen) { if (weiterGehen) gehe('pruefen') }) + }) + } + + function pruefeDaten (g) { + var dd = S.daten + var f = {} + var name = dd.name.trim() + if (!name) f.name = T('Bitte geben Sie Ihren Namen an.', 'Bitte gib deinen Namen an.') + var mail = dd.email.trim() + if (!mail || mail.length > 254 || !EMAIL_FORM.test(mail)) f.email = T('Bitte geben Sie eine gültige E-Mail-Adresse an (genau ein Postfach).', 'Bitte gib eine gültige E-Mail-Adresse an (genau ein Postfach).') + if (g.telefon && g.telefon !== 'aus') { + var tel = dd.telefon.trim() + if (!tel) { + if (g.telefon === 'pflicht') f.telefon = T('Bitte geben Sie eine Telefonnummer an.', 'Bitte gib eine Telefonnummer an.') + } else if (!TELEFON_ZEICHEN.test(tel) || (tel.match(/\d/g) || []).length < 3) { + f.telefon = T('Bitte geben Sie eine gültige Telefonnummer an (Ziffern, Leerzeichen, + - und Klammern).', 'Bitte gib eine gültige Telefonnummer an (Ziffern, Leerzeichen, + - und Klammern).') + } + } + if (g.nachricht === 'pflicht' && !dd.nachricht.trim()) f.nachricht = T('Bitte geben Sie eine Nachricht an.', 'Bitte gib eine Nachricht an.') + ;(Array.isArray(g.zusatz) ? g.zusatz : []).forEach(function (z) { + if (z && z.pflicht === true && !String(dd.zusatz[z.key] || '').trim()) { + f['zusatz.' + z.key] = z.typ === 'auswahl' ? T('Bitte wählen Sie eine Möglichkeit.', 'Bitte wähle eine Möglichkeit.') : T('Bitte füllen Sie dieses Feld aus.', 'Bitte fülle dieses Feld aus.') + } + }) + return f + } + + // ---- Zusammenfassung und Absenden + + function zeile (dl, label, wert) { + if (!wert) return + dl.appendChild(el('dt', '', label)) + dl.appendChild(el('dd', '', wert)) + } + + function absaetze (text, cls) { + var box = el('div', cls) + String(text).split(/\n{2,}/).forEach(function (a) { if (a.trim()) box.appendChild(el('p', '', a.trim())) }) + return box + } + + function zeichnePruefen () { + var c = S.cfg + var l = aktuelleLeistung() + var dd = S.daten + var anfrage = l.buchungsart === 'anfrage' + kopf(anfrage ? T('Bitte prüfen Sie Ihre Anfrage', 'Bitte prüfe deine Anfrage') : T('Bitte prüfen Sie Ihre Buchung', 'Bitte prüfe deine Buchung')) + + var dl = el('dl', 'wkt-zusammen') + zeile(dl, 'Leistung', l.name + (dauerText(l.dauer) ? ' (' + dauerText(l.dauer) + ')' : '')) + var kand = kandidaten(l) + if (personSchritt(l) || kand.length === 1) { + var istPlatz = kand.every(function (r) { return r.typ === 'platz' }) + var wahl = S.ressource === 'egal' ? null : ressourceMitId(S.ressource) + zeile(dl, istPlatz ? 'Platz' : 'Person', wahl ? wahl.name : T('Egal (wird Ihnen zugeteilt)', 'Egal (wird dir zugeteilt)')) + } + zeile(dl, 'Datum', S.datumLang) + zeile(dl, 'Uhrzeit', S.zeit + ' Uhr') + zeile(dl, 'Preis', l.preis ? l.preis + (c.preisHinweis ? ' (' + c.preisHinweis + ')' : '') : '') + zeile(dl, 'Ort', [c.betrieb.name, c.betrieb.adresse].filter(Boolean).join(', ')) + zeile(dl, 'Zahlungsart', c.zahlung && c.zahlung.text) + zeile(dl, 'Name', dd.name.trim()) + zeile(dl, 'E-Mail', dd.email.trim()) + add(dl) + + if (c.storno && istText(c.storno.text)) { + var sb = el('div', 'wkt-box') + sb.appendChild(el('h3', '', T('Stornobedingungen', 'Stornobedingungen'))) + sb.appendChild(el('p', '', c.storno.text)) + 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')) + add(det) + } + + var ds = el('p', 'wkt-datenschutz') + if (istText(c.hinweis)) ds.appendChild(document.createTextNode(c.hinweis + ' ')) + var dsUrl = httpsUrl(c.betrieb.datenschutzUrl) + if (dsUrl) { + var a = el('a', '', T('Zur Datenschutzerklärung', 'Zur Datenschutzerklärung')) + a.href = dsUrl + a.target = '_blank' + a.rel = 'noopener noreferrer' + ds.appendChild(a) + } + add(ds) + + var form = el('form', 'wkt-form') + form.noValidate = true + var refs = {} + if (c.storno) form.appendChild(haken(refs, 'storno', c.texte.storno, dd.storno, true, function (v) { dd.storno = v })) + if (S.vorzeitig) form.appendChild(haken(refs, 'vorzeitig', c.texte.vorzeitig, dd.vorzeitig, true, function (v) { dd.vorzeitig = v })) + if (c.erinnerung && c.erinnerung.anbieten === true) form.appendChild(haken(refs, 'erinnerung', c.texte.erinnerung, dd.erinnerung, false, function (v) { dd.erinnerung = v })) + form.appendChild(honigfeld()) + var fehlerBox = el('div', 'wkt-fehlerbox') + form.appendChild(fehlerBox) + var senden = el('button', 'wkt-btn', l.knopf) + senden.type = 'submit' + var nav = el('div', 'wkt-nav') + nav.appendChild(knopf('wkt-btn wkt-btn-sek', T('Zurück', 'Zurück'), function () { gehe('daten') })) + nav.appendChild(senden) + 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._)) + + form.addEventListener('submit', function (e) { + e.preventDefault() + if (S.sendet) return + var f = {} + if (c.storno && !dd.storno) f.storno = T('Bitte bestätigen Sie, dass Sie die Stornobedingungen gelesen haben.', 'Bitte bestätige, dass du die Stornobedingungen gelesen hast.') + if (S.vorzeitig && !dd.vorzeitig) f.vorzeitig = T('Bitte stimmen Sie der Leistung vor Ablauf der Widerrufsfrist ausdrücklich zu, sonst ist die Buchung nicht möglich.', 'Bitte stimme der Leistung vor Ablauf der Widerrufsfrist ausdrücklich zu, sonst ist die Buchung nicht möglich.') + fehlerBox.textContent = '' + if (Object.keys(f).length) { + zeigeFeldfehler(refs, f) + return + } + zeigeFeldfehler(refs, {}) + sende(senden) + }) + } + + function sende (senden) { + S.sendet = true + senden.disabled = true + senden.setAttribute('aria-busy', 'true') + var dd = S.daten + sichereConfig().then(function (weiter) { + if (!weiter) { S.sendet = false; return null } + var zusatz = {} + Object.keys(dd.zusatz).forEach(function (k) { if (String(dd.zusatz[k] || '').trim()) zusatz[k] = String(dd.zusatz[k]).trim() }) + return request('POST', '/buchungen', { + leistung: S.leistungId, + ressource: S.ressource, + tag: S.tag, + zeit: S.zeit, + name: dd.name.trim(), + email: dd.email.trim(), + telefon: dd.telefon.trim(), + nachricht: dd.nachricht.trim(), + zusatz: zusatz, + erinnerung: !!dd.erinnerung, + zustimmungen: { storno: !!dd.storno, vorzeitig: !!dd.vorzeitig }, + kontakt_url: S.honig, + formToken: S.cfg.formToken + }).then(function (d) { + S.sendet = false + if (d && d.aktiv === false) { zeigeAusweich(); return } + if (!d || d.ok !== true) throw { netz: true } + S.ergebnis = { email: istText(d.email) ? d.email : '', minuten: d.minuten > 0 ? d.minuten : 30 } + gehe('ergebnis') + }) + }).catch(function (err) { + S.sendet = false + nachSendeFehler(err, senden) + }) + } + + 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.') }) + }, function (e2) { fehlerAllgemein(e2, function () { zeichne(true) }) }) + return + } + if (err && !err.netz && err.fehler && Object.keys(err.fehler).length) { + zeigeServerFehler(err.fehler) + return + } + fehlerAllgemein(err, function () { zeichne(true) }) + if (senden) senden.disabled = false + } + + // Fehler des Servers den Schritten zuordnen: der fruehste betroffene Schritt wird mit den Meldungen gezeigt + function zeigeServerFehler (fehler) { + var rang = 4 + Object.keys(fehler).forEach(function (k) { + var r = 4 + if (k === 'leistung') r = 0 + else if (k === 'ressource') r = 1 + else if (k === 'tag' || k === 'zeit') r = 2 + else if (k === 'name' || k === 'email' || k === 'telefon' || k === 'nachricht' || k === 'zusatz' || k.indexOf('zusatz.') === 0) r = 3 + if (r < rang) rang = r + }) + var l = aktuelleLeistung() + var step = SCHRITTE[rang] + if (step === 'leistung' && S.vorLeistung) step = 'termin' + if (step === 'person' && !(l && personSchritt(l))) step = S.vorLeistung ? 'termin' : 'leistung' + if (step === 'termin') { S.zeit = ''; S.tageCache = {} } + gehe(step, { fehler: fehler, fokus: true }) + } + + 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 + } + ausfall(wiederhole) + } + + function behandleFehler (err, wiederhole) { fehlerAllgemein(err, wiederhole) } + + function zeichneErgebnis () { + var e = S.ergebnis + var l = aktuelleLeistung() + var anfrage = l && l.buchungsart === 'anfrage' + add(titel('Fast geschafft')) + var was = anfrage ? T('Ihre Anfrage', 'deine Anfrage') : T('den Termin', 'den Termin') + add(el('p', 'wkt-ergebnis-text', + T('Bitte bestätigen Sie ', 'Bitte bestätige ') + was + T(' über den Link, den wir an ', ' über den Link, den wir an ') + e.email + ' geschickt haben. ' + + T('Der Termin ist ', 'Der Termin ist ') + e.minuten + T(' Minuten für Sie reserviert.', ' Minuten für dich reserviert.'))) + var nav = el('div', 'wkt-nav') + nav.appendChild(knopf('wkt-btn wkt-btn-sek', T('Weiteren Termin buchen', 'Weiteren Termin buchen'), function () { starte(true) })) + add(nav) + } + + // ---- Widerruf ohne Link (Widerrufsfunktion, § 356a BGB) + + function zeichneWiderruf () { + add(titel(T('Vertrag widerrufen', 'Vertrag widerrufen'))) + if (S.widerrufErgebnis) { + var e = S.widerrufErgebnis + add(el('p', 'wkt-ergebnis-text', T('Ihre Erklärung ist eingegangen.', 'Deine Erklärung ist eingegangen.'))) + add(el('p', '', T('Eingang: ', 'Eingang: ') + e.datum + ', ' + e.uhrzeit + ' Uhr. ' + T('Eine Eingangsbestätigung senden wir Ihnen per E-Mail.', 'Eine Eingangsbestätigung senden wir dir per E-Mail.'))) + zurueckAnsicht() + return + } + add(el('p', '', T('Hier können Sie Ihren Vertrag über einen gebuchten Termin widerrufen. Bitte geben Sie Ihren Namen, die E-Mail-Adresse der Buchung und die Buchungsnummer an. Sie steht in Ihrer Bestätigungs-E-Mail.', + 'Hier kannst du deinen Vertrag über einen gebuchten Termin widerrufen. Bitte gib deinen Namen, die E-Mail-Adresse der Buchung und die Buchungsnummer an. Sie steht in deiner Bestätigungs-E-Mail.'))) + var form = el('form', 'wkt-form') + form.noValidate = true + var refs = {} + var w = S.widerrufDaten || (S.widerrufDaten = { name: '', email: '', nummer: '' }) + form.appendChild(feld(refs, { name: 'name', label: T('Ihr Name', 'Dein Name'), typ: 'text', wert: w.name, pflicht: true, autocomplete: 'name', maxlength: 100, onInput: function (v) { w.name = v } })) + form.appendChild(feld(refs, { name: 'email', label: T('E-Mail-Adresse der Buchung', 'E-Mail-Adresse der Buchung'), typ: 'email', wert: w.email, pflicht: true, autocomplete: 'email', maxlength: 254, onInput: function (v) { w.email = v } })) + form.appendChild(feld(refs, { name: 'nummer', label: T('Buchungsnummer', 'Buchungsnummer'), typ: 'text', wert: w.nummer, pflicht: false, maxlength: 40, onInput: function (v) { w.nummer = v } })) + var fehlerBox = el('div', 'wkt-fehlerbox') + form.appendChild(fehlerBox) + 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(senden) + form.appendChild(nav) + add(form) + form.addEventListener('submit', function (e) { + e.preventDefault() + if (S.sendet) return + var f = {} + if (!w.name.trim()) f.name = T('Bitte geben Sie Ihren Namen an.', 'Bitte gib deinen Namen an.') + if (!EMAIL_FORM.test(w.email.trim())) f.email = T('Bitte geben Sie eine gültige E-Mail-Adresse an.', 'Bitte gib eine gültige E-Mail-Adresse an.') + fehlerBox.textContent = '' + zeigeFeldfehler(refs, f) + if (Object.keys(f).length) return + S.sendet = true + senden.disabled = true + request('POST', '/widerruf', { name: w.name.trim(), email: w.email.trim(), nummer: w.nummer.trim() }).then(function (d) { + S.sendet = false + if (d && d.aktiv === false) { zeigeAusweich(); return } + if (!d || d.ok !== true || !d.eingang) throw { netz: true } + S.widerrufErgebnis = { datum: istText(d.eingang.datum) ? d.eingang.datum : '', uhrzeit: istText(d.eingang.uhrzeit) ? d.eingang.uhrzeit : '' } + S.widerrufDaten = null + zeichne(true) + }).catch(function (err) { + S.sendet = false + 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)) }) + senden.disabled = false + return + } + fehlerAllgemein(err, function () { 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) })) + add(nav) + } + + // ------------------------------------------------------------ Inline und Knopf + + function startInline () { + lade().then(function () { + 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 */ }) + } + + function schliesse () { + if (!dlg) return + if (typeof dlg.close === 'function') dlg.close() + else dlg.removeAttribute('open') + if (opener) opener.focus() + } + + function oeffne () { + if (typeof dlg.showModal === 'function') { + if (!dlg.open) dlg.showModal() + } else { + dlg.setAttribute('open', '') + } + if (S.cfg && !S.tot && S.step && S.step !== 'ergebnis') { + sichereConfig().then(function () { if (ui.titel) ui.titel.focus() }) + return + } + S.tot = false + ui.body.textContent = '' + ui.fuss.textContent = '' + var l = el('p', 'wkt-laden', 'Wird geladen …') + l.tabIndex = -1 + ui.body.appendChild(l) + l.focus() + lade().then(function () { + starte(true) + }, function (err) { + if (err && err.inaktiv) { zeigeAusweich(); return } + ui.body.textContent = '' + var p = el('p', 'wkt-fehlertext', 'Gerade nicht erreichbar.') + p.tabIndex = -1 + ui.body.appendChild(p) + if (ausweich) ui.body.appendChild(ausweich.cloneNode(true)) + p.focus() + ansage('Gerade nicht erreichbar.') + }) + } + + function startKnopf () { + injectStyle() + ausweich = document.createDocumentFragment() + while (root.firstChild) ausweich.appendChild(root.firstChild) + root.classList.add('wkt-knopf-box') + opener = knopf('wkt-knopf', knopfText, oeffne) + opener.setAttribute('aria-haspopup', 'dialog') + root.appendChild(opener) + dlg = el('dialog', 'wkt-dialog') + dlg.setAttribute('aria-label', knopfText) + var box = el('div') + var zu = knopf('wkt-schliessen', '×', schliesse) + zu.setAttribute('aria-label', 'Schließen') + box.appendChild(zu) + bauGeruest(box) + dlg.appendChild(box) + root.appendChild(dlg) + dlg.addEventListener('click', function (e) { if (e.target === dlg) schliesse() }) + dlg.addEventListener('close', function () { if (opener) opener.focus() }) + } + + if (modus === 'knopf') startKnopf() + else startInline() + } + + function run () { + var nodes = document.querySelectorAll('[data-webklar-termine]') + for (var i = 0; i < nodes.length; i++) init(nodes[i]) + } + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run) + else run() +})() diff --git a/test/termineEmbedStatic.test.js b/test/termineEmbedStatic.test.js new file mode 100644 index 0000000..7aacbb9 --- /dev/null +++ b/test/termineEmbedStatic.test.js @@ -0,0 +1,111 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import vm from 'node:vm' + +// Statische Pruefung des Einbau-Skripts public/embed/termine.js (Aufgabe 19, Rulings R3 und R4): +// - Das Widget rechnet nie mit dem Datum oder der Zeitzone des Browsers (Kalender, Wochentage, Uhrzeiten und Datumstexte kommen +// vom Server: monat, tage, zeiten, datumLang). Darum kein Date, getDay, toLocale*, Intl. +// - Kein Browser-Speicher, keine Cookies, kein Auslesen von Referrer oder Zeitzone, keine Fremdadressen im Code. +// - Kundentext nur ueber textContent/Attribute: innerHTML nur mit festen Zeichenketten. +// Die Pruefung der Seiten drumherum (Demo, Einbauanleitung) steht am Ende. + +const lies = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8') +const QUELLE = lies('public/embed/termine.js') + +/** Quelltext ohne Kommentare (die Kopfzeile darf Beispiele mit Adressen enthalten, der Code nicht). */ +const OHNE_KOMMENTARE = QUELLE.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1') + +test('Quelltext ist gueltiges JavaScript (kompiliert, wird nicht ausgefuehrt)', () => { + assert.doesNotThrow(() => new vm.Script(QUELLE, { filename: 'termine.js' })) +}) + +test('kein Browser-Speicher, keine Cookies, kein Referrer, keine Zeitzone', () => { + for (const verboten of ['localStorage', 'sessionStorage', 'document.cookie', 'indexedDB', 'document.referrer', 'sendBeacon', 'navigator.userAgent', 'resolvedOptions', 'getTimezoneOffset']) { + assert.ok(!OHNE_KOMMENTARE.includes(verboten), `${verboten} darf im Widget nicht vorkommen`) + } +}) + +test('keine Datums- und Zeitzonenrechnung im Browser (Date, getDay, toLocale, Intl.)', () => { + assert.ok(!/\bDate\b/.test(OHNE_KOMMENTARE), 'Date darf nicht vorkommen') + for (const verboten of ['getDay', 'toLocale', 'Intl.', 'getFullYear', 'getMonth', 'getHours', 'getTime(', 'Date.now']) { + assert.ok(!OHNE_KOMMENTARE.includes(verboten), `${verboten} darf im Widget nicht vorkommen`) + } +}) + +test('innerHTML nur mit festen Zeichenketten; kein outerHTML, insertAdjacentHTML, document.write, eval', () => { + const treffer = [...OHNE_KOMMENTARE.matchAll(/innerHTML/g)] + for (const t of treffer) { + const rest = OHNE_KOMMENTARE.slice(t.index + 'innerHTML'.length) + assert.match(rest, /^\s*=\s*(?:'[^'\\+]*'|"[^"\\+]*")\s*(?:;|\n|\))/, 'innerHTML darf nur eine feste Zeichenkette bekommen') + } + for (const verboten of ['outerHTML', 'insertAdjacentHTML', 'document.write', 'eval(', 'new Function', 'setTimeout("', "setTimeout('", 'createContextualFragment', 'srcdoc']) { + assert.ok(!OHNE_KOMMENTARE.includes(verboten), `${verboten} darf im Widget nicht vorkommen`) + } +}) + +test('keine festen Fremdadressen im Code (nur die Adresse aus data-api oder dem Skript)', () => { + assert.doesNotMatch(OHNE_KOMMENTARE, /https?:\/\//, 'Adressen gehoeren in data-api, nicht in den Code') + assert.doesNotMatch(OHNE_KOMMENTARE, /\bimport\s*\(|\bimportScripts\b|\bXMLHttpRequest\b|\bWebSocket\b/) +}) + +test('Anfragen ohne Cookies und nur an die Termine-Schnittstelle', () => { + assert.ok(OHNE_KOMMENTARE.includes("credentials: 'omit'"), "fetch braucht credentials: 'omit'") + assert.ok(OHNE_KOMMENTARE.includes('/api/public/termine/v1/')) + assert.equal([...OHNE_KOMMENTARE.matchAll(/\bfetch\(/g)].length, 1, 'genau ein fetch (eine Stelle fuer alle Anfragen)') +}) + +test('Pflichtbausteine der Spezifikation (Einbau, Stile, Honeypot, Fuss, Texte)', () => { + for (const noetig of [ + 'data-webklar-termine', 'data-api', 'data-modus', 'data-knopf', 'data-leistung', + "'wkt-style'", '@layer wkt', 'wkt-', + '--wkt-akzent', '--wkt-akzent-text', '--wkt-hintergrund', '--wkt-text', '--wkt-rand', '--wkt-radius', '--wkt-schrift', '--wkt-schrift-titel', + 'kontakt_url', 'autocomplete', 'tabindex', 'aria-hidden', 'aria-live', 'polite', 'aria-selected', 'aria-disabled', 'role', 'dialog', + 'performance.now', '100 * 60 * 1000', 'neu_laden', '/widerruf', + 'Vertrag widerrufen', 'Datenschutz', 'Terminbuchung über WEBklar', 'Widerruf bestätigen', + 'Gerade nicht erreichbar. Bitte rufen Sie an: ', + 'Fast geschafft', 'widerrufsbelehrung', 'details', + 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End', 'PageUp', 'PageDown', + ]) { + assert.ok(QUELLE.includes(noetig), `${noetig} fehlt im Skript`) + } +}) + +test('Stile stehen einmal in ${head} +

Testseite

${widgets}
${skript}` + +/** Seite von srv.pageOrigin mit eigenem Inhalt (liefert der Testserver selbst unter /ui-seiten/). */ +async function oeffne(page, html, name = 'testseite.html') { + await page.goto(srv.seite(name, html)) +} + +const shot = (page, name) => page.screenshot({ path: `${SHOTS}/widget-${name}.png`, fullPage: true }) +const w = (page, i = 0) => page.locator('[data-webklar-termine]').nth(i) +const titel = (wd) => wd.locator('h2.wkt-titel').first() +const karte = (wd, text) => wd.locator('.wkt-karte', { hasText: text }) +const tag = (wd, t) => wd.locator(`td[data-tag="${t}"]`) +const zeit = (wd, z) => wd.locator('.wkt-zeit', { hasText: new RegExp(`^${z}$`) }) +const warteTitel = (wd, text) => wd.locator('h2.wkt-titel', { hasText: text }).waitFor() + +/** Leistung, Person, Tag, Uhrzeit, Angaben ausfuellen bis zur Zusammenfassung. Fehlt ein Wert (null), entfaellt der Schritt. */ +async function bisZusammenfassung(page, o = {}) { + const wd = w(page, o.widget ?? 0) + const v = { leistung: 'Damenschnitt', person: 'Egal', tag: '2026-10-12', zeit: '10:00', name: 'Max Mustermann', email: 'max@example.com', ...o } + if (v.leistung) await karte(wd, v.leistung).click() + if (v.person) await karte(wd, v.person).click() + await tag(wd, v.tag).click() + await zeit(wd, v.zeit).click() + await wd.locator('input[name=name]').fill(v.name) + await wd.locator('input[name=email]').fill(v.email) + if (v.telefon) await wd.locator('input[name=telefon]').fill(v.telefon) + await wd.getByRole('button', { name: 'Weiter' }).click() + await wd.locator('.wkt-zusammen').waitFor() + return wd +} + +async function hakenUndBuchen(wd, knopf) { + for (const n of ['storno', 'vorzeitig']) { + const cb = wd.locator(`input[name=${n}]`) + if (await cb.count()) await cb.check() + } + await wd.getByRole('button', { name: knopf }).click() + await wd.getByRole('heading', { name: 'Fast geschafft' }).waitFor() +} + +/** Kein Streifen rechts: Seite und Widget passen in die Breite. */ +const passtInBreite = (page) => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth) + +// ---------------------------------------------------------------- Ablauf der Pruefungen + +const results = [] +let browser = null +const hosts = new Set() + +async function check(name, fn, { eingabe, context = {}, initScript } = {}) { + await srv.zuruecksetzen({ eingabe }) + const ctx = await browser.newContext({ viewport: { width: 1000, height: 900 }, ...context }) + const page = await ctx.newPage() + page.setDefaultTimeout(8000) + const fehler = [] + page.on('pageerror', (e) => fehler.push(e.message)) + page.on('request', (r) => { try { const u = new URL(r.url()); if (u.protocol.startsWith('http')) hosts.add(u.host) } catch { /* data: u. a. */ } }) + if (initScript) await page.addInitScript(initScript) + try { + await fn(page, ctx) + assert(fehler.length === 0, `JavaScript-Fehler auf der Seite: ${fehler.join(' | ')}`) + results.push([name, true]) + console.log(`ok ${name}`) + } catch (e) { + results.push([name, false]) + console.log(`FAIL ${name}: ${e.message.split('\n')[0]}`) + await page.screenshot({ path: `${SHOTS}/fehler-widget-${name}.png`, fullPage: true }).catch(() => {}) + } finally { + await ctx.close() + } +} + +// ---------------------------------------------------------------- 1. Inline bis "Fast geschafft" + +async function pruefungen() { + await check('inline_bis_fast_geschafft', async (page, ctx) => { + await oeffne(page, seitenHtml(eingebettet('data-modus="inline"'))) + const wd = w(page) + await karte(wd, 'Herrenschnitt').waitFor() + assert(await page.locator('#ausweich').count() === 0, 'Ausweichtext blieb stehen, obwohl das Widget lief') + eq(await titel(wd).innerText(), 'Welche Leistung möchten Sie buchen?', 'Titel Schritt 1') + assert((await wd.innerText()).includes('Bezahlung vor Ort: bar, EC-Karte, Kreditkarte'), 'Zahlungsart fehlt im ersten Schritt') + assert((await karte(wd, 'Damenschnitt').innerText()).includes('ab 45,00 €'), 'Preis "ab" fehlt') + assert((await karte(wd, 'Damenschnitt').innerText()).includes('45 Minuten'), 'Dauer fehlt') + await shot(page, 'inline-1-leistung') + + await karte(wd, 'Damenschnitt').click() + await warteTitel(wd, 'Bei wem') + assert(await karte(wd, 'Egal').count() === 1 && await karte(wd, 'Anna').count() === 1 && await karte(wd, 'Ben').count() === 1, 'Personenwahl: Egal, Anna, Ben') + assert((await wd.locator('.wkt-karte').first().innerText()).startsWith('Egal'), '"Egal" muss die erste Option sein') + await shot(page, 'inline-2-person') + + await karte(wd, 'Egal').click() + await warteTitel(wd, 'Wann passt es Ihnen?') + await wd.locator('td.wkt-tag').first().waitFor() + eq(await wd.locator('.wkt-monat').innerText(), 'Oktober 2026', 'Monat') + eq(await tag(wd, '2026-10-11').getAttribute('aria-disabled'), 'true', 'Sonntag ist gesperrt') + eq(await tag(wd, '2026-10-12').getAttribute('aria-disabled'), 'false', 'Montag ist frei') + eq(await tag(wd, '2026-10-12').getAttribute('aria-selected'), 'false', 'noch nichts gewaehlt') + await tag(wd, '2026-10-12').click() + await wd.locator('.wkt-zeit').first().waitFor() + eq(await tag(wd, '2026-10-12').getAttribute('aria-selected'), 'true', 'gewaehlter Tag') + eq(await wd.locator('.wkt-zeiten-titel').innerText(), 'Freie Zeiten am Montag, 12.10.2026', 'Titel der Uhrzeiten') + const zeiten = await wd.locator('.wkt-zeit').allInnerTexts() + eq(zeiten.slice(0, 3), ['09:00', '09:15', '09:30'], 'erste Uhrzeiten (Berliner Wanduhr)') + assert(!zeiten.includes('12:30') && zeiten.includes('13:30'), 'Mittagspause (12:30 bis 13:30) muss fehlen') + await shot(page, 'inline-3-termin') + + await zeit(wd, '10:00').click() + await warteTitel(wd, 'Ihre Angaben') + await wd.locator('input[name=name]').fill('Max Mustermann') + await wd.locator('input[name=email]').fill('max@example.com') + await wd.locator('input[name=telefon]').fill('0171 1234567') + await shot(page, 'inline-4-daten') + await wd.getByRole('button', { name: 'Weiter' }).click() + + await warteTitel(wd, 'Bitte prüfen Sie Ihre Buchung') + const zus = await wd.locator('.wkt-zusammen').innerText() + for (const t of ['Damenschnitt (45 Minuten)', 'Montag, 12.10.2026', '10:00 Uhr', 'ab 45,00 € (inkl. USt)', 'Salon Müller, Hauptstr. 1, 67722 Winnweiler', 'Max Mustermann', 'max@example.com']) { + 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') + 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') + 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') + // Haken: Storno und vorzeitiger Beginn Pflicht (12.10. liegt in der Widerrufsfrist), Erinnerung freiwillig und nicht vorangekreuzt + eq(await wd.locator('label[for$="h-storno"]').innerText(), 'Ich habe die Stornobedingungen gelesen.', 'Haken Storno') + assert((await wd.locator('label[for$="h-vorzeitig"]').innerText()).startsWith('Ich verlange ausdrücklich, dass Salon Müller mit der Leistung vor Ablauf der Widerrufsfrist beginnt.'), 'Haken vorzeitig') + eq(await wd.locator('label[for$="h-erinnerung"]').innerText(), 'Ich möchte 24 Stunden vor dem Termin eine Erinnerung per E-Mail erhalten.', 'Haken Erinnerung') + assert(!(await wd.locator('input[name=erinnerung]').isChecked()), 'Erinnerung darf nicht vorangekreuzt sein') + assert(await wd.locator('input[name=storno]').getAttribute('required') !== null && await wd.locator('input[name=vorzeitig]').getAttribute('required') !== null, 'Pflicht-Haken') + assert(await wd.locator('input[name=erinnerung]').getAttribute('required') === null, 'Erinnerung ist freiwillig') + eq(await wd.locator('.wkt-form button[type=submit]').innerText(), 'Zahlungspflichtig buchen', 'Knopftext aus der Konfiguration') + await shot(page, 'inline-5-pruefen') + + await hakenUndBuchen(wd, 'Zahlungspflichtig buchen') + eq(await titel(wd).innerText(), 'Fast geschafft', 'Titel Ergebnis') + eq(await wd.locator('.wkt-ergebnis-text').innerText(), + 'Bitte bestätigen Sie den Termin über den Link, den wir an m***@example.com geschickt haben. Der Termin ist 30 Minuten für Sie reserviert.', 'Ergebnistext') + await shot(page, 'inline-6-fast-geschafft') + + // Server: genau eine gehaltene Buchung, G01 an den Gast mit Bestaetigungslink + await srv.warteAufVersand() + const buchungen = srv.buchungen() + eq(buchungen.length, 1, 'Anzahl Buchungen') + eq([buchungen[0].status, buchungen[0].gastEmail, buchungen[0].leistungId, buchungen[0].gastName], ['gehalten', 'max@example.com', S_DAMEN, 'Max Mustermann'], 'Buchung') + const g01 = srv.mails().filter((m) => m.to.some((t) => t.address === 'max@example.com')) + eq(g01.length, 1, 'G01-Mails an den Gast') + assert(/termin\.html#v=[A-Za-z0-9_-]{43}/.test(g01[0].text), 'G01 ohne Bestätigungslink') + assert(!g01[0].bcc?.length, 'nie BCC') + + // Kein Cookie, kein Speicher + const speicher = await page.evaluate(() => ({ c: document.cookie, l: localStorage.length, s: sessionStorage.length })) + eq(speicher, { c: '', l: 0, s: 0 }, 'Cookies/Storage') + eq((await ctx.cookies()).length, 0, 'Cookies im Browser') + }) + + // ---------------------------------------------------------------- 2. Demo-Seite und Aufbau + + await check('demo_seite_stile_und_honigtopf', async (page) => { + await page.goto(`${SEITE}/embed/termine-demo.html`) + await karte(w(page, 0), 'Herrenschnitt').waitFor() + assert(await page.locator('#ausweich-inline').count() === 0, 'Ausweichtext (inline) muss ersetzt sein') + eq(await page.locator('#wkt-style').count(), 1, '