Files
Webklar-Kundenbereich/public/termin.js
T
2026-10-03 08:17:44 +00:00

951 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*! 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
}
// 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
}
// Kopf und Fuss einer Fehleransicht: Ist die Vorschau schon geladen (der Link laeuft mitten in der Sitzung ab), bleiben
// Farbe, Anrede und Fusslinks, wie der Gast sie gesehen hat; sonst neutraler Kopf, Sie-Form, kein Betriebsfuss.
function fehlerKopf (name) {
if (S.v) {
setzeKopf(name || S.v.betrieb.name, S.v.farbe)
setzeFuss(S.v.betrieb)
return
}
S.du = false
setzeKopf(name, '')
setzeFuss(null)
}
// Link unbekannt, unvollstaendig oder ohne Hash
function zeigeUngueltig (text, hinweis) {
fehlerKopf('')
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 || {}
fehlerKopf(b.name)
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
S.v = null
S.verschieben = null
// Der Token steht nur im Hash (wird nie an einen Server gesendet) und verschwindet sofort aus der Adresszeile;
// wirft der Browser dabei (eingeschraenkte Umgebung), laeuft die Seite trotzdem weiter
if (roh) {
try {
window.history.replaceState(null, '', window.location.pathname + window.location.search)
} catch (e) { /* Token bleibt dann in der Adresszeile, die Seite funktioniert */ }
}
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]
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.') : T('Ihre Anfrage wurde bereits abgesendet.', 'Deine Anfrage wurde bereits abgesendet.')
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 erhalten eine E-Mail, sobald ' + betrieb + ' sie annimmt.', 'Du erhältst eine E-Mail, sobald ' + betrieb + ' sie annimmt.')
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))
// § 356a BGB: die Widerrufsfunktion ist gut sichtbar und leicht erreichbar, ein gleichrangiger Knopf in derselben Reihe
if (a.widerrufen) nav.appendChild(knopf('t-btn t-btn-sek', 'Vertrag widerrufen', zeigeWiderruf))
if (nav.childNodes.length) add(nav)
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() })
})()