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
@@ -1,6 +1,6 @@
|
||||
# Terminplaner auf der Kundenwebsite einbauen
|
||||
|
||||
Anleitung für das WEBklar-Team. Das Widget lässt Gäste auf der Website des Kunden Termine buchen. Leistungen, Personen und Öffnungszeiten pflegt der Kunde im Portal (Tab „Funktionen“, Kachel „Terminplaner“). Den `siteKey` findest Du im Ticketsystem beim Projekt (16 Zeichen, a–z und 2–7). Ohne aktive Funktion, ohne gespeicherte Einstellung und ohne Telefonnummer des Betriebs (bei Verbrauchern auch ohne Anschrift) ist nichts buchbar: Das Widget zeigt dann den Ausweichtext der Seite.
|
||||
Anleitung für das WEBklar-Team. Das Widget lässt Gäste auf der Website des Kunden Termine buchen. Leistungen, Personen und Öffnungszeiten pflegt der Kunde im Portal (Tab „Funktionen“, Kachel „Terminplaner“). Den `siteKey` (16 Zeichen, a–z und 2–7) und den fertigen Einbau-Code findest Du im Portal: in der Kundenansicht des Kunden unter Terminplaner → Einstellungen, Abschnitt „Einbau auf deiner Website“ (mit Knopf „Code kopieren“). Den `siteKey` zeigt auch das Ticketsystem (WOMS): Kunde → Reiter „Funktionen“, Zeile Terminplaner, mit Kopierknopf. Ohne aktive Funktion, ohne gespeicherte Einstellung und ohne Telefonnummer des Betriebs (bei Verbrauchern auch ohne Anschrift) ist nichts buchbar: Das Widget zeigt dann den Ausweichtext der Seite.
|
||||
|
||||
## 1. Das Snippet
|
||||
|
||||
@@ -19,7 +19,7 @@ Das Skript steht einmal pro Seite, vor `</body>`. Es hat keine Abhängigkeiten,
|
||||
| --- | --- | --- |
|
||||
| `data-webklar-termine` | ja | der `siteKey` des Projekts |
|
||||
| `data-api` | nein | Herkunft des Portals, Standard `https://project.webklar.com`. Ohne Angabe nimmt das Skript die Herkunft seiner eigenen Adresse. Das klappt nur bei einem normalen `<script src>`. Bei Modul-Import, Tag-Manager oder nachgeladenem Skript ist `data-api` Pflicht. |
|
||||
| `data-modus` | nein | `inline` (Standard): die Buchungsstrecke steht direkt in der Seite und lädt beim Aufruf der Seite. `knopf`: ein Knopf öffnet die Strecke in einem Dialog (`<dialog>`), Daten werden erst beim Klick geladen. |
|
||||
| `data-modus` | nein | `inline` (Standard): die Buchungsstrecke steht direkt in der Seite und lädt beim Aufruf der Seite. `knopf`: ein Knopf öffnet die Strecke in einem Dialog (`<dialog>`). Beim Laden der Seite ruft das Widget einmal die Konfiguration ab (für den Knopf „Vertrag widerrufen“, siehe Abschnitt 5), alles Weitere erst beim Klick. |
|
||||
| `data-knopf` | nein | Text des Knopfes im Modus `knopf`, Standard „Termin buchen“. |
|
||||
| `data-leistung` | nein | ID einer Leistung (`s_` plus 8 Zeichen, steht im Portal bei der Leistung). Die Leistung ist vorgewählt, der Schritt „Leistung wählen“ entfällt. Praktisch für einen Knopf neben einer Leistung auf der Seite. Eine unbekannte ID wird ignoriert. |
|
||||
|
||||
@@ -118,22 +118,23 @@ Bricht das Netz mitten in der Strecke ab, zeigt das Widget „Gerade nicht errei
|
||||
5. Zusammenfassung mit Beschreibung der Leistung, Stornobedingungen (daneben der Link „AGB des Betriebs“, wenn im Portal eine https-Adresse hinterlegt ist), Widerrufsbelehrung (aufklappbar, bei Verbraucher-Betrieben), Datenschutz-Hinweis, den Haken (Storno, vorzeitiger Beginn, Erinnerung) und dem Knopf („Zahlungspflichtig buchen“, „Termin buchen“ oder „Termin anfragen“, kommt vom Server).
|
||||
6. „Fast geschafft“: Der Gast bestätigt den Termin über den Link in der E-Mail. Die Reservierung gilt 30 Minuten.
|
||||
|
||||
Im Fuß stehen „Vertrag widerrufen“ (nur wenn der Betrieb verbindliche Leistungen für Verbraucher anbietet), „Datenschutz“ (Link auf die Datenschutzerklärung des Betriebs) und klein „Terminbuchung über WEBklar“. Anrede (Sie oder Du) und Betriebsfarbe kommen aus den Einstellungen. Die rechtlich vorgeschriebenen Texte (Haken, Belehrung) stehen immer in der Sie-Form.
|
||||
Im Fuß stehen „Vertrag widerrufen“ als zweiter Knopf (kein bloßer Textlink; für Verbraucher bei jeder Leistung, ob sofort buchbar oder als Anfrage, weil auch ein angenommener Anfrage-Vertrag widerrufen werden kann), „Datenschutz“ (Link auf die Datenschutzerklärung des Betriebs) und klein „Terminbuchung über WEBklar“. Im Modus `knopf` steht der Fuß nur im geöffneten Dialog. Damit die Widerrufsfunktion (§ 356a BGB) trotzdem dauerhaft auffindbar ist, lädt das Widget dort schon beim Laden der Seite die Konfiguration (ein einziger Abruf, sonst nichts) und setzt, wenn der Betrieb ein Widerrufsrecht hat, den Knopf „Vertrag widerrufen“ als zweiten Knopf neben den Buchungsknopf. Er öffnet den Dialog gleich beim Widerrufsformular. Anrede (Sie oder Du) und Betriebsfarbe kommen aus den Einstellungen. Die rechtlich vorgeschriebenen Texte (Haken, Belehrung) stehen immer in der Sie-Form.
|
||||
|
||||
Bedienung nur mit der Tastatur: Tab und Umschalt+Tab zwischen den Elementen, im Monatsraster Pfeiltasten (Tag), Pos1 und Ende (Wochenanfang und -ende), Bild ↑ und Bild ↓ (Monat), Eingabe oder Leertaste wählt den Tag. Im Dialog schließt Esc.
|
||||
|
||||
## 6. Datenschutzerklärung des Kunden
|
||||
|
||||
Das Skript lädt beim Aufruf der Seite (Modus `inline`) bzw. beim Klick (Modus `knopf`) Termine und Texte von `project.webklar.com`. Dabei sieht der Server die IP-Adresse des Besuchers. Die Datenschutzerklärung der Website braucht deshalb den Absatz „Online-Terminbuchung“ aus `docs/rechtliches/termine-datenschutz-absatz.md` (zwei Fassungen: Sie und Du, nimm die zur Erklärung der Website passende). Der Absatz trägt den Kopf **„ENTWURF – nur Pilot, vor Einsatz bei fremden Betrieben prüfen“**. Solange der Kopf dort steht, gilt: nur beim Pilotkunden einsetzen, bei anderen Betrieben erst nach der Rechtsprüfung. Die `[[wenn …]]`-Sätze richten sich nach den Einstellungen des Betriebs (Telefon-Feld, Nachricht, Erinnerung, Verbraucher). Bau das Widget erst ein, wenn der Absatz in der Datenschutzerklärung steht. Die URL der Datenschutzerklärung (und des Impressums) muss in den Betriebsangaben im Portal stehen, sonst fehlt der Link im Fuß.
|
||||
Das Skript lädt beim Aufruf der Seite die Konfiguration (Texte, Leistungen) von `project.webklar.com`; im Modus `inline` folgen die Termine beim Durchklicken, im Modus `knopf` erst beim Klick auf den Knopf. Dabei sieht der Server die IP-Adresse des Besuchers. Die Datenschutzerklärung der Website braucht deshalb den Absatz „Online-Terminbuchung“ aus `docs/rechtliches/termine-datenschutz-absatz.md` (zwei Fassungen: Sie und Du, nimm die zur Erklärung der Website passende). Der Absatz trägt den Kopf **„ENTWURF – nur Pilot, vor Einsatz bei fremden Betrieben prüfen“**. Solange der Kopf dort steht, gilt: nur beim Pilotkunden einsetzen, bei anderen Betrieben erst nach der Rechtsprüfung. Die `[[wenn …]]`-Sätze richten sich nach den Einstellungen des Betriebs (Telefon-Feld, Nachricht, Erinnerung, Verbraucher). Bau das Widget erst ein, wenn der Absatz in der Datenschutzerklärung steht. Die URL der Datenschutzerklärung (und des Impressums) muss in den Betriebsangaben im Portal stehen, sonst fehlt der Link im Fuß.
|
||||
|
||||
## 7. Abnahme-Checkliste
|
||||
|
||||
- [ ] Funktion im Ticketsystem aktiviert (Zustand „aktiv“), `TERMINE_ENABLED` auf `pilot` oder `on`, Kunde im Pilot.
|
||||
- [ ] Funktion im Ticketsystem aktiviert (Zustand „aktiv“), `TERMINE_ENABLED` auf `pilot` oder `on`, Kunde im Pilot. `siteKey` und Einbau-Code aus dem Portal (Terminplaner → Einstellungen → „Einbau auf deiner Website“) übernommen.
|
||||
- [ ] Einstellungen im Portal gespeichert, Telefon und Anschrift des Betriebs eingetragen (das Portal weist darauf hin, solange etwas fehlt), URLs von Impressum und Datenschutz mit `https://`.
|
||||
- [ ] Datenschutz-Absatz steht in der Datenschutzerklärung der Website (Abschnitt 6).
|
||||
- [ ] Ausweichtext prüfen: JavaScript aus, Funktion im Ticketsystem ausgeschaltet oder falscher `siteKey`: Der Text steht, nichts ist kaputt.
|
||||
- [ ] Komplett durchbuchen bis „Fast geschafft“; die Bestätigungs-E-Mail kommt an, der Link bestätigt, die zweite E-Mail (mit Kalenderdatei) kommt an, im Portal steht der Termin. Danach den Testtermin absagen (Verwaltungslink aus der E-Mail oder im Portal).
|
||||
- [ ] Bei Preis und Verbraucher-Betrieb: Widerrufsbelehrung aufklappbar, Haken für vorzeitigen Beginn erscheint bei Terminen in den nächsten 14 Tagen, „Vertrag widerrufen“ steht im Fuß und das Formular funktioniert.
|
||||
- [ ] Bei Verbraucher-Betrieb (auch mit nur Anfrage-Leistungen): Widerrufsbelehrung aufklappbar, bei Preis erscheint der Haken für vorzeitigen Beginn bei Terminen in den nächsten 14 Tagen, „Vertrag widerrufen“ steht als Knopf im Fuß und das Formular funktioniert.
|
||||
- [ ] Modus `knopf` bei Verbraucher-Betrieb: Neben dem Buchungsknopf steht dauerhaft der Knopf „Vertrag widerrufen“ (nicht erst im Dialog) und öffnet den Dialog gleich beim Widerrufsformular; ohne Widerrufsrecht (Unternehmen, Freizeitleistung) fehlt er.
|
||||
- [ ] Nur mit der Tastatur bedienen: Fokus immer sichtbar, Monatsraster mit Pfeilen, Pos1/Ende und Bild ↑/↓, Esc schließt den Dialog und der Fokus geht zum Knopf zurück.
|
||||
- [ ] Handy (390 px Breite): kein seitliches Scrollen, Knöpfe gut treffbar, Dialog passt in den Bildschirm.
|
||||
- [ ] Zoom 200 % und große Schrift: nichts wird abgeschnitten.
|
||||
|
||||
+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)); }
|
||||
|
||||
@@ -62,7 +62,7 @@ a{color:#111111;}
|
||||
<tr><td class="px mut" style="padding:30px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Für diese Buchung besteht kein Widerrufsrecht (§ 312g Abs. 2 Nr. 9 BGB). Absagen richten sich nach den Stornobedingungen.</td></tr>
|
||||
<!-- ENDE kein_widerruf -->
|
||||
<!-- WENN widerruf -->
|
||||
<tr><td class="px mut" style="padding:30px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;"><!-- WENN sie --><strong>Widerrufsrecht.</strong> Wenn Sie Verbraucher sind, können Sie diesen Vertrag innerhalb von 14 Tagen ab Vertragsschluss ohne Angabe von Gründen widerrufen. Dafür können Sie jederzeit den Knopf „Vertrag widerrufen“ nutzen. Die ausführliche Widerrufsbelehrung und das Muster-Widerrufsformular finden Sie weiter unten in dieser E-Mail.<!-- ENDE sie --><!-- WENN du --><strong>Widerrufsrecht.</strong> Wenn du Verbraucher bist, kannst du diesen Vertrag innerhalb von 14 Tagen ab Vertragsschluss ohne Angabe von Gründen widerrufen. Dafür kannst du jederzeit den Knopf „Vertrag widerrufen“ nutzen. Die ausführliche Widerrufsbelehrung und das Muster-Widerrufsformular findest du weiter unten in dieser E-Mail.<!-- ENDE du --></td></tr>
|
||||
<tr><td class="px mut" style="padding:30px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;"><!-- WENN sie --><strong>Widerrufsrecht.</strong> Wenn Sie Verbraucher sind, können Sie diesen Vertrag innerhalb von 14 Tagen ab Vertragsschluss ohne Angabe von Gründen widerrufen. Bis zum Beginn des Termins geht das über den Knopf „Vertrag widerrufen“; danach über das Formular „Vertrag widerrufen“ auf der Website von {{betrieb_name}} oder per E-Mail an {{betrieb_email}}. Die ausführliche Widerrufsbelehrung und das Muster-Widerrufsformular finden Sie weiter unten in dieser E-Mail.<!-- ENDE sie --><!-- WENN du --><strong>Widerrufsrecht.</strong> Wenn du Verbraucher bist, kannst du diesen Vertrag innerhalb von 14 Tagen ab Vertragsschluss ohne Angabe von Gründen widerrufen. Bis zum Beginn des Termins geht das über den Knopf „Vertrag widerrufen“; danach über das Formular „Vertrag widerrufen“ auf der Website von {{betrieb_name}} oder per E-Mail an {{betrieb_email}}. Die ausführliche Widerrufsbelehrung und das Muster-Widerrufsformular findest du weiter unten in dieser E-Mail.<!-- ENDE du --></td></tr>
|
||||
<tr><td class="px" style="padding:18px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn2" style="border:2px solid #111111;border-radius:10px;"><a href="{{widerruf_link}}" style="display:block;padding:12px 24px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#111111;text-decoration:none;">Vertrag widerrufen</a></td></tr></table></td></tr>
|
||||
<tr><td class="px ink" style="padding:26px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:15px;line-height:22px;font-weight:bold;color:#111111;">Widerrufsbelehrung und Muster-Widerrufsformular</td></tr>
|
||||
<tr><td class="px mut" style="padding:4px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:13px;line-height:20px;mso-line-height-rule:exactly;color:#6b6964;">Rechtlich vorgeschriebener Wortlaut:</td></tr>
|
||||
|
||||
@@ -660,6 +660,10 @@ test('G02: Zusammenfassung, Verwaltungslink, Preis, Person, Storno und Widerruf
|
||||
assert.match(voll.text, /Absage bis 24 Stunden vorher kostenlos\./)
|
||||
assert.match(voll.text, /Wert-betrieb_name hat Ihre Terminanfrage angenommen/)
|
||||
assert.match(voll.text, /Vertrag widerrufen: https:\/\/project\.webklar\.com\/widerrufen\.html#m=tok/)
|
||||
// Ruling R55/Abschlussrunde: der Knopf geht nur bis zum Beginn des Termins, danach Formular auf der Website oder E-Mail
|
||||
const WEG = 'Bis zum Beginn des Termins geht das über den Knopf „Vertrag widerrufen“; danach über das Formular „Vertrag widerrufen“ auf der Website von Wert-betrieb_name oder per E-Mail an Wert-betrieb_email.'
|
||||
assert.ok(voll.text.includes(WEG), voll.text)
|
||||
assert.doesNotMatch(voll.text, /jederzeit den Knopf/)
|
||||
// Belehrung wörtlich, Zeilenumbrüche bleiben, Sonderzeichen sind maskiert
|
||||
assert.match(voll.html, /Widerrufsrecht: 14 Tage <b>ohne<\/b> Gründe\.<br>Zweite Zeile & mehr/)
|
||||
assert.ok(voll.text.includes('Widerrufsbelehrung\n\nWiderrufsrecht: 14 Tage <b>ohne</b> Gründe.\nZweite Zeile & mehr'), voll.text)
|
||||
@@ -680,6 +684,8 @@ test('G02: Zusammenfassung, Verwaltungslink, Preis, Person, Storno und Widerruf
|
||||
const du = renderMail('G02', values, { du: true, anfrage_angenommen: true, widerruf: true })
|
||||
assert.match(du.text, /Dein Termin ist bestätigt/)
|
||||
assert.match(du.text, /hat deine Terminanfrage angenommen/)
|
||||
assert.ok(du.text.includes(WEG), du.text)
|
||||
assert.doesNotMatch(du.text, /jederzeit/)
|
||||
assert.doesNotMatch(du.text, SIE_FORM)
|
||||
})
|
||||
|
||||
|
||||
@@ -87,6 +87,66 @@ test('Ausweichtext wird nie verbraucht (nur Kopien zurueck auf die Seite), Link
|
||||
assert.match(OHNE_KOMMENTARE, /httpsUrl\(c\.betrieb\.agbUrl\)/)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Telefonnummer, Formular-Token, Widerrufsknoepfe (Abschlussrunde)
|
||||
|
||||
/** Die reine Funktion telefonLink des Skripts (zwischen den Marken @telefon-anfang und @telefon-ende) einzeln ausfuehren. */
|
||||
function telefonFunktion() {
|
||||
const m = /\/\/ @telefon-anfang[^\n]*\n([\s\S]*?)\/\/ @telefon-ende/.exec(QUELLE)
|
||||
assert.ok(m, 'Marken @telefon-anfang/@telefon-ende fehlen')
|
||||
return vm.runInNewContext(`${m[1]}; telefonLink`, {})
|
||||
}
|
||||
|
||||
test('telefonLink: Nummern werden gewaehlt, freier Text nie (Buchstaben, Listen), "(0)" hinter der Landesvorwahl entfaellt', () => {
|
||||
const telefonLink = telefonFunktion()
|
||||
const faelle = [
|
||||
['06301 1234', 'tel:063011234'],
|
||||
['+49 6301 1234', 'tel:+4963011234'],
|
||||
['+49 (0) 6301-1234', 'tel:+4963011234'],
|
||||
['+49(0)6301 1234', 'tel:+4963011234'],
|
||||
['+49 (0)6301/12 34 5', 'tel:+49630112345'],
|
||||
['0049 (0) 6301 1234', 'tel:004963011234'],
|
||||
['(06301) 1234', 'tel:063011234'],
|
||||
['06301/1234-0', 'tel:0630112340'],
|
||||
['06301.1234', 'tel:063011234'],
|
||||
[' +49 160 1234567 ', 'tel:+491601234567'],
|
||||
['(0)6301 1234', 'tel:063011234'],
|
||||
// Text mit Buchstaben oder Mehrfachangaben: kein Link
|
||||
['06301 1234 (ab 9 Uhr)', ''],
|
||||
['Tel. 06301 1234', ''],
|
||||
['06301 1234 oder 0171 555', ''],
|
||||
['0171 1, 0171 2', ''],
|
||||
['06301 1234; 0171 2', ''],
|
||||
['ruf an', ''],
|
||||
['+49 (0) 6301 1234 ext. 5', ''],
|
||||
// keine Nummer
|
||||
['', ''], [' ', ''], ['12', ''], ['+', ''], [undefined, ''], [null, ''], [1234567, ''], [{}, ''],
|
||||
]
|
||||
for (const [eingabe, soll] of faelle) assert.equal(telefonLink(eingabe), soll, JSON.stringify(eingabe))
|
||||
})
|
||||
|
||||
test('R53: das Widget wartet vor dem Absenden, bis das Formular-Token formMinSekunden plus Puffer alt ist (Einen Moment …), Messung mit performance.now', () => {
|
||||
for (const noetig of ['formMinSekunden', 'FORM_PUFFER_MS', 'FORM_MIN_VORGABE_S', 'warteAufFormToken', 'Einen Moment …']) {
|
||||
assert.ok(QUELLE.includes(noetig), `${noetig} fehlt im Skript`)
|
||||
}
|
||||
assert.match(OHNE_KOMMENTARE, /FORM_PUFFER_MS\s*=\s*500\b/, 'Puffer 0,5 s')
|
||||
assert.match(OHNE_KOMMENTARE, /FORM_MIN_VORGABE_S\s*=\s*3\b/, 'Vorgabe 3 s wie der Server')
|
||||
// der Abruf der Buchung steht erst nach dem Warten
|
||||
const sende = OHNE_KOMMENTARE.slice(OHNE_KOMMENTARE.indexOf('function sende ('))
|
||||
assert.ok(sende.indexOf('warteAufFormToken(senden)') > 0 && sende.indexOf("request('POST', '/buchungen'") > sende.indexOf('warteAufFormToken(senden)'), 'POST /buchungen nur nach warteAufFormToken')
|
||||
assert.equal([...OHNE_KOMMENTARE.matchAll(/request\('POST', '\/buchungen'/g)].length, 1, 'genau eine Stelle sendet Buchungen')
|
||||
})
|
||||
|
||||
test('R54: Widerruf im Fuss ist ein zweiter Knopf (wkt-btn wkt-btn-sek, 44 px), kein Textlink; im Knopfmodus ein Abruf beim Laden und ein dauerhafter Knopf', () => {
|
||||
assert.ok(OHNE_KOMMENTARE.includes("knopf('wkt-btn wkt-btn-sek', 'Vertrag widerrufen'"), 'Fuss: sekundaerer Knopf')
|
||||
assert.doesNotMatch(OHNE_KOMMENTARE, /wkt-link/, 'kein Textlink-Knopf')
|
||||
assert.match(QUELLE, /\.wkt-btn,\.wkt-knopf\{[^}]*min-height:44px/, 'Knoepfe mindestens 44 px hoch')
|
||||
// Knopfmodus: neben dem Buchungsknopf, oeffnet den Dialog gleich beim Widerruf
|
||||
assert.ok(OHNE_KOMMENTARE.includes("knopf('wkt-knopf wkt-btn-sek', 'Vertrag widerrufen'"), 'dauerhafter Knopf im Knopfmodus')
|
||||
assert.ok(OHNE_KOMMENTARE.includes('oeffne(true)'), 'der Knopf oeffnet den Dialog beim Widerrufsformular')
|
||||
const start = OHNE_KOMMENTARE.slice(OHNE_KOMMENTARE.indexOf('function startKnopf'))
|
||||
assert.ok(start.includes('lade()') && start.indexOf('lade()') < start.indexOf('widerrufKnopf = knopf'), 'startKnopf holt die Konfiguration beim Laden')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Textfarbe auf der Betriebsfarbe (Kontrast)
|
||||
|
||||
/** Die reinen Farbfunktionen des Skripts (zwischen den Marken @farben-anfang und @farben-ende) einzeln ausfuehren. */
|
||||
|
||||
@@ -110,6 +110,41 @@ test('termin.css: keine Fremdressourcen (keine Schrift- oder Bildadressen von au
|
||||
assert.doesNotMatch(CSS, /https?:\/\/|@import|url\(/, 'termin.css darf nichts nachladen')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Telefonnummer (Abschlussrunde)
|
||||
|
||||
/** Die reine Funktion telefonLink des Skripts (zwischen den Marken @telefon-anfang und @telefon-ende) einzeln ausfuehren. */
|
||||
function telefonFunktion() {
|
||||
const m = /\/\/ @telefon-anfang[^\n]*\n([\s\S]*?)\/\/ @telefon-ende/.exec(QUELLE)
|
||||
assert.ok(m, 'Marken @telefon-anfang/@telefon-ende fehlen')
|
||||
return vm.runInNewContext(`${m[1]}; telefonLink`, {})
|
||||
}
|
||||
|
||||
test('telefonLink: Nummern werden gewaehlt, freier Text nie (Buchstaben, Listen), "(0)" hinter der Landesvorwahl entfaellt (dieselbe Regel wie im Widget)', () => {
|
||||
const telefonLink = telefonFunktion()
|
||||
const faelle = [
|
||||
['06301 1234', 'tel:063011234'],
|
||||
['+49 6301 1234', 'tel:+4963011234'],
|
||||
['+49 (0) 6301-1234', 'tel:+4963011234'],
|
||||
['+49(0)6301 1234', 'tel:+4963011234'],
|
||||
['+49 (0)6301/12 34 5', 'tel:+49630112345'],
|
||||
['0049 (0) 6301 1234', 'tel:004963011234'],
|
||||
['(06301) 1234', 'tel:063011234'],
|
||||
['06301/1234-0', 'tel:0630112340'],
|
||||
['06301.1234', 'tel:063011234'],
|
||||
[' +49 160 1234567 ', 'tel:+491601234567'],
|
||||
['(0)6301 1234', 'tel:063011234'],
|
||||
['06301 1234 (ab 9 Uhr)', ''],
|
||||
['Tel. 06301 1234', ''],
|
||||
['06301 1234 oder 0171 555', ''],
|
||||
['0171 1, 0171 2', ''],
|
||||
['06301 1234; 0171 2', ''],
|
||||
['ruf an', ''],
|
||||
['+49 (0) 6301 1234 ext. 5', ''],
|
||||
['', ''], [' ', ''], ['12', ''], ['+', ''], [undefined, ''], [null, ''], [1234567, ''], [{}, ''],
|
||||
]
|
||||
for (const [eingabe, soll] of faelle) assert.equal(telefonLink(eingabe), soll, JSON.stringify(eingabe))
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Textfarbe auf der Betriebsfarbe (Kontrast)
|
||||
|
||||
/** Die reinen Farbfunktionen des Skripts (zwischen den Marken @farben-anfang und @farben-ende) einzeln ausfuehren. */
|
||||
|
||||
@@ -570,7 +570,14 @@ async function pruefungen() {
|
||||
setzeBetrieb({ telefon: '+49 (0) 6301-1234' })
|
||||
await oeffne(page, 'v', b.vToken)
|
||||
await page.getByRole('link', { name: '+49 (0) 6301-1234' }).waitFor()
|
||||
eq(await page.getByRole('link', { name: '+49 (0) 6301-1234' }).getAttribute('href'), 'tel:+49063011234', 'bereinigter tel:-Link')
|
||||
eq(await page.getByRole('link', { name: '+49 (0) 6301-1234' }).getAttribute('href'), 'tel:+4963011234', 'bereinigter tel:-Link: die nationale Null (0) entfaellt')
|
||||
|
||||
// freier Text mit Buchstaben ist keine Nummer, die man waehlen kann: Text ohne Link (dieselbe Regel wie im Widget)
|
||||
setzeBetrieb({ telefon: '06301 1234 (ab 9 Uhr)' })
|
||||
await oeffne(page, 'v', b.vToken)
|
||||
await warteH1(page, 'Diese Reservierung ist abgelaufen.')
|
||||
assert((await text(page)).includes('Telefon: 06301 1234 (ab 9 Uhr)'), 'Telefon als Text')
|
||||
eq(await page.locator('#karte a').count(), 0, 'kein tel:-Link fuer freien Text')
|
||||
}, { eingabe: salonEingabe({ betrieb: { ...salonEingabe().betrieb, name: FEIND_NAME, telefon: 'javascript:alert(1)', adresse: '<b>Hauptstr. 1</b>' } }) })
|
||||
|
||||
// ------------------------------------------------------------ 10. Du-Form
|
||||
|
||||
@@ -1736,7 +1736,7 @@ async function pruefungen() {
|
||||
// Hinweise der Spezifikation
|
||||
const text = await textVon(page.locator('#tm-panel'))
|
||||
// Ruling R52: Wortlaut der rechtlichen Hinweise
|
||||
eq(await textVon(page.locator('#tm-panel .tm-box-info', { hasText: 'Privatpersonen können eine Buchung' })), '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.', 'Hinweis: Widerruf schlägt Stornoregel')
|
||||
eq(await textVon(page.locator('#tm-panel .tm-box-info', { hasText: 'Privatpersonen können eine Buchung' })), '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, bei sofort buchbaren Leistungen ebenso wie bei Anfragen.', 'Hinweis: Widerruf schlägt Stornoregel (R55: auch Anfragen)')
|
||||
eq(await textVon(page.locator('#tm-panel label.tm-haken small', { hasText: 'Nur ankreuzen, wenn' })), '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. Im Zweifel nicht ankreuzen oder dich rechtlich beraten lassen.', 'Hinweis: Freizeit')
|
||||
assert(text.includes('kein fertiger Rechtstext') && await page.locator('.tm-muster .tm-pille', { hasText: 'Muster' }).count() === 1, 'Stornotext-Muster als Muster gekennzeichnet')
|
||||
assert(text.includes('Bitte keine Gesundheitsangaben.'), 'Hinweis am Nachrichtenfeld')
|
||||
@@ -1860,6 +1860,43 @@ async function pruefungen() {
|
||||
}, { reset: { ohneEinstellung: true } })
|
||||
|
||||
// ------------------------------------------------------------ Abgeschaltet: alle neuen Reiter nur lesen
|
||||
// ------------------------------------------------------------ Einbau-Code der Website (Abschlussrunde C4)
|
||||
await check('echt_einbau_code_sichtbar_und_kopierbar', async (page, ctx) => {
|
||||
await ctx.grantPermissions(['clipboard-read', 'clipboard-write'], { origin: srv.url })
|
||||
await oeffneReiter(page, 'einstellungen')
|
||||
const box = page.locator('#tm-panel section', { has: page.getByRole('heading', { name: 'Einbau auf deiner Website' }) })
|
||||
await box.waitFor()
|
||||
const code = box.locator('textarea.tm-code-feld')
|
||||
const antwort = await page.evaluate(async () => (await (await fetch('/api/termine/einstellungen?projectId=p1')).json()).einbau.snippet)
|
||||
eq(await code.inputValue(), antwort, 'der Code ist der Einbau-Code des Servers')
|
||||
assert(antwort.includes(`data-webklar-termine="${KEY}"`) && antwort.includes('/embed/termine.js'), `Code mit siteKey und Skript: ${antwort}`)
|
||||
eq(await code.getAttribute('readonly'), '', 'nur lesbar')
|
||||
eq(await code.getAttribute('aria-label'), 'Einbau-Code für deine Website', 'Name des Feldes')
|
||||
const hoehe = await knopf(box, 'Code kopieren').boundingBox()
|
||||
assert(hoehe.height >= 44 || (await page.viewportSize()).width > 880, `Knopf gut treffbar: ${hoehe.height}`)
|
||||
await shot(page, 'einstellungen-einbau')
|
||||
await knopf(box, 'Code kopieren').click()
|
||||
await box.getByText('Der Code ist kopiert.').waitFor()
|
||||
eq(await page.evaluate(() => navigator.clipboard.readText()), antwort, 'Zwischenablage')
|
||||
assert(await passtInBreite(page), 'kein seitliches Scrollen')
|
||||
})
|
||||
|
||||
await check('echt_einbau_code_ohne_zwischenablage', async (page, ctx) => {
|
||||
await ctx.addInitScript(() => Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true })) // kein Zugriff auf die Zwischenablage
|
||||
await oeffneReiter(page, 'einstellungen')
|
||||
const box = page.locator('#tm-panel section', { has: page.getByRole('heading', { name: 'Einbau auf deiner Website' }) })
|
||||
await knopf(box, 'Code kopieren').click()
|
||||
const status = box.locator('[role="status"]')
|
||||
await status.filter({ hasText: 'Das Kopieren hat nicht geklappt.' }).waitFor()
|
||||
assert((await textVon(status)).includes('markiert'), 'Hinweis: Code ist markiert')
|
||||
const auswahl = await page.evaluate(() => {
|
||||
const el = document.activeElement
|
||||
return { code: el?.classList.contains('tm-code-feld'), von: el?.selectionStart, bis: el?.selectionEnd, laenge: el?.value.length }
|
||||
})
|
||||
eq(auswahl.code, true, 'Fokus im Code-Feld')
|
||||
eq([auswahl.von, auswahl.bis], [0, auswahl.laenge], 'der ganze Code ist markiert')
|
||||
})
|
||||
|
||||
await check('echt_abgeschaltet_reiter_nur_lesen', async (page) => {
|
||||
await srv.setzeFunktion('disabled')
|
||||
const v0 = version()
|
||||
@@ -1895,6 +1932,7 @@ async function pruefungen() {
|
||||
eq(await alleGesperrt(form), true, 'Einstellungen: alle Felder gesperrt')
|
||||
eq(await knopf(page, 'Einstellungen speichern').count(), 0, 'Einstellungen: kein Speichern')
|
||||
eq(await knopf(form, 'Muster in das Textfeld übernehmen').count() + await knopf(form, '+ Zusatzfeld').count(), 0, 'Einstellungen: keine Änderungsknöpfe')
|
||||
eq(await page.locator('#tm-panel [data-aktion="einbau-kopieren"], #tm-panel .tm-code-feld').count(), 0, 'Einstellungen: kein Einbau-Code bei abgeschalteter Funktion')
|
||||
eq(await page.getByRole('link', { name: /CSV/ }).count(), 1, 'Einstellungen: Export bleibt')
|
||||
assert((await textVon(page.locator('#tm-panel .tm-status'))).includes('Abgeschaltet'), 'Einstellungen: Status')
|
||||
await shot(page, 'abgeschaltet-einstellungen')
|
||||
|
||||
@@ -249,7 +249,8 @@ async function pruefungen() {
|
||||
const knopf = page.getByRole('button', { name: 'Termin online buchen' })
|
||||
await knopf.waitFor()
|
||||
await page.waitForTimeout(400)
|
||||
eq(anfragen.length, 0, 'Vor dem Klick darf nichts geladen werden')
|
||||
// R54: beim Laden der Seite genau ein Abruf, die Konfiguration (fuer den Knopf "Vertrag widerrufen"), sonst nichts
|
||||
eq(anfragen.map((u) => new URL(u).pathname.replace(/^.*\/v1\/[^/]+/, '')), ['/config'], 'Vor dem Klick nur die Konfiguration')
|
||||
assert(await page.locator('#ausweich').count() === 0 || !(await page.locator('#ausweich').isVisible()), 'Ausweichtext darf neben dem Knopf nicht stehen')
|
||||
eq(await page.locator('dialog.wkt-dialog').count(), 1, 'ein <dialog>')
|
||||
assert(!(await page.locator('dialog.wkt-dialog').evaluate((d) => d.open)), 'Dialog ist zu')
|
||||
@@ -257,7 +258,7 @@ async function pruefungen() {
|
||||
await knopf.click()
|
||||
const dlg = page.locator('dialog.wkt-dialog')
|
||||
await karte(dlg, 'Herrenschnitt').waitFor()
|
||||
assert(anfragen.some((u) => u.includes('/config')), 'Konfiguration wird beim Klick geladen')
|
||||
eq(anfragen.filter((u) => u.includes('/config')).length, 1, 'der Klick laedt die Konfiguration nicht noch einmal')
|
||||
assert(await dlg.evaluate((d) => d.open && d.matches(':modal')), 'Dialog muss modal offen sein')
|
||||
eq(await dlg.getAttribute('aria-label'), 'Termin online buchen', 'Name des Dialogs')
|
||||
await shot(page, 'knopf-dialog')
|
||||
@@ -303,6 +304,81 @@ async function pruefungen() {
|
||||
assert((await dlg.locator('.wkt-hinweis').first().innerText()).startsWith('Damenschnitt'), 'vorgewaehlte Leistung steht im Termin-Schritt')
|
||||
})
|
||||
|
||||
// Ruling R54: die Widerrufsfunktion steht im Knopfmodus dauerhaft neben dem Buchungsknopf und oeffnet den Dialog gleich beim Formular
|
||||
await check('knopf_widerruf_knopf_dauerhaft', async (page) => {
|
||||
const anfragen = []
|
||||
page.on('request', (r) => { if (r.url().includes('/api/public/termine/')) anfragen.push(new URL(r.url()).pathname.replace(/^.*\/v1\/[^/]+/, '')) })
|
||||
await oeffne(page, seitenHtml(eingebettet('data-modus="knopf" data-knopf="Termin online buchen"')))
|
||||
const haupt = page.getByRole('button', { name: 'Termin online buchen' })
|
||||
const widerruf = page.locator('.wkt-knopf-box > button', { hasText: 'Vertrag widerrufen' })
|
||||
await widerruf.waitFor()
|
||||
await page.waitForTimeout(300)
|
||||
eq(anfragen, ['/config'], 'nur ein Abruf beim Laden der Seite')
|
||||
assert(!(await page.locator('dialog.wkt-dialog').evaluate((d) => d.open)), 'Dialog ist zu')
|
||||
const boxen = await page.evaluate(() => {
|
||||
const [a, b] = [...document.querySelectorAll('.wkt-knopf-box > button')].map((e) => e.getBoundingClientRect())
|
||||
return { gleicheZeile: Math.abs(a.top - b.top) < 4, rechtsDavon: b.left >= a.right - 1, hoeheB: b.height, hoeheA: a.height }
|
||||
})
|
||||
assert(boxen.gleicheZeile && boxen.rechtsDavon, `Widerrufsknopf neben dem Buchungsknopf: ${JSON.stringify(boxen)}`)
|
||||
assert(boxen.hoeheB >= 44, `Widerrufsknopf mindestens 44 px hoch: ${boxen.hoeheB}`)
|
||||
const stil = await widerruf.evaluate((b) => ({ grund: getComputedStyle(b).backgroundColor, rand: getComputedStyle(b).borderTopColor }))
|
||||
assert(stil.grund === 'rgba(0, 0, 0, 0)' || stil.grund === 'transparent', `sekundaer (ohne Fuellung): ${stil.grund}`)
|
||||
await shot(page, 'knopf-mit-widerruf')
|
||||
|
||||
await widerruf.click()
|
||||
const dlg = page.locator('dialog.wkt-dialog')
|
||||
await warteTitel(dlg, 'Vertrag widerrufen')
|
||||
assert(await dlg.evaluate((d) => d.open && d.matches(':modal')), 'Dialog modal offen')
|
||||
eq(await dlg.locator('input[name=name]').count(), 1, 'Widerrufsformular')
|
||||
eq(anfragen, ['/config'], 'auch das Oeffnen laedt nichts nach')
|
||||
eq(await dlg.getByRole('button', { name: 'Zurück zur Terminbuchung' }).count(), 1, 'Weg zurueck zur Buchung')
|
||||
// Fuss im Dialog: ebenfalls ein Knopf (aber auf der Widerrufsseite zeigt er dieselbe Seite)
|
||||
await dlg.getByRole('button', { name: 'Zurück zur Terminbuchung' }).click()
|
||||
await karte(dlg, 'Herrenschnitt').waitFor()
|
||||
eq(anfragen, ['/config'], 'Zurueck zur Buchung laedt nichts nach')
|
||||
// Esc: der Fokus kehrt zu dem Knopf zurueck, der den Dialog geoeffnet hat
|
||||
await page.keyboard.press('Escape')
|
||||
assert(await widerruf.evaluate((b) => document.activeElement === b), 'Fokus nicht zurueck auf dem Widerrufsknopf')
|
||||
// Der Buchungsknopf zeigt wieder die Buchung, nicht das Widerrufsformular
|
||||
await widerruf.click()
|
||||
await warteTitel(dlg, 'Vertrag widerrufen')
|
||||
await page.keyboard.press('Escape')
|
||||
await haupt.click()
|
||||
await karte(dlg, 'Herrenschnitt').waitFor()
|
||||
eq(await dlg.locator('input[name=name]').count(), 0, 'Buchungsknopf: kein Widerrufsformular')
|
||||
assert(await haupt.evaluate((b) => b !== document.activeElement), 'Dialog offen: Fokus im Dialog')
|
||||
// Widerruf aus dem Fuss des laufenden Dialogs, dann zurueck (der Stand bleibt)
|
||||
await karte(dlg, 'Herrenschnitt').click()
|
||||
await dlg.locator('.wkt-fuss').getByRole('button', { name: 'Vertrag widerrufen' }).click()
|
||||
await warteTitel(dlg, 'Vertrag widerrufen')
|
||||
await dlg.getByRole('button', { name: 'Zurück zur Terminbuchung' }).click()
|
||||
await karte(dlg, 'Egal').waitFor()
|
||||
})
|
||||
|
||||
// Widerrufsformular gleich beim Oeffnen, danach "Zurueck zur Terminbuchung" beginnt die Buchung mit der vorgewaehlten Leistung
|
||||
await check('knopf_widerruf_mit_vorgewaehlter_leistung', async (page) => {
|
||||
await oeffne(page, seitenHtml(eingebettet(`data-modus="knopf" data-knopf="Damenschnitt buchen" data-leistung="${S_DAMEN}"`)))
|
||||
await page.locator('.wkt-knopf-box > button', { hasText: 'Vertrag widerrufen' }).click()
|
||||
const dlg = page.locator('dialog.wkt-dialog')
|
||||
await warteTitel(dlg, 'Vertrag widerrufen')
|
||||
await dlg.getByRole('button', { name: 'Zurück zur Terminbuchung' }).click()
|
||||
await warteTitel(dlg, 'Bei wem')
|
||||
eq(await karte(dlg, 'Herrenschnitt').count(), 0, 'Leistungsliste darf nicht erscheinen')
|
||||
})
|
||||
|
||||
await check('knopf_ohne_widerrufsrecht_kein_widerrufsknopf', async (page) => {
|
||||
const anfragen = []
|
||||
page.on('request', (r) => { if (r.url().includes('/api/public/termine/')) anfragen.push(r.url()) })
|
||||
await oeffne(page, seitenHtml(eingebettet('data-modus="knopf" data-knopf="Termin buchen"')))
|
||||
await page.getByRole('button', { name: 'Termin buchen' }).waitFor()
|
||||
await page.waitForTimeout(500)
|
||||
eq(await page.locator('.wkt-knopf-box > button').count(), 1, 'nur der Buchungsknopf (Gaeste sind Unternehmen)')
|
||||
eq(anfragen.length, 1, 'ein Abruf beim Laden')
|
||||
await page.getByRole('button', { name: 'Termin buchen' }).click()
|
||||
await karte(page.locator('dialog.wkt-dialog'), 'Herrenschnitt').waitFor()
|
||||
eq(await page.locator('dialog.wkt-dialog .wkt-fuss').getByRole('button', { name: 'Vertrag widerrufen' }).count(), 0, 'auch im Fuss kein Widerruf')
|
||||
}, { eingabe: salonEingabe({ recht: { gaesteSind: 'unternehmer', freizeit: false } }) })
|
||||
|
||||
// ---------------------------------------------------------------- 4. Nur Tastatur
|
||||
|
||||
await check('nur_tastatur', async (page) => {
|
||||
@@ -687,6 +763,69 @@ async function pruefungen() {
|
||||
eq(konfig.length, 2, 'nach 101 Minuten wird neu geladen')
|
||||
}, { initScript: () => { const echt = performance.now.bind(performance); window.__vorsprung = 0; performance.now = () => echt() + window.__vorsprung } })
|
||||
|
||||
// Ruling R53: ein frisch geladenes Formular-Token ist dem Server zu jung (formMinSekunden): das Widget wartet, statt eine Buchung
|
||||
// abzusenden, die der Server mit der normalen Erfolgsantwort verwirft
|
||||
const MIN_SEKUNDEN = 3
|
||||
const mitVorsprung = () => { const echt = performance.now.bind(performance); window.__vorsprung = 0; performance.now = () => echt() + window.__vorsprung }
|
||||
await check('formular_token_nach_neuladen_wartet_und_bucht', async (page) => {
|
||||
srv.config.termine.formMinSekunden = MIN_SEKUNDEN
|
||||
const konfig = []
|
||||
page.on('request', (r) => { if (r.url().includes('/config')) konfig.push(r.url()) })
|
||||
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
|
||||
const wd = await bisZusammenfassung(page, { tag: '2026-10-26' })
|
||||
eq(konfig.length, 1, 'bisher eine Konfiguration')
|
||||
// die Konfiguration ist im Widget aelter als 100 Minuten: sende() laedt sie neu und das Token ist beim Absenden nur Millisekunden alt
|
||||
await page.evaluate(() => { window.__vorsprung = 101 * 60 * 1000 })
|
||||
await wd.locator('input[name=storno]').check()
|
||||
const knopf = wd.getByRole('button', { name: 'Zahlungspflichtig buchen' })
|
||||
const start = Date.now()
|
||||
await knopf.click()
|
||||
await wd.getByRole('button', { name: 'Einen Moment …' }).waitFor()
|
||||
eq(konfig.length, 2, 'Konfiguration neu geladen')
|
||||
eq(await wd.getByRole('button', { name: 'Einen Moment …' }).isDisabled(), true, 'Knopf bleibt gesperrt')
|
||||
eq(srv.buchungen().length, 0, 'waehrend des Wartens noch nichts gesendet')
|
||||
await wd.getByRole('heading', { name: 'Fast geschafft' }).waitFor()
|
||||
const dauer = Date.now() - start
|
||||
assert(dauer >= (MIN_SEKUNDEN + 0.4) * 1000, `das Widget wartete nur ${dauer} ms`)
|
||||
await srv.warteAufVersand()
|
||||
eq(srv.buchungen().map((b) => [b.gastEmail, b.status]), [['max@example.com', 'gehalten']], 'die Buchung wurde wirklich angelegt (kein stilles 201)')
|
||||
eq(srv.mails().map((m) => m.to.map((t) => t.address).join()), ['max@example.com'], 'G01 an den Gast')
|
||||
}, { initScript: mitVorsprung })
|
||||
|
||||
await check('formular_token_neu_laden_und_gleich_nochmal_bucht', async (page) => {
|
||||
srv.config.termine.formMinSekunden = MIN_SEKUNDEN
|
||||
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
|
||||
const wd = await bisZusammenfassung(page, { tag: '2026-10-26' })
|
||||
await wd.locator('input[name=storno]').check()
|
||||
srv.schiebeUhr(3 * 60 * 60 * 1000) // beim Server ist das Token jetzt aelter als 2 Stunden
|
||||
await wd.getByRole('button', { name: 'Zahlungspflichtig buchen' }).click()
|
||||
await wd.locator('.wkt-meldung').waitFor()
|
||||
eq(await wd.locator('.wkt-meldung').innerText(), 'Das Formular wurde erneuert. Bitte versuchen Sie es noch einmal.', 'Hinweis nach neu_laden')
|
||||
eq(srv.buchungen().length, 0, 'noch nichts gebucht')
|
||||
// sofort noch einmal: das neue Token ist erst Sekundenbruchteile alt
|
||||
await wd.getByRole('button', { name: 'Zahlungspflichtig buchen' }).click()
|
||||
await wd.getByRole('button', { name: 'Einen Moment …' }).waitFor()
|
||||
await wd.getByRole('heading', { name: 'Fast geschafft' }).waitFor()
|
||||
await srv.warteAufVersand()
|
||||
eq(srv.buchungen().length, 1, 'zweiter Versuch bucht trotz junger Token')
|
||||
eq(srv.mails().map((m) => m.to.map((t) => t.address).join()), ['max@example.com'], 'G01 an den Gast')
|
||||
})
|
||||
|
||||
await check('formular_token_wartezeit_zurueck_bricht_ab', async (page) => {
|
||||
srv.config.termine.formMinSekunden = MIN_SEKUNDEN
|
||||
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
|
||||
const wd = await bisZusammenfassung(page, { tag: '2026-10-26' })
|
||||
await wd.locator('input[name=storno]').check()
|
||||
await page.evaluate(() => { window.__vorsprung = 101 * 60 * 1000 })
|
||||
await wd.getByRole('button', { name: 'Zahlungspflichtig buchen' }).click()
|
||||
await wd.getByRole('button', { name: 'Einen Moment …' }).waitFor()
|
||||
await wd.getByRole('button', { name: 'Zurück' }).click() // der Gast geht waehrend des Wartens einen Schritt zurueck
|
||||
await warteTitel(wd, 'Ihre Angaben')
|
||||
await page.waitForTimeout((MIN_SEKUNDEN + 1.2) * 1000)
|
||||
eq(srv.buchungen().length, 0, 'es wird nichts mehr gesendet, wenn die Ansicht gewechselt hat')
|
||||
eq(await titel(wd).innerText(), 'Ihre Angaben', 'die Ansicht bleibt, wo der Gast ist')
|
||||
}, { initScript: mitVorsprung })
|
||||
|
||||
// ---------------------------------------------------------------- 10. Widerruf ohne Link
|
||||
|
||||
await check('vertrag_widerrufen_im_fuss', async (page) => {
|
||||
@@ -694,6 +833,12 @@ async function pruefungen() {
|
||||
const wd = w(page)
|
||||
await karte(wd, 'Herrenschnitt').waitFor()
|
||||
eq((await wd.locator('.wkt-fuss').innerText()).replace(/\s+/g, ' '), 'Vertrag widerrufen Datenschutz Terminbuchung über WEBklar', 'Fuss')
|
||||
// Ruling R54: ein zweiter Knopf (wkt-btn wkt-btn-sek), kein Textlink; mindestens 44 px hoch
|
||||
const fussKnopf = wd.locator('.wkt-fuss button')
|
||||
eq(await fussKnopf.getAttribute('class'), 'wkt-btn wkt-btn-sek', 'Klassen des Widerrufsknopfs')
|
||||
const fussBox = await fussKnopf.boundingBox()
|
||||
assert(fussBox.height >= 44, `Widerrufsknopf im Fuss mindestens 44 px hoch: ${fussBox.height}`)
|
||||
eq(await fussKnopf.evaluate((b) => getComputedStyle(b).textDecorationLine), 'none', 'kein unterstrichener Textlink')
|
||||
const ds = wd.locator('.wkt-fuss a')
|
||||
eq([await ds.getAttribute('href'), await ds.getAttribute('rel'), await ds.getAttribute('target')], ['https://salon-mueller.example/datenschutz', 'noopener noreferrer', '_blank'], 'Datenschutz-Link')
|
||||
await wd.getByRole('button', { name: 'Vertrag widerrufen' }).click()
|
||||
@@ -716,6 +861,47 @@ async function pruefungen() {
|
||||
await karte(wd, 'Herrenschnitt').waitFor()
|
||||
})
|
||||
|
||||
// Ruling R55: auch ein Betrieb mit nur Anfrage-Leistungen (Privatpersonen) zeigt Belehrung und Widerrufsfunktion
|
||||
await check('nur_anfragen_zeigen_belehrung_und_widerruf', async (page) => {
|
||||
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
|
||||
const wd = w(page)
|
||||
await karte(wd, 'Brautfrisur').waitFor()
|
||||
eq(await wd.locator('.wkt-karte').count(), 1, 'nur die Anfrage-Leistung')
|
||||
eq((await wd.locator('.wkt-fuss').innerText()).replace(/\s+/g, ' '), 'Vertrag widerrufen Datenschutz Terminbuchung über WEBklar', 'Fuss mit Widerruf')
|
||||
await bisZusammenfassung(page, { leistung: 'Brautfrisur', tag: '2026-10-26' })
|
||||
await wd.locator('details.wkt-belehrung').waitFor()
|
||||
assert((await wd.locator('details.wkt-belehrung').innerText()).includes('Widerrufsbelehrung'), 'Belehrung vor dem Absenden')
|
||||
await hakenUndBuchen(wd, 'Termin anfragen')
|
||||
await srv.warteAufVersand()
|
||||
const buchungen = srv.buchungen()
|
||||
eq(buchungen.map((b) => [b.buchungsart, b.verbraucher]), [['anfrage', true]], 'Anfrage von einer Privatperson')
|
||||
assert(/^[0-9a-f]{64}$/.test(JSON.parse(buchungen[0].nachweis).widerruf?.hash || ''), 'Nachweis haelt die gezeigte Belehrung fest')
|
||||
}, { eingabe: (() => { const e = salonEingabe(); e.leistungen = e.leistungen.filter((l) => l.buchungsart === 'anfrage'); return e })() })
|
||||
|
||||
// Telefonnummer im Ausfalltext: Nummer wird zum Link, freier Text bleibt Text (kein Link auf eine falsche Nummer)
|
||||
const ausfallOeffnen = async (page) => {
|
||||
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
|
||||
const wd = w(page)
|
||||
await karte(wd, 'Herrenschnitt').click()
|
||||
await karte(wd, 'Egal').click()
|
||||
await wd.locator('td.wkt-tag').first().waitFor()
|
||||
await page.route(API, (route) => route.abort())
|
||||
await tag(wd, '2026-10-12').click()
|
||||
await wd.locator('.wkt-ausfall').waitFor()
|
||||
return wd.locator('.wkt-ausfall')
|
||||
}
|
||||
const mitTelefon = (telefon) => { const e = salonEingabe(); e.betrieb.telefon = telefon; return e }
|
||||
await check('ausfall_telefon_freitext_bleibt_text', async (page) => {
|
||||
const meldung = await ausfallOeffnen(page)
|
||||
eq(await meldung.innerText(), 'Gerade nicht erreichbar. Bitte rufen Sie an: 06301 1234 (ab 9 Uhr)', 'Text mit Nummer und Zusatz')
|
||||
eq(await meldung.locator('a').count(), 0, 'kein tel:-Link fuer freien Text')
|
||||
}, { eingabe: mitTelefon('06301 1234 (ab 9 Uhr)') })
|
||||
await check('ausfall_telefon_landesvorwahl_ohne_null', async (page) => {
|
||||
const meldung = await ausfallOeffnen(page)
|
||||
eq(await meldung.locator('a').getAttribute('href'), 'tel:+4963011234', '(0) hinter der Landesvorwahl entfaellt')
|
||||
eq(await meldung.locator('a').innerText(), '+49 (0) 6301-1234', 'angezeigt wird, was der Betrieb eingetragen hat')
|
||||
}, { eingabe: mitTelefon('+49 (0) 6301-1234') })
|
||||
|
||||
// ---------------------------------------------------------------- 11. Stile der Seite
|
||||
|
||||
await check('seitenstile_und_betriebsfarbe', async (page, ctx) => {
|
||||
|
||||
Reference in new issue
Block a user