import { isValidBookingPhone, phoneError, } from "../../shared/phone-validation.js"; import { previewBooking, submitBooking } from "./booking-api.js"; import "./booking.css"; import { orderSummaryRows } from "../../shared/order-summary.js"; import { paymentNotice } from "../../shared/booking-copy.js"; // Die Detailseite kann dieselbe Section direkt mounten und später aufräumen. // Die Zieladresse kann bei der Integration bei Bedarf überschrieben werden. export function mountBookingPage( root, { packageId, variantId, confirmationUrl = root.dataset.confirmationUrl || "/booking-confirmation.html", } = {}, ) { const controller = new AbortController(); const { signal } = controller; const instanceId = `booking-${crypto.randomUUID()}`; root.innerHTML = `

Paket buchen

Paket wird geladen …

`; const intro = root.querySelector(".booking__intro"); const content = root.querySelector(".booking__content"); async function load() { if (!packageId) { intro.textContent = "Bitte wähle zuerst ein Paket auf der Angebotsseite aus."; return; } try { const selected = await previewBooking(packageId, variantId, signal); if (signal.aborted) return; intro.remove(); renderForm(selected); } catch (error) { if (signal.aborted) return; if ( [ "UNKNOWN_PACKAGE", "INVALID_VARIANT", "INVALID_INPUT", ].includes(error.code) ) { intro.textContent = error.message; return; } intro.textContent = "Das Paket konnte nicht geladen werden. Bitte prüfe deine Verbindung und versuche es erneut."; const retry = document.createElement("button"); retry.className = "booking__button"; retry.textContent = "Paket erneut laden"; retry.addEventListener( "click", () => { retry.remove(); intro.textContent = "Paket wird geladen …"; load(); }, { once: true, signal }, ); content.replaceChildren(retry); } } function renderForm(selected) { content.innerHTML = `

Enthaltene Leistungen

Paket buchen

Bitte gib deine Kontaktdaten ein. Name und E-Mail-Adresse sind Pflichtfelder.

An diese Adresse senden wir deine Bestätigung.

