Files
Webklar-Kundenbereich/public/embed/termine.js
T
ClaudeandClaude Sonnet 5.5 5fef7e47f0 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 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00

1393 lines
62 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*! 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>, 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()
})()