Terminplaner: Gaesteseiten Bestaetigen, Verwalten, Widerrufen

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 781eccb2d6
commit 734e397e73
5 files changed
+1876

No files matched your search

+146
View File
@@ -0,0 +1,146 @@
/* Gästeseite des Terminplaners (termin.html). Der Kopf trägt die Farbe des Betriebs (--t-akzent, Schrift --t-akzent-text,
vom Skript gesetzt, Schrift immer mit mindestens 4,5 : 1); Systemschrift, keine Fremdressourcen. */
:root {
color-scheme: light;
--t-akzent: #1f2937;
--t-akzent-text: #ffffff;
--t-akzent-rand: #1f2937;
--t-text: #1f2937;
--t-grund: #f4f5f7;
--t-karte: #ffffff;
--t-rand: #c9ced6;
--t-weich: #f3f4f6;
--t-fehler: #b91c1c;
--t-ok: #166534;
--t-radius: 12px;
}
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
min-height: 100vh;
min-height: 100dvh;
display: flex;
flex-direction: column;
background: var(--t-grund);
color: var(--t-text);
font: 16px/1.5 system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
.t-innen { width: 100%; max-width: 40rem; margin: 0 auto; padding: 0 16px; }
/* Kopf in der Farbe des Betriebs */
.t-kopf { background: var(--t-akzent); color: var(--t-akzent-text); padding: 14px 0; padding-top: max(14px, env(safe-area-inset-top)); }
.t-betrieb { margin: 0; font-size: 1.15rem; font-weight: 700; line-height: 1.3; overflow-wrap: anywhere; min-height: 1.5em; }
.t-haupt { flex: 1 0 auto; padding: 20px 0 8px; }
.t-karte { background: var(--t-karte); border: 1px solid var(--t-rand); border-radius: var(--t-radius); padding: 22px 20px; }
.t-titel { margin: 0 0 .75rem; font-size: 1.45rem; line-height: 1.25; font-weight: 700; }
.t-titel:focus, .t-titel:focus-visible { outline: none; } /* nur zum Anspringen per Skript, kein Bedienelement */
.t-karte p { margin: 0 0 .85rem; }
.t-laden { opacity: .8; }
/* Zusammenfassung des Termins */
.t-zusammen { margin: 0 0 1.1rem; padding: .85rem 1rem; border-radius: var(--t-radius); background: var(--t-weich); display: grid; gap: .4rem; }
.t-zeile { display: grid; grid-template-columns: 9.5rem 1fr; gap: .15rem 1rem; }
.t-zeile dt { font-weight: 600; }
.t-zeile dd { margin: 0; overflow-wrap: anywhere; }
.t-neu dd { font-weight: 700; }
/* Status */
.t-status { margin: 0 0 .85rem; }
.t-status-text { display: inline-block; padding: .15rem .7rem; border-radius: 999px; font-size: .9rem; font-weight: 700; background: var(--t-weich); border: 1px solid var(--t-rand); }
.t-status-bestaetigt .t-status-text { background: #dcfce7; border-color: #86c79b; color: #14532d; }
.t-status-angefragt .t-status-text { background: #fef9c3; border-color: #d9c55a; color: #713f12; }
.t-status-abgesagt .t-status-text, .t-status-abgelehnt .t-status-text { background: #fee2e2; border-color: #e0a0a0; color: #7f1d1d; }
/* Meldungen und Hinweise */
.t-meldung { margin: 0 0 .85rem; padding: .65rem .85rem; border-left: 4px solid var(--t-fehler); background: var(--t-weich); font-weight: 600; }
.t-meldung-ok { border-left-color: var(--t-ok); }
.t-box { padding: .65rem .85rem; border-radius: var(--t-radius); background: var(--t-weich); }
.t-hinweis { font-size: .9rem; opacity: .9; }
.t-meldungen:empty { display: none; }
/* Knöpfe */
.t-nav { display: flex; flex-wrap: wrap; gap: .75rem; margin-top: 1.1rem; }
.t-btn {
font: inherit; font-weight: 600; line-height: 1.25; min-height: 44px; padding: .55rem 1.25rem; border-radius: var(--t-radius);
border: 2px solid var(--t-akzent-rand); background: var(--t-akzent); color: var(--t-akzent-text); cursor: pointer;
}
.t-btn:hover { filter: brightness(1.08); }
.t-btn-sek { background: var(--t-karte); color: var(--t-text); border-color: #858f9e; }
.t-btn-sek:hover { background: var(--t-weich); filter: none; }
.t-btn-warn { background: #b91c1c; color: #ffffff; border-color: #b91c1c; }
.t-btn[disabled] { opacity: .6; cursor: default; }
.t-link { font: inherit; color: inherit; background: none; border: 0; padding: .35rem 0; min-height: 44px; text-decoration: underline; cursor: pointer; }
.t-weitere { margin: 1.1rem 0 0; }
.t-karte :focus-visible, .t-fuss :focus-visible { outline: 3px solid var(--t-text); outline-offset: 2px; }
/* Kontakt (abgelaufen) */
.t-kontakt { margin: 0 0 .85rem; padding: 0; list-style: none; display: grid; gap: .4rem; }
.t-kontakt a { color: inherit; display: inline-block; padding: .25rem 0; min-height: 24px; }
/* Kalender (Verschieben) */
.t-kal-kopf { display: flex; align-items: center; justify-content: space-between; gap: .5rem; margin-bottom: .5rem; }
.t-monat { flex: 1; margin: 0; text-align: center; font-size: 1.1rem; font-weight: 700; }
.t-pfeil { font: inherit; font-size: 1.4rem; line-height: 1; min-width: 44px; min-height: 44px; border-radius: var(--t-radius); border: 1px solid #858f9e; background: var(--t-karte); color: inherit; cursor: pointer; }
.t-pfeil[disabled] { opacity: .35; cursor: default; }
.t-raster { width: 100%; border-collapse: separate; border-spacing: 3px; table-layout: fixed; }
.t-raster th { padding: .25rem 0; font-size: .8rem; font-weight: 600; text-align: center; }
.t-raster td { padding: 0; text-align: center; }
.t-tag { width: 100%; height: 44px; padding: 0; font: inherit; border-radius: 8px; border: 1px solid transparent; background: transparent; color: inherit; opacity: .45; cursor: default; }
.t-tag:not([disabled]) { opacity: 1; font-weight: 700; border-color: var(--t-akzent-rand); cursor: pointer; }
.t-tag:not([disabled]):hover { background: var(--t-weich); }
.t-tag[aria-pressed="true"], .t-tag[aria-pressed="true"]:hover { background: var(--t-akzent); color: var(--t-akzent-text); }
.t-zeiten { margin-top: 1rem; }
.t-zeiten-titel { margin: 0 0 .5rem; font-size: 1rem; font-weight: 700; }
.t-zeiten-liste { display: grid; grid-template-columns: repeat(auto-fill, minmax(5.5rem, 1fr)); gap: .5rem; }
.t-zeit { font: inherit; font-weight: 600; min-height: 44px; border-radius: var(--t-radius); border: 1px solid var(--t-akzent-rand); background: var(--t-karte); color: inherit; cursor: pointer; }
.t-zeit:hover { background: var(--t-akzent); color: var(--t-akzent-text); }
/* Widerruf-Formular */
.t-form { margin: 0; }
.t-feld { margin: 0 0 1rem; }
.t-label { display: block; margin-bottom: .25rem; font-weight: 600; }
.t-eingabe { display: block; width: 100%; min-height: 44px; font: inherit; color: inherit; background: var(--t-karte); border: 1px solid #858f9e; border-radius: 8px; padding: .5rem .7rem; }
.t-eingabe[aria-invalid="true"] { border-color: var(--t-fehler); border-width: 2px; }
.t-fest { background: var(--t-weich); }
.t-feldfehler { margin: .3rem 0 0; color: var(--t-fehler); font-weight: 600; font-size: .9rem; }
.t-feldfehler:empty { display: none; }
.t-erklaerung { margin: 0 0 1rem; padding: .65rem .85rem; border-left: 4px solid var(--t-akzent-rand); background: var(--t-weich); }
.t-eingang { font-size: 1.1rem; }
/* Fuß */
.t-fuss { margin-top: 16px; padding: 14px 0 22px; padding-bottom: max(22px, env(safe-area-inset-bottom)); font-size: .875rem; }
.t-fuss a { color: inherit; display: inline-block; padding: .2rem 0; min-height: 24px; }
.t-links { display: flex; flex-wrap: wrap; gap: .25rem 1.25rem; margin: 0 0 .35rem; }
.t-links:empty { display: none; }
.t-powered { margin: 0; opacity: .8; font-size: .8rem; }
.t-vh { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0; }
@media (max-width: 30rem) {
.t-karte { padding: 18px 16px; }
.t-zeile { grid-template-columns: 1fr; gap: 0; }
.t-zeile dd { margin-bottom: .35rem; }
.t-nav { flex-direction: column-reverse; align-items: stretch; }
.t-nav .t-btn { width: 100%; }
.t-raster { border-spacing: 2px; }
.t-tag { height: 40px; }
.t-zeiten-liste { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (forced-colors: active) {
.t-tag[aria-pressed="true"] { outline: 2px solid Highlight; }
.t-btn { border-color: ButtonText; }
}
@media print {
.t-kopf, .t-nav, .t-weitere { display: none; }
body { background: #fff; }
.t-karte { border: 0; }
}
+35
View File
@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="robots" content="noindex, nofollow">
<meta name="referrer" content="no-referrer">
<meta name="color-scheme" content="light">
<title>Ihr Termin</title>
<link rel="stylesheet" href="/termin.css">
</head>
<body>
<!-- Gästeseite des Terminplaners: Links aus den Terminmails (…/termin.html#v=<Token> bestätigen, #m=<Token> verwalten).
Das Skript liest den Token aus dem Hash und entfernt ihn sofort aus der Adresszeile. Keine Cookies, kein Browser-Speicher. -->
<header class="t-kopf" id="kopf">
<div class="t-innen"><p class="t-betrieb" id="betrieb"></p></div>
</header>
<main class="t-haupt" id="inhalt">
<div class="t-innen">
<div class="t-karte" id="karte">
<noscript><p class="t-meldung">Bitte aktivieren Sie JavaScript, um Ihren Termin zu bestätigen oder zu verwalten.</p></noscript>
<p class="t-laden">Wird geladen …</p>
</div>
</div>
</main>
<div class="t-vh" id="ansage" aria-live="polite"></div>
<footer class="t-fuss" id="fuss">
<div class="t-innen">
<p class="t-links" id="fuss-links"></p>
<p class="t-powered"><a href="/impressum.html" target="_blank" rel="noopener noreferrer">Impressum WEBklar</a> · Terminbuchung über WEBklar</p>
</div>
</footer>
<script src="/termin.js" defer></script>
</body>
</html>
+944
View File
@@ -0,0 +1,944 @@
/*! WEBklar Terminplaner: Gaesteseite (termin.html). Links aus den Terminmails:
* termin.html#v=<Token> Termin bestaetigen (G01) termin.html#m=<Token> Termin verwalten (G02, G03, G05, G07)
* Der Token wird sofort aus der Adresszeile entfernt (history.replaceState) und nur im Speicher der Seite gehalten.
* Keine Cookies, kein Browser-Speicher, keine Drittanfragen: alle Anfragen gehen als POST an /api/public/termine/v1/termin/*.
* Alle Texte (auch die des Betriebs) nur ueber textContent. Die Seite rechnet nie mit Datum oder Zeitzone des Browsers:
* Datums- und Uhrzeittexte, Kalenderdaten und der Eingang eines Widerrufs kommen vom Server (Berliner Zeit). */
(function () {
'use strict'
const API_PFAD = '/api/public/termine/v1/termin/'
const TOKEN_RE = /^[A-Za-z0-9_-]{43}$/
const MONAT_RE = /^\d{4}-(0[1-9]|1[0-2])$/
const TAG_RE = /^\d{4}-\d{2}-\d{2}$/
const ZEIT_RE = /^\d{2}:\d{2}$/
const FARBE_RE = /^#[0-9a-fA-F]{6}$/
const TIMEOUT_MS = 20000
const WOCHE_KURZ = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
const WOCHE_LANG = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag']
const MONATE_FENSTER = 12 // Verschieben: so viele Monate vor und nach dem Monat des Termins sind waehlbar
const KOPF_VORGABE = '#1f2937'
const karte = document.getElementById('karte')
const kopfBetrieb = document.getElementById('betrieb')
const fussLinks = document.getElementById('fuss-links')
const ansageBox = document.getElementById('ansage')
// Zustand der Seite (nur im Speicher): Token, Antwort der Vorschau, Stand des Verschiebens
const S = { token: '', v: null, du: false, verschieben: null }
// ---------------------------------------------------------------- kleine Helfer
function T (sie, du) { return S.du ? du : sie }
function el (tag, cls, text) {
const e = document.createElement(tag)
if (cls) e.className = cls
if (text != null) e.textContent = text
return e
}
function knopf (cls, text, onClick) {
const b = el('button', cls, text)
b.type = 'button'
if (onClick) b.addEventListener('click', onClick)
return b
}
function istText (v) { return typeof v === 'string' }
function pad2 (n) { return n < 10 ? '0' + n : String(n) }
// Nur geprueftes https wird zum Link (Betriebsangaben sind Kundentext)
function httpsUrl (u) {
if (!istText(u)) return ''
try {
const p = new URL(u)
return p.protocol === 'https:' ? p.href : ''
} catch (e) { return '' }
}
// Telefonnummer als tel:-Link, wenn sie nur aus Ziffern, Leerzeichen, + - ( ) / besteht
function telefonLink (nr) {
if (!istText(nr) || !/^[0-9 +()/-]+$/.test(nr)) return ''
return 'tel:' + nr.replace(/[^0-9+]/g, '')
}
function externerLink (href, text) {
const a = el('a', '', text)
a.href = href
a.target = '_blank'
a.rel = 'noopener noreferrer'
return a
}
function ansage (text) {
ansageBox.textContent = ''
window.setTimeout(function () { ansageBox.textContent = text }, 50)
}
// @farben-anfang (reine Funktionen, test/termineSeiteStatic.test.js fuehrt sie einzeln aus)
// Relative Leuchtdichte nach WCAG 2.1 fuer '#rrggbb'.
function luminanz (farbe) {
const rgb = [1, 3, 5].map(function (i) {
const c = parseInt(farbe.slice(i, i + 2), 16) / 255
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
})
return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]
}
// Kontrastverhaeltnis zweier Leuchtdichten (1 bis 21).
function kontrast (a, b) {
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)
}
// Schrift auf der Betriebsfarbe: Weiss oder das dunkle Grau, je nachdem was mehr Kontrast gibt; erreicht keines 4,5 : 1
// (mittlere Toene), nimmt sie Schwarz (mindestens 4,58 : 1 fuer jede Farbe).
function textFarbeAuf (farbe) {
const l = luminanz(farbe)
const weiss = kontrast(l, 1)
const dunkel = kontrast(l, luminanz('#111827'))
const beste = Math.max(weiss, dunkel)
if (beste < 4.5 && kontrast(l, 0) > beste) return '#000000'
return weiss >= dunkel ? '#ffffff' : '#111827'
}
// @farben-ende
// Kopf in der Farbe des Betriebs; ohne gueltige Farbe das dunkle Grau
function setzeKopf (name, farbe) {
const f = FARBE_RE.test(farbe || '') ? farbe : KOPF_VORGABE
const text = textFarbeAuf(f)
const wurzel = document.documentElement.style
wurzel.setProperty('--t-akzent', f)
wurzel.setProperty('--t-akzent-text', text)
wurzel.setProperty('--t-akzent-rand', text === '#ffffff' ? f : text)
kopfBetrieb.textContent = istText(name) ? name : ''
}
// Impressum und Datenschutz des Betriebs im Fuss (nur https-Adressen, in neuem Tab: der Token ist aus der Adresse entfernt)
function setzeFuss (betrieb) {
fussLinks.textContent = ''
if (!betrieb) return
const imp = httpsUrl(betrieb.impressumUrl)
const ds = httpsUrl(betrieb.datenschutzUrl)
if (imp) fussLinks.appendChild(externerLink(imp, 'Impressum'))
if (ds) fussLinks.appendChild(externerLink(ds, 'Datenschutz'))
}
function titelSetzen (text) {
const name = S.v && S.v.betrieb && istText(S.v.betrieb.name) ? S.v.betrieb.name : ''
document.title = text + (name ? ' – ' + name : '')
}
// ---------------------------------------------------------------- Aufbau einer Ansicht
function leere () {
karte.textContent = ''
}
function add (node) {
karte.appendChild(node)
return node
}
function titel (text) {
const h = el('h1', 't-titel', text)
h.tabIndex = -1
titelSetzen(text)
return h
}
// Ansicht aufbauen: Inhalt leeren, fn fuellt ihn, danach die Ueberschrift fokussieren (Bildschirmleser, Tastatur)
function zeichne (fn, optionen) {
leere()
fn()
const h = karte.querySelector('h1')
if (h && !(optionen && optionen.ohneFokus)) h.focus({ preventScroll: false })
}
function meldung (text, art) {
const m = el('p', 't-meldung' + (art === 'ok' ? ' t-meldung-ok' : ''), text)
m.setAttribute('role', art === 'ok' ? 'status' : 'alert')
return m
}
function absaetze (text, cls) {
const wrap = el('div', cls || '')
String(text).split(/\n{2,}/).forEach(function (a) { if (a.trim()) wrap.appendChild(el('p', '', a.trim())) })
return wrap
}
// Zusammenfassung des Termins (alles vom Server, nur Texte)
function zusammenfassung (t) {
const dl = el('dl', 't-zusammen')
const zeilen = [
['Leistung', t.leistung],
['Bei', t.person],
['Datum', t.datum],
['Uhrzeit', istText(t.zeit) && t.zeit ? t.zeit + ' Uhr' : ''],
['Dauer', t.dauer],
['Preis', t.preis],
['Zahlung', t.zahlung],
['Ort', t.ort],
['Buchungsnummer', t.nummer]
]
zeilen.forEach(function (z) {
if (!istText(z[1]) || z[1] === '') return
const zeile = el('div', 't-zeile')
zeile.appendChild(el('dt', '', z[0]))
zeile.appendChild(el('dd', '', z[1]))
dl.appendChild(zeile)
})
return dl
}
// ---------------------------------------------------------------- Anfragen
function textObjekt (o) {
const r = {}
Object.keys(o).forEach(function (k) { if (istText(o[k])) r[k] = o[k] })
return r
}
// Fehlerobjekt aus einer Antwort: { status, fehler{feld:text}, code, betrieb|null, netz }
function fehlerAus (r) {
const b = r.body && typeof r.body === 'object' ? r.body : {}
return {
status: r.status,
fehler: b.fehler && typeof b.fehler === 'object' ? textObjekt(b.fehler) : {},
code: istText(b.code) ? b.code : '',
betrieb: b.betrieb && typeof b.betrieb === 'object' ? textObjekt(b.betrieb) : null,
netz: r.status === 0 || r.status >= 500 || r.status === 403
}
}
// POST an /termin/<aktion> mit dem Token; liefert den JSON-Body oder wirft ein Fehlerobjekt
function post (aktion, body) {
const ctrl = new AbortController()
const timer = window.setTimeout(function () { ctrl.abort() }, TIMEOUT_MS)
return fetch(API_PFAD + aktion, {
method: 'POST',
credentials: 'omit',
cache: 'no-store',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(Object.assign({ token: S.token }, body || {})),
signal: ctrl.signal
}).then(function (r) {
return r.json().then(
function (d) { return { status: r.status, ok: r.ok, body: d } },
function () { return { status: r.status, ok: r.ok, body: null } }
)
}, function () {
throw { netz: true, fehler: {}, code: '', status: 0 }
}).then(function (r) {
window.clearTimeout(timer)
if (r.ok && r.body && typeof r.body === 'object') return r.body
throw fehlerAus(r)
}, function (err) {
window.clearTimeout(timer)
throw err
})
}
// ---------------------------------------------------------------- Fehleransichten
function ersterText (fehler, vorgabe) {
const k = fehler ? Object.keys(fehler) : []
return k.length ? fehler[k[0]] : vorgabe
}
// Link unbekannt, unvollstaendig oder ohne Hash
function zeigeUngueltig (text, hinweis) {
S.du = false
setzeKopf('', '')
setzeFuss(null)
zeichne(function () {
add(titel(text))
add(el('p', '', hinweis))
})
}
// Abgelaufen: die Antwort traegt Name, Telefon und Website des Betriebs
function zeigeAbgelaufen (err) {
const b = err.betrieb || {}
setzeKopf(b.name, '')
setzeFuss(null)
zeichne(function () {
add(titel(ersterText(err.fehler, 'Diese Reservierung ist abgelaufen.')))
add(el('p', '', T('Bitte buchen Sie Ihren Termin noch einmal oder rufen Sie uns an.', 'Bitte buche deinen Termin noch einmal oder ruf uns an.')))
const liste = el('ul', 't-kontakt')
const seite = httpsUrl(b.websiteUrl)
if (seite) {
const li = el('li')
li.appendChild(externerLink(seite, 'Zurück zur Website' + (b.name ? ' von ' + b.name : '')))
liste.appendChild(li)
}
if (istText(b.telefon) && b.telefon) {
const li = el('li')
li.appendChild(document.createTextNode('Telefon: '))
const tel = telefonLink(b.telefon)
if (tel) {
const a = el('a', '', b.telefon)
a.href = tel
li.appendChild(a)
} else {
li.appendChild(document.createTextNode(b.telefon))
}
liste.appendChild(li)
}
if (liste.childNodes.length) add(liste)
})
}
// Netz weg, Server nicht erreichbar oder Grenze erreicht: Meldung und "Erneut versuchen"
function zeigeAusfall (text, wiederhole) {
zeichne(function () {
add(titel('Das hat leider nicht geklappt'))
add(meldung(text))
if (wiederhole) {
const nav = el('div', 't-nav')
nav.appendChild(knopf('t-btn', 'Erneut versuchen', wiederhole))
add(nav)
}
})
}
// Gemeinsame Behandlung: unbekannt, abgelaufen, Ausfall; alles andere (Fehler des Gastes) uebernimmt fachlich(err)
function behandle (err, wiederhole, fachlich) {
if (err && err.code === 'unbekannt') return zeigeUngueltig(ersterText(err.fehler, 'Dieser Link ist ungültig oder abgelaufen.'), T('Bitte öffnen Sie den Link aus Ihrer E-Mail noch einmal.', 'Bitte öffne den Link aus deiner E-Mail noch einmal.'))
if (err && err.code === 'abgelaufen') return zeigeAbgelaufen(err)
if (!err || err.netz || err.status === 404 || err.status === 429 || !fachlich) {
const text = err && err.status === 429 ? ersterText(err.fehler, 'Zu viele Anfragen. Bitte versuchen Sie es später noch einmal.')
: T('Gerade nicht erreichbar. Bitte versuchen Sie es in einem Moment noch einmal.', 'Gerade nicht erreichbar. Bitte versuche es in einem Moment noch einmal.')
return zeigeAusfall(text, wiederhole)
}
return fachlich(err)
}
// ---------------------------------------------------------------- Start
function start () {
const roh = window.location.hash
// Der Token steht nur im Hash (wird nie an einen Server gesendet) und verschwindet sofort aus der Adresszeile
if (roh) window.history.replaceState(null, '', window.location.pathname + window.location.search)
if (!roh) {
zeigeUngueltig('Bitte öffnen Sie den Link aus Ihrer E-Mail', 'Diese Seite wird über den Link in der E-Mail zu Ihrem Termin aufgerufen. Bitte öffnen Sie diese E-Mail und klicken Sie dort noch einmal auf den Link.')
return
}
const m = /^#[vm]=([A-Za-z0-9_-]*)$/.exec(roh)
if (!m || !TOKEN_RE.test(m[1])) {
zeigeUngueltig('Dieser Link ist unvollständig oder ungültig', 'Bitte öffnen Sie den Link aus Ihrer E-Mail noch einmal. Manche E-Mail-Programme brechen lange Links um: dann hilft es, den ganzen Link zu kopieren und in die Adresszeile einzufügen.')
return
}
S.token = m[1]
S.v = null
S.verschieben = null
lade()
}
function vorschauGueltig (d) {
return d && (d.art === 'bestaetigen' || d.art === 'verwalten') && istText(d.status) &&
d.termin && typeof d.termin === 'object' && d.betrieb && typeof d.betrieb === 'object' &&
d.aktionen && typeof d.aktionen === 'object'
}
// Vorschau holen und die passende Ansicht zeigen; notiz = Hinweis, der oben in der Ansicht steht
function lade (notiz) {
zeichne(function () { add(el('p', 't-laden', 'Wird geladen …')) }, { ohneFokus: true })
post('vorschau').then(function (d) {
if (!vorschauGueltig(d)) throw { netz: true, fehler: {}, code: '', status: 0 }
S.v = d
S.du = d.anrede === 'du'
S.verschieben = null
setzeKopf(d.betrieb.name, d.farbe)
setzeFuss(d.betrieb)
if (d.art === 'bestaetigen' && d.status === 'gehalten' && d.aktionen.bestaetigen === true) zeigeBestaetigen()
else if (d.art === 'bestaetigen') zeigeStatus(false)
else zeigeVerwalten(notiz)
}).catch(function (err) {
behandle(err, function () { lade(notiz) })
})
}
// ---------------------------------------------------------------- Bestaetigen (G01)
function zeigeBestaetigen () {
const v = S.v
const anfrage = istText(v.knopf) && v.knopf.indexOf('Anfrage') === 0
zeichne(function () {
add(titel(anfrage ? 'Anfrage bestätigen' : 'Termin bestätigen'))
add(el('p', '', T('Bitte prüfen Sie die Angaben und bestätigen Sie mit dem Knopf unten.', 'Bitte prüfe die Angaben und bestätige mit dem Knopf unten.')))
add(zusammenfassung(v.termin))
const melde = add(el('div', 't-meldungen'))
const nav = add(el('div', 't-nav'))
const btn = knopf('t-btn', istText(v.knopf) && v.knopf ? v.knopf : 'Termin bestätigen', function () {
if (btn.disabled) return
btn.disabled = true
melde.textContent = ''
post('bestaetigen').then(function (d) {
if (!d || d.ok !== true || !istText(d.status)) throw { netz: true, fehler: {}, code: '', status: 0 }
S.v.status = d.status
S.v.aktionen.bestaetigen = false
zeigeStatus(d.schon !== true)
}).catch(function (err) {
btn.disabled = false
behandle(err, function () { zeigeBestaetigen() }, function (e) {
melde.appendChild(meldung(ersterText(e.fehler, 'Das hat leider nicht geklappt.')))
})
})
})
nav.appendChild(btn)
})
}
// Zustand nach dem Bestaetigen (neu = gerade bestaetigt) oder beim erneuten Aufruf des Links (bereits bestaetigt, abgesagt, ...)
function zeigeStatus (neu) {
const v = S.v
const betrieb = v.betrieb.name
let t = ''
let text = ''
let ok = false
switch (v.status) {
case 'bestaetigt':
ok = true
t = neu ? T('Ihr Termin ist bestätigt.', 'Dein Termin ist bestätigt.') : 'Dieser Termin ist bereits bestätigt.'
text = neu
? T('Wir haben Ihnen eine Bestätigung mit allen Angaben per E-Mail geschickt. Dort finden Sie auch den Link, mit dem Sie den Termin absagen oder verschieben können.',
'Wir haben dir eine Bestätigung mit allen Angaben per E-Mail geschickt. Dort findest du auch den Link, mit dem du den Termin absagen oder verschieben kannst.')
: T('Sie müssen nichts weiter tun. Den Link zum Absagen oder Verschieben finden Sie in Ihrer Bestätigungs-E-Mail.', 'Du musst nichts weiter tun. Den Link zum Absagen oder Verschieben findest du in deiner Bestätigungs-E-Mail.')
break
case 'angefragt':
ok = true
t = neu ? T('Ihre Anfrage ist eingegangen.', 'Deine Anfrage ist eingegangen.') : 'Diese Anfrage ist bereits bestätigt.'
text = neu
? T(betrieb + ' prüft Ihre Anfrage und meldet sich bei Ihnen. Eine Eingangsbestätigung haben wir Ihnen per E-Mail geschickt.',
betrieb + ' prüft deine Anfrage und meldet sich bei dir. Eine Eingangsbestätigung haben wir dir per E-Mail geschickt.')
: T('Sie müssen nichts weiter tun. ' + betrieb + ' meldet sich bei Ihnen.', 'Du musst nichts weiter tun. ' + betrieb + ' meldet sich bei dir.')
break
case 'abgesagt':
t = 'Dieser Termin wurde abgesagt.'
break
case 'abgelehnt':
t = 'Diese Anfrage wurde abgelehnt.'
break
case 'erledigt':
t = 'Dieser Termin hat bereits stattgefunden.'
break
default:
t = 'Dieser Termin kann hier nicht mehr bestätigt werden.'
}
zeichne(function () {
add(titel(t))
if (text) add(meldung(text, 'ok'))
add(zusammenfassung(v.termin))
if (!ok && v.betrieb.telefon) add(el('p', '', T('Bei Fragen erreichen Sie uns unter ', 'Bei Fragen erreichst du uns unter ') + v.betrieb.telefon + '.'))
})
if (neu) ansage(t)
}
// ---------------------------------------------------------------- Verwalten (G02, G03, G05, G07)
const STATUS_TEXT = {
bestaetigt: 'Bestätigt',
angefragt: 'Angefragt',
abgesagt: 'Abgesagt',
abgelehnt: 'Abgelehnt',
erledigt: 'Stattgefunden',
nichterschienen: 'Vorbei'
}
function zeigeVerwalten (notiz) {
const v = S.v
const a = v.aktionen
zeichne(function () {
add(titel(T('Ihr Termin', 'Dein Termin')))
const status = el('p', 't-status t-status-' + v.status.replace(/[^a-z]/g, ''))
status.appendChild(el('span', 't-status-text', STATUS_TEXT[v.status] || v.status))
add(status)
if (notiz) add(meldung(notiz))
add(zusammenfassung(v.termin))
if (v.status === 'angefragt') {
add(el('p', 't-box', T(v.betrieb.name + ' hat diese Anfrage noch nicht angenommen. Sie erhalten eine E-Mail, sobald der Termin bestätigt oder abgelehnt ist.',
v.betrieb.name + ' hat diese Anfrage noch nicht angenommen. Du bekommst eine E-Mail, sobald der Termin bestätigt oder abgelehnt ist.')))
}
if (istText(v.hinweis) && v.hinweis) add(el('p', 't-box', v.hinweis))
const nav = el('div', 't-nav')
if (a.verschieben) nav.appendChild(knopf('t-btn', 'Termin verschieben', function () { zeigeVerschieben() }))
if (a.absagen) nav.appendChild(knopf('t-btn t-btn-sek', 'Termin absagen', zeigeAbsageFrage))
if (nav.childNodes.length) add(nav)
if (a.widerrufen) {
const w = el('p', 't-weitere')
w.appendChild(knopf('t-link', 'Vertrag widerrufen', zeigeWiderruf))
add(w)
}
const offen = v.status === 'bestaetigt' || v.status === 'angefragt'
if (offen && !a.absagen && !a.verschieben && !a.widerrufen && !(istText(v.hinweis) && v.hinweis)) {
add(el('p', 't-box', 'Dieser Termin kann online nicht mehr geändert werden.' +
(v.betrieb.telefon ? ' ' + T('Bitte rufen Sie uns an: ', 'Bitte ruf uns an: ') + v.betrieb.telefon : '')))
}
})
}
// ---- Absagen (mit Rueckfrage in der Seite)
function zeigeAbsageFrage () {
const v = S.v
zeichne(function () {
add(titel('Termin wirklich absagen?'))
add(zusammenfassung(v.termin))
add(el('p', '', 'Der Termin wird freigegeben. Das lässt sich nicht rückgängig machen.'))
const melde = add(el('div', 't-meldungen'))
const nav = add(el('div', 't-nav'))
nav.appendChild(knopf('t-btn t-btn-sek', 'Nein, Termin behalten', function () { zeigeVerwalten() }))
const ja = knopf('t-btn t-btn-warn', 'Ja, Termin absagen', function () {
if (ja.disabled) return
ja.disabled = true
melde.textContent = ''
post('absagen').then(function (d) {
if (!d || d.ok !== true) throw { netz: true, fehler: {}, code: '', status: 0 }
zeigeAbgesagt()
}).catch(function (err) {
ja.disabled = false
behandle(err, zeigeAbsageFrage, function (e) {
// Frist vorbei oder nicht mehr moeglich: Stand neu laden, die Meldung des Servers steht oben
lade(ersterText(e.fehler, 'Der Termin kann nicht mehr abgesagt werden.'))
})
})
})
nav.appendChild(ja)
})
}
function zeigeAbgesagt () {
const v = S.v
v.status = 'abgesagt'
v.aktionen = { bestaetigen: false, absagen: false, verschieben: false, widerrufen: false }
zeichne(function () {
const t = T('Ihr Termin wurde abgesagt.', 'Dein Termin wurde abgesagt.')
add(titel(t))
add(meldung(T('Wir haben Ihnen eine Bestätigung per E-Mail geschickt.', 'Wir haben dir eine Bestätigung per E-Mail geschickt.'), 'ok'))
add(zusammenfassung(v.termin))
ansage(t)
})
}
// ---- Verschieben (Kalender und Uhrzeiten wie im Widget, Daten von /termin/tage und /termin/zeiten)
// 'Montag, 12.10.2026' aus dem Tag 'YYYY-MM-DD' und den Kalenderdaten des Monats (ersterWochentag kommt vom Server)
function tagText (tag, info) {
const d = parseInt(tag.slice(8, 10), 10)
const wochentag = (info.ersterWochentag - 1 + d - 1) % 7
return WOCHE_LANG[wochentag] + ', ' + pad2(d) + '.' + tag.slice(5, 7) + '.' + tag.slice(0, 4)
}
function monatNr (monat) { return parseInt(monat.slice(0, 4), 10) * 12 + parseInt(monat.slice(5, 7), 10) - 1 }
// Monat des bisherigen Termins aus dem Datumstext des Servers ('Montag, 12.10.2026')
function terminMonat (datum) {
const m = /(\d{2})\.(\d{2})\.(\d{4})\s*$/.exec(datum || '')
return m ? m[3] + '-' + m[2] : ''
}
function verschiebeStand () {
if (!S.verschieben) {
const start = terminMonat(S.v.termin.datum)
const nr = start ? monatNr(start) : 0
S.verschieben = { start: start, monat: '', tag: '', zeit: '', cache: {}, minNr: nr - MONATE_FENSTER, maxNr: nr + MONATE_FENSTER, zeitSeq: 0, seq: 0 }
}
return S.verschieben
}
function zeigeVerschieben (notiz) {
const t = S.v.termin
const V = verschiebeStand()
if (!V.start) {
zeigeAusfall(T('Gerade nicht erreichbar. Bitte versuchen Sie es in einem Moment noch einmal.', 'Gerade nicht erreichbar. Bitte versuche es in einem Moment noch einmal.'), function () { zeigeVerschieben() })
return
}
V.cache = {} // Tage immer frisch beim Betreten der Ansicht
const seq = ++V.seq
let aktuell = null // { monat, info, frei, anzahl }
let zurueck, vor, monLabel, tbody, zeitenBox
zeichne(function () {
add(titel('Termin verschieben'))
add(el('p', '', T('Ihr bisheriger Termin: ', 'Dein bisheriger Termin: ') + t.datum + ', ' + t.zeit + ' Uhr' + (t.leistung ? ' (' + t.leistung + ')' : '')))
add(el('p', 't-hinweis', T('Bitte wählen Sie einen Tag mit freien Zeiten und danach die neue Uhrzeit.', 'Bitte wähle einen Tag mit freien Zeiten und danach die neue Uhrzeit.')))
if (notiz) add(meldung(notiz))
const kal = add(el('div', 't-kal'))
const kopf = el('div', 't-kal-kopf')
zurueck = knopf('t-pfeil', '‹')
zurueck.setAttribute('aria-label', 'Vorheriger Monat')
monLabel = el('h2', 't-monat')
monLabel.id = 't-monat'
vor = knopf('t-pfeil', '›')
vor.setAttribute('aria-label', 'Nächster Monat')
kopf.appendChild(zurueck)
kopf.appendChild(monLabel)
kopf.appendChild(vor)
kal.appendChild(kopf)
const tabelle = el('table', 't-raster')
tabelle.setAttribute('aria-labelledby', 't-monat')
const thead = el('thead')
const tr = el('tr')
WOCHE_KURZ.forEach(function (k, i) {
const th = el('th', '', k)
th.scope = 'col'
th.abbr = WOCHE_LANG[i]
th.setAttribute('aria-label', WOCHE_LANG[i])
tr.appendChild(th)
})
thead.appendChild(tr)
tbody = el('tbody')
tabelle.appendChild(thead)
tabelle.appendChild(tbody)
kal.appendChild(tabelle)
zeitenBox = add(el('div', 't-zeiten'))
const nav = add(el('div', 't-nav'))
nav.appendChild(knopf('t-btn t-btn-sek', 'Zurück', function () { V.seq++; zeigeVerwalten() }))
})
function ausfall (err) {
if (seq !== V.seq) return
behandle(err, function () { zeigeVerschieben() }, function (e) {
// Frist vorbei, nicht mehr moeglich: Stand neu laden
lade(ersterText(e.fehler, 'Der Termin kann nicht mehr verschoben werden.'))
})
}
function holeTage (monat) {
if (V.cache[monat]) return Promise.resolve(V.cache[monat])
return post('tage', { monat: monat }).then(function (d) {
const i = d && d.monat
const ok = d && Array.isArray(d.tage) && i && istText(i.label) && i.ersterWochentag >= 1 && i.ersterWochentag <= 7 &&
i.anzahlTage >= 28 && i.anzahlTage <= 31 && MONAT_RE.test(i.vorher || '') && MONAT_RE.test(i.nachher || '')
if (!ok) throw { netz: true, fehler: {}, code: '', status: 0 }
const frei = {}
d.tage.forEach(function (x) { if (istText(x) && TAG_RE.test(x)) frei[x] = true })
V.cache[monat] = { monat: monat, info: i, frei: frei, anzahl: Object.keys(frei).length }
return V.cache[monat]
})
}
function baueRaster (m) {
aktuell = m
V.monat = m.monat
monLabel.textContent = m.info.label
const hatteFokus = document.activeElement
zurueck.disabled = monatNr(m.info.vorher) < V.minNr
vor.disabled = monatNr(m.info.nachher) > V.maxNr
tbody.textContent = ''
let zeile = null
let spalte = 0
function neueZeile () {
zeile = el('tr')
tbody.appendChild(zeile)
spalte = 0
}
neueZeile()
for (let i = 1; i < m.info.ersterWochentag; i++) {
const leer = el('td', 't-leer')
leer.setAttribute('aria-hidden', 'true')
zeile.appendChild(leer)
spalte++
}
for (let d = 1; d <= m.info.anzahlTage; d++) {
if (spalte === 7) neueZeile()
const tag = m.monat + '-' + pad2(d)
const frei = m.frei[tag] === true
const td = el('td', 't-zelle')
const b = el('button', 't-tag', String(d))
b.type = 'button'
b.setAttribute('data-tag', tag)
b.setAttribute('aria-label', d + '. ' + m.info.label + (frei ? '' : ', keine freien Zeiten'))
if (frei) b.setAttribute('aria-pressed', V.tag === tag ? 'true' : 'false')
else b.disabled = true
td.appendChild(b)
zeile.appendChild(td)
spalte++
}
// Ein Pfeilknopf, der gerade gesperrt wurde, darf den Fokus nicht verlieren
if ((hatteFokus === zurueck && zurueck.disabled) || (hatteFokus === vor && vor.disabled)) {
const anderer = hatteFokus === zurueck ? vor : zurueck
if (!anderer.disabled) anderer.focus()
}
}
// auto: ohne freien Tag zum naechsten Monat (hoechstens drei Mal), damit nicht ein leerer Monat am Anfang steht
function zeigeMonat (monat, auto, versuch) {
return holeTage(monat).then(function (m) {
if (seq !== V.seq) return null
if (auto && m.anzahl === 0 && versuch < 3 && monatNr(m.info.nachher) <= V.maxNr) return zeigeMonat(m.info.nachher, true, versuch + 1)
baueRaster(m)
ansage(m.anzahl === 0 ? 'In diesem Monat gibt es keine freien Termine.' : m.info.label)
if (m.anzahl === 0) {
zeitenBox.textContent = ''
zeitenBox.appendChild(el('p', '', 'In diesem Monat gibt es keine freien Termine.'))
}
return m
}, ausfall)
}
function wechsle (monat) {
const nr = monatNr(monat)
if (nr < V.minNr || nr > V.maxNr) return
zeitenBox.textContent = ''
holeTage(monat).then(function (m) {
if (seq !== V.seq) return
baueRaster(m)
ansage(m.info.label)
if (m.anzahl === 0) zeitenBox.appendChild(el('p', '', 'In diesem Monat gibt es keine freien Termine.'))
else if (V.tag && V.tag.slice(0, 7) === m.monat) holeZeiten(V.tag, m)
}, ausfall)
}
zurueck.addEventListener('click', function () { if (aktuell) wechsle(aktuell.info.vorher) })
vor.addEventListener('click', function () { if (aktuell) wechsle(aktuell.info.nachher) })
tbody.addEventListener('click', function (e) {
const b = e.target && e.target.closest ? e.target.closest('button[data-tag]') : null
if (!b || b.disabled || !aktuell) return
const tag = b.getAttribute('data-tag')
V.tag = tag
V.zeit = ''
const alle = tbody.querySelectorAll('button[data-tag]')
for (let i = 0; i < alle.length; i++) if (!alle[i].disabled) alle[i].setAttribute('aria-pressed', alle[i] === b ? 'true' : 'false')
holeZeiten(tag, aktuell)
})
function holeZeiten (tag, m) {
const nr = ++V.zeitSeq
const text = tagText(tag, m.info)
zeitenBox.textContent = ''
zeitenBox.appendChild(el('p', 't-laden', 'Zeiten werden geladen …'))
post('zeiten', { tag: tag }).then(function (d) {
if (!d || !Array.isArray(d.zeiten)) throw { netz: true, fehler: {}, code: '', status: 0 }
if (nr !== V.zeitSeq || seq !== V.seq) return
zeitenBox.textContent = ''
const h = el('h3', 't-zeiten-titel', 'Freie Zeiten am ' + text)
h.id = 't-zeiten-titel'
zeitenBox.appendChild(h)
const zeiten = d.zeiten.filter(function (z) { return istText(z) && ZEIT_RE.test(z) })
if (zeiten.length === 0) {
zeitenBox.appendChild(el('p', '', T('An diesem Tag ist nichts mehr frei. Bitte wählen Sie einen anderen Tag.', 'An diesem Tag ist nichts mehr frei. Bitte wähle einen anderen Tag.')))
ansage('Keine freien Zeiten am ' + text)
return
}
const liste = el('div', 't-zeiten-liste')
liste.setAttribute('role', 'group')
liste.setAttribute('aria-labelledby', 't-zeiten-titel')
zeiten.forEach(function (z) {
const b = knopf('t-zeit', z, function () {
V.zeit = z
zeigeVerschiebeFrage(tag, z, text)
})
b.setAttribute('aria-label', z + ' Uhr, ' + text)
liste.appendChild(b)
})
zeitenBox.appendChild(liste)
ansage(zeiten.length + (zeiten.length === 1 ? ' freie Zeit am ' : ' freie Zeiten am ') + text)
}).catch(function (err) {
if (nr === V.zeitSeq) ausfall(err)
})
}
zeigeMonat(V.monat || V.start, !V.monat, 0).then(function (m) {
if (m && V.tag && V.tag.slice(0, 7) === m.monat && m.frei[V.tag]) {
const b = tbody.querySelector('button[data-tag="' + V.tag + '"]')
if (b) holeZeiten(V.tag, m)
}
})
}
// Rueckfrage vor dem Verschieben: bisher und neu nebeneinander
function zeigeVerschiebeFrage (tag, zeit, datumText) {
const t = S.v.termin
zeichne(function () {
add(titel('Termin verschieben?'))
const dl = el('dl', 't-zusammen')
const bisher = el('div', 't-zeile')
bisher.appendChild(el('dt', '', 'Bisher'))
bisher.appendChild(el('dd', '', t.datum + ', ' + t.zeit + ' Uhr'))
dl.appendChild(bisher)
const neu = el('div', 't-zeile t-neu')
neu.appendChild(el('dt', '', 'Neu'))
neu.appendChild(el('dd', '', datumText + ', ' + zeit + ' Uhr'))
dl.appendChild(neu)
if (t.leistung) {
const l = el('div', 't-zeile')
l.appendChild(el('dt', '', 'Leistung'))
l.appendChild(el('dd', '', t.leistung))
dl.appendChild(l)
}
add(dl)
const melde = add(el('div', 't-meldungen'))
const nav = add(el('div', 't-nav'))
nav.appendChild(knopf('t-btn t-btn-sek', 'Andere Zeit wählen', function () { zeigeVerschieben() }))
const ja = knopf('t-btn', 'Termin jetzt verschieben', function () {
if (ja.disabled) return
ja.disabled = true
melde.textContent = ''
post('verschieben', { tag: tag, zeit: zeit }).then(function (d) {
if (!d || d.ok !== true || !d.termin || !istText(d.termin.datum) || !istText(d.termin.zeit)) throw { netz: true, fehler: {}, code: '', status: 0 }
zeigeVerschoben(d)
}).catch(function (err) {
ja.disabled = false
behandle(err, function () { zeigeVerschiebeFrage(tag, zeit, datumText) }, function (e) {
if (e.fehler.zeit || e.fehler.tag) {
// Zeit inzwischen vergeben oder dieselbe Zeit: zurueck zur Auswahl, die Meldung des Servers steht oben
zeigeVerschieben(ersterText(e.fehler, ''))
} else {
lade(ersterText(e.fehler, 'Der Termin kann nicht verschoben werden.'))
}
})
})
})
nav.appendChild(ja)
})
}
function zeigeVerschoben (d) {
const v = S.v
v.termin.datum = d.termin.datum
v.termin.zeit = d.termin.zeit
S.verschieben = null
zeichne(function () {
const t = T('Ihr Termin wurde verschoben.', 'Dein Termin wurde verschoben.')
add(titel(t))
add(meldung(T('Wir haben Ihnen eine neue Bestätigung per E-Mail geschickt.', 'Wir haben dir eine neue Bestätigung per E-Mail geschickt.'), 'ok'))
add(zusammenfassung(v.termin))
const nav = add(el('div', 't-nav'))
nav.appendChild(knopf('t-btn t-btn-sek', 'Zur Terminübersicht', function () { lade() }))
ansage(t)
})
}
// ---------------------------------------------------------------- Widerruf (Formular nach § 356a BGB)
function zeigeWiderruf () {
const v = S.v
const t = v.termin
const gast = v.gast && typeof v.gast === 'object' ? v.gast : { name: '', email: '' }
zeichne(function () {
add(titel('Vertrag widerrufen'))
add(el('p', '', T('Hier können Sie den Vertrag über diesen Termin widerrufen (§ 356a BGB). Bitte prüfen Sie Ihre Angaben und senden Sie die Erklärung mit dem Knopf unten ab. Den Eingang bestätigen wir Ihnen per E-Mail.',
'Hier kannst du den Vertrag über diesen Termin widerrufen (§ 356a BGB). Bitte prüfe deine Angaben und sende die Erklärung mit dem Knopf unten ab. Den Eingang bestätigen wir dir per E-Mail.')))
add(zusammenfassung(t))
const form = el('form', 't-form')
form.noValidate = true
const idName = 't-f-name'
const feldName = el('div', 't-feld')
const lName = el('label', 't-label', 'Name')
lName.htmlFor = idName
const iName = el('input', 't-eingabe')
iName.id = idName
iName.name = 'name'
iName.type = 'text'
iName.autocomplete = 'name'
iName.maxLength = 100
iName.required = true
iName.value = istText(gast.name) ? gast.name : ''
const fehlerName = el('p', 't-feldfehler')
fehlerName.id = 't-f-name-fehler'
feldName.appendChild(lName)
feldName.appendChild(iName)
feldName.appendChild(fehlerName)
form.appendChild(feldName)
function festesFeld (id, label, wert, typ) {
const f = el('div', 't-feld')
const l = el('label', 't-label', label)
l.htmlFor = id
const i = el('input', 't-eingabe t-fest')
i.id = id
i.type = typ
i.readOnly = true
i.value = wert
f.appendChild(l)
f.appendChild(i)
return f
}
form.appendChild(festesFeld('t-f-email', 'E-Mail-Adresse der Buchung', istText(gast.email) ? gast.email : '', 'text'))
form.appendChild(festesFeld('t-f-nummer', 'Buchungsnummer', istText(t.nummer) ? t.nummer : '', 'text'))
const erklaerung = el('p', 't-erklaerung',
'Hiermit widerrufe ich den von mir abgeschlossenen Vertrag über die Erbringung der folgenden Dienstleistung: ' +
(t.leistung || 'Termin') + ', ' + t.datum + ', ' + t.zeit + ' Uhr (Buchungsnummer ' + (t.nummer || '–') + ').')
form.appendChild(erklaerung)
const melde = el('div', 't-meldungen')
form.appendChild(melde)
const nav = el('div', 't-nav')
nav.appendChild(knopf('t-btn t-btn-sek', 'Zurück', function () { zeigeVerwalten() }))
const senden = el('button', 't-btn t-btn-warn', 'Widerruf bestätigen')
senden.type = 'submit'
nav.appendChild(senden)
form.appendChild(nav)
add(form)
function nameFehler (text) {
fehlerName.textContent = text
if (text) {
iName.setAttribute('aria-invalid', 'true')
iName.setAttribute('aria-describedby', fehlerName.id)
} else {
iName.removeAttribute('aria-invalid')
iName.removeAttribute('aria-describedby')
}
}
form.addEventListener('submit', function (e) {
e.preventDefault()
if (senden.disabled) return
const name = iName.value.trim()
melde.textContent = ''
nameFehler('')
if (!name) {
nameFehler(T('Bitte geben Sie Ihren Namen an.', 'Bitte gib deinen Namen an.'))
iName.focus()
return
}
senden.disabled = true
post('widerruf', { name: name }).then(function (d) {
if (!d || !d.eingang || !istText(d.eingang.datum) || !istText(d.eingang.uhrzeit)) throw { netz: true, fehler: {}, code: '', status: 0 }
zeigeWiderrufEingang(d.eingang, gast)
}).catch(function (err) {
senden.disabled = false
behandle(err, zeigeWiderruf, function (er) {
if (er.fehler.name) {
nameFehler(er.fehler.name)
iName.focus()
} else if (er.code === 'kein_widerruf') {
lade(ersterText(er.fehler, 'Für diesen Termin ist kein Widerruf möglich.'))
} else {
melde.appendChild(meldung(ersterText(er.fehler, 'Das hat leider nicht geklappt.')))
}
})
})
})
})
}
// Eingangsbestaetigung: Datum und Uhrzeit (Berliner Zeit) vom Server
function zeigeWiderrufEingang (eingang, gast) {
const v = S.v
v.status = 'abgesagt'
v.aktionen = { bestaetigen: false, absagen: false, verschieben: false, widerrufen: false }
zeichne(function () {
const t = T('Ihr Widerruf ist bei uns eingegangen.', 'Dein Widerruf ist bei uns eingegangen.')
add(titel(t))
const eing = el('p', 't-eingang')
eing.appendChild(el('span', 't-eingang-label', 'Eingang: '))
eing.appendChild(el('strong', '', eingang.datum + ', ' + eingang.uhrzeit + ' Uhr'))
add(eing)
add(el('p', '', T('Eine Bestätigung des Eingangs haben wir Ihnen per E-Mail', 'Eine Bestätigung des Eingangs haben wir dir per E-Mail') +
(istText(gast.email) && gast.email ? ' an ' + gast.email : '') + ' geschickt.'))
add(zusammenfassung(v.termin))
ansage(t)
})
}
start()
// Wird ein neuer Link in dieselbe Seite eingefuegt (nur der Hash aendert sich), laedt der Browser nicht neu: dann neu starten
window.addEventListener('hashchange', function () { if (window.location.hash) start() })
})()
+129
View File
@@ -0,0 +1,129 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import vm from 'node:vm'
// Statische Pruefung der Gaesteseite public/termin.html + termin.js + termin.css (Aufgabe 20, Rulings R3 und "termin.html /
// termin.js: kein Browser-Speicher, keine Cookies, noindex"): Datums- und Uhrzeittexte, Kalenderdaten und der Eingang eines
// Widerrufs kommen vom Server (Berliner Zeit), die Seite rechnet nie mit Datum oder Zeitzone des Browsers. Kundentext nur
// ueber textContent.
const lies = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8')
const QUELLE = lies('public/termin.js')
const HTML = lies('public/termin.html')
const CSS = lies('public/termin.css')
/** Quelltext ohne Kommentare (die Kopfzeile darf Beispiele mit Adressen enthalten, der Code nicht). */
const OHNE_KOMMENTARE = QUELLE.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1')
test('Quelltext ist gueltiges JavaScript (kompiliert, wird nicht ausgefuehrt)', () => {
assert.doesNotThrow(() => new vm.Script(QUELLE, { filename: 'termin.js' }))
})
test('kein Browser-Speicher, keine Cookies, kein Referrer, keine Zeitzone', () => {
for (const verboten of ['localStorage', 'sessionStorage', 'document.cookie', 'indexedDB', 'document.referrer', 'sendBeacon', 'navigator.userAgent', 'resolvedOptions', 'getTimezoneOffset']) {
assert.ok(!OHNE_KOMMENTARE.includes(verboten), `${verboten} darf in termin.js nicht vorkommen`)
}
})
test('keine Datums- und Zeitzonenrechnung im Browser (Date, getDay, toLocale, Intl.)', () => {
assert.ok(!/\bDate\b/.test(OHNE_KOMMENTARE), 'Date darf nicht vorkommen')
for (const verboten of ['getDay', 'toLocale', 'Intl.', 'getFullYear', 'getMonth', 'getHours', 'getTime(', 'Date.now']) {
assert.ok(!OHNE_KOMMENTARE.includes(verboten), `${verboten} darf in termin.js nicht vorkommen`)
}
})
test('Kundentext nur ueber textContent: kein innerHTML, outerHTML, insertAdjacentHTML, document.write, eval', () => {
for (const verboten of ['innerHTML', 'outerHTML', 'insertAdjacentHTML', 'document.write', 'eval(', 'new Function', 'setTimeout("', "setTimeout('", 'createContextualFragment', 'srcdoc']) {
assert.ok(!OHNE_KOMMENTARE.includes(verboten), `${verboten} darf in termin.js nicht vorkommen`)
}
})
test('keine festen Fremdadressen im Code, keine nachgeladenen Skripte', () => {
assert.doesNotMatch(OHNE_KOMMENTARE, /https?:\/\//, 'Adressen gehoeren nicht in den Code')
assert.doesNotMatch(OHNE_KOMMENTARE, /\bimport\s*\(|\bimportScripts\b|\bXMLHttpRequest\b|\bWebSocket\b/)
})
test('genau ein fetch, ohne Cookies, nur POST an die Token-Seiten der Schnittstelle', () => {
assert.equal([...OHNE_KOMMENTARE.matchAll(/\bfetch\(/g)].length, 1, 'genau ein fetch (eine Stelle fuer alle Anfragen)')
assert.ok(OHNE_KOMMENTARE.includes("credentials: 'omit'"), "fetch braucht credentials: 'omit'")
assert.ok(OHNE_KOMMENTARE.includes("method: 'POST'"), 'Zustandsaenderungen und Abfragen nur per POST')
assert.ok(OHNE_KOMMENTARE.includes("'/api/public/termine/v1/termin/'"))
for (const aktion of ['vorschau', 'bestaetigen', 'absagen', 'verschieben', 'widerruf', 'tage', 'zeiten']) {
assert.ok(OHNE_KOMMENTARE.includes(`'${aktion}'`), `Aktion ${aktion} fehlt`)
}
})
test('der Token wird aus der Adresszeile entfernt und nur im Speicher gehalten', () => {
assert.ok(OHNE_KOMMENTARE.includes('history.replaceState'), 'Hash muss per history.replaceState entfernt werden')
assert.ok(OHNE_KOMMENTARE.includes('location.hash'))
assert.ok(!OHNE_KOMMENTARE.includes('location.href'), 'keine Weitergabe der Adresse')
assert.ok(!/location\.(?:assign|replace)\(|window\.open\(/.test(OHNE_KOMMENTARE), 'keine Weiterleitung mit dem Token')
})
test('Pflichtbausteine der Aufgabe (Ansichten, Knopftexte, Fuss, Farbe)', () => {
for (const noetig of [
'Widerruf bestätigen', 'Vertrag widerrufen', 'Termin absagen', 'Termin verschieben', 'Ja, Termin absagen', 'Nein, Termin behalten',
'bereits bestätigt', 'Eingang: ', 'eingang.datum', 'eingang.uhrzeit', 'websiteUrl', 'telefon', 'impressumUrl', 'datenschutzUrl',
'noopener noreferrer', 'httpsUrl', 'v.knopf', 'd.anrede', 'd.farbe', '§ 356a BGB', 'role',
]) {
assert.ok(QUELLE.includes(noetig), `${noetig} fehlt in termin.js`)
}
assert.ok(!/\bconfirm\(|\balert\(|\bprompt\(/.test(OHNE_KOMMENTARE), 'keine Browser-Dialoge: die Rueckfrage steht in der Seite')
})
test('termin.html: noindex, kein Referrer, Stile und Skript von der eigenen Herkunft, Fuss mit Impressum WEBklar', () => {
assert.match(HTML, /<meta name="robots" content="[^"]*noindex/, 'noindex fehlt')
assert.match(HTML, /<meta name="referrer" content="no-referrer"/)
assert.match(HTML, /<html lang="de">/)
assert.match(HTML, /<meta name="viewport" content="width=device-width, initial-scale=1/)
assert.match(HTML, /aria-live="polite"/)
assert.match(HTML, /<link rel="stylesheet" href="\/termin\.css">/)
assert.match(HTML, /<script src="\/termin\.js" defer><\/script>/)
assert.match(HTML, /<a href="\/impressum\.html"[^>]*>Impressum WEBklar<\/a>/)
assert.doesNotMatch(HTML, /https?:\/\//, 'keine Fremdadressen in termin.html')
assert.doesNotMatch(HTML, /<script(?![^>]*\bsrc=)[^>]*>/, 'kein Inline-Skript')
assert.doesNotMatch(HTML, /\sstyle=/, 'keine Inline-Stile')
})
test('termin.css: keine Fremdressourcen (keine Schrift- oder Bildadressen von aussen)', () => {
assert.doesNotMatch(CSS, /https?:\/\/|@import|url\(/, 'termin.css darf nichts nachladen')
})
// ---------------------------------------------------------------- Textfarbe auf der Betriebsfarbe (Kontrast)
/** Die reinen Farbfunktionen des Skripts (zwischen den Marken @farben-anfang und @farben-ende) einzeln ausfuehren. */
function farbFunktionen() {
const m = /\/\/ @farben-anfang[^\n]*\n([\s\S]*?)\/\/ @farben-ende/.exec(QUELLE)
assert.ok(m, 'Marken @farben-anfang/@farben-ende fehlen')
return vm.runInNewContext(`${m[1]}; ({ luminanz, kontrast, textFarbeAuf })`, {})
}
// Unabhaengige Gegenrechnung (WCAG 2.1: relative Leuchtdichte und Kontrastverhaeltnis)
function wcagKontrast(hexA, hexB) {
const l = (hex) => {
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255).map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4))
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
const [x, y] = [l(hexA), l(hexB)]
return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05)
}
test('Kopf: Textfarbe auf der Betriebsfarbe erreicht mindestens 4,5 : 1 (Mitteltoene und ein Raster ueber den Farbraum)', () => {
const { textFarbeAuf, luminanz, kontrast } = farbFunktionen()
for (const farbe of ['#ea580c', '#16a34a', '#0ea5e9', '#14b8a6', '#ec4899']) {
const text = textFarbeAuf(farbe)
assert.ok(wcagKontrast(farbe, text) >= 4.5, `${farbe}: ${text} nur ${wcagKontrast(farbe, text).toFixed(2)} : 1`)
}
assert.equal(textFarbeAuf('#0f766e'), '#ffffff')
assert.equal(textFarbeAuf('#fde68a'), '#111827')
const hex = (r, g, b) => `#${[r, g, b].map((c) => c.toString(16).padStart(2, '0')).join('')}`
const proben = []
for (let g = 0; g < 256; g += 5) proben.push(hex(g, g, g))
for (let r = 0; r < 256; r += 15) for (let g = 0; g < 256; g += 15) for (let b = 0; b < 256; b += 15) proben.push(hex(r, g, b))
for (const farbe of proben) {
const text = textFarbeAuf(farbe)
assert.ok(wcagKontrast(farbe, text) >= 4.5, `${farbe}: ${text} nur ${wcagKontrast(farbe, text).toFixed(2)} : 1`)
assert.ok(Math.abs(kontrast(luminanz(farbe), luminanz(text)) - wcagKontrast(farbe, text)) < 1e-9, `${farbe}: Skript und Gegenrechnung weichen ab`)
}
})
+622
View File
@@ -0,0 +1,622 @@
// Playwright-Checks der Gaesteseiten public/termin.html + termin.js + termin.css gegen den UI-Testserver (test/ui/termineTestServer.mjs):
// echte Router, Nachbau von Appwrite/Stripe, Mail-Senke. Nicht Teil von npm test.
// Aufruf: nice node test/ui/termin-seiten.ui.mjs (vorher free -m; es laeuft nur ein Browser)
// Screenshots: /root/reports/terminplaner-ui/termin-*.png (bei Fehlern fehler-termin-<Pruefung>.png). Exit-Code 1, wenn eine Pruefung scheitert.
//
// Ablauf: Buchungen und Bestaetigungen, die nicht selbst geprueft werden, legt das Skript ueber die oeffentliche Schnittstelle an;
// den Link der Mail (G01 #v=, G02 #m=) holt es aus der Mail-Senke. Die Seiten laufen unter srv.url (gleiche Herkunft wie die
// Schnittstelle, wie im Betrieb). Server-Uhr: Montag 2026-10-05 10:00 Berliner Zeit (laeuft in Echtzeit weiter, srv.schiebeUhr/setzeUhr).
import { mkdir, readdir, rm } from 'node:fs/promises'
import path from 'node:path'
import {
startTermineUiServer, salonEingabe,
R_ANNA, S_DAMEN, S_KENNEN, S_ANFRAGE,
} from './termineTestServer.mjs'
const PW = process.env.PLAYWRIGHT_CORE || '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs'
const { chromium } = await import(PW)
const SHOTS = '/root/reports/terminplaner-ui'
await mkdir(SHOTS, { recursive: true })
for (const datei of await readdir(SHOTS)) if (/^(termin|fehler-termin)-.*\.png$/.test(datei)) await rm(path.join(SHOTS, datei)) // alte Laeufe
const srv = await startTermineUiServer()
const KEY = srv.SITE_KEY
const API = `${srv.url}/api/public/termine/v1`
const SEITE = `${srv.url}/termin.html`
const BETRIEB_MAIL = 'info@salon-mueller.example'
// ---------------------------------------------------------------- Helfer
function assert(cond, msg) { if (!cond) throw new Error(msg) }
function eq(ist, soll, msg) {
const a = JSON.stringify(ist)
const b = JSON.stringify(soll)
if (a !== b) throw new Error(`${msg}: ${a} statt ${b}`)
}
const JSON_KOPF = { 'content-type': 'application/json', origin: srv.url }
/** Buchung ueber die oeffentliche Schnittstelle (wie das Widget); liefert den Bestaetigungslink-Token (#v=) aus der Mail G01. */
async function buche(o = {}) {
const v = { leistung: S_DAMEN, ressource: R_ANNA, tag: '2026-10-12', zeit: '10:00', name: 'Max Mustermann', email: 'max@example.com', vorzeitig: true, ...o }
const conf = await (await fetch(`${API}/${KEY}/config`)).json()
const res = await fetch(`${API}/${KEY}/buchungen`, {
method: 'POST',
headers: JSON_KOPF,
body: JSON.stringify({
leistung: v.leistung, ressource: v.ressource, tag: v.tag, zeit: v.zeit, name: v.name, email: v.email, telefon: '', nachricht: '', zusatz: {},
erinnerung: false, zustimmungen: { storno: true, vorzeitig: v.vorzeitig }, kontakt_url: '', formToken: conf.formToken,
}),
})
eq(res.status, 201, `Buchung ${v.tag} ${v.zeit} (${await res.clone().text()})`)
await srv.warteAufVersand()
return { ...v, vToken: tokenAusMail(v.email, 'v') }
}
/** Token (43 Zeichen) des Links #v= oder #m= aus der letzten passenden Mail an die Adresse. */
function tokenAusMail(email, art) {
const mails = srv.mails().filter((m) => m.to.some((t) => t.address === email)).reverse()
for (const m of mails) {
const treffer = new RegExp(`termin\\.html#${art}=([A-Za-z0-9_-]{43})`).exec(`${m.text}\n${m.html}`)
if (treffer) return treffer[1]
}
throw new Error(`Kein Link #${art}= in den Mails an ${email}`)
}
/** Bestaetigung ueber die Schnittstelle (wie der Klick auf der Seite); liefert den Verwaltungs-Token (#m=) aus der Mail G02. */
async function bestaetige(b) {
const res = await fetch(`${API}/termin/bestaetigen`, { method: 'POST', headers: JSON_KOPF, body: JSON.stringify({ token: b.vToken }) })
eq(res.status, 200, 'Bestaetigung')
await srv.warteAufVersand()
return tokenAusMail(b.email, 'm')
}
/** Gebucht und bestaetigt: { mToken, ... }. */
async function buchungBestaetigt(o) {
const b = await buche(o)
return { ...b, mToken: await bestaetige(b) }
}
const zuletzt = () => srv.buchungen().at(-1)
const buchungVon = (email) => srv.buchungen().find((b) => b.gastEmail === email)
const mailsAn = (adresse) => srv.mails().filter((m) => m.to.some((t) => t.address === adresse))
const shot = (page, name) => page.screenshot({ path: `${SHOTS}/termin-${name}.png`, fullPage: true })
const h1 = (page) => page.locator('h1.t-titel')
const warteH1 = (page, text) => page.locator('h1.t-titel', { hasText: text }).waitFor()
const knopf = (page, name) => page.getByRole('button', { name, exact: true })
const oeffne = (page, art, token) => page.goto(`${SEITE}#${art}=${token}`)
const text = (page) => page.locator('#karte').innerText()
/** Kein Streifen rechts: Seite und Karte passen in die Breite. */
const passtInBreite = (page) => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
/** Alle sichtbaren Knoepfe mindestens 44 px hoch (Touch-Ziele). */
async function knoepfeGross(page) {
const kleine = await page.evaluate(() => [...document.querySelectorAll('#karte button')]
.filter((b) => b.offsetParent !== null && !b.classList.contains('t-link'))
.map((b) => ({ t: b.textContent, h: b.getBoundingClientRect().height }))
.filter((b) => b.h < 39.5)) // Tageszellen im 390er Raster sind 40 px, alles andere 44
return kleine
}
// ---------------------------------------------------------------- Ablauf der Pruefungen
const results = []
let browser = null
const hosts = new Set()
async function check(name, fn, { eingabe, context = {} } = {}) {
await srv.zuruecksetzen({ eingabe })
const ctx = await browser.newContext({ viewport: { width: 1000, height: 900 }, ...context })
const page = await ctx.newPage()
page.setDefaultTimeout(8000)
const fehler = []
page.on('pageerror', (e) => fehler.push(`JavaScript-Fehler: ${e.message}`))
page.on('dialog', (d) => { fehler.push(`Browser-Dialog ${d.type()}: ${d.message()}`); d.dismiss().catch(() => {}) })
page.on('request', (r) => {
try {
const u = new URL(r.url())
if (!u.protocol.startsWith('http')) return
hosts.add(u.host)
if (u.pathname.startsWith('/api/') && !(r.method() === 'POST' && u.pathname.startsWith('/api/public/termine/v1/termin/'))) fehler.push(`unerwartete Anfrage ${r.method()} ${u.pathname}`)
} catch { /* data: u. a. */ }
})
page.on('response', (r) => { if (r.headers()['set-cookie']) fehler.push(`Set-Cookie von ${r.url()}`) })
try {
await fn(page, ctx)
assert(fehler.length === 0, fehler.join(' | '))
results.push([name, true])
console.log(`ok ${name}`)
} catch (e) {
results.push([name, false])
console.log(`FAIL ${name}: ${e.message.split('\n')[0]}`)
await page.screenshot({ path: `${SHOTS}/fehler-termin-${name}.png`, fullPage: true }).catch(() => {})
} finally {
await ctx.close()
}
}
async function pruefungen() {
// ------------------------------------------------------------ 1. G01-Link: bestaetigen, G02 im Sink, zweiter Aufruf
await check('g01_bestaetigen_g02_im_sink', async (page, ctx) => {
const b = await buche()
eq(mailsAn('max@example.com').length, 1, 'nur G01 vor dem Klick')
await oeffne(page, 'v', b.vToken)
await warteH1(page, 'Termin bestätigen')
// Token weg aus der Adresszeile, Verlauf und Seite
eq(page.url(), SEITE, 'Adresszeile ohne Hash')
eq(await page.evaluate(() => location.hash), '', 'location.hash leer')
// Kopf in der Farbe des Betriebs (#0f766e), Schrift weiss; Betriebsname im Kopf
const kopf = await page.locator('#kopf').evaluate((e) => ({ bg: getComputedStyle(e).backgroundColor, farbe: getComputedStyle(e).color, name: e.innerText.trim() }))
eq(kopf, { bg: 'rgb(15, 118, 110)', farbe: 'rgb(255, 255, 255)', name: 'Salon Müller' }, 'Kopf')
eq(await page.title(), 'Termin bestätigen – Salon Müller', 'Seitentitel')
// Zusammenfassung ausschliesslich aus der Antwort des Servers (Berliner Zeit)
const t = await text(page)
for (const erwartet of ['Damenschnitt', 'Montag, 12.10.2026', '10:00 Uhr', '45 Minuten', 'ab 45,00 €', 'Hauptstr. 1, 67722 Winnweiler', 'Bei', 'Anna']) assert(t.includes(erwartet), `Zusammenfassung ohne "${erwartet}": ${t}`)
assert(/Buchungsnummer\s+[A-Z0-9]{8}/.test(t), 'Buchungsnummer fehlt')
eq(await knopf(page, 'Zahlungspflichtig bestätigen').count(), 1, 'Knopf des Servers')
eq(await page.evaluate(() => document.activeElement.tagName), 'H1', 'Fokus auf der Ueberschrift')
await shot(page, 'bestaetigen')
await knopf(page, 'Zahlungspflichtig bestätigen').click()
await warteH1(page, 'Ihr Termin ist bestätigt.')
assert((await text(page)).includes('Bestätigung mit allen Angaben per E-Mail'), 'Hinweis auf die Mail')
eq(buchungVon('max@example.com').status, 'bestaetigt', 'Buchung bestaetigt')
await srv.warteAufVersand()
const anGast = mailsAn('max@example.com')
eq(anGast.length, 2, 'G01 und G02 an den Gast')
assert(/termin\.html#m=[A-Za-z0-9_-]{43}/.test(`${anGast[1].text}${anGast[1].html}`), 'G02 enthaelt den Verwaltungslink')
assert(anGast[1].attachments?.length || anGast[1].icalEvent, 'G02 mit Kalendereinladung')
eq(mailsAn(BETRIEB_MAIL).length, 1, 'K20 an den Betrieb')
await shot(page, 'bestaetigt')
// zweiter Aufruf desselben Links (neue Seite): bereits bestaetigt, kein Knopf
const zweite = await ctx.newPage()
await oeffne(zweite, 'v', b.vToken)
await warteH1(zweite, 'bereits bestätigt')
eq(await zweite.getByRole('button').count(), 0, 'kein Knopf mehr')
assert((await text(zweite)).includes('Sie müssen nichts weiter tun'), 'Hinweis ohne weitere Aktion')
await shot(zweite, 'bereits-bestaetigt')
await srv.warteAufVersand()
eq(mailsAn('max@example.com').length, 2, 'der zweite Aufruf schickt keine Mail')
})
// ------------------------------------------------------------ 2. Zwei Seiten, zwei Klicks: genau eine Bestaetigung
await check('zweiter_klick_schon_bestaetigt', async (page, ctx) => {
const b = await buche()
await oeffne(page, 'v', b.vToken)
await warteH1(page, 'Termin bestätigen')
const zweite = await ctx.newPage()
await oeffne(zweite, 'v', b.vToken)
await warteH1(zweite, 'Termin bestätigen')
await knopf(page, 'Zahlungspflichtig bestätigen').click()
await warteH1(page, 'Ihr Termin ist bestätigt.')
await knopf(zweite, 'Zahlungspflichtig bestätigen').click()
await warteH1(zweite, 'Dieser Termin ist bereits bestätigt.')
await srv.warteAufVersand()
eq(mailsAn('max@example.com').length, 2, 'genau eine G02')
})
// ------------------------------------------------------------ 3. Abgelaufen
await check('abgelaufen_reservierung', async (page, ctx) => {
const b = await buche()
// Seite offen, dann laeuft die Haltung (30 min) ab: der Klick fuehrt zur Ansicht "Abgelaufen"
await oeffne(page, 'v', b.vToken)
await warteH1(page, 'Termin bestätigen')
srv.schiebeUhr(31 * 60 * 1000)
await knopf(page, 'Zahlungspflichtig bestätigen').click()
await warteH1(page, 'Diese Reservierung ist abgelaufen.')
eq(buchungVon('max@example.com').status === 'bestaetigt', false, 'abgelaufene Haltung wird nicht bestaetigt')
// frischer Aufruf: dieselbe Ansicht mit Link zur Website und Telefon des Betriebs
const neu = await ctx.newPage()
await oeffne(neu, 'v', b.vToken)
await warteH1(neu, 'Diese Reservierung ist abgelaufen.')
eq(await neu.locator('#betrieb').innerText(), 'Salon Müller', 'Betriebsname im Kopf')
const link = neu.getByRole('link', { name: 'Zurück zur Website von Salon Müller' })
eq(await link.getAttribute('href'), 'https://salon-mueller.example/', 'Link zur Website')
eq(await link.getAttribute('rel'), 'noopener noreferrer', 'rel des Links')
const tel = neu.getByRole('link', { name: '06301 1234' })
eq(await tel.getAttribute('href'), 'tel:063011234', 'Telefonnummer als tel-Link')
eq(await neu.getByRole('button').count(), 0, 'keine Aktion in der Ansicht')
const kopf = await neu.locator('#kopf').evaluate((e) => getComputedStyle(e).backgroundColor)
eq(kopf, 'rgb(31, 41, 55)', 'neutrale Kopffarbe ohne Betriebsfarbe')
await shot(neu, 'abgelaufen')
})
await check('abgelaufen_verwaltungslink', async (page) => {
const b = await buchungBestaetigt()
srv.setzeUhr('2026-10-14T08:00:00.000Z') // Terminende + 1 Tag ist vorbei
await oeffne(page, 'm', b.mToken)
await warteH1(page, 'Diese Reservierung ist abgelaufen.')
eq(await page.getByRole('button').count(), 0, 'keine Aktion')
})
// ------------------------------------------------------------ 4. Knopftexte und Anfrage
await check('knopftexte_und_anfrage', async (page, ctx) => {
const a = await buche({ leistung: S_DAMEN, email: 'a@example.com' })
const k = await buche({ leistung: S_KENNEN, email: 'k@example.com', zeit: '11:00', vorzeitig: false })
const f = await buche({ leistung: S_ANFRAGE, email: 'f@example.com', zeit: '14:00', vorzeitig: false, name: 'Frieda Fischer' })
await oeffne(page, 'v', a.vToken)
await warteH1(page, 'Termin bestätigen')
eq(await knopf(page, 'Zahlungspflichtig bestätigen').count(), 1, 'Preis: Zahlungspflichtig bestätigen')
await oeffne(page, 'v', k.vToken)
await warteH1(page, 'Termin bestätigen')
eq(await knopf(page, 'Termin bestätigen').count(), 1, 'ohne Preis: Termin bestätigen')
await oeffne(page, 'v', f.vToken)
await warteH1(page, 'Anfrage bestätigen')
eq(await knopf(page, 'Anfrage absenden').count(), 1, 'Anfrage: Anfrage absenden')
await shot(page, 'anfrage-bestaetigen')
await knopf(page, 'Anfrage absenden').click()
await warteH1(page, 'Ihre Anfrage ist eingegangen.')
assert((await text(page)).includes('Salon Müller prüft Ihre Anfrage'), 'Text der Anfrage')
await srv.warteAufVersand()
eq(buchungVon('f@example.com').status, 'angefragt', 'Status angefragt')
eq(mailsAn('f@example.com').length, 2, 'G01 und G03')
eq(mailsAn(BETRIEB_MAIL).length, 1, 'K20 (Anfrage) an den Betrieb')
await shot(page, 'anfrage-eingegangen')
// Verwaltungslink der Anfrage: angefragt, Absagen moeglich, kein Widerruf (noch kein Vertrag)
const m = tokenAusMail('f@example.com', 'm')
await oeffne(page, 'm', m)
await warteH1(page, 'Ihr Termin')
assert((await text(page)).includes('Angefragt'), 'Status Angefragt')
assert((await text(page)).includes('noch nicht angenommen'), 'Hinweis: noch nicht angenommen')
eq(await knopf(page, 'Termin absagen').count(), 1, 'Anfrage kann abgesagt werden')
eq(await knopf(page, 'Vertrag widerrufen').count(), 0, 'kein Widerruf bei einer Anfrage')
await shot(page, 'anfrage-verwalten')
})
// ------------------------------------------------------------ 5. Ungueltige Links, Neuladen
await check('ungueltige_links_und_neuladen', async (page) => {
await page.goto(SEITE)
await warteH1(page, 'Bitte öffnen Sie den Link aus Ihrer E-Mail')
await page.goto(`${SEITE}#v=zukurz`)
await warteH1(page, 'unvollständig oder ungültig')
eq(page.url(), SEITE, 'auch ein ungueltiger Hash verschwindet')
await page.goto(`${SEITE}#m=${'A'.repeat(43)}`)
await warteH1(page, 'Dieser Link ist ungültig oder abgelaufen.')
await shot(page, 'unbekannt')
// Neuladen: der Token ist aus der Adresse entfernt, die Seite bittet um den Link aus der Mail
const b = await buche()
await oeffne(page, 'v', b.vToken)
await warteH1(page, 'Termin bestätigen')
await page.reload()
await warteH1(page, 'Bitte öffnen Sie den Link aus Ihrer E-Mail')
eq(await knopf(page, 'Zahlungspflichtig bestätigen').count(), 0, 'nach dem Neuladen kein Knopf')
})
// ------------------------------------------------------------ 6. Verwalten und Absagen
await check('verwalten_absagen', async (page) => {
const b = await buchungBestaetigt()
await oeffne(page, 'm', b.mToken)
await warteH1(page, 'Ihr Termin')
eq(page.url(), SEITE, 'Adresszeile ohne Hash')
const t = await text(page)
assert(t.includes('Bestätigt') && t.includes('Montag, 12.10.2026') && t.includes('10:00 Uhr'), `Verwaltungsansicht: ${t}`)
for (const name of ['Termin verschieben', 'Termin absagen', 'Vertrag widerrufen']) eq(await knopf(page, name).count(), 1, `Knopf ${name}`)
eq(await page.getByRole('button', { name: 'Zahlungspflichtig' }).count(), 0, 'kein Bestaetigungsknopf')
await shot(page, 'verwalten')
// Rueckfrage in der Seite (kein Browser-Dialog: check() meldet jeden Dialog als Fehler); "Nein" laesst alles wie es war
await knopf(page, 'Termin absagen').click()
await warteH1(page, 'Termin wirklich absagen?')
await shot(page, 'absagen-frage')
await knopf(page, 'Nein, Termin behalten').click()
await warteH1(page, 'Ihr Termin')
eq(buchungVon('max@example.com').status, 'bestaetigt', 'nach "Nein" unveraendert')
await knopf(page, 'Termin absagen').click()
await warteH1(page, 'Termin wirklich absagen?')
await knopf(page, 'Ja, Termin absagen').click()
await warteH1(page, 'Ihr Termin wurde abgesagt.')
const doc = buchungVon('max@example.com')
eq([doc.status, doc.abgesagtVon], ['abgesagt', 'gast'], 'abgesagt vom Gast')
await srv.warteAufVersand()
eq(mailsAn('max@example.com').length, 3, 'G01, G02 und G06 an den Gast')
eq(mailsAn(BETRIEB_MAIL).length, 2, 'K20 und K21 an den Betrieb')
await shot(page, 'abgesagt')
// erneut geoeffnet: abgesagt, keine Aktionen
await oeffne(page, 'm', b.mToken)
await warteH1(page, 'Ihr Termin')
assert((await text(page)).includes('Abgesagt'), 'Status Abgesagt')
eq(await page.getByRole('button').count(), 0, 'keine Aktion fuer einen abgesagten Termin')
})
// ------------------------------------------------------------ 7. Verschieben
await check('verwalten_verschieben', async (page) => {
const b = await buchungBestaetigt()
await oeffne(page, 'm', b.mToken)
await warteH1(page, 'Ihr Termin')
await knopf(page, 'Termin verschieben').click()
await warteH1(page, 'Termin verschieben')
await page.locator('.t-monat', { hasText: 'Oktober 2026' }).waitFor()
assert((await text(page)).includes('Ihr bisheriger Termin: Montag, 12.10.2026, 10:00 Uhr (Damenschnitt)'), 'bisheriger Termin')
const tag = (t) => page.locator(`button[data-tag="${t}"]`)
eq(await tag('2026-10-04').isDisabled(), true, 'Sonntag ohne freie Zeit')
eq(await tag('2026-10-13').isDisabled(), false, 'Dienstag frei')
await shot(page, 'verschieben-kalender')
// (a) derselbe Termin: die Zeit steht zur Auswahl (die eigene Buchung belegt sie nicht), der Server lehnt sie ab
await tag('2026-10-12').click()
await page.getByRole('heading', { name: 'Freie Zeiten am Montag, 12.10.2026' }).waitFor()
await page.locator('.t-zeit', { hasText: /^10:00$/ }).click()
await warteH1(page, 'Termin verschieben?')
await knopf(page, 'Termin jetzt verschieben').click()
await warteH1(page, 'Termin verschieben')
await page.locator('.t-meldung', { hasText: 'Das ist bereits der gebuchte Termin.' }).waitFor()
eq(new Date(buchungVon('max@example.com').startsAt).toISOString(), '2026-10-12T08:00:00.000Z', 'Termin unveraendert')
// (b) ein anderer Gast ist schneller: Zeit inzwischen vergeben, Auswahl bleibt mit Meldung stehen
await tag('2026-10-13').click()
await page.getByRole('heading', { name: 'Freie Zeiten am Dienstag, 13.10.2026' }).waitFor()
await page.locator('.t-zeit', { hasText: /^11:30$/ }).click()
await warteH1(page, 'Termin verschieben?')
const frage = await text(page)
assert(frage.includes('Bisher') && frage.includes('Montag, 12.10.2026, 10:00 Uhr') && frage.includes('Neu') && frage.includes('Dienstag, 13.10.2026, 11:30 Uhr'), `Rueckfrage: ${frage}`)
await shot(page, 'verschieben-frage')
await buche({ email: 'anderer@example.com', name: 'Anderer Gast', tag: '2026-10-13', zeit: '11:30' })
await knopf(page, 'Termin jetzt verschieben').click()
await warteH1(page, 'Termin verschieben')
await page.locator('.t-meldung', { hasText: 'Diese Zeit ist leider nicht mehr frei.' }).waitFor()
await page.getByRole('heading', { name: 'Freie Zeiten am Dienstag, 13.10.2026' }).waitFor()
eq(await page.locator('.t-zeit', { hasText: /^11:30$/ }).count(), 0, '11:30 steht nicht mehr zur Auswahl')
// Monatswechsel: November, wieder zurueck
await page.getByRole('button', { name: 'Nächster Monat' }).click()
await page.locator('.t-monat', { hasText: 'November 2026' }).waitFor()
await page.getByRole('button', { name: 'Vorheriger Monat' }).click()
await page.locator('.t-monat', { hasText: 'Oktober 2026' }).waitFor()
// (c) neue Zeit: Dienstag 14:00
await tag('2026-10-13').click()
await page.locator('.t-zeit', { hasText: /^14:00$/ }).click()
await warteH1(page, 'Termin verschieben?')
await knopf(page, 'Termin jetzt verschieben').click()
await warteH1(page, 'Ihr Termin wurde verschoben.')
const t = await text(page)
assert(t.includes('Dienstag, 13.10.2026') && t.includes('14:00 Uhr'), `neuer Termin in der Zusammenfassung: ${t}`)
eq(new Date(buchungVon('max@example.com').startsAt).toISOString(), '2026-10-13T12:00:00.000Z', 'Termin gespeichert (14:00 Berlin = 12:00 UTC)')
await shot(page, 'verschoben')
await srv.warteAufVersand()
eq(mailsAn('max@example.com').length, 3, 'G01, G02 und G07 an den Gast')
// Zurueck zur Uebersicht: neuer Stand vom Server
await knopf(page, 'Zur Terminübersicht').click()
await warteH1(page, 'Ihr Termin')
assert((await text(page)).includes('Dienstag, 13.10.2026'), 'Uebersicht mit dem neuen Termin')
})
// ------------------------------------------------------------ 8. Absagefrist vorbei: Hinweis, Widerruf bleibt
await check('frist_vorbei_hinweis', async (page) => {
const b = await buchungBestaetigt({ tag: '2026-10-05', zeit: '15:00' }) // heute in 5 Stunden, Absagefrist 24 Stunden
await oeffne(page, 'm', b.mToken)
await warteH1(page, 'Ihr Termin')
assert((await text(page)).includes('Absage nur telefonisch: 06301 1234'), 'Hinweis auf die Telefonnummer')
eq(await knopf(page, 'Termin absagen').count(), 0, 'kein Absagen')
eq(await knopf(page, 'Termin verschieben').count(), 0, 'kein Verschieben')
eq(await knopf(page, 'Vertrag widerrufen').count(), 1, 'Widerruf bleibt moeglich (unabhaengig von der Stornofrist)')
await shot(page, 'frist-vorbei')
})
await check('unternehmer_ohne_widerruf', async (page) => {
const b = await buchungBestaetigt({ vorzeitig: false })
await oeffne(page, 'm', b.mToken)
await warteH1(page, 'Ihr Termin')
eq(await knopf(page, 'Termin absagen').count(), 1, 'Absagen moeglich')
eq(await knopf(page, 'Vertrag widerrufen').count(), 0, 'Unternehmer: kein Widerruf')
}, { eingabe: salonEingabe({ recht: { gaesteSind: 'unternehmer', freizeit: false } }) })
// ------------------------------------------------------------ 9. Widerruf (Browser in New York, Eingang in Berliner Zeit)
await check('widerruf_new_york_berliner_eingang', async (page) => {
const zone = await page.evaluate(() => new Intl.DateTimeFormat().resolvedOptions().timeZone)
eq(zone, 'America/New_York', 'Browser laeuft in New York')
const b = await buchungBestaetigt()
srv.setzeUhr('2026-10-05T08:30:00.000Z') // 10:30 Berlin = 04:30 New York
await oeffne(page, 'm', b.mToken)
await warteH1(page, 'Ihr Termin')
const t = await text(page)
assert(t.includes('Montag, 12.10.2026') && t.includes('10:00 Uhr'), `Termin in Berliner Zeit trotz New York: ${t}`)
await knopf(page, 'Vertrag widerrufen').click()
await warteH1(page, 'Vertrag widerrufen')
// vorbefuellt: Name (aenderbar), E-Mail-Adresse und Buchungsnummer (nur zum Lesen)
eq(await page.locator('#t-f-name').inputValue(), 'Max Mustermann', 'Name vorbefuellt')
eq(await page.locator('#t-f-email').inputValue(), 'max@example.com', 'E-Mail vorbefuellt')
const nummer = await page.locator('#t-f-nummer').inputValue()
assert(/^[A-Z0-9]{8}$/.test(nummer), `Buchungsnummer vorbefuellt: ${nummer}`)
eq([await page.locator('#t-f-email').getAttribute('readonly') !== null, await page.locator('#t-f-nummer').getAttribute('readonly') !== null], [true, true], 'E-Mail und Nummer schreibgeschuetzt')
assert((await text(page)).includes(`Buchungsnummer ${nummer}).`) && (await text(page)).includes('Hiermit widerrufe ich'), 'Erklaerung mit Buchungsnummer')
eq(await knopf(page, 'Widerruf bestätigen').count(), 1, 'Knopf "Widerruf bestätigen"')
await shot(page, 'widerruf-formular')
// ohne Namen geht nichts raus
await page.locator('#t-f-name').fill(' ')
await knopf(page, 'Widerruf bestätigen').click()
await page.locator('#t-f-name-fehler', { hasText: 'Bitte geben Sie Ihren Namen an.' }).waitFor()
eq(await page.locator('#t-f-name').getAttribute('aria-invalid'), 'true', 'Name als fehlerhaft markiert')
eq(buchungVon('max@example.com').status, 'bestaetigt', 'nichts widerrufen ohne Namen')
await page.locator('#t-f-name').fill('Max Mustermann')
await knopf(page, 'Widerruf bestätigen').click()
await warteH1(page, 'Ihr Widerruf ist bei uns eingegangen.')
const eingang = await page.locator('.t-eingang').innerText()
assert(/^Eingang: 05\.10\.2026, 10:[0-5]\d Uhr$/.test(eingang), `Eingang in Berliner Zeit (nicht 04:xx aus New York): ${eingang}`)
assert((await text(page)).includes('per E-Mail an max@example.com geschickt'), 'Hinweis auf die Eingangsbestaetigung')
const doc = buchungVon('max@example.com')
eq([doc.status, Boolean(doc.widerrufenAt)], ['abgesagt', true], 'widerrufen')
await srv.warteAufVersand()
eq(mailsAn('max@example.com').length, 3, 'G01, G02 und G08 (Eingangsbestaetigung) an den Gast')
eq(mailsAn(BETRIEB_MAIL).length, 2, 'K20 und K21 an den Betrieb')
await shot(page, 'widerruf-eingang')
}, { context: { timezoneId: 'America/New_York', locale: 'en-US' } })
// ------------------------------------------------------------ 10. Du-Form
await check('du_anrede', async (page) => {
const b = await buche()
await oeffne(page, 'v', b.vToken)
await warteH1(page, 'Termin bestätigen')
assert((await text(page)).includes('Bitte prüfe die Angaben und bestätige mit dem Knopf unten.'), 'Du-Form beim Bestaetigen')
await shot(page, 'du-bestaetigen')
await knopf(page, 'Zahlungspflichtig bestätigen').click()
await warteH1(page, 'Dein Termin ist bestätigt.')
await srv.warteAufVersand()
await oeffne(page, 'm', tokenAusMail('max@example.com', 'm'))
await warteH1(page, 'Dein Termin')
await knopf(page, 'Vertrag widerrufen').click()
await warteH1(page, 'Vertrag widerrufen')
assert((await text(page)).includes('Hier kannst du den Vertrag über diesen Termin widerrufen'), 'Du-Form im Widerruf')
await shot(page, 'du-widerruf')
}, { eingabe: salonEingabe({ anrede: 'du' }) })
// ------------------------------------------------------------ 11. Farben und Fuss
await check('kopf_farben_kontrast', async (page) => {
const b = await buche()
const farben = []
for (const farbe of ['#fde68a', '#ea580c']) {
await srv.speichere(salonEingabe({ farbe }))
await oeffne(page, 'v', b.vToken)
await warteH1(page, 'Termin bestätigen')
const f = await page.locator('#kopf').evaluate((e) => ({ bg: getComputedStyle(e).backgroundColor, fg: getComputedStyle(e).color }))
const grau = (rgb) => rgb.match(/\d+/g).slice(0, 3).map(Number)
const lum = (rgb) => grau(rgb).map((c) => c / 255).map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)).reduce((s, c, i) => s + c * [0.2126, 0.7152, 0.0722][i], 0)
const [x, y] = [lum(f.bg), lum(f.fg)]
const kontrast = (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05)
assert(kontrast >= 4.5, `${farbe}: Kontrast im Kopf nur ${kontrast.toFixed(2)} : 1`)
farben.push(f)
}
eq(farben[0].bg, 'rgb(253, 230, 138)', 'helle Betriebsfarbe im Kopf')
eq(farben[0].fg, 'rgb(17, 24, 39)', 'dunkle Schrift auf heller Farbe')
})
await check('fuss_impressum_datenschutz', async (page) => {
const b = await buche()
await oeffne(page, 'v', b.vToken)
await warteH1(page, 'Termin bestätigen')
const links = await page.locator('#fuss a').evaluateAll((as) => as.map((a) => ({ t: a.textContent, href: a.getAttribute('href'), rel: a.getAttribute('rel'), target: a.getAttribute('target') })))
eq(links, [
{ t: 'Impressum', href: 'https://salon-mueller.example/impressum', rel: 'noopener noreferrer', target: '_blank' },
{ t: 'Datenschutz', href: 'https://salon-mueller.example/datenschutz', rel: 'noopener noreferrer', target: '_blank' },
{ t: 'Impressum WEBklar', href: '/impressum.html', rel: 'noopener noreferrer', target: '_blank' },
], 'Fusszeile')
assert((await page.locator('#fuss').innerText()).includes('Terminbuchung über WEBklar'), 'Hinweis "Terminbuchung über WEBklar"')
await page.locator('#fuss').scrollIntoViewIfNeeded()
})
// ------------------------------------------------------------ 12. Netz weg, Grenze erreicht
await check('ausfall_und_grenze', async (page) => {
const b = await buche()
await page.route('**/termin/vorschau', (route) => route.abort())
await oeffne(page, 'v', b.vToken)
await warteH1(page, 'Das hat leider nicht geklappt')
assert((await text(page)).includes('Gerade nicht erreichbar.'), 'Meldung bei fehlendem Netz')
await shot(page, 'ausfall')
await page.unroute('**/termin/vorschau')
await knopf(page, 'Erneut versuchen').click()
await warteH1(page, 'Termin bestätigen')
// Grenze (429): Text des Servers, Knopf zum erneuten Versuch
await page.route('**/termin/bestaetigen', (route) => route.fulfill({ status: 429, contentType: 'application/json', body: JSON.stringify({ fehler: { _: 'Zu viele Anfragen. Bitte versuchen Sie es später noch einmal.' } }) }))
await knopf(page, 'Zahlungspflichtig bestätigen').click()
await warteH1(page, 'Das hat leider nicht geklappt')
assert((await text(page)).includes('Zu viele Anfragen.'), 'Meldung bei 429')
eq(buchungVon('max@example.com').status, 'gehalten', 'nichts bestaetigt')
})
// ------------------------------------------------------------ 13. Kein Speicher, keine Cookies, nur die eigene Herkunft
await check('keine_cookies_kein_speicher_noindex', async (page, ctx) => {
const b = await buchungBestaetigt()
await oeffne(page, 'v', b.vToken)
await warteH1(page, 'bereits bestätigt')
await oeffne(page, 'm', b.mToken)
await warteH1(page, 'Ihr Termin')
const speicher = await page.evaluate(() => ({ cookie: document.cookie, local: localStorage.length, session: sessionStorage.length, robots: document.querySelector('meta[name=robots]')?.content, referrer: document.querySelector('meta[name=referrer]')?.content }))
eq(speicher, { cookie: '', local: 0, session: 0, robots: 'noindex, nofollow', referrer: 'no-referrer' }, 'Speicher und Metadaten')
eq(await ctx.cookies(), [], 'Browser hat keine Cookies')
const dbs = await page.evaluate(() => (indexedDB.databases ? indexedDB.databases() : []))
eq(dbs, [], 'keine IndexedDB')
})
// ------------------------------------------------------------ 14. Handy (390 px)
await check('handy_390', async (page) => {
const b = await buche()
const pruefe = async (name) => {
assert(await passtInBreite(page), `${name}: Seite breiter als 390 px`)
const klein = await knoepfeGross(page)
eq(klein, [], `${name}: Knoepfe unter 40 px Hoehe`)
}
await oeffne(page, 'v', b.vToken)
await warteH1(page, 'Termin bestätigen')
await pruefe('bestaetigen')
await shot(page, '390-1-bestaetigen')
await knopf(page, 'Zahlungspflichtig bestätigen').click()
await warteH1(page, 'Ihr Termin ist bestätigt.')
await pruefe('bestaetigt')
await shot(page, '390-2-bestaetigt')
const m = tokenAusMail('max@example.com', 'm')
await srv.warteAufVersand()
await oeffne(page, 'm', tokenAusMail('max@example.com', 'm'))
await warteH1(page, 'Ihr Termin')
await pruefe('verwalten')
await shot(page, '390-3-verwalten')
await knopf(page, 'Termin verschieben').click()
await page.locator('.t-monat', { hasText: 'Oktober 2026' }).waitFor()
await page.locator('button[data-tag="2026-10-13"]').click()
await page.locator('.t-zeit').first().waitFor()
await pruefe('verschieben')
await shot(page, '390-4-verschieben')
await page.locator('.t-zeit').first().click()
await warteH1(page, 'Termin verschieben?')
await pruefe('verschieben-frage')
await shot(page, '390-5-verschieben-frage')
await oeffne(page, 'm', m)
await warteH1(page, 'Ihr Termin')
await knopf(page, 'Termin absagen').click()
await warteH1(page, 'Termin wirklich absagen?')
await pruefe('absagen')
await shot(page, '390-6-absagen')
await knopf(page, 'Nein, Termin behalten').click()
await warteH1(page, 'Ihr Termin')
await knopf(page, 'Vertrag widerrufen').click()
await warteH1(page, 'Vertrag widerrufen')
await pruefe('widerruf')
await shot(page, '390-7-widerruf')
await knopf(page, 'Widerruf bestätigen').click()
await warteH1(page, 'Ihr Widerruf ist bei uns eingegangen.')
await pruefe('widerruf-eingang')
await shot(page, '390-8-widerruf-eingang')
await srv.zuruecksetzen({ uhrNeu: false })
const c = await buche({ email: 'c@example.com' })
srv.schiebeUhr(31 * 60 * 1000)
await oeffne(page, 'v', c.vToken)
await warteH1(page, 'Diese Reservierung ist abgelaufen.')
await pruefe('abgelaufen')
await shot(page, '390-9-abgelaufen')
}, { context: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true } })
}
// ---------------------------------------------------------------- Lauf
let abbruch = null
try {
browser = await chromium.launch()
await pruefungen()
const fremd = [...hosts].filter((h) => h !== new URL(srv.url).host)
if (fremd.length) {
results.push(['nur_eigene_herkunft', false])
console.log(`FAIL nur_eigene_herkunft: Anfragen an fremde Hosts: ${fremd.join(', ')}`)
} else {
results.push(['nur_eigene_herkunft', true])
console.log('ok nur_eigene_herkunft')
}
} catch (e) {
abbruch = e
console.log(`ABBRUCH: ${e.stack || e.message}`)
} finally {
if (browser) await browser.close().catch(() => {})
await srv.stop().catch((e) => console.log(`Server beenden: ${e.message}`))
}
const fehlgeschlagen = results.filter(([, ok]) => !ok)
console.log(`${results.length - fehlgeschlagen.length}/${results.length} Checks bestanden`)
process.exit(abbruch || fehlgeschlagen.length ? 1 : 0)