- R53: Das Widget wartet vor jedem Absenden, bis das Formular-Token formMinSekunden plus 0,5 s alt
ist ("Einen Moment …", Knopf gesperrt); gilt nach dem Neuladen der Konfiguration und beim Wiederholen
nach "neu_laden". Kein stilles Erfolgs-201 ohne Buchung mehr.
- R54: "Vertrag widerrufen" im Fuß ist ein zweiter Knopf (44 px). Im Knopfmodus lädt das Widget die
Konfiguration beim Laden der Seite (ein Abruf) und zeigt bei Widerrufsrecht dauerhaft einen zweiten
Knopf "Vertrag widerrufen" neben dem Buchungsknopf, der den Dialog beim Widerrufsformular öffnet.
- Telefonlink im Widget und auf der Gästeseite: Text mit Buchstaben oder Mehrfachangaben wird nie zum
tel:-Link, "(0)" hinter der Landesvorwahl entfällt.
- Portal, Einstellungen: Kasten "Einbau auf deiner Website" mit Einbau-Code und Kopierknopf; Hinweistexte
zum Widerruf passen zu R55 (auch Anfragen).
- G02: Widerrufssatz nennt, dass der Knopf bis zum Beginn des Termins gilt und danach das Formular auf der
Website oder die E-Mail an den Betrieb.
- Einbau-Anleitung angepasst (siteKey und Code im Portal, Knopfmodus, Checkliste); Tests und Playwright.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1613 lines
74 KiB
JavaScript
1613 lines
74 KiB
JavaScript
/*! WEBklar Terminplaner: Einbau-Skript fuer Kundenwebsites.
|
||
* <div data-webklar-termine="SITEKEY" data-api="https://project.webklar.com" data-modus="inline">Ausweichtext</div>
|
||
* data-modus: inline (Strecke in der Seite) | knopf (Knopf mit data-knopf="Text", oeffnet ein <dialog>; beim Laden der Seite ein einziger
|
||
* Abruf der Konfiguration, damit der Knopf "Vertrag widerrufen" dauerhaft neben dem Buchungsknopf stehen kann, sonst nichts bis zum 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 FORM_MIN_VORGABE_S = 3 // so lange muss ein Formular-Token mindestens alt sein, wenn die Konfiguration formMinSekunden nicht nennt
|
||
var FORM_PUFFER_MS = 500 // Sicherheitszuschlag zu formMinSekunden (Laufzeit der Anfrage, Uhrzeit des Servers)
|
||
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,.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}',
|
||
'.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:flex;flex-wrap:wrap;align-items:center;gap:.5rem .75rem}',
|
||
'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' }
|
||
|
||
// @telefon-anfang (reine Funktion, test/termineEmbedStatic.test.js fuehrt sie einzeln aus; dieselbe Regel in public/termin.js)
|
||
// Telefonnummer des Betriebs (freier Text) als tel:-Adresse. Steht ein Buchstabe oder ein anderes Zeichen als Ziffern, Leerzeichen,
|
||
// + - ( ) / . darin ("06301 1234 (ab 9 Uhr)", "0171 1, 0171 2"), ist es keine Nummer, die man waehlen kann: dann '' und der Text bleibt
|
||
// Text. Sonst bleiben ein fuehrendes + und die Ziffern; die nationale Null "(0)" hinter der Landesvorwahl entfaellt.
|
||
function telefonLink (nr) {
|
||
if (typeof nr !== 'string') return ''
|
||
var s = nr.replace(/^\s+|\s+$/g, '')
|
||
if (!/^[0-9 +()\/.-]+$/.test(s)) return ''
|
||
var lv = /^((?:\+|00)\s*\d{1,3})[\s.\/-]*\(\s*0\s*\)/.exec(s)
|
||
if (lv) s = lv[1] + s.slice(lv[0].length)
|
||
var ziffern = s.replace(/[^0-9]/g, '')
|
||
if (ziffern.length < 3) return ''
|
||
return 'tel:' + (s.charAt(0) === '+' ? '+' : '') + ziffern
|
||
}
|
||
// @telefon-ende
|
||
|
||
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 ''
|
||
}
|
||
|
||
// @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)
|
||
})
|
||
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', textFarbeAuf(farbe))
|
||
}
|
||
|
||
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 // Knopf, der den Dialog zuletzt geoeffnet hat (dorthin geht der Fokus zurueck)
|
||
var hauptKnopf = null
|
||
var widerrufKnopf = 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 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) {
|
||
if (!konfigGueltig(d)) 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 }
|
||
|
||
// Der Server speichert nichts (und antwortet doch mit Erfolg), wenn das Formular-Token juenger als formMinSekunden ist (R53: Schutz
|
||
// vor Automaten). Frisch geladen ist es das, darum wartet das Widget vor dem Absenden, bis es formMinSekunden plus einen Puffer alt ist.
|
||
// Gemessen wird ab Eintreffen der Konfiguration (performance.now): das Token ist beim Server immer etwas aelter, nie juenger.
|
||
function formWartezeitMs () {
|
||
var min = S.cfg && typeof S.cfg.formMinSekunden === 'number' && S.cfg.formMinSekunden >= 0 && S.cfg.formMinSekunden <= 120 ? S.cfg.formMinSekunden : FORM_MIN_VORGABE_S
|
||
return Math.max(0, min * 1000 + FORM_PUFFER_MS - (performance.now() - S.cfgAt))
|
||
}
|
||
|
||
// true = Token alt genug (nach dem Warten); false = die Ansicht hat sich inzwischen geaendert (Zurueck, Dialog neu begonnen): nicht senden.
|
||
function warteAufFormToken (senden) {
|
||
var ms = formWartezeitMs()
|
||
if (ms <= 0) return Promise.resolve(true)
|
||
var zustand = S
|
||
var seq = S.seq
|
||
var beschriftung = senden.textContent
|
||
senden.textContent = 'Einen Moment …'
|
||
ansage('Einen Moment …')
|
||
return new Promise(function (resolve) {
|
||
setTimeout(function () {
|
||
if (S !== zustand || S.seq !== seq || S.tot) { zustand.sendet = false; resolve(false); return }
|
||
senden.textContent = beschriftung
|
||
resolve(true)
|
||
}, ms)
|
||
})
|
||
}
|
||
|
||
// 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 () {
|
||
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
|
||
})
|
||
}
|
||
|
||
// 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
|
||
})
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
// Widerrufsfunktion (§ 356a BGB) oeffnen; der Stand der Buchung bleibt, "Zurueck zur Terminbuchung" kehrt dorthin zurueck
|
||
function zeigeWiderruf () {
|
||
S.ansicht = 'widerruf'
|
||
S.widerrufErgebnis = null
|
||
S.fehler = {}
|
||
S.meldung = ''
|
||
zeichne(true)
|
||
}
|
||
|
||
function zeichneFuss () {
|
||
var c = S.cfg
|
||
ui.fuss.textContent = ''
|
||
var links = el('p', 'wkt-fuss-links')
|
||
if (c.widerruf === true) {
|
||
links.appendChild(knopf('wkt-btn wkt-btn-sek', 'Vertrag widerrufen', zeigeWiderruf))
|
||
}
|
||
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, 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', meldung || T('Gerade nicht erreichbar. Bitte rufen Sie an: ', 'Gerade nicht erreichbar. Bitte ruf an: '))
|
||
p.tabIndex = -1
|
||
if (tel && !meldung) {
|
||
var href = telefonLink(tel)
|
||
if (href) {
|
||
var a = el('a', '', tel)
|
||
a.href = href
|
||
p.appendChild(a)
|
||
} else {
|
||
p.appendChild(document.createTextNode(tel)) // freier Text ("06301 1234 (ab 9 Uhr)"): kein Link, der eine falsche Nummer waehlt
|
||
}
|
||
}
|
||
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.cloneNode(true)) // Kopie: ein zweiter Aufruf darf den Text nicht verlieren
|
||
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
|
||
|
||
// zumWiderruf (Knopf "Vertrag widerrufen" neben dem Buchungsknopf): gleich das Widerrufsformular zeigen; die Buchung beginnt erst auf
|
||
// "Zurück zur Terminbuchung" (S.step bleibt leer, siehe zurueckKnopf)
|
||
function starte (fokus, meldung, zumWiderruf) {
|
||
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 (zumWiderruf) zeigeWiderruf()
|
||
else if (S.vorLeistung) waehleLeistung(S.vorLeistung, { fokus: fokus, meldung: meldung })
|
||
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) {
|
||
S.ressource = ''
|
||
S.monat = ''
|
||
S.tag = ''
|
||
S.zeit = ''
|
||
hakenZurueck()
|
||
}
|
||
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?'))
|
||
zeigeFehlerListe()
|
||
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?'))
|
||
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: '' }) })
|
||
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) {
|
||
if (!zielSchritt) return
|
||
var nav = el('div', 'wkt-nav')
|
||
nav.appendChild(knopf('wkt-btn wkt-btn-sek', 'Zurück', function () { gehe(zielSchritt) }))
|
||
add(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?'))
|
||
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)
|
||
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)
|
||
var hatteFokus = document.activeElement
|
||
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)
|
||
// 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) {
|
||
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
|
||
fehlerAllgemein(err, function () { zeichne(true) })
|
||
return null
|
||
})
|
||
}
|
||
|
||
// 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 = 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) fehlerAllgemein(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) {
|
||
if (S.tag !== tag) {
|
||
S.zeit = ''
|
||
hakenZurueck()
|
||
}
|
||
S.tag = tag
|
||
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 () {
|
||
if (S.zeit !== z) hakenZurueck()
|
||
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) 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
|
||
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) {
|
||
wechsleMonat(info.vorher, function (vi) { return vi.anzahlTage + ziel }, true)
|
||
} else {
|
||
wechsleMonat(info.nachher, 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
|
||
}
|
||
|
||
// 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 () {
|
||
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)
|
||
|
||
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
|
||
fehlerBox.textContent = ''
|
||
zeigeRest(fehlerBox, zeigeFeldfehler(refs, f))
|
||
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) + ')' : ''))
|
||
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' })
|
||
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)
|
||
|
||
// 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')
|
||
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', '', '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)
|
||
}
|
||
|
||
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)
|
||
|
||
if (Object.keys(S.fehler).length) zeigeRest(fehlerBox, zeigeFeldfehler(refs, 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 }
|
||
return warteAufFormToken(senden).then(function (los) {
|
||
if (!los) 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
|
||
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
|
||
}
|
||
if (angebotFehler(err)) {
|
||
// Leistung oder Person unbekannt: das Angebot hat sich vermutlich geaendert. Erst frisch laden; gilt die Auswahl nicht mehr, beginnt
|
||
// ladeNeu() von vorn (mit Hinweis), sonst steht die Meldung des Servers.
|
||
ladeNeu().then(function (weiter) { if (weiter) zeigeServerFehler(err.fehler) }, 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
|
||
}
|
||
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 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.')
|
||
}
|
||
|
||
// 400 des Servers mit Fehler an leistung oder ressource (nicht aus dem Netz): die Auswahl passt nicht mehr zum Angebot
|
||
function angebotFehler (err) {
|
||
return !!(err && !err.netz && err.fehler && (err.fehler.leistung || err.fehler.ressource))
|
||
}
|
||
|
||
// 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) { ausfall(wiederhole, meldung429(err)); return }
|
||
if (angebotFehler(err)) {
|
||
// Veraltetes Angebot (z. B. Leistung entfernt): frisch laden, ladeNeu() beginnt bei Aenderung von vorn mit Hinweis. Gilt die
|
||
// Auswahl noch, steht die Meldung mit "Erneut versuchen" (kein Neuaufbau, der denselben Abruf von selbst wiederholen wuerde).
|
||
ladeNeu().then(function (weiter) {
|
||
if (weiter) ausfall(wiederhole, Object.keys(err.fehler).map(function (k) { return err.fehler[k] }).join(' '))
|
||
}, function (e2) { fehlerAllgemein(e2, wiederhole) })
|
||
return
|
||
}
|
||
ausfall(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
|
||
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(zurueckKnopf())
|
||
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
|
||
mitMeldung429(err)
|
||
if (err && !err.netz && err.fehler && Object.keys(err.fehler).length) {
|
||
zeigeRest(fehlerBox, zeigeFeldfehler(refs, err.fehler))
|
||
senden.disabled = false
|
||
return
|
||
}
|
||
fehlerAllgemein(err, function () { zeichne(true) })
|
||
})
|
||
})
|
||
}
|
||
|
||
// 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'
|
||
if (S.step) zeichne(true)
|
||
else starte(true) // der Dialog wurde gleich beim Widerruf geoeffnet: die Buchung beginnt jetzt
|
||
})
|
||
}
|
||
|
||
function zurueckAnsicht () {
|
||
var nav = el('div', 'wkt-nav')
|
||
nav.appendChild(zurueckKnopf())
|
||
add(nav)
|
||
}
|
||
|
||
// ------------------------------------------------------------ Inline und Knopf
|
||
|
||
function startInline () {
|
||
lade().then(function () {
|
||
injectStyle()
|
||
ausweich = document.createDocumentFragment()
|
||
while (root.firstChild) ausweich.appendChild(root.firstChild)
|
||
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 () {
|
||
if (!dlg) return
|
||
if (typeof dlg.close === 'function') dlg.close()
|
||
else dlg.removeAttribute('open')
|
||
if (opener) opener.focus()
|
||
}
|
||
|
||
// zumWiderruf: der Dialog geht gleich beim Widerrufsformular auf (Knopf "Vertrag widerrufen")
|
||
function oeffne (zumWiderruf) {
|
||
if (typeof dlg.showModal === 'function') {
|
||
if (!dlg.open) dlg.showModal()
|
||
} else {
|
||
dlg.setAttribute('open', '')
|
||
}
|
||
var lebt = S.cfg && !S.tot
|
||
if (lebt && ((S.step && S.step !== 'ergebnis') || S.ansicht === 'widerruf')) {
|
||
// eine Strecke oder das Widerrufsformular steht noch im Dialog: dort weitermachen
|
||
if (zumWiderruf && S.ansicht !== 'widerruf') { zeigeWiderruf(); return }
|
||
if (!zumWiderruf && S.ansicht === 'widerruf') {
|
||
S.ansicht = 'buchung'
|
||
if (S.step) zeichne(true)
|
||
else starte(true)
|
||
return
|
||
}
|
||
sichereConfig().then(function () { if (ui.titel) ui.titel.focus() })
|
||
return
|
||
}
|
||
S.tot = false
|
||
if (lebt && configFrisch()) {
|
||
// die Konfiguration kam beim Laden der Seite und ist noch gueltig: kein zweiter Abruf
|
||
try {
|
||
starte(true, undefined, zumWiderruf === true)
|
||
} catch (e) {
|
||
zeigeAusweich()
|
||
}
|
||
return
|
||
}
|
||
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 () {
|
||
try {
|
||
starte(true, undefined, zumWiderruf === true)
|
||
} catch (e) {
|
||
zeigeAusweich() // Aufbau scheitert: der Ausweichtext statt "Wird geladen …"
|
||
}
|
||
}, 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')
|
||
hauptKnopf = knopf('wkt-knopf', knopfText, function () { opener = hauptKnopf; oeffne(false) })
|
||
hauptKnopf.setAttribute('aria-haspopup', 'dialog')
|
||
opener = hauptKnopf
|
||
root.appendChild(hauptKnopf)
|
||
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() })
|
||
|
||
// Einziger Abruf beim Laden der Seite: die Konfiguration. Verlangt sie die Widerrufsfunktion (§ 356a BGB), steht der Knopf "Vertrag
|
||
// widerrufen" dauerhaft neben dem Buchungsknopf (nicht erst im geoeffneten Dialog). Scheitert der Abruf oder ist die Funktion nicht
|
||
// aktiv, bleibt alles wie es ist; der Klick auf den Buchungsknopf versucht es noch einmal.
|
||
lade().then(function () {
|
||
if (!S.cfg || S.cfg.widerruf !== true || widerrufKnopf) return
|
||
widerrufKnopf = knopf('wkt-knopf wkt-btn-sek', 'Vertrag widerrufen', function () { opener = widerrufKnopf; oeffne(true) })
|
||
widerrufKnopf.setAttribute('aria-haspopup', 'dialog')
|
||
root.insertBefore(widerrufKnopf, hauptKnopf.nextSibling)
|
||
}, function () { /* nicht erreichbar oder nicht aktiv */ })
|
||
}
|
||
|
||
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()
|
||
})()
|