Buchungskalender: Buchungsseite für Gäste
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bc9b4d9576
commit
0b0d8da335
5 files changed
+598
-2
No files matched your search
@@ -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}}
|
||||
@@ -0,0 +1,258 @@
|
||||
/* 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.',
|
||||
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 : '')
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- 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) {
|
||||
return h('li', {}, [h('span', { text: x.text }), 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 zahlarten = (d.zahlarten || []).map(function (z) { return z === 'online' ? 'online (Karte, über Stripe)' : 'vor Ort' })
|
||||
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('Zahlarten: ' + zahlarten.join(' oder ') + (wahl ? ' (gewählt: ' + (wahl === 'online' ? 'online' : 'vor Ort') + ')' : ''), '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', Object.assign({ 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),
|
||||
knopf,
|
||||
meldung,
|
||||
])
|
||||
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
|
||||
}
|
||||
sendet = false
|
||||
knopf.removeAttribute('disabled')
|
||||
if (r.status === 404) return nurText('Buchung', TXT.ungueltig)
|
||||
if (r.body.fehler === 'abgelaufen') return nurText('Buchung', TXT.abgelaufen)
|
||||
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) kinder.push(p('Die Zahlung ist eingegangen. 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.status === 'freigegeben') zeige(formular(d), hinweisAbbruch())
|
||||
else if (d.status === 'gebucht') 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 }
|
||||
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) })
|
||||
})()
|
||||
@@ -0,0 +1,18 @@
|
||||
<!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>Unterkunft</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>
|
||||
@@ -1,11 +1,67 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { Router } from 'express'
|
||||
import { SITE_KEY_RE } from '../services/unterkunft/kontext.js'
|
||||
|
||||
/**
|
||||
* Oeffentliche Standalone-Seiten des Buchungskalenders: /unterkunft/:siteKey und /unterkunft/:siteKey/buchung/:id
|
||||
* (Vorbild: shopSeite.js). Geruest (Task 5): Task 7 und Strang 3 fuellen die Seiten.
|
||||
* 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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
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': 'strict-origin-when-cross-origin',
|
||||
'Content-Security-Policy': CSP,
|
||||
'X-Robots-Tag': 'noindex',
|
||||
})
|
||||
return res.status(200).send(html)
|
||||
}
|
||||
|
||||
export function unterkunftSeiteRouter() {
|
||||
const router = Router()
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
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('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), /Zahlung ist eingegangen/)
|
||||
})
|
||||
})
|
||||
|
||||
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: 409, body: { fehler: '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: 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)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user