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()
|
||||
|
||||
+13
-3
@@ -57,11 +57,21 @@
|
||||
} catch (e) { return '' }
|
||||
}
|
||||
|
||||
// Telefonnummer als tel:-Link, wenn sie nur aus Ziffern, Leerzeichen, + - ( ) / besteht
|
||||
// @telefon-anfang (reine Funktion, test/termineSeiteStatic.test.js fuehrt sie einzeln aus; dieselbe Regel in public/embed/termine.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 (!istText(nr) || !/^[0-9 +()/-]+$/.test(nr)) return ''
|
||||
return 'tel:' + nr.replace(/[^0-9+]/g, '')
|
||||
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 externerLink (href, text) {
|
||||
const a = el('a', '', text)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* Terminplaner im Kundenbereich: Reiter "Einstellungen" (Spezifikation Abschnitt 9, Punkt 4) – Betriebsangaben fuer Gaeste, Anrede und Farbe,
|
||||
Regeln, Angaben von Gaesten (Zusatzfelder), Erinnerung, Stornobedingungen (mit Muster), Rechtliches, Bezahlung, Aufbewahrung,
|
||||
darunter Export und Status. render(panel, ctx) ist der Einstieg (Registry REITER in termine.js).
|
||||
darunter der Einbau-Code fuer die Website, Export und Status. render(panel, ctx) ist der Einstieg (Registry REITER in termine.js).
|
||||
Alles ist EIN Formular mit einem Speichern (termine-speichern.js). Jeder Abschnitt ist eine Funktion ctx => { el, lese(entwurf, fehler) }:
|
||||
el die Karte, lese uebertraegt die Felder in den Entwurf und meldet Eingaben, die kein Feld verstehen kann, in fehler (Pfad ⇒ Text).
|
||||
Pruefen und Begrenzen bleibt Sache des Servers. Abgeschaltete Funktion (ctx.nurLesen): Felder gesperrt, kein Speichern, Export bleibt. */
|
||||
@@ -16,7 +16,7 @@ import { nachSpeichern, neuerSpeicher } from './termine-speichern.js'
|
||||
const WIDERRUF_HINWEIS = 'Privatpersonen können eine Buchung innerhalb von 14 Tagen nach Vertragsschluss widerrufen (bei Anfragen zählt deine Annahme). '
|
||||
+ 'Das gesetzliche Widerrufsrecht geht deiner Stornoregel vor: In dieser Zeit fällt bei einem Widerruf keine Stornogebühr an, auch wenn deine Stornofrist schon abgelaufen ist. '
|
||||
+ 'Liegt der Termin innerhalb dieser 14 Tage, fragt der Terminplaner deine Gäste, ob du vorher beginnen darfst. '
|
||||
+ 'Belehrung und Knopf „Vertrag widerrufen“ zeigt der Terminplaner automatisch, wenn deine Gäste Privatpersonen sind und du eine Leistung mit sofortiger Buchung anbietest.'
|
||||
+ 'Belehrung und Knopf „Vertrag widerrufen“ zeigt der Terminplaner automatisch, wenn deine Gäste Privatpersonen sind, bei sofort buchbaren Leistungen ebenso wie bei Anfragen.'
|
||||
const FREIZEIT_HINWEIS = 'Nur ankreuzen, wenn deine Termine Freizeitveranstaltungen zu einem festen Termin sind (§ 312g Abs. 2 Nr. 9 BGB), zum Beispiel ein einzelner Tanzabend oder ein Escape Room. '
|
||||
+ 'Friseur, Kosmetik, Massage, Tattoo, Beratung, Fotoshootings und Weiterbildungen sind in der Regel keine Freizeitleistung. '
|
||||
+ 'Kreuzt du es an, zeigt der Terminplaner keine Widerrufsbelehrung. Kreuzt du es zu Unrecht an, können deine Gäste noch bis zu 12 Monate und 14 Tage widerrufen. '
|
||||
@@ -62,7 +62,7 @@ export function render(panel, ctx) {
|
||||
if (!ok || !sp.nochHier()) return
|
||||
nachSpeichern(t, 'Einstellungen gespeichert.', 'form[aria-label="Einstellungen"] button[type="submit"]')
|
||||
})
|
||||
wurzel.replaceChildren(sp.konflikt, form, exportUndStatus(ctx))
|
||||
wurzel.replaceChildren(sp.konflikt, form, einbau(ctx), exportUndStatus(ctx))
|
||||
}
|
||||
zeichne()
|
||||
}
|
||||
@@ -89,7 +89,7 @@ function betrieb(ctx, e) {
|
||||
feld('betrieb.email', 'E-Mail des Betriebs', f.email, 'Hierhin schicken wir dir Nachrichten zu Terminen. Gäste können auf ihre Mails hier antworten.')),
|
||||
h('div', { class: 'tm-raster' },
|
||||
feld('betrieb.telefon', 'Telefon (Pflicht)', f.telefon, 'Gäste sehen die Nummer als Ausweichweg und in ihren Mails.'),
|
||||
feld('betrieb.adresse', 'Anschrift', f.adresse, 'Pflicht, wenn Privatpersonen eine Leistung sofort buchen: Die Anschrift steht in der Widerrufsbelehrung.')),
|
||||
feld('betrieb.adresse', 'Anschrift', f.adresse, 'Pflicht, wenn Privatpersonen bei dir buchen oder anfragen: Die Anschrift steht in der Widerrufsbelehrung.')),
|
||||
hakenFeld('betrieb.kleinunternehmer', f.kleinunternehmer, 'Ich bin Kleinunternehmer nach § 19 UStG', 'Dann steht bei den Preisen „kein Ausweis der USt nach § 19 UStG“ statt „inkl. USt“.'),
|
||||
h('div', { class: 'tm-raster' },
|
||||
feld('betrieb.impressumUrl', 'Link zum Impressum', f.impressumUrl, 'Vollständige Adresse mit https://'),
|
||||
@@ -277,6 +277,36 @@ function aufbewahrung(e) {
|
||||
return { el: abschnitt('Aufbewahrung', null, tage.el), lese(entwurf, fehler) { entwurf.aufbewahrungTage = liesZahl(fehler, 'aufbewahrungTage', tage.input) } }
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------ Einbau-Code (Website) --- */
|
||||
/** Einbau-Code aus GET /einstellungen (einbau.snippet, vom Server gebaut, nur lesbar) mit Kopierknopf; gleiches Muster wie der Kalender-Link. */
|
||||
function einbau(ctx) {
|
||||
const snippet = ctx.daten.einbau?.snippet
|
||||
if (typeof snippet !== 'string' || !snippet || ctx.nurLesen) return null
|
||||
const eingabe = h('textarea', { readonly: true, rows: '5', class: 'tm-code-feld', spellcheck: 'false', wrap: 'off', 'aria-label': 'Einbau-Code für deine Website' })
|
||||
eingabe.value = snippet
|
||||
eingabe.addEventListener('focus', () => eingabe.select())
|
||||
const status = h('p', { class: 'tm-hinweis', role: 'status' })
|
||||
|
||||
async function kopiere() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(eingabe.value)
|
||||
status.textContent = 'Der Code ist kopiert.'
|
||||
} catch {
|
||||
// kein Zugriff auf die Zwischenablage (Browser, Einstellung): der Code bleibt sichtbar und markiert zum Kopieren von Hand
|
||||
eingabe.focus()
|
||||
eingabe.select()
|
||||
status.textContent = 'Das Kopieren hat nicht geklappt. Der Code ist markiert: drücke Strg+C (Mac: Cmd+C), am Handy halte ihn gedrückt und wähle „Kopieren“.'
|
||||
}
|
||||
}
|
||||
|
||||
return abschnitt('Einbau auf deiner Website',
|
||||
'Diesen Code fügst du auf deiner Website an der Stelle ein, an der Gäste Termine buchen sollen. Der Text darin bleibt stehen, solange die Buchung nicht geladen werden kann. '
|
||||
+ 'Gebucht werden kann erst, wenn deine Angaben vollständig sind und die Adresse deiner Website bei deinem Projekt als Live-Adresse eingetragen ist.',
|
||||
eingabe,
|
||||
h('div', { class: 'tm-frage-knoepfe' }, h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'einbau-kopieren', onclick: kopiere }, 'Code kopieren')),
|
||||
status)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ Export, Status --- */
|
||||
function exportUndStatus(ctx) {
|
||||
const seit = aktivSeitText(ctx.daten.aktivSeit)
|
||||
|
||||
+6
-1
@@ -170,6 +170,11 @@
|
||||
width: 100%; box-sizing: border-box; padding: 10px 12px; border-radius: 12px; border: 1px solid var(--kb-border-strong);
|
||||
background: rgba(255, 255, 255, 0.05); color: inherit; font: inherit; font-size: 13px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; text-overflow: ellipsis;
|
||||
}
|
||||
.tm-code-feld {
|
||||
display: block; width: 100%; box-sizing: border-box; margin: 4px 0 10px; padding: 10px 12px; border-radius: 12px; border: 1px solid var(--kb-border-strong);
|
||||
background: rgba(255, 255, 255, 0.05); color: inherit; font: inherit; font-size: 13px; line-height: 1.5; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
white-space: pre; overflow: auto; resize: vertical;
|
||||
}
|
||||
.tm-muster { margin-top: 14px; padding: 14px 16px; border-radius: 12px; border: 1px dashed var(--kb-border-strong); }
|
||||
.tm-muster > p { margin: 0 0 10px; font-size: 13.5px; line-height: 1.55; color: var(--kb-muted); }
|
||||
.tm-muster-text { margin: 0 0 12px; padding: 0 0 0 12px; border-left: 3px solid var(--kb-border-strong); font-size: 13.5px; line-height: 1.6; color: var(--kb-muted); font-style: italic; }
|
||||
@@ -191,7 +196,7 @@
|
||||
.tm-details summary { display: flex; align-items: center; min-height: 44px; padding: 0; }
|
||||
.tm-haken { align-items: center; min-height: 44px; margin-top: 0; margin-bottom: 0; }
|
||||
.tm-haken input { margin-top: 0; }
|
||||
.tm-link-feld { min-height: 44px; font-size: 16px; } /* 16 px: iOS zoomt beim Antippen kleinerer Felder */
|
||||
.tm-link-feld, .tm-code-feld { min-height: 44px; font-size: 16px; } /* 16 px: iOS zoomt beim Antippen kleinerer Felder */
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.tm-reiterleiste { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
|
||||
Reference in new issue
Block a user