411 lines
19 KiB
JavaScript
411 lines
19 KiB
JavaScript
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 = `
|
||
<section class="booking" aria-labelledby="${instanceId}-heading">
|
||
<h2 id="${instanceId}-heading">Paket buchen</h2>
|
||
<p class="booking__intro" role="status">Paket wird geladen …</p>
|
||
<div class="booking__content"></div>
|
||
</section>`;
|
||
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 = `
|
||
<div class="booking__package">
|
||
<h2 class="booking__package-name"></h2>
|
||
<p class="booking__summary"></p>
|
||
<p class="booking__description"></p>
|
||
<dl class="booking__totals"></dl>
|
||
<h3>Enthaltene Leistungen</h3>
|
||
<ul class="booking__features"></ul>
|
||
<div class="booking__steps"></div>
|
||
</div>
|
||
<aside class="booking__payment" aria-labelledby="${instanceId}-booking-payment-heading">
|
||
<h2 id="${instanceId}-booking-payment-heading">Hinweis zum Buchungsablauf</h2>
|
||
<p class="booking__payment-notice"></p>
|
||
</aside>
|
||
<button class="booking__button booking__open" type="button" aria-haspopup="dialog" aria-controls="${instanceId}-dialog" aria-expanded="false">Jetzt buchen</button>
|
||
<dialog class="booking__dialog" id="${instanceId}-dialog" aria-labelledby="${instanceId}-dialog-heading" aria-describedby="${instanceId}-dialog-intro">
|
||
<button class="booking__close" type="button" aria-label="Buchungsdialog schließen">×</button>
|
||
<h2 id="${instanceId}-dialog-heading">Paket buchen</h2>
|
||
<p id="${instanceId}-dialog-intro">Bitte gib deine Kontaktdaten ein. Name und E-Mail-Adresse sind Pflichtfelder.</p>
|
||
<form novalidate>
|
||
<div class="booking__errors" role="alert" tabindex="-1" hidden></div>
|
||
<div class="booking__field">
|
||
<label for="${instanceId}-booking-name">Name (Pflichtfeld)</label>
|
||
<input id="${instanceId}-booking-name" name="name" type="text" autocomplete="name" required maxlength="120" aria-describedby="${instanceId}-booking-name-error" />
|
||
<p class="booking__field-error" id="${instanceId}-booking-name-error"></p>
|
||
</div>
|
||
<div class="booking__field">
|
||
<label for="${instanceId}-booking-email">E-Mail-Adresse (Pflichtfeld)</label>
|
||
<input id="${instanceId}-booking-email" name="email" type="email" autocomplete="email" inputmode="email" required maxlength="254" aria-describedby="${instanceId}-booking-email-hint ${instanceId}-booking-email-error" />
|
||
<p id="${instanceId}-booking-email-hint">An diese Adresse senden wir deine Bestätigung.</p>
|
||
<p class="booking__field-error" id="${instanceId}-booking-email-error"></p>
|
||
</div>
|
||
<div class="booking__field">
|
||
<label for="${instanceId}-booking-phone">Telefonnummer (optional)</label>
|
||
<input id="${instanceId}-booking-phone" name="phone" type="tel" autocomplete="tel" maxlength="50" aria-describedby="${instanceId}-booking-phone-error" />
|
||
<p class="booking__field-error" id="${instanceId}-booking-phone-error"></p>
|
||
</div>
|
||
<div class="booking__actions">
|
||
<button class="booking__button booking__cancel" type="button">Abbrechen</button>
|
||
<button class="booking__button" type="submit">Paket buchen</button>
|
||
</div>
|
||
<p class="booking__progress" role="status"></p>
|
||
</form>
|
||
<div class="booking__result" role="status" tabindex="-1" hidden></div>
|
||
</dialog>`;
|
||
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();
|
||
};
|
||
}
|