diff --git a/public/unterkunft-buchung.css b/public/unterkunft-buchung.css new file mode 100644 index 0000000..17190b7 --- /dev/null +++ b/public/unterkunft-buchung.css @@ -0,0 +1,28 @@ +:root{--uk-akzent:#0f766e;--uk-akzent-text:#fff} +*{box-sizing:border-box} +body{margin:0;font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;background:#f6f7f9;color:#1f2937;line-height:1.5} +.uk-seite{max-width:760px;margin:0 auto;padding:24px 16px 56px} +.uk-ausweich{margin:0;padding:16px;color:#4b5563} +.uk-karte{background:#fff;border:1px solid #e5e7eb;border-top:4px solid var(--uk-akzent);border-radius:10px;padding:20px 20px 24px;margin:0 0 16px} +.uk-karte h1{font-size:1.4rem;margin:0 0 8px} +.uk-karte h2{font-size:1.05rem;margin:20px 0 8px} +.uk-hinweis{margin:0 0 16px;padding:10px 14px;border-radius:8px;background:#fef3c7;color:#78350f} +.uk-fehler{margin:12px 0 0;padding:10px 14px;border-radius:8px;background:#fee2e2;color:#7f1d1d} +.uk-feldfehler{display:block;color:#b91c1c;font-size:.85rem} +.uk-feld{display:block;margin:0 0 12px} +.uk-feld span.uk-bez{display:block;font-size:.9rem;font-weight:600;margin-bottom:4px} +.uk-feld input{width:100%;padding:10px 12px;border:1px solid #cbd5e1;border-radius:8px;font:inherit;background:#fff} +.uk-feld input:focus-visible,.uk-knopf:focus-visible,.uk-zahlart input:focus-visible,.uk-link:focus-visible{outline:3px solid var(--uk-akzent);outline-offset:2px} +.uk-zeile2{display:grid;grid-template-columns:1fr 2fr;gap:12px} +.uk-zahlart{display:flex;gap:10px;align-items:flex-start;margin:0 0 8px} +.uk-zahlart input{margin-top:5px} +.uk-pflicht{margin:20px 0 16px;padding:14px 16px;border:1px solid #e5e7eb;border-radius:8px;background:#f9fafb;font-size:.92rem} +.uk-pflicht h2{margin:0 0 10px} +.uk-pflicht p{margin:0 0 10px} +.uk-pflicht ul{list-style:none;margin:0 0 10px;padding:0} +.uk-pflicht li{display:flex;justify-content:space-between;gap:12px;padding:2px 0} +.uk-gesamt{font-weight:700;font-size:1.05rem} +.uk-knopf{display:block;width:100%;padding:14px 18px;border:0;border-radius:8px;background:var(--uk-akzent);color:var(--uk-akzent-text);font:inherit;font-weight:700;cursor:pointer;text-align:center;text-decoration:none} +.uk-knopf[disabled]{opacity:.6;cursor:default} +.uk-link{display:inline-block;margin:8px 0;color:var(--uk-akzent);font-weight:600} +@media (max-width:520px){.uk-zeile2{grid-template-columns:1fr}.uk-karte{padding:16px}} diff --git a/public/unterkunft-buchung.js b/public/unterkunft-buchung.js new file mode 100644 index 0000000..c027ed0 --- /dev/null +++ b/public/unterkunft-buchung.js @@ -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//buchung/?t= - Buchung pruefen und "Zahlungspflichtig buchen". + * Der Token (?t=) wird nur an die Schnittstelle gesendet, nie geloggt oder anderswohin geschickt. Alle Texte per textContent. */ +(function () { + 'use strict' + var API = '/api/public/unterkunft/v1/' + var app = document.getElementById('uk-app') + if (!app) return + var siteKey = app.getAttribute('data-sitekey') || '' + if (app.getAttribute('data-ansicht') !== 'buchung') { + app.textContent = '' + app.setAttribute('data-webklar-unterkunft', siteKey) + return + } + + var teile = String(window.location.pathname || '').split('/') + var id = decodeURIComponent(teile[teile.length - 1] || '') + var params = new URLSearchParams(window.location.search || '') + var token = params.get('t') || '' + var zahlungParam = params.get('wk_zahlung') || '' + var basis = API + encodeURIComponent(siteKey) + '/buchung/' + encodeURIComponent(id) + var tokenQuery = '?t=' + encodeURIComponent(token) + + var TXT = { + ungueltig: 'Dieser Link ist ungültig.', + abgelaufen: 'Die Reservierung ist abgelaufen. Bitte fragen Sie erneut an.', + abbruch: 'Zahlung abgebrochen – Sie können jetzt oder vor Ort bezahlen.', + widerruf: 'Ein Widerrufsrecht besteht nicht (§ 312g Abs. 2 Nr. 9 BGB).', + kleinunternehmer: 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.', + laden: 'Die Buchung konnte nicht geladen werden. Bitte versuchen Sie es später erneut.', + senden: 'Die Buchung konnte nicht abgeschlossen werden. Bitte versuchen Sie es erneut.', + 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) }) +})() diff --git a/public/unterkunft-seite.html b/public/unterkunft-seite.html new file mode 100644 index 0000000..3f263ec --- /dev/null +++ b/public/unterkunft-seite.html @@ -0,0 +1,18 @@ + + + + + + +Unterkunft + + + +
+

Die Seite wird geladen …

+ +
+ + + + diff --git a/server/routes/unterkunftSeite.js b/server/routes/unterkunftSeite.js index 3d78e25..7273d25 100644 --- a/server/routes/unterkunftSeite.js +++ b/server/routes/unterkunftSeite.js @@ -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