Terminplaner: Abschluss – Client (Formular-Token, Widerruf prominent, Telefonlink, Einbau-Code, G02)

- 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>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-03 08:17:44 +00:00
1 parent bd65c9dc58
commit 794a63c0d7
12 files changed
+536 -64

No files matched your search

+139 -45
View File
@@ -1,6 +1,7 @@
/*! 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-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. */
@@ -11,6 +12,8 @@
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']]
@@ -94,11 +97,10 @@
'.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}',
'.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}',
@@ -161,6 +163,22 @@
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
@@ -217,7 +235,9 @@
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 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 }
@@ -323,6 +343,32 @@
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 () {
@@ -454,18 +500,21 @@
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-link', 'Vertrag widerrufen', function () {
S.ansicht = 'widerruf'
S.widerrufErgebnis = null
S.fehler = {}
S.meldung = ''
zeichne(true)
}))
links.appendChild(knopf('wkt-btn wkt-btn-sek', 'Vertrag widerrufen', zeigeWiderruf))
}
var ds = httpsUrl(c.betrieb.datenschutzUrl)
if (ds) {
@@ -489,9 +538,14 @@
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 a = el('a', '', tel)
a.href = 'tel:' + tel.replace(/[^0-9+]/g, '')
p.appendChild(a)
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() }))
@@ -518,7 +572,9 @@
// ------------------------------------------------------------ Schritte
function starte (fokus, meldung) {
// 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()
@@ -526,7 +582,8 @@
S.cfgAt = at
S.vorLeistung = vorgabeLeistung && leistungMitId(vorgabeLeistung) ? vorgabeLeistung : ''
setzeBetriebsfarbe(ui.box, cfg.farbe)
if (S.vorLeistung) waehleLeistung(S.vorLeistung, { fokus: fokus, meldung: meldung })
if (zumWiderruf) zeigeWiderruf()
else if (S.vorLeistung) waehleLeistung(S.vorLeistung, { fokus: fokus, meldung: meldung })
else gehe('leistung', { fokus: fokus, meldung: meldung })
}
@@ -1238,28 +1295,31 @@
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')
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
@@ -1416,7 +1476,11 @@
// Aus der Widerrufsansicht zurueck zur Buchung (der Stand der Buchung bleibt)
function zurueckKnopf () {
return knopf('wkt-btn wkt-btn-sek', 'Zurück zur Terminbuchung', function () { S.ansicht = 'buchung'; zeichne(true) })
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 () {
@@ -1449,17 +1513,36 @@
if (opener) opener.focus()
}
function oeffne () {
// 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', '')
}
if (S.cfg && !S.tot && S.step && S.step !== 'ergebnis') {
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 …')
@@ -1468,7 +1551,7 @@
l.focus()
lade().then(function () {
try {
starte(true)
starte(true, undefined, zumWiderruf === true)
} catch (e) {
zeigeAusweich() // Aufbau scheitert: der Ausweichtext statt "Wird geladen …"
}
@@ -1489,9 +1572,10 @@
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)
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')
@@ -1503,6 +1587,16 @@
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()