Buchungskalender: Strang widget zusammenführen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5.5 committed 2026-10-07 14:50:03 +00:00
commit cebde0e070
10 files changed
+1380 -2

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>Buchungskalender Testseite</title>
<style>
body{margin:0;font-family:system-ui,sans-serif;background:#f6f7f9;color:#1f2937;line-height:1.5}
header,footer{padding:16px 24px;background:#e8ebf0}
main{max-width:1180px;margin:0 auto;padding:32px 16px}
/* Gestaltung ueber CSS-Variablen der Seite (alle optional):
[data-webklar-unterkunft]{ --wku-akzent:#0f766e; --wku-radius:8px; --wku-schrift:Georgia,serif; } */
</style>
</head>
<body>
<header>Testseite der Kundenwebsite (intern, nicht verlinkt)</header>
<main>
<h1>Unterkunft anfragen</h1>
<!-- data-api weglassen = Herkunft des Skripts; die SiteKey ist ein Platzhalter -->
<div data-webklar-unterkunft="abcdefghijklmnop"></div>
</main>
<footer>Ende der Testseite</footer>
<script src="unterkunft.js" defer></script>
</body>
</html>
+486
View File
@@ -0,0 +1,486 @@
/*! WEBklar Buchungskalender: Einbau-Skript fuer Kundenwebsites.
* <div data-webklar-unterkunft="SITEKEY" data-api="https://project.webklar.com"></div>
* <script src="https://project.webklar.com/embed/unterkunft.js" defer></script>
* 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()
})()
+28
View File
@@ -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}}
+276
View File
@@ -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/<siteKey>/buchung/<id>?t=<token> - 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()
})()
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<meta name="referrer" content="no-referrer">
<title>Buchung</title>
<link rel="stylesheet" href="/unterkunft-buchung.css">
</head>
<body>
<main class="uk-seite">
<div id="uk-app" data-sitekey="__SITEKEY__" data-ansicht="__ANSICHT__" data-api=""><p class="uk-ausweich">Die Seite wird geladen …</p></div>
<noscript><p class="uk-ausweich">Für diese Seite wird JavaScript benötigt.</p></noscript>
</main>
<script src="/unterkunft-buchung.js" defer></script>
<script src="/embed/unterkunft.js" defer></script>
</body>
</html>
+60 -2
View File
@@ -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 <style>-Element an
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'none'",
"form-action 'self'",
].join('; ')
let vorlage = null
export function resetUnterkunftSeiteForTests() {
vorlage = null
}
function escapeAttr(s) {
return String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
function seite(res, next, siteKey, ansicht) {
try {
if (vorlage === null) vorlage = readFileSync(DATEI, 'utf8')
} catch (err) {
return next(err)
}
const html = vorlage.replaceAll('__SITEKEY__', escapeAttr(siteKey)).replaceAll('__ANSICHT__', escapeAttr(ansicht))
res.set({
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'no-referrer',
'Content-Security-Policy': CSP,
'X-Robots-Tag': 'noindex',
})
return res.status(200).send(html)
}
export function unterkunftSeiteRouter() {
const router = Router()
@@ -48,7 +99,14 @@ export function unterkunftSeiteRouter() {
next(err)
}
})
router.get('/unterkunft/:siteKey', (req, res, next) => {
if (!SITE_KEY_RE.test(req.params.siteKey)) return next()
return seite(res, next, req.params.siteKey, 'widget')
})
router.get('/unterkunft/:siteKey/buchung/:id', (req, res, next) => {
if (!SITE_KEY_RE.test(req.params.siteKey) || !ID_RE.test(req.params.id)) return next()
return seite(res, next, req.params.siteKey, 'buchung')
})
return router
}
+31
View File
@@ -0,0 +1,31 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync, statSync } from 'node:fs'
import vm from 'node:vm'
// Statische Pruefung des Einbau-Skripts public/embed/unterkunft.js (Task 16; Muster test/shopEmbedStatic.test.js).
const QUELLE = readFileSync(new URL('../public/embed/unterkunft.js', import.meta.url), 'utf8')
const OHNE_KOMMENTARE = QUELLE.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1')
test('gueltiges JavaScript, unter 40 KB, IIFE mit use strict', () => {
assert.doesNotThrow(() => new vm.Script(QUELLE, { filename: 'unterkunft.js' }))
assert.ok(statSync(new URL('../public/embed/unterkunft.js', import.meta.url)).size < 40 * 1024)
assert.match(OHNE_KOMMENTARE, /^\s*\(function \(\) \{\s*'use strict'/)
})
test('kein innerHTML, outerHTML, insertAdjacentHTML, document.write, eval, new Function', () => {
assert.doesNotMatch(OHNE_KOMMENTARE, /innerHTML|outerHTML|insertAdjacentHTML|document\.write|\beval\s*\(|new Function/)
})
test('API-Pfad, Praefix .wku-, @layer wku, Style-Id, kein Storage, keine Cookies, keine Fremdadressen', () => {
assert.ok(OHNE_KOMMENTARE.includes('/api/public/unterkunft/v1/'))
assert.ok(OHNE_KOMMENTARE.includes('@layer wku'))
assert.ok(OHNE_KOMMENTARE.includes('wku-style'))
assert.match(OHNE_KOMMENTARE, /\.wku-root/)
assert.doesNotMatch(OHNE_KOMMENTARE, /localStorage|sessionStorage|document\.cookie|https?:\/\//)
})
test('Pflichttexte und Honeypot', () => {
for (const t of ['Anfragen gerne per Telefon oder E-Mail.', 'Bitte wählen Sie Anreise und Abreise im Kalender.', 'Anfrage senden', 'kontakt_url', 'formToken']) assert.ok(QUELLE.includes(t), t)
assert.match(QUELLE, /Danke! Ihre Anfrage/)
})
+88
View File
@@ -0,0 +1,88 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import vm from 'node:vm'
// Reine Kalenderlogik des Widgets public/embed/unterkunft.js (Task 16): Datei per node:vm laden, data-test="1" macht WKU_LOGIK als __wkuLogik sichtbar.
const QUELLE = readFileSync(new URL('../public/embed/unterkunft.js', import.meta.url), 'utf8')
function lade() {
const doc = { readyState: 'complete', currentScript: { dataset: { test: '1' } }, querySelectorAll: () => [], addEventListener() {} }
const ctx = vm.createContext({ document: doc, console })
vm.runInContext(QUELLE, ctx, { filename: 'unterkunft.js' })
return ctx.__wkuLogik
}
const L = lade()
test('ohne data-test bleibt die Logik verborgen', () => {
const doc = { readyState: 'complete', currentScript: { dataset: {} }, querySelectorAll: () => [], addEventListener() {} }
const ctx = vm.createContext({ document: doc })
vm.runInContext(QUELLE, ctx)
assert.equal(ctx.__wkuLogik, undefined)
})
test('monatsRaster: Montag zuerst, 1.11.2026 (Sonntag) hat 6 Leerfelder, 30 Tage', () => {
const r = L.monatsRaster(2026, 11)
assert.equal(r.length % 7, 0)
assert.ok(r.slice(0, 6).every((z) => z.tag === null))
assert.equal(r[6].tag, '2026-11-01')
assert.equal(r.filter((z) => z.tag).length, 30)
assert.equal(L.monatsRaster(2026, 2).filter((z) => z.tag).length, 28)
assert.equal(L.monatsRaster(2028, 2).filter((z) => z.tag).length, 29)
assert.equal(L.monatsRaster(2026, 12)[1].tag, '2026-12-01') // 1.12.2026 ist Dienstag
})
test('nachtBelegt: Nacht von von (inklusive) bis bis (exklusive)', () => {
const b = [{ von: '2026-11-10', bis: '2026-11-12' }]
assert.equal(L.nachtBelegt('2026-11-09', b), false)
assert.equal(L.nachtBelegt('2026-11-10', b), true)
assert.equal(L.nachtBelegt('2026-11-11', b), true)
assert.equal(L.nachtBelegt('2026-11-12', b), false)
})
test('waehle: Anreise, Abreise, Neustart', () => {
const b = [{ von: '2026-11-20', bis: '2026-11-25' }]
let z = L.waehle({}, '2026-11-12', b, 1)
assert.deepEqual({ von: z.von, bis: z.bis }, { von: '2026-11-12', bis: null })
z = L.waehle(z, '2026-11-15', b, 1)
assert.deepEqual({ von: z.von, bis: z.bis }, { von: '2026-11-12', bis: '2026-11-15' })
z = L.waehle(z, '2026-11-14', b, 1) // dritter Klick = neue Anreise
assert.deepEqual({ von: z.von, bis: z.bis }, { von: '2026-11-14', bis: null })
z = L.waehle(z, '2026-11-10', b, 1) // Klick vor Anreise
assert.deepEqual({ von: z.von, bis: z.bis }, { von: '2026-11-10', bis: null })
})
test('waehle: belegte Nacht dazwischen -> neue Anreise; Abreise am Anreisetag eines Fremdbuchers erlaubt', () => {
const b = [{ von: '2026-11-20', bis: '2026-11-25' }]
let z = L.waehle({ von: '2026-11-18', bis: null }, '2026-11-27', b, 1)
assert.deepEqual({ von: z.von, bis: z.bis }, { von: '2026-11-27', bis: null })
z = L.waehle({ von: '2026-11-18', bis: null }, '2026-11-20', b, 1) // Nacht 18., 19. frei, Abreise am 20.
assert.deepEqual({ von: z.von, bis: z.bis }, { von: '2026-11-18', bis: '2026-11-20' })
z = L.waehle({ von: '2026-11-18', bis: null }, '2026-11-21', b, 1)
assert.deepEqual({ von: z.von, bis: z.bis }, { von: '2026-11-18', bis: null }) // Nacht 20. belegt, 21. selbst belegt: weder Abreise noch Anreise, Zustand bleibt
})
test('waehle: Anreise auf belegter Nacht nicht moeglich; Anreise am Abreisetag eines Fremdbuchers moeglich', () => {
const b = [{ von: '2026-11-20', bis: '2026-11-25' }]
const z1 = L.waehle({}, '2026-11-22', b, 1)
assert.equal(z1.von, null)
const z2 = L.waehle({}, '2026-11-25', b, 1)
assert.equal(z2.von, '2026-11-25')
})
test('waehle: unter den Mindestnaechten -> Hinweis-Zustand, Anreise bleibt', () => {
const z = L.waehle({ von: '2026-11-12', bis: null }, '2026-11-13', [], 3)
assert.equal(z.fehler, 'mindestnaechte')
assert.equal(z.von, '2026-11-12')
assert.equal(z.bis, null)
const ok = L.waehle({ von: '2026-11-12', bis: null }, '2026-11-15', [], 3)
assert.equal(ok.bis, '2026-11-15')
assert.equal(ok.fehler, undefined)
})
test('zusammenfassung', () => {
assert.equal(L.zusammenfassung('2026-11-12', '2026-11-30'), '12.–30. Nov. · 18 Nächte')
assert.equal(L.zusammenfassung('2026-11-28', '2026-12-03'), '28. Nov. – 3. Dez. · 5 Nächte')
assert.equal(L.zusammenfassung('2026-11-12', '2026-11-13'), '12.–13. Nov. · 1 Nacht')
assert.equal(L.zusammenfassung('2026-12-30', '2027-01-02'), '30. Dez. 2026 – 2. Jan. 2027 · 3 Nächte')
assert.equal(L.zusammenfassung('2026-03-01', '2026-03-05').slice(0, 18), '1.–5. März · 4 Näc')
})
+274
View File
@@ -0,0 +1,274 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import vm from 'node:vm'
import { useTestEnv } from './support/testEnv.js'
import { installFakeDom } from './support/fakeDom.js'
// Task 17: Standalone-Seiten /unterkunft/:siteKey und /unterkunft/:siteKey/buchung/:id plus Skript public/unterkunft-buchung.js
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: 'http://127.0.0.1:1' })
const { startTestApp } = await import('./support/testApp.js')
const { unterkunftSeiteRouter, resetUnterkunftSeiteForTests } = await import('../server/routes/unterkunftSeite.js')
resetUnterkunftSeiteForTests()
const app = await startTestApp({ '/': unterkunftSeiteRouter() })
after(() => app.close())
const KEY = 'abcdefghijklmnop'
const get = async (path) => {
const r = await fetch(app.url + path)
return { status: r.status, headers: r.headers, text: await r.text() }
}
const lies = (pfad) => readFileSync(new URL(`../${pfad}`, import.meta.url), 'utf8')
/* ----------------------------------------------------------------- Routen --- */
test('Widget-Seite und Buchungsseite: 200, Schluessel escaped, Schutz-Header', async () => {
for (const [pfad, ansicht] of [[`/unterkunft/${KEY}`, 'widget'], [`/unterkunft/${KEY}/buchung/Ab_c-123?t=geheim`, 'buchung']]) {
const r = await get(pfad)
assert.equal(r.status, 200, pfad)
assert.match(r.headers.get('content-type'), /^text\/html; charset=utf-8/)
assert.match(r.text, new RegExp(`<div id="uk-app" data-sitekey="${KEY}" data-ansicht="${ansicht}"`))
assert.doesNotMatch(r.text, /__SITEKEY__|__ANSICHT__|geheim/)
assert.match(r.text, /<script src="\/unterkunft-buchung\.js" defer><\/script>/)
assert.equal(r.headers.get('cache-control'), 'no-store')
assert.equal(r.headers.get('x-robots-tag'), 'noindex')
assert.equal(r.headers.get('referrer-policy'), 'no-referrer')
assert.match(r.text, /<meta name="referrer" content="no-referrer">/)
assert.equal(r.headers.get('x-content-type-options'), 'nosniff')
const csp = r.headers.get('content-security-policy')
for (const d of ["default-src 'self'", "script-src 'self'", "connect-src 'self'", "frame-ancestors 'none'", "base-uri 'none'", "form-action 'self'"]) assert.ok(csp.includes(d), d)
assert.ok(!/script-src[^;]*unsafe/.test(csp))
}
})
test('ungueltige Schluessel, ical und fremde Pfade: 404 ohne Container', async () => {
for (const p of ['/unterkunft/xyz', '/unterkunft/ical', `/unterkunft/${KEY.toUpperCase()}`, `/unterkunft/${KEY}a`, '/unterkunft/%3Cscript%3E', '/unterkunft/ical/buchung/abc', `/unterkunft/${KEY}/buchung`, `/unterkunft/${KEY}/buchung/a%20b`, `/unterkunft/${KEY}/buchung/${'a'.repeat(37)}`, `/unterkunft/${KEY}/agb`, '/unterkunft', '/unterkunft/']) {
const r = await get(p)
assert.equal(r.status, 404, p)
assert.doesNotMatch(r.text, /uk-app/, p)
}
})
test('Seitenvorlage: kein Inline-Skript, Stylesheet und Skripte vom eigenen Ursprung', () => {
const html = lies('public/unterkunft-seite.html')
assert.doesNotMatch(html, /<script(?![^>]*\bsrc=)/)
assert.match(html, /name="robots" content="noindex"/)
assert.match(html, /<script src="\/embed\/unterkunft\.js" defer>/)
})
/* ------------------------------------------------------------ Seitenskript --- */
const QUELLE = lies('public/unterkunft-buchung.js')
const BASIS = `/api/public/unterkunft/v1/${KEY}/buchung/B1`
const DATEN = {
status: 'freigegeben', nummer: 'A-1234', unterkunft: { name: 'Ferienwohnung', checkIn: '15:00', checkOut: '11:00' },
von: '2026-11-10', bis: '2026-11-14', naechte: 4, personen: 2,
posten: [{ text: 'Übernachtung, 4 Nächte', menge: 4, einzelCent: 10000, ustProzent: 7 }, { text: 'Endreinigung', menge: 1, einzelCent: 5000, ustProzent: 19 }],
summen: { bruttoCent: 45000, nettoCent: 41000, ust: [{ satz: 7, nettoCent: 37383, steuerCent: 2617 }, { satz: 19, nettoCent: 4202, steuerCent: 798 }] },
steuerart: 'regel', fristAt: '2026-10-10T12:30:00.000Z', stornobedingungen: 'Bis 14 Tage vor Anreise kostenfrei.',
anbieter: { firma: 'Haus Test GmbH', inhaber: 'Mimi', strasse: 'Weg 1', plz: '78532', ort: 'Tuttlingen', email: 'mimi@example.com', telefon: '07461 1', datenschutzUrl: 'https://kunde.example/datenschutz' },
gast: { name: 'Max Muster', firma: '' }, zahlarten: ['vorort', 'online'], bezahlt: false, akzentfarbe: '#0f766e',
}
function start(antworten, { pfad = `/unterkunft/${KEY}/buchung/B1`, suche = '?t=tok-123', ansicht = 'buchung' } = {}) {
const fx = installFakeDom({ antworten })
fx.window.location.pathname = pfad
fx.window.location.search = suche
fx.assigned = []
fx.window.location.assign = (url) => fx.assigned.push(url)
const el = fx.document.createElement('div')
el.id = 'uk-app'
el.setAttribute('data-sitekey', KEY)
el.setAttribute('data-ansicht', ansicht)
fx.document.body.appendChild(el)
vm.runInThisContext(QUELLE, { filename: 'unterkunft-buchung.js' })
return { fx, el }
}
const mitFx = async (antworten, opt, fn) => {
const { fx, el } = start(antworten, opt)
try { await fx.ruhe(); await fn(fx, el) } finally { fx.restore() }
}
test('Ansicht widget: Container bekommt data-webklar-unterkunft, kein Abruf', async () => {
await mitFx([], { pfad: `/unterkunft/${KEY}`, suche: '', ansicht: 'widget' }, (fx, el) => {
assert.equal(el.getAttribute('data-webklar-unterkunft'), KEY)
assert.equal(fx.fetchLog.length, 0)
})
})
test('freigegeben: Pflichtangaben in Reihenfolge direkt ueber dem Knopf, Abruf mit Token', async () => {
await mitFx([{ status: 200, body: DATEN }], undefined, (fx, el) => {
assert.equal(fx.fetchLog[0].method, 'GET')
assert.equal(fx.fetchLog[0].url, `${BASIS}?t=tok-123`)
const knopf = fx.finde(el, 'button[type="submit"]')
assert.equal(knopf.textContent, 'Zahlungspflichtig buchen')
const form = fx.finde(el, 'form')
const kinder = form.children
const block = fx.finde(el, '[data-tag="pflichtangaben"]')
assert.equal(kinder[kinder.indexOf(block) + 1], knopf, 'Block steht direkt ueber dem Knopf')
const t = fx.text(block)
const marken = ['Anbieter: Haus Test GmbH', 'Weg 1', 'Unterkunft: Ferienwohnung, 10.11.2026 bis 14.11.2026', 'Check-in ab 15:00 Uhr', 'Check-out bis 11:00 Uhr', 'Personen: 2', 'Übernachtung, 4 Nächte', 'Endreinigung', 'Gesamtpreis: 450,00', '7 % USt', 'Zahlarten:', 'Stornobedingungen: Bis 14 Tage vor Anreise kostenfrei.', 'Ein Widerrufsrecht besteht nicht (§ 312g Abs. 2 Nr. 9 BGB).', 'Datenschutz:', 'Reserviert bis 10.10.2026 14:30 Uhr']
let pos = -1
for (const m of marken) {
const i = t.indexOf(m, pos + 1)
assert.ok(i > pos, `"${m}" fehlt oder steht an falscher Stelle in: ${t}`)
pos = i
}
assert.doesNotMatch(t, /§ 19 UStG/)
assert.equal(fx.finde(el, 'a[href="https://kunde.example/datenschutz"]')?.getAttribute('rel'), 'noopener noreferrer')
assert.match(el.getAttribute('style'), /--uk-akzent:#0f766e/)
assert.equal(fx.finde(el, '[name="name"]').value, 'Max Muster')
assert.equal(fx.finde(el, '[name="land"]').value, 'Deutschland')
})
})
test('Kleinunternehmer: § 19-Hinweis statt USt-Ausweis; Option online fehlt ohne zahlarten', async () => {
const d = { ...DATEN, steuerart: 'kleinunternehmer', summen: { bruttoCent: 45000, nettoCent: 45000, ust: [] }, zahlarten: ['vorort'] }
await mitFx([{ status: 200, body: d }], undefined, (fx, el) => {
const t = fx.text(fx.finde(el, '[data-tag="pflichtangaben"]'))
assert.match(t, /Gemäß § 19 UStG wird keine Umsatzsteuer berechnet\./)
assert.doesNotMatch(t, /% USt/)
assert.equal(fx.finde(el, 'input[value="online"]'), null)
assert.ok(fx.finde(el, 'input[value="vorort"]'))
})
})
test('Pflichtfelder der Rechnungsadresse werden vor dem Senden geprueft', async () => {
await mitFx([{ status: 200, body: DATEN }], undefined, async (fx, el) => {
await fx.klick(fx.finde(el, 'button[type="submit"]'))
assert.equal(fx.fetchLog.length, 1)
assert.match(fx.text(fx.finde(el, '[data-fehler="strasse"]')), /Straße/)
assert.match(fx.text(el), /Bitte prüfen Sie Ihre Angaben\./)
})
})
async function fuelle(fx, el) {
await fx.setze(fx.finde(el, '[name="strasse"]'), 'Hauptstr. 5')
await fx.setze(fx.finde(el, '[name="plz"]'), '12345')
await fx.setze(fx.finde(el, '[name="ort"]'), 'Berlin')
}
test('Buchen vor Ort: POST mit Vertragsfeldern, Bestaetigung mit Rechnung-Link, kein Online-Knopf', async () => {
await mitFx([{ status: 200, body: DATEN }, { status: 200, body: { status: 'gebucht', rechnungNummer: 'R-2026-1' } }], undefined, async (fx, el) => {
await fuelle(fx, el)
await fx.klick(fx.finde(el, 'input[value="vorort"]'))
await fx.klick(fx.finde(el, 'button[type="submit"]'))
const post = fx.fetchLog[1]
assert.equal(post.method, 'POST')
assert.equal(post.url, `${BASIS}?t=tok-123`)
assert.deepEqual(post.body, { rechnungsadresse: { name: 'Max Muster', firma: '', strasse: 'Hauptstr. 5', plz: '12345', ort: 'Berlin', land: 'Deutschland' }, zahlart: 'vorort' })
assert.match(fx.text(el), /Ihre Buchung ist bestätigt/)
assert.equal(fx.finde(el, '[data-tag="rechnung"]').getAttribute('href'), `${BASIS}/rechnung.pdf?t=tok-123&art=rechnung`)
assert.equal(fx.finde(el, '[data-tag="online-zahlen"]'), null)
assert.deepEqual(fx.assigned, [])
})
})
test('Buchen online mit zahlungUrl: Weiterleitung', async () => {
await mitFx([{ status: 200, body: DATEN }, { status: 200, body: { status: 'gebucht', rechnungNummer: 'R-1', zahlungUrl: 'https://checkout.stripe.com/c/pay/x' } }], undefined, async (fx, el) => {
await fuelle(fx, el)
await fx.klick(fx.finde(el, 'input[value="online"]'))
await fx.klick(fx.finde(el, 'button[type="submit"]'))
assert.equal(fx.fetchLog[1].body.zahlart, 'online')
assert.deepEqual(fx.assigned, ['https://checkout.stripe.com/c/pay/x'])
})
})
test('gebucht, online unbezahlt: „Jetzt online bezahlen“ holt /zahlung und leitet weiter', async () => {
const d = { ...DATEN, status: 'gebucht', zahlart: 'online', bezahlt: false, rechnungNummer: 'R-1' }
await mitFx([{ status: 200, body: d }, { status: 200, body: { url: 'https://checkout.stripe.com/c/pay/y' } }], undefined, async (fx, el) => {
assert.match(fx.text(el), /Ihre Buchung ist bestätigt/)
assert.ok(fx.finde(el, '[data-tag="rechnung"]'))
await fx.klick(fx.finde(el, '[data-tag="online-zahlen"]'))
assert.equal(fx.fetchLog[1].method, 'POST')
assert.equal(fx.fetchLog[1].url, `${BASIS}/zahlung?t=tok-123`)
assert.deepEqual(fx.assigned, ['https://checkout.stripe.com/c/pay/y'])
})
})
test('gebucht und bezahlt: kein Zahlknopf', async () => {
await mitFx([{ status: 200, body: { ...DATEN, status: 'gebucht', zahlart: 'online', bezahlt: true } }], undefined, (fx, el) => {
assert.equal(fx.finde(el, '[data-tag="online-zahlen"]'), null)
assert.match(fx.text(el), /Bezahlt – vielen Dank\./)
})
})
test('abgelaufen, 404 und Netzfehler: feste Texte; ohne Token kein Abruf', async () => {
await mitFx([{ status: 200, body: { ...DATEN, status: 'abgelaufen' } }], undefined, (fx, el) => {
assert.match(fx.text(el), /Die Reservierung ist abgelaufen\. Bitte fragen Sie erneut an\./)
})
await mitFx([{ status: 404, body: { fehler: 'nicht_gefunden' } }], undefined, (fx, el) => {
assert.match(fx.text(el), /Dieser Link ist ungültig\./)
})
await mitFx([], { suche: '' }, (fx, el) => {
assert.match(fx.text(el), /Dieser Link ist ungültig\./)
assert.equal(fx.fetchLog.length, 0)
})
await mitFx([() => { throw new Error('netz') }], undefined, (fx, el) => {
assert.match(fx.text(el), /nicht geladen werden/)
})
})
test('Fehler beim Buchen: abgelaufen, Feldfehler, Server-Ausfall (Knopf wieder frei)', async () => {
await mitFx([{ status: 200, body: DATEN }, { status: 410, body: { fehler: 'frist_abgelaufen' } }], undefined, async (fx, el) => {
await fuelle(fx, el)
await fx.klick(fx.finde(el, 'button[type="submit"]'))
assert.match(fx.text(el), /Die Reservierung ist abgelaufen\./)
})
await mitFx([{ status: 200, body: DATEN }, { status: 409, body: { fehler: 'zeitraum_belegt' } }], undefined, async (fx, el) => {
await fuelle(fx, el)
await fx.klick(fx.finde(el, 'button[type="submit"]'))
assert.match(fx.text(el), /Der Zeitraum ist leider inzwischen belegt\. Bitte fragen Sie einen anderen Zeitraum an\./)
})
await mitFx([{ status: 200, body: DATEN }, { status: 400, body: { fehler: 'ungueltig', felder: { plz: 'PLZ ungültig' } } }, { status: 500, body: {} }], undefined, async (fx, el) => {
await fuelle(fx, el)
await fx.klick(fx.finde(el, 'button[type="submit"]'))
assert.equal(fx.text(fx.finde(el, '[data-fehler="plz"]')), 'PLZ ungültig')
assert.equal(fx.finde(el, 'button[type="submit"]').hasAttribute('disabled'), false)
await fx.klick(fx.finde(el, 'button[type="submit"]'))
assert.match(fx.text(el), /nicht abgeschlossen werden/)
})
})
test('wk_zahlung=abbruch zeigt den Hinweis', async () => {
await mitFx([{ status: 200, body: { ...DATEN, status: 'gebucht', zahlart: 'online', bezahlt: false } }], { suche: '?t=tok-123&wk_zahlung=abbruch' }, (fx, el) => {
assert.match(fx.text(el), /Zahlung abgebrochen – Sie können jetzt oder vor Ort bezahlen\./)
})
})
test('Token verlaesst die Seite nur in den Schnittstellen-Aufrufen', async () => {
assert.doesNotMatch(QUELLE, /console\.|localStorage|sessionStorage|sendBeacon|document\.cookie/)
await mitFx([{ status: 200, body: DATEN }], undefined, (fx) => {
for (const a of fx.fetchLog) assert.ok(a.url.startsWith('/api/public/unterkunft/v1/'), a.url)
})
})
test('Status bezahlt wie gebucht: Bestaetigung, kein Zahlknopf', async () => {
await mitFx([{ status: 200, body: { ...DATEN, status: 'bezahlt', zahlart: 'online', bezahlt: true } }], undefined, (fx, el) => {
assert.match(fx.text(el), /Ihre Buchung ist bestätigt/)
assert.match(fx.text(el), /Bezahlt – vielen Dank\./)
assert.equal(fx.finde(el, '[data-tag="online-zahlen"]'), null)
})
})
test('Wiederholung: POST 409 oder ok ohne gebucht laedt neu und zeigt den aktuellen Stand', async () => {
const gebucht = { ...DATEN, status: 'gebucht', zahlart: 'vorort', bezahlt: false, rechnungNummer: 'R-1' }
for (const post of [{ status: 409, body: { fehler: 'bereits_gebucht' } }, { status: 200, body: {} }]) {
await mitFx([{ status: 200, body: DATEN }, post, { status: 200, body: gebucht }], undefined, async (fx, el) => {
await fuelle(fx, el)
await fx.klick(fx.finde(el, 'button[type="submit"]'))
assert.equal(fx.fetchLog.length, 3)
assert.equal(fx.fetchLog[2].method, 'GET')
assert.match(fx.text(el), /Ihre Buchung ist bestätigt/)
})
}
})
test('Uebersicht: Menge × Einzelpreis, gewaehlte Zahlart, Titel', async () => {
await mitFx([{ status: 200, body: DATEN }], undefined, async (fx, el) => {
assert.match(fx.text(fx.finde(el, '[data-tag="posten"]')), /Übernachtung, 4 Nächte \(4 × 100,00\s€\)400,00\s€/)
assert.match(fx.text(fx.finde(el, '.uk-zahlarten')), /\(gewählt: vor Ort\)/)
await fx.klick(fx.finde(el, 'input[value="online"]'))
assert.match(fx.text(fx.finde(el, '.uk-zahlarten')), /\(gewählt: online\)/)
assert.equal(fx.document.title, 'Buchung – Ferienwohnung')
})
})
+92
View File
@@ -0,0 +1,92 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import vm from 'node:vm'
import { installFakeDom } from './support/fakeDom.js'
// Ablauf des Widgets public/embed/unterkunft.js gegen den Vertrag docs/funktionen/unterkunft-api.md (Fake-fetch, Minimal-DOM).
const QUELLE = readFileSync(new URL('../public/embed/unterkunft.js', import.meta.url), 'utf8')
const CFG = { aktiv: true, anbieter: { name: 'Haus Test', ort: 'X', datenschutzUrl: 'https://kunde.example/datenschutz' }, akzentfarbe: '#0f766e', formToken: 'tok1', unterkuenfte: [{ id: 'u1', name: 'Ferienwohnung', maxPersonen: 3, mindestNaechte: 2, checkIn: '15:00', checkOut: '11:00' }] }
const BEL = { belegt: [{ von: '2026-11-20', bis: '2026-11-25' }], heute: '2026-11-01', bisMax: '2028-04-30' }
function start(antworten) {
const fx = installFakeDom({ antworten })
fx.document.readyState = 'complete'
fx.document.currentScript = { dataset: {}, src: 'https://project.webklar.com/embed/unterkunft.js' }
const el = fx.document.createElement('div')
el.setAttribute('data-webklar-unterkunft', 'abcdefghijklmnop')
fx.document.body.appendChild(el)
vm.runInThisContext(QUELLE, { filename: 'unterkunft.js' })
return { fx, el }
}
const tag = (fx, el, t) => fx.finde(el, `[data-tag="${t}"]`)
test('Ladefehler und aktiv:false zeigen nur den Ausweichsatz', async () => {
for (const antwort of [{ status: 200, body: { aktiv: false } }, { status: 500, body: {} }]) {
const { fx, el } = start([antwort])
try {
await fx.ruhe()
assert.equal(fx.text(el), 'Anfragen gerne per Telefon oder E-Mail.')
} finally { fx.restore() }
}
})
test('Kalender, Auswahl, Mindestnaechte, Absenden mit Vertragsfeldern, Danke-Text', async () => {
const { fx, el } = start([{ status: 200, body: CFG }, { status: 200, body: BEL }, { status: 201, body: { nummer: 'A-1234' } }, { status: 200, body: BEL }])
try {
await fx.ruhe(); await fx.ruhe()
assert.match(fx.fetchLog[0].url, /^https:\/\/project\.webklar\.com\/api\/public\/unterkunft\/v1\/abcdefghijklmnop$/)
assert.match(fx.fetchLog[1].url, /\/belegung\?unterkunft=u1&von=\d{4}-\d{2}-\d{2}&bis=\d{4}-\d{2}-\d{2}$/)
assert.equal(tag(fx, el, '2026-11-10').getAttribute('aria-label'), 'Dienstag, 10. November 2026, frei')
assert.equal(tag(fx, el, '2026-11-21').getAttribute('aria-label').includes('belegt'), true)
assert.ok(fx.finde(el, '[name="kontakt_url"]'), 'Honeypot')
// ohne Zeitraum
await fx.setze(fx.finde(el, '[name="name"]'), 'Max'); await fx.setze(fx.finde(el, '[name="email"]'), 'max@example.com')
await fx.klick(fx.finde(el, 'button[type="submit"]'))
assert.match(fx.text(el), /Bitte wählen Sie Anreise und Abreise im Kalender\./)
assert.equal(fx.fetchLog.length, 2)
// Mindestnaechte (2): 1 Nacht -> Hinweis
await fx.klick(tag(fx, el, '2026-11-10')); await fx.klick(tag(fx, el, '2026-11-11'))
assert.match(fx.text(el), /Mindestaufenthalt: 2 Nächte/)
await fx.klick(tag(fx, el, '2026-11-14'))
assert.match(fx.text(el), /10\.–14\. Nov\. · 4 Nächte/)
await fx.klick(fx.finde(el, 'button[type="submit"]'))
const post = fx.fetchLog[2]
assert.equal(post.method, 'POST')
assert.match(post.url, /\/anfrage$/)
assert.deepEqual(post.body, { unterkunft: 'u1', von: '2026-11-10', bis: '2026-11-14', personen: 1, name: 'Max', firma: '', email: 'max@example.com', telefon: '', nachricht: '', formToken: 'tok1', kontakt_url: '' })
assert.match(fx.text(el), /Danke! Ihre Anfrage A-1234 ist eingegangen\. Sie erhalten gleich eine Bestätigung per E-Mail\./)
} finally { fx.restore() }
})
test('Pfeiltasten bewegen den Fokus', async () => {
const { fx, el } = start([{ status: 200, body: CFG }, { status: 200, body: BEL }])
try {
await fx.ruhe(); await fx.ruhe()
await fx.klick(tag(fx, el, '2026-11-10'))
await fx.sende(tag(fx, el, '2026-11-10'), 'keydown', { key: 'ArrowRight' })
assert.equal(fx.document.activeElement.getAttribute('data-tag'), '2026-11-11')
await fx.sende(fx.document.activeElement, 'keydown', { key: 'ArrowDown' })
assert.equal(fx.document.activeElement.getAttribute('data-tag'), '2026-11-18')
} finally { fx.restore() }
})
test('Monatsnavigation: erster Monat hat aktiven Weiter-Knopf, Fokus bleibt, Tab-Stop im gezeigten Monat, Abreisetag-Label', async () => {
const { fx, el } = start([{ status: 200, body: CFG }, { status: 200, body: BEL }])
try {
await fx.ruhe(); await fx.ruhe()
const erster = fx.finde(el, '.wku-monat')
const weiter = fx.finde(erster, '[data-nav="vor1"]')
assert.ok(weiter && !weiter.hasAttribute('disabled'))
await fx.klick(weiter)
assert.match(fx.text(el), /Dezember 2026/)
assert.equal(fx.document.activeElement.getAttribute('data-nav'), 'vor1')
assert.equal(fx.finde(el, '[data-tag][tabindex="0"]').getAttribute('data-tag'), '2026-12-01')
await fx.klick(fx.finde(el, '[data-nav="zur"]'))
assert.equal(fx.document.activeElement.getAttribute('data-nav'), 'vor1') // Zurueck ist im ersten Monat gesperrt, Fokus wechselt zum Weiter-Knopf
assert.equal(fx.finde(el, '[data-tag][tabindex="0"]').getAttribute('data-tag'), '2026-11-01')
await fx.klick(tag(fx, el, '2026-11-18'))
assert.match(tag(fx, el, '2026-11-20').getAttribute('aria-label'), /belegt – als Abreisetag möglich/)
assert.doesNotMatch(tag(fx, el, '2026-11-22').getAttribute('aria-label'), /Abreisetag möglich/)
} finally { fx.restore() }
})