`; const dialog = content.querySelector("dialog"); const opener = content.querySelector(".booking__open"); opener.addEventListener( "click", () => { dialog.showModal(); opener.setAttribute("aria-expanded", "true"); const focusTarget = content.querySelector(".booking__result:not([hidden])") || content.querySelector('[name="name"]'); focusTarget.focus(); }, { signal }, ); for (const close of content.querySelectorAll( ".booking__close, .booking__cancel", )) { close.addEventListener("click", () => dialog.close(), { signal }); } dialog.addEventListener( "close", () => { opener.setAttribute("aria-expanded", "false"); opener.focus(); }, { signal }, ); function renderOrder(order) { content.querySelector(".booking__package-name").textContent = order.title; content.querySelector(".booking__summary").textContent = order.summaryForBooking; content.querySelector(".booking__description").textContent = order.subtitle; const totals = content.querySelector(".booking__totals"); totals.replaceChildren(); for (const [label, value] of orderSummaryRows(order)) { const term = document.createElement("dt"); const description = document.createElement("dd"); term.textContent = label; description.textContent = value; totals.append(term, description); } const features = content.querySelector(".booking__features"); features.replaceChildren( ...order.includedFeatures.map((text) => { const item = document.createElement("li"); item.textContent = text; return item; }), ); const steps = content.querySelector(".booking__steps"); steps.replaceChildren(); if (order.processSteps?.length) { const heading = document.createElement("h3"); heading.textContent = "So geht es weiter"; steps.append(heading); for (const entry of order.processSteps) { const paragraph = document.createElement("p"); paragraph.textContent = `${entry.step}: ${entry.text}`; steps.append(paragraph); } } } renderOrder(selected); content.querySelector(".booking__payment-notice").textContent = paymentNotice; const form = content.querySelector("form"); const name = form.elements.namedItem("name"); const email = form.elements.namedItem("email"); const phone = form.elements.namedItem("phone"); const button = form.querySelector('button[type="submit"]'); const errors = form.querySelector(".booking__errors"); const progress = form.querySelector(".booking__progress"); const result = content.querySelector(".booking__result"); const storageKey = `trihub.booking.${selected.packageId}${selected.variantId ? `.${selected.variantId}` : ""}`; let attempt = null; let busy = false; let finished = false; function lockFields(locked) { name.readOnly = locked; email.readOnly = locked; phone.readOnly = locked; } function showError(message, fields = {}) { errors.textContent = message; errors.hidden = false; for (const field of [name, email, phone]) { const message = fields[field.name] || ""; content.querySelector( `#${instanceId}-booking-${field.name}-error`, ).textContent = message; field.setAttribute("aria-invalid", String(Boolean(message))); } errors.focus(); } function clearErrors() { errors.hidden = true; for (const field of [name, email, phone]) { field.removeAttribute("aria-invalid"); content.querySelector( `#${instanceId}-booking-${field.name}-error`, ).textContent = ""; } } function showResult(body, focus = true) { if (body.bookedPackage) renderOrder(body.bookedPackage); const messages = { accepted: "Buchung gespeichert. Die Bestätigung wurde vom Mailserver zum Versand angenommen. Die Zustellung ist noch nicht bestätigt.", failed: "Buchung gespeichert, aber der Bestätigungsversand ist fehlgeschlagen. Bitte nicht erneut buchen. Wende dich mit deiner Buchungsnummer an Tri-Hub.", pending: "Buchung gespeichert. Der Bestätigungsversand konnte noch nicht gestartet werden. Du kannst den Versand mit der bestehenden Buchung erneut versuchen.", unknown: "Buchung gespeichert. Der Versandstatus der Bestätigung ist unklar. Bitte nicht erneut buchen. Wende dich mit deiner Buchungsnummer an Tri-Hub.", }; result.textContent = `${messages[body.emailStatus] || messages.unknown} Buchungsnummer: ${body.bookingId}`; result.hidden = false; lockFields(true); finished = body.emailStatus !== "pending"; button.disabled = finished; button.textContent = finished ? "Buchung gespeichert" : "Bestätigungsversand erneut versuchen"; if (focus) result.focus(); } try { const stored = JSON.parse(sessionStorage.getItem(storageKey)); if ( stored?.key && stored.payload?.packageId === selected.packageId ) { attempt = stored; if (stored.order) renderOrder(stored.order); name.value = stored.payload.name; email.value = stored.payload.email; phone.value = stored.payload.phone || ""; lockFields(true); button.textContent = "Status prüfen / erneut versuchen"; if (stored.result) showResult(stored.result, false); else progress.textContent = "Eine frühere Anfrage ist noch offen. Bitte ihren Status prüfen, bevor du erneut buchst."; } } catch { // Speichern wird vor der ersten Anfrage zwingend erneut geprüft. } form.addEventListener( "submit", async (event) => { event.preventDefault(); if (busy || finished) return; clearErrors(); if (!attempt) { const fields = {}; name.value = name.value.trim(); email.value = email.value.trim(); if ( !name.value || name.value.length > 120 || /[\x00-\x1f\x7f]/.test(name.value) ) fields.name = "Bitte deinen Namen eingeben (höchstens 120 Zeichen, ohne Zeilenumbrüche)."; if (!email.validity.valid || email.value.length > 254) fields.email = "Bitte eine gültige E-Mail-Adresse eingeben."; phone.value = phone.value.trim(); if (!isValidBookingPhone(phone.value)) fields.phone = phoneError; if (Object.keys(fields).length) { showError( "Bitte die markierten Angaben prüfen.", fields, ); return; } const nextAttempt = { key: crypto.randomUUID(), order: selected, payload: { packageId: selected.packageId, ...(selected.variantId ? { variantId: selected.variantId } : {}), name: name.value, email: email.value, ...(phone.value ? { phone: phone.value } : {}), }, }; try { sessionStorage.setItem( storageKey, JSON.stringify(nextAttempt), ); } catch { showError( "Der Browser kann die Anfrage nicht für einen sicheren erneuten Versuch merken. Bitte erlaube den Sitzungsspeicher und versuche es erneut.", ); return; } attempt = nextAttempt; } busy = true; button.disabled = true; button.textContent = "Buchung wird verarbeitet …"; form.setAttribute("aria-busy", "true"); lockFields(true); progress.textContent = "Bitte warten. Deine Buchung wird geprüft und gespeichert."; try { const body = await submitBooking( attempt.payload, attempt.key, ); attempt.result = body; try { sessionStorage.setItem( storageKey, JSON.stringify(attempt), ); } catch { /* Der ursprüngliche Schlüssel bleibt erhalten. */ } if (!signal.aborted) { showResult(body); if ( body.saved && body.emailStatus === "accepted" && confirmationUrl ) { // Nur weiterleiten, wenn die Bestätigungsseite die // Serverantwort in diesem Tab auch lesen kann. try { sessionStorage.setItem( "trihub.booking.confirmation", JSON.stringify(body), ); } catch { return; // Die bestehende Bestätigung bleibt sichtbar. } window.location.assign(confirmationUrl); } } } catch (error) { // Nur eindeutige Ablehnungen vor dem Speichern erlauben eine // Korrektur. Bei Verbindungsfehlern bleiben Daten und Key fest. if ( [ "INVALID_INPUT", "UNKNOWN_PACKAGE", "INVALID_VARIANT", "INVALID_KEY", ].includes(error.code) ) { attempt = null; try { sessionStorage.removeItem(storageKey); } catch { /* Keine Anfrage gespeichert. */ } lockFields(false); } if (!signal.aborted) { showError(error.message, error.fields); button.textContent = attempt ? "Status prüfen / erneut versuchen" : "Paket buchen"; } } finally { busy = false; if (!signal.aborted) { form.removeAttribute("aria-busy"); progress.textContent = ""; button.disabled = finished; } } }, { signal }, ); } load(); return () => { controller.abort(); root.querySelector(".booking__dialog")?.close(); root.replaceChildren(); }; }