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()
+13 -3
View File
@@ -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)
+34 -4
View File
@@ -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
View File
@@ -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)); }