/*! 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() }) })()