diff --git a/public/embed/unterkunft-demo.html b/public/embed/unterkunft-demo.html new file mode 100644 index 0000000..94a9790 --- /dev/null +++ b/public/embed/unterkunft-demo.html @@ -0,0 +1,26 @@ + + + + + + +Buchungskalender Testseite + + + +
Testseite der Kundenwebsite (intern, nicht verlinkt)
+
+

Unterkunft anfragen

+ +
+
+ + + + diff --git a/public/embed/unterkunft.js b/public/embed/unterkunft.js new file mode 100644 index 0000000..eee06c9 --- /dev/null +++ b/public/embed/unterkunft.js @@ -0,0 +1,486 @@ +/*! WEBklar Buchungskalender: Einbau-Skript fuer Kundenwebsites. + *
+ * + * Keine Abhaengigkeiten, keine Cookies, kein Storage, keine Drittanfragen. Alle Texte nur ueber textContent. + * Gestaltung ueber CSS-Variablen der Seite (alle optional): --wku-akzent, --wku-akzent-text, --wku-hintergrund, --wku-text, + * --wku-rand, --wku-radius, --wku-schrift, --wku-schrift-titel. + * Belegte Naechte kommen als [von,bis) (bis = Abreisetag, nicht belegt). Datumsrechnung ist reine Kalenderrechnung auf Zeichenketten + * (UTC), die Zeitzone des Browsers spielt nie eine Rolle; "heute" und das Ende des Buchungsfensters liefert der Server. */ +(function () { + 'use strict' + var SELF = document.currentScript + var STYLE_ID = 'wku-style' + var API_PFAD = '/api/public/unterkunft/v1/' + var AUSWEICH = 'Anfragen gerne per Telefon oder E-Mail.' + var TIMEOUT_MS = 20000 + var MONAT_KURZ = ['Jan.', 'Feb.', 'März', 'Apr.', 'Mai', 'Juni', 'Juli', 'Aug.', 'Sept.', 'Okt.', 'Nov.', 'Dez.'] + var MONAT_LANG = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'] + var WOCHE_KURZ = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] + var WOCHE_LANG = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'] + var EMAIL_FORM = /^[^\s@,;<>"]+@[^\s@,;<>"]+\.[^\s@,;<>"]+$/ + var FARBE = /^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/ + var zaehler = 0 + + /* ------------------------------------------------------------ reine Kalenderlogik --- */ + function zwei(n) { return (n < 10 ? '0' : '') + n } + function iso(j, m, t) { return j + '-' + zwei(m) + '-' + zwei(t) } + function zerlege(s) { var p = s.split('-'); return [parseInt(p[0], 10), parseInt(p[1], 10), parseInt(p[2], 10)] } + function utc(s) { var p = zerlege(s); return Date.UTC(p[0], p[1] - 1, p[2]) } + function plusTage(s, n) { + var d = new Date(utc(s) + n * 86400000) + return iso(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate()) + } + function tageZwischen(a, b) { return Math.round((utc(b) - utc(a)) / 86400000) } + function wochentag(s) { return (new Date(utc(s)).getUTCDay() + 6) % 7 } // 0 = Montag + function tageImMonat(j, m) { return new Date(Date.UTC(j, m, 0)).getUTCDate() } + + var WKU_LOGIK = { + monatsRaster: function (jahr, monat) { + var r = [] + var lead = wochentag(iso(jahr, monat, 1)) + var n = tageImMonat(jahr, monat) + var i + for (i = 0; i < lead; i += 1) r.push({ tag: null }) + for (i = 1; i <= n; i += 1) r.push({ tag: iso(jahr, monat, i) }) + while (r.length % 7) r.push({ tag: null }) + return r + }, + nachtBelegt: function (tag, belegt) { + for (var i = 0; i < (belegt || []).length; i += 1) if (belegt[i].von <= tag && tag < belegt[i].bis) return true + return false + }, + waehle: function (zustand, tag, belegt, mindestNaechte) { + var z = zustand || {} + var L = WKU_LOGIK + var neu = function () { return L.nachtBelegt(tag, belegt) ? { von: z.von || null, bis: z.bis || null } : { von: tag, bis: null } } + if (!z.von || z.bis || tag <= z.von) return neu() + for (var d = z.von; d < tag; d = plusTage(d, 1)) if (L.nachtBelegt(d, belegt)) return neu() + if (tageZwischen(z.von, tag) < (mindestNaechte || 1)) return { von: z.von, bis: null, fehler: 'mindestnaechte' } + return { von: z.von, bis: tag } + }, + zusammenfassung: function (von, bis) { + var a = zerlege(von) + var b = zerlege(bis) + var n = tageZwischen(von, bis) + var tail = ' · ' + n + (n === 1 ? ' Nacht' : ' Nächte') + if (a[0] !== b[0]) return a[2] + '. ' + MONAT_KURZ[a[1] - 1] + ' ' + a[0] + ' – ' + b[2] + '. ' + MONAT_KURZ[b[1] - 1] + ' ' + b[0] + tail + if (a[1] === b[1]) return a[2] + '.–' + b[2] + '. ' + MONAT_KURZ[a[1] - 1] + tail + return a[2] + '. ' + MONAT_KURZ[a[1] - 1] + ' – ' + b[2] + '. ' + MONAT_KURZ[b[1] - 1] + tail + } + } + var testModus = false + try { testModus = !!(SELF && SELF.dataset && SELF.dataset.test === '1') } catch (e) { testModus = false } + if (testModus) globalThis.__wkuLogik = WKU_LOGIK + + function langesDatum(s) { + var p = zerlege(s) + return WOCHE_LANG[wochentag(s)] + ', ' + p[2] + '. ' + MONAT_LANG[p[1] - 1] + ' ' + p[0] + } + + /* ------------------------------------------------------------------- Gestaltung --- */ + var CSS = [ + '@layer wku{', + '.wku-root{--wku-i-akzent:var(--wku-akzent,var(--wku-vorgabe-akzent,#0f766e));--wku-i-akzent-text:var(--wku-akzent-text,var(--wku-vorgabe-akzent-text,#fff));', + '--wku-i-bg:var(--wku-hintergrund,#fff);--wku-i-text:var(--wku-text,#1f2937);--wku-i-rand:var(--wku-rand,#b6bcc6);--wku-i-radius:var(--wku-radius,12px);', + '--wku-i-weich:#f3f4f6;--wku-i-bereich:#e5eceb;--wku-i-fehler:#b91c1c;--wku-i-schrift:var(--wku-schrift,inherit);--wku-i-titel:var(--wku-schrift-titel,var(--wku-schrift,inherit))}', + '@supports (color:color-mix(in srgb,red,blue)){.wku-root{--wku-i-weich:color-mix(in srgb,var(--wku-i-text) 6%,var(--wku-i-bg));--wku-i-bereich:color-mix(in srgb,var(--wku-i-akzent) 18%,var(--wku-i-bg))}}', + '.wku-root{container:wku/inline-size;display:block;position:relative;box-sizing:border-box;color:var(--wku-i-text);font-family:var(--wku-i-schrift);font-size:16px;line-height:1.5;text-align:left}', + '.wku-root *,.wku-root *::before,.wku-root *::after{box-sizing:border-box}', + '.wku-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}', + '.wku-haupt{display:grid;grid-template-columns:minmax(0,1fr);gap:24px}', + '.wku-karte{background:var(--wku-i-bg);border:1px solid var(--wku-i-rand);border-radius:var(--wku-i-radius);padding:20px}', + '.wku-titel{font-family:var(--wku-i-titel);font-size:1.2rem;line-height:1.25;font-weight:700;margin:0 0 .75rem}', + '.wku-ausweich{margin:0}', + '.wku-monate{display:grid;grid-template-columns:minmax(0,1fr);gap:20px}', + '.wku-zwei{display:none}', + '.wku-monat-kopf{display:flex;align-items:center;justify-content:space-between;gap:.5rem;min-height:44px;margin-bottom:.35rem}', + '.wku-monat-name{font-family:var(--wku-i-titel);font-size:1.05rem;font-weight:700;margin:0;flex:1;text-align:center}', + '.wku-pfeil{font:inherit;font-size:1.4rem;line-height:1;min-width:44px;min-height:44px;border-radius:50%;border:1px solid var(--wku-i-rand);background:var(--wku-i-bg);color:inherit;cursor:pointer;padding:0}', + '.wku-pfeil:hover{background:var(--wku-i-weich)}.wku-pfeil[disabled]{opacity:.3;cursor:default}.wku-pfeil-leer{display:inline-block;width:44px;height:44px}', + '.wku-raster{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));row-gap:2px}', + '.wku-wt{font-size:.78rem;font-weight:600;text-align:center;padding:.25rem 0;opacity:.7}', + '.wku-leer{min-height:44px}', + '.wku-tag{font:inherit;position:relative;min-height:44px;padding:0;margin:0;border:0;border-radius:0;background:transparent;color:inherit;text-align:center;cursor:pointer;overflow:hidden}', + '.wku-tag>span{position:relative;display:inline-flex;align-items:center;justify-content:center;width:38px;height:38px;border-radius:50%;border:1px solid transparent;z-index:1}', + '.wku-tag:hover>span{background:var(--wku-i-weich)}', + '.wku-heute>span{border-color:var(--wku-i-text)}', + '.wku-vergangen,.wku-zu{opacity:.35;cursor:default}.wku-vergangen:hover>span,.wku-zu:hover>span{background:transparent}', + '.wku-belegt{color:#6b7280;cursor:default;background:linear-gradient(to top right,transparent calc(50% - 1px),#9ca3af calc(50% - 1px),#9ca3af calc(50% + 1px),transparent calc(50% + 1px)) center/60% 60% no-repeat}', + '.wku-belegt:hover>span{background:transparent}', + '.wku-bereich{background:var(--wku-i-bereich)}', + '.wku-an{background:linear-gradient(to right,transparent 50%,var(--wku-i-bereich) 50%)}', + '.wku-ab{background:linear-gradient(to left,transparent 50%,var(--wku-i-bereich) 50%)}', + '.wku-an.wku-ab{background:transparent}', + '.wku-an>span,.wku-ab>span,.wku-an:hover>span,.wku-ab:hover>span{background:var(--wku-i-akzent);color:var(--wku-i-akzent-text);border-color:var(--wku-i-akzent)}', + '.wku-root :focus-visible{outline:3px solid var(--wku-i-text);outline-offset:2px}', + '.wku-legende{display:flex;flex-wrap:wrap;gap:.25rem 1rem;margin:.75rem 0 0;font-size:.8rem;opacity:.8}', + '.wku-zus{margin:1rem 0 0;font-weight:700;min-height:1.5rem}', + '.wku-hinweis{margin:.25rem 0 0;font-size:.9rem;color:var(--wku-i-fehler);font-weight:600;min-height:1.4rem}', + '.wku-form{margin:0}', + '.wku-feld{margin:0 0 .9rem}', + '.wku-label{display:block;font-weight:600;font-size:.92rem;margin-bottom:.25rem}', + '.wku-stern{color:var(--wku-i-fehler)}', + '.wku-eingabe{display:block;width:100%;min-height:44px;font:inherit;color:inherit;background:var(--wku-i-bg);border:1px solid var(--wku-i-rand);border-radius:calc(var(--wku-i-radius) * .7);padding:.5rem .7rem}', + 'textarea.wku-eingabe{min-height:5.5rem;resize:vertical}', + '.wku-eingabe[aria-invalid="true"]{border-color:var(--wku-i-fehler);border-width:2px}', + '.wku-feldfehler{color:var(--wku-i-fehler);font-weight:600;font-size:.88rem;margin:.25rem 0 0}', + '.wku-reihe{display:grid;grid-template-columns:minmax(0,1fr);gap:0 12px}', + '.wku-fehler{margin:0 0 .75rem;padding:.6rem .8rem;border-left:4px solid var(--wku-i-fehler);background:var(--wku-i-weich);font-weight:600}', + '.wku-datenschutz{font-size:.85rem;margin:0 0 1rem;opacity:.9}.wku-datenschutz a{color:inherit}', + '.wku-btn{font:inherit;font-weight:700;min-height:48px;padding:.6rem 1.5rem;border-radius:var(--wku-i-radius);border:2px solid var(--wku-i-akzent);background:var(--wku-i-akzent);color:var(--wku-i-akzent-text);cursor:pointer;line-height:1.25;width:100%}', + '.wku-btn:hover{filter:brightness(1.08)}.wku-btn[disabled]{opacity:.6;cursor:default}', + '.wku-danke{margin:0;font-size:1.05rem;font-weight:600}', + '.wku-hp{position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden}', + '@container wku (min-width:520px){.wku-reihe{grid-template-columns:1fr 1fr}}', + '@container wku (min-width:760px){.wku-haupt{grid-template-columns:minmax(0,1.15fr) minmax(0,1fr);align-items:start}}', + '@container wku (min-width:1100px){.wku-zwei{display:block}.wku-monate{grid-template-columns:1fr 1fr}.wku-weiter-ein{display:none}}', + '@media (prefers-reduced-motion:no-preference){.wku-btn,.wku-tag>span{transition:background-color .15s}}', + '}' + ].join('') + + function ensureStyle() { + if (document.getElementById(STYLE_ID)) return + var st = document.createElement('style') + st.id = STYLE_ID + st.textContent = CSS + ;(document.head || document.documentElement).appendChild(st) + } + + /* ------------------------------------------------------------------------ Helfer --- */ + function h(tag, attrs, kinder) { + var el = document.createElement(tag) + var k + attrs = attrs || {} + for (k in attrs) { + if (!Object.prototype.hasOwnProperty.call(attrs, k) || attrs[k] === null || attrs[k] === undefined || attrs[k] === false) continue + if (k === 'text') el.textContent = attrs[k] + else if (k === 'class') el.className = attrs[k] + else el.setAttribute(k, attrs[k] === true ? '' : String(attrs[k])) + } + ;(kinder || []).forEach(function (c) { if (c) el.appendChild(c) }) + return el + } + + function apiBasis(el) { + var a = el.getAttribute('data-api') + if (!a && SELF && SELF.src) { var m = /^(https?:\/\/[^\/]+)/.exec(SELF.src); a = m ? m[1] : '' } + return (a || '').replace(/\/+$/, '') + } + + function holen(url, opt) { + var ctl = typeof AbortController === 'function' ? new AbortController() : null + var t = ctl ? setTimeout(function () { ctl.abort() }, TIMEOUT_MS) : null + var o = opt || {} + if (ctl) o.signal = ctl.signal + return fetch(url, o).then(function (r) { + if (t) clearTimeout(t) + return r.json().then(function (body) { return { status: r.status, ok: r.ok, body: body || {} } }, function () { return { status: r.status, ok: r.ok, body: {} } }) + }, function (e) { if (t) clearTimeout(t); throw e }) + } + + function textFarbe(hex) { + var s = hex.slice(1) + if (s.length === 3) s = s[0] + s[0] + s[1] + s[1] + s[2] + s[2] + var r = parseInt(s.slice(0, 2), 16); var g = parseInt(s.slice(2, 4), 16); var b = parseInt(s.slice(4, 6), 16) + return (0.299 * r + 0.587 * g + 0.114 * b) > 160 ? '#111827' : '#ffffff' + } + + function ausweich(el) { + el.textContent = '' + el.appendChild(h('p', { class: 'wku-root wku-ausweich', text: AUSWEICH })) + } + + /* ------------------------------------------------------------------------ Widget --- */ + function starten(el) { + if (el.getAttribute('data-wku-fertig')) return + el.setAttribute('data-wku-fertig', '1') + var siteKey = el.getAttribute('data-webklar-unterkunft') + if (!/^[A-Za-z0-9_-]{8,64}$/.test(siteKey || '')) return + ensureStyle() + var basis = apiBasis(el) + API_PFAD + encodeURIComponent(siteKey) + holen(basis, { headers: { Accept: 'application/json' } }).then(function (r) { + if (!r.ok || !r.body || r.body.aktiv !== true || !r.body.unterkuenfte || !r.body.unterkuenfte.length) return ausweich(el) + bauen(el, basis, r.body) + }, function () { ausweich(el) }) + } + + function bauen(el, basis, cfg) { + var id = 'wku' + (zaehler += 1) + '-' + var heute = null + var bisMax = null + var belegt = [] + var sel = { von: null, bis: null } + var hinweis = '' + var unterk = cfg.unterkuenfte[0] + var token = cfg.formToken + var ansicht = null // { j, m } erster angezeigter Monat + var fokusTag = null + var sendet = false + + var root = h('div', { class: 'wku-root' }) + if (cfg.akzentfarbe && FARBE.test(cfg.akzentfarbe) && root.style) { + root.style.setProperty('--wku-vorgabe-akzent', cfg.akzentfarbe) + root.style.setProperty('--wku-vorgabe-akzent-text', textFarbe(cfg.akzentfarbe)) + } + var haupt = h('div', { class: 'wku-haupt' }) + var kal = h('div', { class: 'wku-karte' }) + var formBox = h('div', { class: 'wku-karte' }) + var zus = h('p', { class: 'wku-zus', 'aria-live': 'polite' }) + var hint = h('p', { class: 'wku-hinweis', 'aria-live': 'polite' }) + var monate = h('div', { class: 'wku-monate' }) + kal.appendChild(h('h2', { class: 'wku-titel', text: 'Zeitraum wählen' })) + kal.appendChild(monate) + kal.appendChild(h('div', { class: 'wku-legende' }, [h('span', { text: 'Grau durchgestrichen: belegt' }), h('span', { text: 'Umrandet: heute' })])) + kal.appendChild(zus) + kal.appendChild(hint) + haupt.appendChild(kal) + haupt.appendChild(formBox) + root.appendChild(haupt) + el.textContent = '' + el.appendChild(root) + + /* ---- Formular ---- */ + var feld = {} + var fehlerBox = h('p', { class: 'wku-fehler', role: 'alert', hidden: true }) + var form = h('form', { class: 'wku-form', novalidate: true }) + var selU = null + var selP = h('select', { class: 'wku-eingabe', id: id + 'personen', name: 'personen' }) + function eingabe(name, label, opt) { + opt = opt || {} + var inp = h(opt.mehrzeilig ? 'textarea' : 'input', { class: 'wku-eingabe', id: id + name, name: name, type: opt.mehrzeilig ? null : (opt.type || 'text'), autocomplete: opt.auto || 'off', maxlength: opt.max || 200, required: opt.pflicht ? true : null, rows: opt.mehrzeilig ? 4 : null }) + var fe = h('p', { class: 'wku-feldfehler', id: id + name + '-f', hidden: true }) + var box = h('div', { class: 'wku-feld' }, [h('label', { class: 'wku-label', for: id + name }, [h('span', { text: label }), opt.pflicht ? h('span', { class: 'wku-stern', 'aria-hidden': 'true', text: ' *' }) : null]), inp, fe]) + feld[name] = { inp: inp, fe: fe } + return box + } + function fuellePersonen() { + selP.textContent = '' + var max = Math.max(1, parseInt(unterk.maxPersonen, 10) || 1) + for (var i = 1; i <= max; i += 1) selP.appendChild(h('option', { value: i, text: i === 1 ? '1 Person' : i + ' Personen' })) + } + if (cfg.unterkuenfte.length > 1) { + selU = h('select', { class: 'wku-eingabe', id: id + 'unterkunft', name: 'unterkunft' }) + cfg.unterkuenfte.forEach(function (u, i) { selU.appendChild(h('option', { value: i, text: u.name })) }) + form.appendChild(h('div', { class: 'wku-feld' }, [h('label', { class: 'wku-label', for: id + 'unterkunft', text: 'Unterkunft' }), selU])) + } + form.appendChild(h('div', { class: 'wku-feld' }, [h('label', { class: 'wku-label', for: id + 'personen', text: 'Personen' }), selP])) + form.appendChild(eingabe('firma', 'Firma (optional)', { auto: 'organization' })) + form.appendChild(eingabe('name', 'Name', { pflicht: true, auto: 'name' })) + form.appendChild(h('div', { class: 'wku-reihe' }, [eingabe('email', 'E-Mail', { pflicht: true, type: 'email', auto: 'email' }), eingabe('telefon', 'Telefon (optional)', { type: 'tel', auto: 'tel', max: 50 })])) + form.appendChild(eingabe('nachricht', 'Nachricht (optional)', { mehrzeilig: true, max: 2000 })) + var hp = h('input', { type: 'text', name: 'kontakt_url', id: id + 'hp', tabindex: '-1', autocomplete: 'off' }) + form.appendChild(h('div', { class: 'wku-hp', 'aria-hidden': 'true' }, [hp])) + var ds = h('p', { class: 'wku-datenschutz' }) + var anb = cfg.anbieter || {} + ds.appendChild(document.createTextNode('Ihre Angaben werden zur Bearbeitung Ihrer Anfrage verwendet' + (anb.name ? ' (' + anb.name + ')' : '') + '. Mehr dazu in der ')) + if (anb.datenschutzUrl && /^https?:\/\//i.test(anb.datenschutzUrl)) ds.appendChild(h('a', { href: anb.datenschutzUrl, target: '_blank', rel: 'noopener noreferrer', text: 'Datenschutzerklärung' })) + else ds.appendChild(document.createTextNode('Datenschutzerklärung')) + ds.appendChild(document.createTextNode('.')) + form.appendChild(ds) + var btn = h('button', { class: 'wku-btn', type: 'submit', text: 'Anfrage senden' }) + form.appendChild(fehlerBox) + form.appendChild(btn) + formBox.appendChild(h('h2', { class: 'wku-titel', text: 'Unverbindlich anfragen' })) + formBox.appendChild(form) + fuellePersonen() + + function zeigeFehler(text) { fehlerBox.textContent = text || ''; fehlerBox.hidden = !text } + function feldFehler(name, text) { + var f = feld[name] + if (!f) return false + f.fe.textContent = text || '' + f.fe.hidden = !text + if (text) { f.inp.setAttribute('aria-invalid', 'true'); f.inp.setAttribute('aria-describedby', f.fe.id) } else { f.inp.removeAttribute('aria-invalid'); f.inp.removeAttribute('aria-describedby') } + return true + } + + /* ---- Kalender ---- */ + function monatKey(j, m) { return j * 12 + (m - 1) } + function zweiSichtbar() { return (root.offsetWidth || root.clientWidth || 0) >= 1100 } + function aktualisiereZus() { + if (sel.von && sel.bis) { zus.textContent = WKU_LOGIK.zusammenfassung(sel.von, sel.bis); hint.textContent = '' } + else if (sel.von) { zus.textContent = 'Anreise ' + langesDatum(sel.von) + '. Bitte Abreise wählen.'; hint.textContent = hinweis } + else { zus.textContent = ''; hint.textContent = '' } + if (hinweis && !sel.bis) hint.textContent = hinweis + } + function tageBisFrei(von, bis) { + for (var d = von; d < bis; d = plusTage(d, 1)) if (WKU_LOGIK.nachtBelegt(d, belegt)) return false + return true + } + function tagKnopf(tag) { + var p = zerlege(tag) + var vergangen = tag < heute + var zu = tag > bisMax + var nb = WKU_LOGIK.nachtBelegt(tag, belegt) + var kl = ['wku-tag'] + if (tag === heute) kl.push('wku-heute') + if (vergangen) kl.push('wku-vergangen') + if (zu) kl.push('wku-zu') + if (nb && !vergangen && !zu) kl.push('wku-belegt') + var istAn = sel.von === tag + var istAb = sel.bis === tag + if (istAn && sel.bis) kl.push('wku-an') + else if (istAn) kl.push('wku-an', 'wku-ab') + if (istAb) kl.push('wku-ab') + if (sel.von && sel.bis && tag > sel.von && tag < sel.bis) kl.push('wku-bereich') + var status = (nb ? (sel.von && !sel.bis && tag > sel.von && !vergangen && !zu && !WKU_LOGIK.nachtBelegt(sel.von, belegt) && tageBisFrei(sel.von, tag) ? 'belegt – als Abreisetag möglich' : 'belegt') : 'frei') + (istAn ? ', Anreise' : '') + (istAb ? ', Abreise' : '') + var b = h('button', { class: kl.join(' '), type: 'button', 'data-tag': tag, 'aria-label': langesDatum(tag) + ', ' + status, 'aria-pressed': (istAn || istAb) ? 'true' : 'false', tabindex: tag === fokusTag ? '0' : '-1' }, [h('span', { text: String(p[2]) })]) + if (vergangen || zu) { b.setAttribute('aria-disabled', 'true') } + b.addEventListener('click', function () { tagKlick(tag) }) + return b + } + function monatBauen(j, m, erster) { + var kopf = h('div', { class: 'wku-monat-kopf' }) + var min = monatKey(heute.slice(0, 4) * 1, heute.slice(5, 7) * 1) + var max = monatKey(bisMax.slice(0, 4) * 1, bisMax.slice(5, 7) * 1) + var zur + if (erster) { + zur = h('button', { class: 'wku-pfeil', type: 'button', 'data-nav': 'zur', 'aria-label': 'Vorheriger Monat', text: '‹' }) + if (monatKey(j, m) <= min) zur.setAttribute('disabled', '') + zur.addEventListener('click', function () { blaettern(-1) }) + kopf.appendChild(zur) + } else kopf.appendChild(h('span', { class: 'wku-pfeil-leer' })) + kopf.appendChild(h('h3', { class: 'wku-monat-name', text: MONAT_LANG[m - 1] + ' ' + j })) + // Erster Monat: immer ein echter Weiter-Knopf (bei zwei sichtbaren Monaten per CSS ausgeblendet, dann sitzt er im zweiten Monat). + var vor = h('button', { class: 'wku-pfeil' + (erster ? ' wku-weiter-ein' : ''), type: 'button', 'data-nav': erster ? 'vor1' : 'vor2', 'aria-label': 'Nächster Monat', text: '›' }) + if (monatKey(j, m) >= max) vor.setAttribute('disabled', '') + vor.addEventListener('click', function () { blaettern(1) }) + kopf.appendChild(vor) + var grid = h('div', { class: 'wku-raster', role: 'group', 'aria-label': MONAT_LANG[m - 1] + ' ' + j }) + WOCHE_KURZ.forEach(function (w, i) { grid.appendChild(h('div', { class: 'wku-wt', 'aria-hidden': 'true', text: w, title: WOCHE_LANG[i] })) }) + WKU_LOGIK.monatsRaster(j, m).forEach(function (z) { grid.appendChild(z.tag ? tagKnopf(z.tag) : h('div', { class: 'wku-leer' })) }) + return h('div', { class: 'wku-monat' + (erster ? '' : ' wku-zwei') }, [kopf, grid]) + } + function zeichne(fokus) { + if (!heute) return + var a = ansicht + var b = a.m === 12 ? { j: a.j + 1, m: 1 } : { j: a.j, m: a.m + 1 } + monate.textContent = '' + monate.appendChild(monatBauen(a.j, a.m, true)) + monate.appendChild(monatBauen(b.j, b.m, false)) + aktualisiereZus() + if (fokus && fokusTag) { + var k = monate.querySelector('[data-tag="' + fokusTag + '"]') + if (k && k.focus) k.focus() + } + } + function blaettern(d) { + var m = ansicht.m + d + var j = ansicht.j + if (m < 1) { m = 12; j -= 1 } else if (m > 12) { m = 1; j += 1 } + var min = monatKey(heute.slice(0, 4) * 1, heute.slice(5, 7) * 1) + var max = monatKey(bisMax.slice(0, 4) * 1, bisMax.slice(5, 7) * 1) + if (monatKey(j, m) < min || monatKey(j, m) > max) return + ansicht = { j: j, m: m } + var erster = iso(j, m, 1) + fokusTag = erster < heute ? heute : erster + zeichne(false) + var zwei = zweiSichtbar() + var wunsch = d > 0 ? (zwei ? 'vor2' : 'vor1') : 'zur' + var knopf = monate.querySelector('[data-nav="' + wunsch + '"]') + if (!knopf || knopf.hasAttribute('disabled')) knopf = monate.querySelector(d > 0 ? '[data-nav="zur"]' : (zwei ? '[data-nav="vor2"]' : '[data-nav="vor1"]')) + if (knopf && knopf.focus) knopf.focus() + } + function tagKlick(tag) { + if (tag < heute || tag > bisMax) return + var neu = WKU_LOGIK.waehle(sel, tag, belegt, parseInt(unterk.mindestNaechte, 10) || 1) + hinweis = neu.fehler === 'mindestnaechte' ? 'Mindestaufenthalt: ' + (unterk.mindestNaechte === 1 ? '1 Nacht' : unterk.mindestNaechte + ' Nächte') + '.' : '' + sel = { von: neu.von || null, bis: neu.bis || null } + fokusTag = tag + zeichne(true) + } + monate.addEventListener('keydown', function (ev) { + var t = ev.target && ev.target.getAttribute ? ev.target.getAttribute('data-tag') : null + if (!t) return + var d = ev.key === 'ArrowRight' ? 1 : ev.key === 'ArrowLeft' ? -1 : ev.key === 'ArrowDown' ? 7 : ev.key === 'ArrowUp' ? -7 : 0 + if (!d) return + ev.preventDefault() + var ziel = plusTage(t, d) + if (ziel < heute || ziel > bisMax) return + fokusTag = ziel + var p = zerlege(ziel) + var sichtbar = monatKey(p[0], p[1]) >= monatKey(ansicht.j, ansicht.m) && monatKey(p[0], p[1]) <= monatKey(ansicht.j, ansicht.m) + (zweiSichtbar() ? 1 : 0) + if (!sichtbar) ansicht = { j: p[0], m: p[1] } + zeichne(true) + }) + + function pad(n) { return zwei(n) } + var ladeNr = 0 + function laden() { + var nr = (ladeNr += 1) + var jetzt = new Date() + var von = jetzt.getUTCFullYear() + '-' + pad(jetzt.getUTCMonth() + 1) + '-' + pad(jetzt.getUTCDate()) + von = plusTage(von, -1) + var bis = plusTage(von, 540) + return holen(basis + '/belegung?unterkunft=' + encodeURIComponent(unterk.id) + '&von=' + von + '&bis=' + bis, { headers: { Accept: 'application/json' } }).then(function (r) { + if (nr !== ladeNr) return + if (!r.ok || !r.body.heute || !r.body.bisMax) throw new Error('belegung') + belegt = r.body.belegt || [] + heute = r.body.heute + bisMax = r.body.bisMax + if (!ansicht) { var p = zerlege(heute); ansicht = { j: p[0], m: p[1] } } + if (!fokusTag) fokusTag = heute + sel = { von: null, bis: null } + hinweis = '' + zeichne(false) + }) + } + function ladenOderAusweichen() { laden().then(null, function () { ausweich(el) }) } + + if (selU) selU.addEventListener('change', function () { + unterk = cfg.unterkuenfte[parseInt(selU.value, 10) || 0] + fuellePersonen() + ladenOderAusweichen() + }) + + form.addEventListener('submit', function (ev) { + ev.preventDefault() + if (sendet) return + zeigeFehler('') + Object.keys(feld).forEach(function (k) { feldFehler(k, '') }) + if (!sel.von || !sel.bis) { zeigeFehler('Bitte wählen Sie Anreise und Abreise im Kalender.'); return } + var wert = function (n) { return String(feld[n].inp.value || '').trim() } + var fehler = 0 + if (!wert('name')) { feldFehler('name', 'Bitte geben Sie Ihren Namen an.'); fehler += 1 } + if (!EMAIL_FORM.test(wert('email'))) { feldFehler('email', 'Bitte geben Sie eine gültige E-Mail-Adresse an.'); fehler += 1 } + if (fehler) { var ersteFehl = form.querySelector('[aria-invalid="true"]'); if (ersteFehl && ersteFehl.focus) ersteFehl.focus(); return } + sendet = true + btn.setAttribute('disabled', '') + var body = { unterkunft: unterk.id, von: sel.von, bis: sel.bis, personen: parseInt(selP.value, 10) || 1, name: wert('name'), firma: wert('firma'), email: wert('email'), telefon: wert('telefon'), nachricht: wert('nachricht'), formToken: token, kontakt_url: hp.value } + holen(basis + '/anfrage', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(body) }).then(function (r) { + sendet = false + btn.removeAttribute('disabled') + if (r.ok && r.body.nummer) { + formBox.textContent = '' + formBox.appendChild(h('p', { class: 'wku-danke', role: 'status', tabindex: '-1', text: 'Danke! Ihre Anfrage ' + r.body.nummer + ' ist eingegangen. Sie erhalten gleich eine Bestätigung per E-Mail.' })) + var d = formBox.querySelector('.wku-danke'); if (d && d.focus) d.focus() + laden().then(null, function () {}) + return + } + var b = r.body || {} + var gezeigt = false + if (b.felder) Object.keys(b.felder).forEach(function (k) { if (Object.prototype.hasOwnProperty.call(b.felder, k) && feldFehler(k, String(b.felder[k]))) gezeigt = true }) + if (gezeigt) return + if (b.fehler === 'belegt' || r.status === 409) { zeigeFehler('Dieser Zeitraum ist leider nicht mehr frei. Bitte wählen Sie einen anderen.'); laden().then(null, function () {}) } + else if (r.status === 429) zeigeFehler('Zu viele Anfragen. Bitte versuchen Sie es später noch einmal.') + else { + zeigeFehler('Die Anfrage konnte nicht gesendet werden. Bitte versuchen Sie es erneut oder melden Sie sich per Telefon oder E-Mail.') + holen(basis, { headers: { Accept: 'application/json' } }).then(function (c) { if (c.ok && c.body.formToken) token = c.body.formToken }, function () {}) + } + }, function () { + sendet = false + btn.removeAttribute('disabled') + zeigeFehler('Die Anfrage konnte nicht gesendet werden. Bitte versuchen Sie es erneut oder melden Sie sich per Telefon oder E-Mail.') + }) + }) + + ladenOderAusweichen() + } + + function alleStarten() { + var liste = document.querySelectorAll('[data-webklar-unterkunft]') + for (var i = 0; i < liste.length; i += 1) starten(liste[i]) + } + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', alleStarten) + else alleStarten() +})() diff --git a/public/unterkunft-buchung.css b/public/unterkunft-buchung.css new file mode 100644 index 0000000..17190b7 --- /dev/null +++ b/public/unterkunft-buchung.css @@ -0,0 +1,28 @@ +:root{--uk-akzent:#0f766e;--uk-akzent-text:#fff} +*{box-sizing:border-box} +body{margin:0;font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;background:#f6f7f9;color:#1f2937;line-height:1.5} +.uk-seite{max-width:760px;margin:0 auto;padding:24px 16px 56px} +.uk-ausweich{margin:0;padding:16px;color:#4b5563} +.uk-karte{background:#fff;border:1px solid #e5e7eb;border-top:4px solid var(--uk-akzent);border-radius:10px;padding:20px 20px 24px;margin:0 0 16px} +.uk-karte h1{font-size:1.4rem;margin:0 0 8px} +.uk-karte h2{font-size:1.05rem;margin:20px 0 8px} +.uk-hinweis{margin:0 0 16px;padding:10px 14px;border-radius:8px;background:#fef3c7;color:#78350f} +.uk-fehler{margin:12px 0 0;padding:10px 14px;border-radius:8px;background:#fee2e2;color:#7f1d1d} +.uk-feldfehler{display:block;color:#b91c1c;font-size:.85rem} +.uk-feld{display:block;margin:0 0 12px} +.uk-feld span.uk-bez{display:block;font-size:.9rem;font-weight:600;margin-bottom:4px} +.uk-feld input{width:100%;padding:10px 12px;border:1px solid #cbd5e1;border-radius:8px;font:inherit;background:#fff} +.uk-feld input:focus-visible,.uk-knopf:focus-visible,.uk-zahlart input:focus-visible,.uk-link:focus-visible{outline:3px solid var(--uk-akzent);outline-offset:2px} +.uk-zeile2{display:grid;grid-template-columns:1fr 2fr;gap:12px} +.uk-zahlart{display:flex;gap:10px;align-items:flex-start;margin:0 0 8px} +.uk-zahlart input{margin-top:5px} +.uk-pflicht{margin:20px 0 16px;padding:14px 16px;border:1px solid #e5e7eb;border-radius:8px;background:#f9fafb;font-size:.92rem} +.uk-pflicht h2{margin:0 0 10px} +.uk-pflicht p{margin:0 0 10px} +.uk-pflicht ul{list-style:none;margin:0 0 10px;padding:0} +.uk-pflicht li{display:flex;justify-content:space-between;gap:12px;padding:2px 0} +.uk-gesamt{font-weight:700;font-size:1.05rem} +.uk-knopf{display:block;width:100%;padding:14px 18px;border:0;border-radius:8px;background:var(--uk-akzent);color:var(--uk-akzent-text);font:inherit;font-weight:700;cursor:pointer;text-align:center;text-decoration:none} +.uk-knopf[disabled]{opacity:.6;cursor:default} +.uk-link{display:inline-block;margin:8px 0;color:var(--uk-akzent);font-weight:600} +@media (max-width:520px){.uk-zeile2{grid-template-columns:1fr}.uk-karte{padding:16px}} diff --git a/public/unterkunft-buchung.js b/public/unterkunft-buchung.js new file mode 100644 index 0000000..a0e963e --- /dev/null +++ b/public/unterkunft-buchung.js @@ -0,0 +1,276 @@ +/* Buchungskalender, Standalone-Seiten fuer Gaeste (Task 17). + * Ansicht "widget": setzt data-webklar-unterkunft auf den Container, public/embed/unterkunft.js (danach geladen) baut das Widget. + * Ansicht "buchung": /unterkunft//buchung/?t= - Buchung pruefen und "Zahlungspflichtig buchen". + * Der Token (?t=) wird nur an die Schnittstelle gesendet, nie geloggt oder anderswohin geschickt. Alle Texte per textContent. */ +(function () { + 'use strict' + var API = '/api/public/unterkunft/v1/' + var app = document.getElementById('uk-app') + if (!app) return + var siteKey = app.getAttribute('data-sitekey') || '' + if (app.getAttribute('data-ansicht') !== 'buchung') { + app.textContent = '' + app.setAttribute('data-webklar-unterkunft', siteKey) + return + } + + var teile = String(window.location.pathname || '').split('/') + var id = decodeURIComponent(teile[teile.length - 1] || '') + var params = new URLSearchParams(window.location.search || '') + var token = params.get('t') || '' + var zahlungParam = params.get('wk_zahlung') || '' + var basis = API + encodeURIComponent(siteKey) + '/buchung/' + encodeURIComponent(id) + var tokenQuery = '?t=' + encodeURIComponent(token) + + var TXT = { + ungueltig: 'Dieser Link ist ungültig.', + abgelaufen: 'Die Reservierung ist abgelaufen. Bitte fragen Sie erneut an.', + abbruch: 'Zahlung abgebrochen – Sie können jetzt oder vor Ort bezahlen.', + widerruf: 'Ein Widerrufsrecht besteht nicht (§ 312g Abs. 2 Nr. 9 BGB).', + kleinunternehmer: 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.', + laden: 'Die Buchung konnte nicht geladen werden. Bitte versuchen Sie es später erneut.', + senden: 'Die Buchung konnte nicht abgeschlossen werden. Bitte versuchen Sie es erneut.', + belegt: 'Der Zeitraum ist leider inzwischen belegt. Bitte fragen Sie einen anderen Zeitraum an.', + knopf: 'Zahlungspflichtig buchen', + } + + function h(tag, attrs, kinder) { + var el = document.createElement(tag) + var a = attrs || {} + Object.keys(a).forEach(function (k) { + if (a[k] === null || a[k] === undefined || a[k] === false) return + if (k === 'text') el.textContent = a[k] + else if (k === 'class') el.className = a[k] + else el.setAttribute(k, a[k] === true ? '' : String(a[k])) + }) + ;(kinder || []).forEach(function (c) { if (c) el.appendChild(c) }) + return el + } + var p = function (text, klasse) { return h('p', { text: text, class: klasse }) } + + function euro(cent) { + return (cent / 100).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' }) + } + function datum(iso) { + var m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(iso || '')) + return m ? m[3] + '.' + m[2] + '.' + m[1] : String(iso || '') + } + function zeitpunkt(iso) { + var d = new Date(iso) + if (isNaN(d.getTime())) return String(iso || '') + return d.toLocaleString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }).replace(',', '') + ' Uhr' + } + + function api(pfad, methode, body) { + var opt = { method: methode || 'GET' } + if (body) { opt.headers = { 'Content-Type': 'application/json' }; opt.body = JSON.stringify(body) } + return fetch(basis + pfad + tokenQuery, opt).then(function (r) { + return r.json().then(function (b) { return { status: r.status, ok: r.ok, body: b || {} } }, function () { return { status: r.status, ok: r.ok, body: {} } }) + }) + } + + function zeige(karte, hinweis) { + app.textContent = '' + if (hinweis) app.appendChild(p(hinweis, 'uk-hinweis')) + app.appendChild(karte) + } + function nurText(titel, text) { + zeige(h('section', { class: 'uk-karte' }, [h('h1', { text: titel }), p(text)])) + } + function akzent(daten) { + var f = daten && daten.akzentfarbe + if (typeof f !== 'string' || !/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(f)) return + var s = f.slice(1) + if (s.length === 3) s = s[0] + s[0] + s[1] + s[1] + s[2] + s[2] + var hell = (0.299 * parseInt(s.slice(0, 2), 16) + 0.587 * parseInt(s.slice(2, 4), 16) + 0.114 * parseInt(s.slice(4, 6), 16)) > 160 + app.setAttribute('style', '--uk-akzent:' + f + ';--uk-akzent-text:' + (hell ? '#111827' : '#ffffff')) + } + var hinweisAbbruch = function () { return zahlungParam === 'abbruch' ? TXT.abbruch : '' } + + function anschrift(a) { + var zeile = [a.strasse, [a.plz, a.ort].filter(Boolean).join(' ')].filter(Boolean).join(', ') + return [a.firma, a.inhaber].filter(Boolean).join(', ') + (zeile ? ', ' + zeile : '') + } + + function zahlartenText(d, wahl) { + var z = (d.zahlarten || []).map(function (x) { return x === 'online' ? 'online (Karte, über Stripe)' : 'vor Ort' }) + return 'Zahlarten: ' + z.join(' oder ') + (wahl ? ' (gewählt: ' + (wahl === 'online' ? 'online' : 'vor Ort') + ')' : '') + } + + /* ------------------------------------------------------------- Pflichtangaben --- */ + function pflichtangaben(d, wahl) { + var s = d.summen || {} + var kopf = h('h2', { text: 'Ihre Buchung im Überblick' }) + var a = d.anbieter || {} + var kontakt = [a.email, a.telefon].filter(Boolean).join(', ') + var u = d.unterkunft || {} + var posten = h('ul', { 'data-tag': 'posten' }, (d.posten || []).map(function (x) { + var bez = x.menge !== 1 ? x.text + ' (' + x.menge + ' × ' + euro(x.einzelCent) + ')' : x.text + return h('li', {}, [h('span', { text: bez }), h('span', { text: euro(x.menge * x.einzelCent) })]) + })) + var gesamt = 'Gesamtpreis: ' + euro(s.bruttoCent || 0) + var ust = [] + if (d.steuerart === 'kleinunternehmer') ust.push(TXT.kleinunternehmer) + else (s.ust || []).filter(function (z) { return z.satz > 0 }).forEach(function (z) { ust.push(z.satz + ' % USt ' + euro(z.steuerCent)) }) + var preisText = d.steuerart === 'kleinunternehmer' ? gesamt + '. ' + TXT.kleinunternehmer : gesamt + (ust.length ? ' inkl. ' + ust.join(' und ') : ' inkl. USt') + var ds = a.datenschutzUrl && /^https:\/\//.test(a.datenschutzUrl) ? a.datenschutzUrl : '' + var datenschutz = [p('Datenschutz: ' + (a.firma || 'Der Anbieter') + ' verarbeitet Ihre Angaben zur Durchführung der Buchung und für die Rechnung (Art. 6 Abs. 1 lit. b und c DSGVO). Näheres steht in der Datenschutzerklärung des Anbieters.')] + if (ds) datenschutz.push(h('a', { class: 'uk-link', href: ds, target: '_blank', rel: 'noopener noreferrer', text: 'Datenschutzerklärung des Anbieters' })) + return h('section', { class: 'uk-pflicht', 'data-tag': 'pflichtangaben' }, [ + kopf, + p('Anbieter: ' + anschrift(a) + (kontakt ? ' · ' + kontakt : ''), 'uk-anbieter'), + p('Unterkunft: ' + (u.name || '') + ', ' + datum(d.von) + ' bis ' + datum(d.bis) + ' (' + d.naechte + ' ' + (d.naechte === 1 ? 'Nacht' : 'Nächte') + '), Check-in ab ' + (u.checkIn || '') + ' Uhr, Check-out bis ' + (u.checkOut || '') + ' Uhr', 'uk-unterkunft'), + p('Personen: ' + d.personen, 'uk-personen'), + posten, + p(preisText, 'uk-gesamt'), + p(zahlartenText(d, wahl), 'uk-zahlarten'), + p('Stornobedingungen: ' + (d.stornobedingungen || ''), 'uk-storno'), + p(TXT.widerruf, 'uk-widerruf'), + h('div', { class: 'uk-datenschutz' }, datenschutz), + p('Reserviert bis ' + zeitpunkt(d.fristAt), 'uk-frist'), + ]) + } + + /* ------------------------------------------------------------------- Formular --- */ + function formular(d) { + var ra = d.rechnungsadresse || {} + var g = d.gast || {} + var felder = {} + function feld(name, bez, wert, extra) { + var input = h('input', { type: 'text', name: name, autocomplete: (extra && extra.ac) || 'off', maxlength: 120 }) + input.value = wert || '' + felder[name] = input + var fehler = h('span', { class: 'uk-feldfehler', 'data-fehler': name, hidden: true }) + return { wurzel: h('label', { class: 'uk-feld' }, [h('span', { class: 'uk-bez', text: bez }), input, fehler]), fehler: fehler } + } + var fName = feld('name', 'Name', ra.name || g.name, { ac: 'name' }) + var fFirma = feld('firma', 'Firma (optional)', ra.firma || g.firma, { ac: 'organization' }) + var fStrasse = feld('strasse', 'Straße und Hausnummer', ra.strasse, { ac: 'street-address' }) + var fPlz = feld('plz', 'PLZ', ra.plz, { ac: 'postal-code' }) + var fOrt = feld('ort', 'Ort', ra.ort, { ac: 'address-level2' }) + var fLand = feld('land', 'Land', ra.land || 'Deutschland', { ac: 'country-name' }) + var alle = { name: fName, firma: fFirma, strasse: fStrasse, plz: fPlz, ort: fOrt, land: fLand } + + var arten = (d.zahlarten || []).filter(function (z) { return z === 'vorort' || z === 'online' }) + var gewaehlt = arten.indexOf(d.zahlart) >= 0 ? d.zahlart : arten[0] + var radios = arten.map(function (z) { + var r = h('input', { type: 'radio', name: 'zahlart', value: z, id: 'uk-zahlart-' + z }) + if (z === gewaehlt) r.checked = true + return h('label', { class: 'uk-zahlart' }, [r, h('span', { text: z === 'online' ? 'Online bezahlen (Karte)' : 'Vor Ort bezahlen' })]) + }) + var meldung = h('div', { class: 'uk-fehler', role: 'alert', hidden: true }) + var knopf = h('button', { type: 'submit', class: 'uk-knopf', text: TXT.knopf }) + var form = h('form', { novalidate: true }, [ + h('h2', { text: 'Rechnungsadresse' }), + fName.wurzel, fFirma.wurzel, fStrasse.wurzel, + h('div', { class: 'uk-zeile2' }, [fPlz.wurzel, fOrt.wurzel]), + fLand.wurzel, + h('h2', { text: 'Zahlart' }), + h('div', { 'data-tag': 'zahlart' }, radios), + pflichtangaben(d, gewaehlt), + knopf, + meldung, + ]) + radios.forEach(function (l) { + l.querySelector('input').addEventListener('change', function () { + var z = form.querySelector('.uk-zahlarten') + if (z) z.textContent = zahlartenText(d, waehl()) + }) + }) + function waehl() { var r = radios.map(function (l) { return l.querySelector('input') }).find(function (i) { return i.checked }); return r ? r.value : gewaehlt } + var sendet = false + function fehlerAnzeigen(felderFehler, text) { + Object.keys(alle).forEach(function (k) { alle[k].fehler.hidden = true; alle[k].fehler.textContent = '' }) + Object.keys(felderFehler || {}).forEach(function (k) { if (alle[k]) { alle[k].fehler.textContent = String(felderFehler[k]); alle[k].fehler.hidden = false } }) + meldung.textContent = text || '' + meldung.hidden = !text + } + form.addEventListener('submit', function (ev) { + ev.preventDefault() + if (sendet) return + var val = {} + Object.keys(alle).forEach(function (k) { val[k] = String(felder[k].value || '').trim() }) + var f = {} + if (!val.name && !val.firma) f.name = 'Bitte geben Sie einen Namen oder eine Firma an.' + if (!val.strasse) f.strasse = 'Bitte geben Sie die Straße an.' + if (!val.plz) f.plz = 'Bitte geben Sie die PLZ an.' + if (!val.ort) f.ort = 'Bitte geben Sie den Ort an.' + if (!val.land) f.land = 'Bitte geben Sie das Land an.' + if (!gewaehlt) return fehlerAnzeigen({}, 'Es ist keine Zahlart verfügbar. Bitte wenden Sie sich an den Anbieter.') + if (Object.keys(f).length) return fehlerAnzeigen(f, 'Bitte prüfen Sie Ihre Angaben.') + fehlerAnzeigen({}, '') + sendet = true + knopf.setAttribute('disabled', '') + var zahlart = waehl() + api('', 'POST', { rechnungsadresse: val, zahlart: zahlart }).then(function (r) { + if (r.ok && r.body.status === 'gebucht') { + if (zahlart === 'online' && r.body.zahlungUrl) { window.location.assign(r.body.zahlungUrl); return } + bestaetigt(Object.assign({}, d, { status: 'gebucht', zahlart: zahlart, bezahlt: false, rechnungNummer: r.body.rechnungNummer })) + return + } + if (r.status === 404) return nurText('Buchung', TXT.ungueltig) + if (r.status === 410 || r.body.fehler === 'frist_abgelaufen' || r.body.fehler === 'abgelaufen') return nurText('Buchung', TXT.abgelaufen) + if (r.body.fehler === 'zeitraum_belegt') return nurText('Buchung', TXT.belegt) + if (r.status === 409 || r.ok) { laden(); return } + sendet = false + knopf.removeAttribute('disabled') + fehlerAnzeigen(r.body.felder, r.status === 400 && r.body.felder ? 'Bitte prüfen Sie Ihre Angaben.' : TXT.senden) + }, function () { + sendet = false + knopf.removeAttribute('disabled') + fehlerAnzeigen({}, TXT.senden) + }) + }) + return h('section', { class: 'uk-karte' }, [h('h1', { text: 'Ihre Buchung bestätigen' }), p('Hallo ' + (g.name || '') + ', Ihre Anfrage wurde freigegeben. Bitte prüfen Sie die Angaben und buchen Sie verbindlich.'), form]) + } + + /* --------------------------------------------------------------- Bestaetigung --- */ + function bestaetigt(d) { + var kinder = [h('h1', { text: 'Ihre Buchung ist bestätigt' })] + kinder.push(p((d.unterkunft && d.unterkunft.name ? d.unterkunft.name + ', ' : '') + datum(d.von) + ' bis ' + datum(d.bis) + '. Buchungsnummer: ' + (d.nummer || '') + '.')) + if (d.rechnungNummer) kinder.push(p('Rechnung ' + d.rechnungNummer)) + kinder.push(h('a', { class: 'uk-link', 'data-tag': 'rechnung', href: basis + '/rechnung.pdf' + tokenQuery + '&art=rechnung', text: 'Rechnung herunterladen (PDF)' })) + var online = (d.zahlarten || []).indexOf('online') >= 0 + if (d.bezahlt || d.status === 'bezahlt') kinder.push(p('Bezahlt – vielen Dank.')) + else if (d.zahlart === 'online' && online) { + var meldung = h('div', { class: 'uk-fehler', role: 'alert', hidden: true }) + var knopf = h('button', { type: 'button', class: 'uk-knopf', 'data-tag': 'online-zahlen', text: 'Jetzt online bezahlen' }) + knopf.addEventListener('click', function () { + knopf.setAttribute('disabled', '') + api('/zahlung', 'POST', {}).then(function (r) { + if (r.ok && r.body.url) { window.location.assign(r.body.url); return } + knopf.removeAttribute('disabled') + meldung.textContent = 'Die Zahlung konnte nicht gestartet werden. Bitte versuchen Sie es erneut.'; meldung.hidden = false + }, function () { + knopf.removeAttribute('disabled') + meldung.textContent = 'Die Zahlung konnte nicht gestartet werden. Bitte versuchen Sie es erneut.'; meldung.hidden = false + }) + }) + kinder.push(knopf, meldung) + } else kinder.push(p('Die Zahlung erfolgt vor Ort.')) + kinder.push(p('Sie erhalten die Bestätigung auch per E-Mail.')) + zeige(h('section', { class: 'uk-karte' }, kinder), hinweisAbbruch()) + } + + /* ----------------------------------------------------------------------- Start --- */ + function darstellen(d) { + akzent(d) + if (d.unterkunft && d.unterkunft.name) document.title = 'Buchung – ' + d.unterkunft.name + if (d.status === 'freigegeben') zeige(formular(d), hinweisAbbruch()) + else if (d.status === 'gebucht' || d.status === 'bezahlt') bestaetigt(d) + else if (d.status === 'abgelaufen') nurText('Buchung', TXT.abgelaufen) + else if (d.status === 'abgelehnt') nurText('Buchung', 'Diese Anfrage wurde leider abgelehnt.') + else if (d.status === 'storniert') nurText('Buchung', 'Diese Buchung wurde storniert.') + else nurText('Buchung', 'Ihre Anfrage wird noch geprüft. Sie erhalten eine E-Mail, sobald sie freigegeben ist.') + } + + if (!token || !id) { nurText('Buchung', TXT.ungueltig); return } + function laden() { + return api('', 'GET').then(function (r) { + if (r.status === 404 || r.status === 401 || r.status === 403) return nurText('Buchung', TXT.ungueltig) + if (!r.ok) return nurText('Buchung', TXT.laden) + darstellen(r.body) + }, function () { nurText('Buchung', TXT.laden) }) + } + laden() +})() diff --git a/public/unterkunft-seite.html b/public/unterkunft-seite.html new file mode 100644 index 0000000..f7987e1 --- /dev/null +++ b/public/unterkunft-seite.html @@ -0,0 +1,19 @@ + + + + + + + +Buchung + + + +
+

Die Seite wird geladen …

+ +
+ + + + diff --git a/server/routes/unterkunftSeite.js b/server/routes/unterkunftSeite.js index 76c790c..8fe74f9 100644 --- a/server/routes/unterkunftSeite.js +++ b/server/routes/unterkunftSeite.js @@ -1,8 +1,11 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' import { Router } from 'express' import { config } from '../config.js' import { listDocuments, Query } from '../services/appwriteAdmin.js' import { baueIcs } from '../services/unterkunft/ical.js' -import { ladeFuerProjekt } from '../services/unterkunft/kontext.js' +import { ladeFuerProjekt, SITE_KEY_RE } from '../services/unterkunft/kontext.js' import { unterkunftFuerKunde, unterkunftModus } from '../services/unterkunft/schalter.js' import { listeAnfragenNachStatus, listeSperren } from '../services/unterkunft/speicher.js' @@ -30,6 +33,54 @@ async function exportEreignisse(unterkunft, jetzt) { return out.sort((x, y) => (x.von < y.von ? -1 : x.von > y.von ? 1 : x.uid < y.uid ? -1 : 1)) } +/** + * Oeffentliche Standalone-Seiten des Buchungskalenders (Vorbild: shopSeite.js): + * GET /unterkunft/:siteKey Widget public/embed/unterkunft.js auf neutraler Seite + * GET /unterkunft/:siteKey/buchung/:id Buchungsseite fuer Gaeste (public/unterkunft-buchung.js, Token ?t= liest nur das Skript) + * Die Seite verraet nicht, ob Schluessel oder Buchung existieren; das Skript fragt die oeffentliche Schnittstelle. + * Ungueltige Schluessel (z. B. "ical" der Export-Route /unterkunft/ical/:token.ics) und Ids: next(), am Ende 404. + */ + +const ID_RE = /^[A-Za-z0-9_-]{1,36}$/ +const DATEI = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'public', 'unterkunft-seite.html') +const CSP = [ + "default-src 'self'", + "img-src 'self' data:", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", // das Widget legt ein