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:
1 parent
bd65c9dc58
commit
794a63c0d7
12 files changed
+536
-64
No files matched your search
+139
-45
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user