diff --git a/public/unterkunft-buchung.css b/public/unterkunft-buchung.css index 17190b7..5ce6b6f 100644 --- a/public/unterkunft-buchung.css +++ b/public/unterkunft-buchung.css @@ -14,6 +14,8 @@ body{margin:0;font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;b .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-gruppe{border:0;margin:0 0 12px;padding:0;min-width:0}.uk-zahlart-gruppe legend{font-size:1.1rem;font-weight:700;padding:0;margin:0 0 8px} +.uk-feld input[aria-invalid="true"]{border-color:#b91c1c;border-width:2px} .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} diff --git a/public/unterkunft-buchung.js b/public/unterkunft-buchung.js index 282ffc8..f4f9605 100644 --- a/public/unterkunft-buchung.js +++ b/public/unterkunft-buchung.js @@ -26,12 +26,16 @@ 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).', + widerruf: 'Ein Widerrufsrecht besteht nicht (§ 312g Abs. 2 Satz 1 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', + onlineAus: 'Online-Zahlung ist gerade nicht möglich – bitte wählen Sie „Vor Ort bezahlen“.', + nichtMoeglich: 'Die Buchung ist gerade nicht möglich. Bitte wenden Sie sich direkt an den Anbieter.', + standGeaendert: 'Der Stand Ihrer Buchung hat sich geändert.', + zahlungStart: 'Die Zahlung konnte nicht gestartet werden. Bitte versuchen Sie es erneut.', } function h(tag, attrs, kinder) { @@ -69,9 +73,13 @@ }) } + function httpsUrl(u) { return typeof u === 'string' && /^https:\/\//i.test(u) ? u : '' } + var meldungNachLaden = '' function zeige(karte, hinweis) { app.textContent = '' - if (hinweis) app.appendChild(p(hinweis, 'uk-hinweis')) + var text = [hinweis, meldungNachLaden].filter(Boolean).join(' ') + meldungNachLaden = '' + if (text) app.appendChild(h('p', { text: text, class: 'uk-hinweis', role: 'status' })) app.appendChild(karte) } function nurText(titel, text) { @@ -140,7 +148,7 @@ 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 }) + var fehler = h('span', { class: 'uk-feldfehler', 'data-fehler': name, id: 'uk-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' }) @@ -165,8 +173,7 @@ 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), + h('fieldset', { class: 'uk-zahlart-gruppe', 'data-tag': 'zahlart' }, [h('legend', { text: 'Zahlart' })].concat(radios)), pflichtangaben(d, gewaehlt), knopf, meldung, @@ -180,10 +187,20 @@ 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 } }) + Object.keys(alle).forEach(function (k) { markiere(k, '') }) + var erstes = null + Object.keys(alle).forEach(function (k) { + if (felderFehler && Object.prototype.hasOwnProperty.call(felderFehler, k)) { markiere(k, String(felderFehler[k])); if (!erstes) erstes = felder[k] } + }) meldung.textContent = text || '' meldung.hidden = !text + if (erstes && erstes.focus) erstes.focus() + } + function markiere(k, text) { + alle[k].fehler.textContent = text + alle[k].fehler.hidden = !text + if (text) { felder[k].setAttribute('aria-invalid', 'true'); felder[k].setAttribute('aria-describedby', 'uk-fehler-' + k) } + else { felder[k].removeAttribute('aria-invalid'); felder[k].removeAttribute('aria-describedby') } } form.addEventListener('submit', function (ev) { ev.preventDefault() @@ -204,14 +221,24 @@ 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 } + if (zahlart === 'online' && httpsUrl(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 } + var code = r.body.fehler + if (r.status === 409 && (code === 'online_aus' || code === 'einstellungen_unvollstaendig' || code === 'zustimmung_fehlt')) { + sendet = false + knopf.removeAttribute('disabled') + if (code === 'online_aus') { + var vor = radios.map(function (l) { return l.querySelector('input') }).find(function (i) { return i.value === 'vorort' }) + if (vor) vor.checked = true + } + return fehlerAnzeigen({}, code === 'online_aus' ? TXT.onlineAus : TXT.nichtMoeglich) + } + if (r.status === 409 || r.ok) { meldungNachLaden = TXT.standGeaendert; 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) @@ -224,7 +251,7 @@ // Fehler verschwinden, sobald der Gast das Feld aendert; die allgemeine Meldung, wenn kein Feldfehler mehr steht Object.keys(alle).forEach(function (k) { felder[k].addEventListener('input', function () { - alle[k].fehler.hidden = true; alle[k].fehler.textContent = '' + markiere(k, '') if (!Object.keys(alle).some(function (n) { return !alle[n].fehler.hidden })) { meldung.textContent = ''; meldung.hidden = true } }) }) @@ -245,9 +272,11 @@ 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 } + if (r.ok && httpsUrl(r.body.url)) { window.location.assign(r.body.url); return } + if (r.status === 409 && r.body.fehler === 'status_ungueltig') { meldungNachLaden = TXT.standGeaendert; laden(); return } knopf.removeAttribute('disabled') - meldung.textContent = 'Die Zahlung konnte nicht gestartet werden. Bitte versuchen Sie es erneut.'; meldung.hidden = false + meldung.textContent = r.status === 409 && r.body.fehler === 'online_aus' ? TXT.onlineAus : r.status === 409 && (r.body.fehler === 'einstellungen_unvollstaendig' || r.body.fehler === 'zustimmung_fehlt') ? TXT.nichtMoeglich : TXT.zahlungStart + meldung.hidden = false }, function () { knopf.removeAttribute('disabled') meldung.textContent = 'Die Zahlung konnte nicht gestartet werden. Bitte versuchen Sie es erneut.'; meldung.hidden = false diff --git a/test/unterkunftSeite.test.js b/test/unterkunftSeite.test.js index 76b0c80..58770e0 100644 --- a/test/unterkunftSeite.test.js +++ b/test/unterkunftSeite.test.js @@ -106,7 +106,7 @@ test('freigegeben: Pflichtangaben in Reihenfolge direkt ueber dem Knopf, Abruf m 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'] + 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 Satz 1 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) @@ -289,3 +289,61 @@ test('Fehler der Rechnungsadresse verschwinden, sobald der Gast die Felder ausfu assert.ok(meldung.hidden) }) }) + +/* ----------------------------------------------------- Abschluss: 409-Codes, a11y --- */ +test('409-Codes beim Buchen: eigene Banner-Texte statt stillem Neuladen', async () => { + const faelle = [ + ['online_aus', /Online-Zahlung ist gerade nicht möglich – bitte wählen Sie „Vor Ort bezahlen“\./, false], + ['einstellungen_unvollstaendig', /Die Buchung ist gerade nicht möglich\. Bitte wenden Sie sich direkt an den Anbieter\./, false], + ['zustimmung_fehlt', /Die Buchung ist gerade nicht möglich\. Bitte wenden Sie sich direkt an den Anbieter\./, false], + ['status_ungueltig', /Der Stand Ihrer Buchung hat sich geändert\./, true], + ] + for (const [code, muster, neuLaden] of faelle) { + const antworten = [{ status: 200, body: DATEN }, { status: 409, body: { fehler: code } }] + if (neuLaden) antworten.push({ status: 200, body: { ...DATEN, status: 'gebucht', zahlart: 'vorort' } }) + await mitFx(antworten, undefined, async (fx, el) => { + await fuelle(fx, el) + await fx.klick(fx.finde(el, 'button[type="submit"]')) + assert.match(fx.text(el), muster, code) + assert.equal(fx.fetchLog.length, neuLaden ? 3 : 2, code) + }) + } +}) + +test('409 bei /zahlung: status_ungueltig laedt neu mit Hinweis; online_aus zeigt Text', async () => { + const d = { ...DATEN, status: 'gebucht', zahlart: 'online', bezahlt: false, rechnungNummer: 'R-1' } + await mitFx([{ status: 200, body: d }, { status: 409, body: { fehler: 'status_ungueltig' } }, { status: 200, body: { ...d, bezahlt: true } }], undefined, async (fx, el) => { + await fx.klick(fx.finde(el, '[data-tag="online-zahlen"]')) + assert.match(fx.text(el), /Der Stand Ihrer Buchung hat sich geändert\./) + assert.match(fx.text(el), /Bezahlt – vielen Dank\./) + }) + await mitFx([{ status: 200, body: d }, { status: 409, body: { fehler: 'online_aus' } }], undefined, async (fx, el) => { + await fx.klick(fx.finde(el, '[data-tag="online-zahlen"]')) + assert.match(fx.text(el), /Online-Zahlung ist gerade nicht möglich/) + }) +}) + +test('zahlungUrl nur bei https; Widerrufs-Zitat vollstaendig', async () => { + await mitFx([{ status: 200, body: DATEN }, { status: 200, body: { status: 'gebucht', rechnungNummer: 'R-1', zahlungUrl: 'javascript:alert(1)' } }], undefined, async (fx, el) => { + assert.match(fx.text(el), /§ 312g Abs\. 2 Satz 1 Nr\. 9 BGB/) + await fuelle(fx, el) + await fx.klick(fx.finde(el, 'input[value="online"]')) + await fx.klick(fx.finde(el, 'button[type="submit"]')) + assert.deepEqual(fx.assigned, []) + }) +}) + +test('Feldfehler: aria-invalid, aria-describedby, Fokus auf erstes Fehlerfeld; Zahlart als fieldset/legend', async () => { + await mitFx([{ status: 200, body: DATEN }], undefined, async (fx, el) => { + const gruppe = fx.finde(el, 'fieldset[data-tag="zahlart"]') + assert.ok(gruppe) + assert.equal(fx.text(fx.finde(gruppe, 'legend')), 'Zahlart') + await fx.klick(fx.finde(el, 'button[type="submit"]')) + const strasse = fx.finde(el, '[name="strasse"]') + assert.equal(strasse.getAttribute('aria-invalid'), 'true') + assert.equal(strasse.getAttribute('aria-describedby'), 'uk-fehler-strasse') + assert.equal(fx.document.activeElement, strasse) + await fx.setze(strasse, 'Weg 1') + assert.equal(strasse.getAttribute('aria-invalid'), null) + }) +})