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
`;
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();
};
}