diff --git a/public/termin.css b/public/termin.css new file mode 100644 index 0000000..6e63a87 --- /dev/null +++ b/public/termin.css @@ -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; } +} diff --git a/public/termin.html b/public/termin.html new file mode 100644 index 0000000..55dd464 --- /dev/null +++ b/public/termin.html @@ -0,0 +1,35 @@ + + + + + + + + + Ihr Termin + + + + +
+

+
+
+
+
+ +

Wird geladen …

+
+
+
+
+ + + + diff --git a/public/termin.js b/public/termin.js new file mode 100644 index 0000000..ad02d85 --- /dev/null +++ b/public/termin.js @@ -0,0 +1,944 @@ +/*! WEBklar Terminplaner: Gaesteseite (termin.html). Links aus den Terminmails: + * termin.html#v= Termin bestaetigen (G01) termin.html#m= 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/ 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() }) +})() diff --git a/test/termineSeiteStatic.test.js b/test/termineSeiteStatic.test.js new file mode 100644 index 0000000..06882dc --- /dev/null +++ b/test/termineSeiteStatic.test.js @@ -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, //) + assert.match(HTML, //) + assert.match(HTML, /