Compare commits

..

No commits in common. "main" and "feature/booking-component" have entirely different histories.

17 changed files with 229 additions and 504 deletions

View File

@ -4,28 +4,13 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Ernährungs-Pakete – Tri-Hub</title> <title>Ernährungs-Pakete – Tri-Hub</title>
<link rel="stylesheet" href="/src/styles/navigation.css" />
<link rel="stylesheet" href="/src/styles/packages.css" />
</head> </head>
<body class="packages-page"> <body>
<div id="navbar-placeholder"></div> <header class="container"><a href="/">Zur Startseite</a></header>
<main class="packages-section" id="main-content" tabindex="-1"> <main class="packages-section" id="angebotsuebersicht-section">
<div class="packages-header"> <div class="packages-header"><h1>Unsere Ernährungs-Pakete</h1></div>
<div>
<p class="packages-eyebrow">TRI-HUB · ERNÄHRUNG</p>
<h1>Unsere Ernährungs-Pakete</h1>
</div>
<p class="packages-intro">
Wähle das Paket, das zu deinem aktuellen Stand passt. Von
den Grundlagen bis zur Wettkampf-Vorbereitung.
</p>
</div>
<div class="packages-container" id="packages-container"></div> <div class="packages-container" id="packages-container"></div>
</main> </main>
<script
type="module"
src="/src/components/navigationsbar/navigation.js"
></script>
<script <script
type="module" type="module"
src="/src/features/angebote/angebote-entry.js" src="/src/features/angebote/angebote-entry.js"

View File

@ -1101,7 +1101,6 @@
></div> ></div>
</form> </form>
</div> </div>
>>>>>>>>> Temporary merge branch 2
</section> </section>
</main> </main>

7
package-lock.json generated
View File

@ -10,7 +10,6 @@
"dependencies": { "dependencies": {
"csv-parse": "^7.0.2", "csv-parse": "^7.0.2",
"csv-stringify": "^6.8.3", "csv-stringify": "^6.8.3",
"libphonenumber-js": "^1.13.14",
"nodemailer": "^10.0.10" "nodemailer": "^10.0.10"
}, },
"devDependencies": { "devDependencies": {
@ -983,12 +982,6 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/libphonenumber-js": {
"version": "1.13.14",
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.14.tgz",
"integrity": "sha512-llihgCcx0BFLksecLP+x1J+6JDE1GsXS1RN/LoPF6qcwpeQcnjj0lcvZxY8AzbEpYwyZWPZW/nDuqkqzm3amiw==",
"license": "MIT"
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.19", "version": "3.3.19",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",

View File

@ -26,7 +26,6 @@
"dependencies": { "dependencies": {
"csv-parse": "^7.0.2", "csv-parse": "^7.0.2",
"csv-stringify": "^6.8.3", "csv-stringify": "^6.8.3",
"libphonenumber-js": "^1.13.14",
"nodemailer": "^10.0.10" "nodemailer": "^10.0.10"
} }
} }

View File

@ -1,24 +1,29 @@
<!doctype html> <!doctype html>
<html lang="de"> <html lang="de" class="dark">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Paket-Details – Tri-Hub Ernährungsberatung</title> <title>Paket-Details – Tri-Hub Ernährungsberatung</title>
<link rel="stylesheet" href="/src/styles/navigation.css" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Playfair+Display:wght@600;700&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/src/styles/packages.css" /> <link rel="stylesheet" href="/src/styles/packages.css" />
</head> </head>
<body class="packages-page">
<div id="navbar-placeholder"></div> <body>
<main class="packages-section" id="main-content" tabindex="-1"> <header class="container">
<a class="packages-back" href="/angebotsuebersicht.html" <a href="angebotsuebersicht.html">← Zurück zur Angebotsübersicht</a>
>← Zurück zur Angebotsübersicht</a </header>
>
<div id="package-detail-container"></div> <main class="packages-section">
<div id="package-detail-container" class="detail-page-container">
<!-- Wird dynamisch durch paket-details.js gefüllt -->
</div>
</main> </main>
<script
type="module"
src="/src/components/navigationsbar/navigation.js"
></script>
<script <script
type="module" type="module"
src="/src/features/angebote/paket-details.js" src="/src/features/angebote/paket-details.js"

View File

@ -32,19 +32,31 @@
> >
</li> </li>
<li> <li>
<a href="/index.html" class="navbar__link">Ernährung</a> <a href="#ernaehrung" class="navbar__link">Ernährung</a>
</li> </li>
<li> <li>
<a href="/index.html" class="navbar__link">Beraterprofil</a> <a
href="/index.html"
class="navbar__link"
>Beraterprofil</a
>
</li> </li>
<li> <li>
<a href="/index.html" class="navbar__link">FAQ</a> <a
href="/index.html"
class="navbar__link"
>FAQ</a
>
</li> </li>
<li> <li>
<a href="#erfahrungen" class="navbar__link">Rezensionen</a> <a href="#erfahrungen" class="navbar__link">Rezensionen</a>
</li> </li>
<li> <li>
<a href="/index.html" class="navbar__link">Kontakt</a> <a
href="/index.html"
class="navbar__link"
>Kontakt</a
>
</li> </li>
<li> <li>
<a <a

View File

@ -1,3 +1,5 @@
import "../../styles/styles.css";
import "../../styles/packages.css";
import { renderAngebotsuebersicht } from "./angebotsuebersicht.js"; import { renderAngebotsuebersicht } from "./angebotsuebersicht.js";
renderAngebotsuebersicht(); renderAngebotsuebersicht();

View File

@ -23,7 +23,7 @@ export function renderAngebotsuebersicht() {
${highlightHtml} ${highlightHtml}
</div> </div>
<h2 class="package-title">${pkg.name}</h2> <h3 class="package-title">${pkg.name}</h3>
<p class="package-desc">${pkg.summary}</p> <p class="package-desc">${pkg.summary}</p>
<div class="package-details-box"> <div class="package-details-box">
@ -41,7 +41,7 @@ export function renderAngebotsuebersicht() {
</div> </div>
</div> </div>
<a href="/paket-details.html?id=${pkg.id}" class="package-btn">${pkg.buttonText} <span aria-hidden="true">→</span></a> <a href="paket-details.html?id=${pkg.id}" class="package-btn">${pkg.buttonText}</a>
</div> </div>
`; `;

View File

@ -1,158 +1,102 @@
import { packagesDetails } from "../../shared/packagesdetails.js"; import { packagesDetails } from "../../shared/packagesdetails.js";
import { mountBookingPage } from "../booking/booking-page.js";
import { formatMoney } from "../../shared/order-summary.js";
import { paymentNotice } from "../../shared/booking-copy.js";
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
const container = document.getElementById("package-detail-container"); const container = document.getElementById("package-detail-container");
if (!container) return; if (!container) return;
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const pkg = packagesDetails.find((item) => item.id === params.get("id")); const packageId = params.get("id");
const requestedVariant = params.get("variant");
const invalidVariant =
requestedVariant &&
!pkg?.variants?.some(
(variant) => variant.variantId === requestedVariant,
);
if (!pkg || invalidVariant) { const pkg = packagesDetails.find((item) => item.id === packageId);
if (!pkg) {
container.innerHTML = ` container.innerHTML = `
<div class="package-card"> <div class="package-card">
<h1>Paket nicht gefunden</h1> <h2>Paket nicht gefunden</h2>
<p>Bitte wähle ein gültiges Paket und eine passende Variante aus.</p> <p>Bitte wähle ein gültiges Angebot aus der Übersicht.</p>
<a href="/angebotsuebersicht.html" class="package-btn">Zur Übersicht</a> <a href="angebotsuebersicht.html" class="package-btn">Zur Übersicht</a>
</div>`; </div>
`;
return; return;
} }
document.title = `${pkg.type} – Tri-Hub Ernährungsberatung`; const variantsHtml = pkg.variants
const variantsHtml = pkg.variants?.length
? `<div class="detail-box"> ? `<div class="detail-box">
<label for="variant-select">Wähle deine Variante</label> <h3>Wähle deine Variante</h3>
<select id="variant-select" class="variant-select"> <select id="variant-select" class="variant-select">
${pkg.variants ${pkg.variants.map((v) => `<option value="${v.variantId}" data-price="${v.price}">${v.label} –${v.price.toFixed(2)} €</option>`).join("")}
.map( </select>
(variant) => ` </div>`
<option value="${variant.variantId}">
${variant.label} – ${formatMoney(variant.price, pkg.currency)}
</option>`,
)
.join("")}
</select>
</div>`
: ""; : "";
container.innerHTML = ` container.innerHTML = `
<header class="detail-heading"> <div class="package-card detail-view">
<div class="package-type-container"> <div class="package-type-container">
<span class="package-type">${pkg.type} · <span id="package-duration"></span></span> <span class="package-type">${pkg.type} · ${pkg.durationLabel}</span>
${pkg.badge ? `<span class="package-highlight">${pkg.badge}</span>` : ""} <span class="package-highlight">${pkg.badge}</span>
</div> </div>
<h1>${pkg.title}</h1>
<p class="package-desc">${pkg.subtitle}</p>
</header>
<div class="detail-grid">
<div class="detail-content">
<section aria-labelledby="target-heading">
<h2 id="target-heading">Für wen ist dieses Paket ideal?</h2>
<ul class="detail-list">
${pkg.targetGroup.map((item) => `<li>${item}</li>`).join("")}
</ul>
</section>
<section aria-labelledby="features-heading">
<h2 id="features-heading">Das ist im Paket enthalten</h2>
<ul class="detail-list">
${pkg.includedFeatures.map((feature) => `<li>${feature}</li>`).join("")}
</ul>
</section>
<section aria-labelledby="steps-heading">
<h2 id="steps-heading">So läuft deine Begleitung ab</h2>
${pkg.processSteps
.map(
(step) => `
<div class="process-step">
<strong>${step.step}</strong>
<p class="detail-text">${step.text}</p>
</div>`,
)
.join("")}
</section>
</div>
<section class="package-checkout" aria-labelledby="checkout-heading">
<h2 id="checkout-heading">Dein ${pkg.type.toLowerCase().replace(/^./, (letter) => letter.toUpperCase())}-Paket</h2>
<div class="price-box" aria-live="polite">
<span class="price-amount" id="displayed-price"></span>
<span class="price-note">inkl. ${pkg.taxRate} % MwSt. · ${pkg.billingInterval}</span>
<p id="selected-duration" class="price-note"></p>
</div>
${variantsHtml}
<button id="proceed-to-booking-btn" class="package-btn primary-cta" type="button" aria-haspopup="dialog" aria-expanded="false" disabled>
Buchungsformular wird geladen …
</button>
<p class="booking-notice" id="booking-notice"></p>
<div id="booking-root"></div>
</section>
</div>`;
container.querySelector("#booking-notice").textContent = paymentNotice; <h1 class="package-title">${pkg.title}</h1>
const variantSelect = container.querySelector("#variant-select"); <p class="package-desc">${pkg.subtitle}</p>
if (variantSelect && requestedVariant)
variantSelect.value = requestedVariant;
const bookingBtn = container.querySelector("#proceed-to-booking-btn");
const bookingRoot = container.querySelector("#booking-root");
let cleanupBooking;
let activeDialog;
function prepareBooking() { <div class="price-box">
cleanupBooking?.(); <span class="price-amount" id="displayed-price">${pkg.price.toFixed(2)} €</span>
activeDialog = null; <span class="price-note">inkl. ${pkg.taxRate}% MwSt. (${pkg.billingInterval})</span>
bookingBtn.disabled = true; </div>
bookingBtn.textContent = "Buchungsformular wird geladen …";
bookingBtn.setAttribute("aria-expanded", "false"); ${variantsHtml}
bookingBtn.removeAttribute("aria-controls");
const variant = pkg.variants?.find( <div class="package-details-box">
(item) => item.variantId === variantSelect?.value, <div class="detail-item">
); <h3 class="detail-title">Für wen ist dieses Paket ideal?</h3>
const duration = variant <ul class="detail-list">
? `${variant.durationWeeks} Wochen Begleitung` ${pkg.targetGroup.map((item) => `<li>${item}</li>`).join("")}
: pkg.durationLabel; </ul>
container.querySelector("#displayed-price").textContent = formatMoney( </div>
variant?.price ?? pkg.price,
pkg.currency, <div class="detail-item">
); <h3 class="detail-title">Das ist im Paket enthalten</h3>
container.querySelector("#package-duration").textContent = duration; <ul class="detail-list">
container.querySelector("#selected-duration").textContent = duration; ${pkg.includedFeatures.map((feat) => `<li>${feat}</li>`).join("")}
cleanupBooking = mountBookingPage(bookingRoot, { </ul>
packageId: pkg.id, </div>
variantId: variantSelect?.value,
<div class="detail-item">
<h3 class="detail-title">So läuft deine Begleitung ab</h3>
${pkg.processSteps
.map(
(s) => `
<div class="process-step">
<strong>${s.step}:</strong> <span class="detail-text">${s.text}</span>
</div>
`,
)
.join("")}
</div>
</div>
<button id="proceed-to-booking-btn" class="package-btn primary-cta">
${pkg.ctaButtonText}
</button>
</div>
`;
const variantSelect = document.getElementById("variant-select");
const displayedPrice = document.getElementById("displayed-price");
if (variantSelect && displayedPrice) {
variantSelect.addEventListener("change", (e) => {
const selectedOption = e.target.selectedOptions[0];
const newPrice = parseFloat(
selectedOption.getAttribute("data-price"),
);
displayedPrice.textContent = `${newPrice.toFixed(2)} €`;
}); });
} }
// Booking lädt die Paketvorschau asynchron, auch bei einem erneuten Versuch. const bookingBtn = document.getElementById("proceed-to-booking-btn");
const observer = new MutationObserver(() => {
const dialog = bookingRoot.querySelector(".booking__dialog");
if (!dialog || dialog === activeDialog) return;
activeDialog = dialog;
bookingBtn.disabled = false;
bookingBtn.textContent = "Jetzt buchen";
bookingBtn.setAttribute("aria-controls", dialog.id);
dialog.addEventListener("close", () => {
bookingBtn.setAttribute("aria-expanded", "false");
bookingBtn.focus();
});
});
observer.observe(bookingRoot, { childList: true, subtree: true });
prepareBooking();
variantSelect?.addEventListener("change", prepareBooking);
bookingBtn.addEventListener("click", () => { bookingBtn.addEventListener("click", () => {
if (!activeDialog || activeDialog.open) return; const params = new URLSearchParams({ id: pkg.id });
activeDialog.showModal(); if (variantSelect) params.set("variant", variantSelect.value);
bookingBtn.setAttribute("aria-expanded", "true"); window.location.href = `booking.html?${params}`;
const result = activeDialog.querySelector(
".booking__result:not([hidden])",
);
(result || activeDialog.querySelector('[name="name"]')).focus();
}); });
}); });

View File

@ -90,7 +90,7 @@ if (
if (body.emailStatus === "pending" && body.packageId) { if (body.emailStatus === "pending" && body.packageId) {
const params = new URLSearchParams({ id: body.packageId }); const params = new URLSearchParams({ id: body.packageId });
if (order?.variantId) params.set("variant", order.variantId); if (order?.variantId) params.set("variant", order.variantId);
select("confirmation-retry").href = `/paket-details.html?${params}`; select("confirmation-retry").href = `/booking.html?${params}`;
select("confirmation-retry").hidden = false; select("confirmation-retry").hidden = false;
} }
select("confirmation-empty").hidden = true; select("confirmation-empty").hidden = true;

View File

@ -1,7 +1,3 @@
import {
isValidBookingPhone,
phoneError,
} from "../../shared/phone-validation.js";
import { previewBooking, submitBooking } from "./booking-api.js"; import { previewBooking, submitBooking } from "./booking-api.js";
import "./booking.css"; import "./booking.css";
import { orderSummaryRows } from "../../shared/order-summary.js"; import { orderSummaryRows } from "../../shared/order-summary.js";
@ -288,8 +284,12 @@ export function mountBookingPage(
fields.email = fields.email =
"Bitte eine gültige E-Mail-Adresse eingeben."; "Bitte eine gültige E-Mail-Adresse eingeben.";
phone.value = phone.value.trim(); phone.value = phone.value.trim();
if (!isValidBookingPhone(phone.value)) if (
fields.phone = phoneError; phone.value.length > 50 ||
/[\x00-\x1f\x7f]/.test(phone.value)
)
fields.phone =
"Bitte höchstens 50 Zeichen ohne Zeilenumbrüche eingeben.";
if (Object.keys(fields).length) { if (Object.keys(fields).length) {
showError( showError(
"Bitte die markierten Angaben prüfen.", "Bitte die markierten Angaben prüfen.",

View File

@ -1,5 +1,4 @@
import "./styles/styles.css"; import "./styles/styles.css";
import "./components/navigationsbar/navigation.js"; import "./components/navigationsbar/navigation.js";
document.querySelector("#year").textContent = new Date().getFullYear(); document.querySelector("#year").textContent = new Date().getFullYear();

View File

@ -1,7 +1,3 @@
import {
isValidBookingPhone,
phoneError,
} from "../../shared/phone-validation.js";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { packagesDetails } from "../../shared/packagesdetails.js"; import { packagesDetails } from "../../shared/packagesdetails.js";
@ -116,12 +112,16 @@ function validateInput(input, key) {
) { ) {
fields.email = "Bitte eine gültige E-Mail-Adresse eingeben."; fields.email = "Bitte eine gültige E-Mail-Adresse eingeben.";
} }
const phone = customer ? customer.phone : input.phone; if (!customer && input.phone !== undefined) {
if (phone !== undefined) { if (
if (!isValidBookingPhone(phone)) { typeof input.phone !== "string" ||
fields.phone = phoneError; input.phone.length > 50 ||
} else if (!customer && phone.trim()) { /[\x00-\x1f\x7f]/.test(input.phone)
result.phone = phone.trim(); ) {
fields.phone =
"Bitte höchstens 50 Zeichen ohne Zeilenumbrüche eingeben.";
} else if (input.phone.trim()) {
result.phone = input.phone.trim();
} }
} }
if (Object.keys(fields).length) { if (Object.keys(fields).length) {

View File

@ -553,7 +553,7 @@
"phone": { "phone": {
"type": "string", "type": "string",
"maxLength": 50, "maxLength": 50,
"description": "Optionale Telefonnummer: deutsche Nummer mit Vorwahl oder internationale Nummer mit Ländervorwahl. Prüfung des Nummernformats, keine Bestätigung der Erreichbarkeit. Bei customer gilt customer.phone.", "description": "Optionale Telefonnummer bei Übergabe von name und email, ohne Steuerzeichen. Bei customer gilt customer.phone.",
"example": "+49 170 1234567" "example": "+49 170 1234567"
}, },
"customer": { "customer": {
@ -575,8 +575,6 @@
}, },
"phone": { "phone": {
"type": "string", "type": "string",
"maxLength": 50,
"description": "Optional. Deutsche Nummer mit Vorwahl oder internationale Nummer mit Ländervorwahl; keine Bestätigung der Erreichbarkeit.",
"example": "+49 170 1234567" "example": "+49 170 1234567"
}, },
"ageGroup": { "ageGroup": {

View File

@ -1,14 +0,0 @@
import { isValidPhoneNumber } from "libphonenumber-js/max";
export const phoneError =
"Bitte eine gültige Telefonnummer mit Vorwahl eingeben, z. B. 0170 1234567 oder +49 170 1234567.";
export function isValidBookingPhone(value) {
if (typeof value !== "string" || value.length > 50) return false;
if (/[\x00-\x1f\x7f]/.test(value)) return false;
const phone = value.trim();
if (!phone) return true;
if (!/^\+?[0-9 ()/.-]+$/.test(phone)) return false;
if (!/^(?:\+|0)/.test(phone)) return false;
return isValidPhoneNumber(phone, "DE");
}

View File

@ -1,279 +1,123 @@
.packages-page { :root {
margin: 0; --bg-dark: #0d0d1a;
min-height: 100vh; --bg-card: #1a1a2e;
color: #f8fafc; --accent-teal: #10b981;
background: --text-white: #ffffff;
radial-gradient(circle at 12% 0%, #10b98117, transparent 30%), --text-lightgray: #f8fafc;
radial-gradient(circle at 88% 26%, #10b9810e, transparent 27%), --text-muted: #94a3b8;
linear-gradient(145deg, #0d0d1a, #1a1a2e);
font-family: Inter, system-ui, sans-serif; --font-display: "Playfair Display", serif;
line-height: 1.6; --font-body: "Inter", system-ui, sans-serif;
color-scheme: dark;
}
.packages-section,
.packages-section * {
box-sizing: border-box;
} }
.packages-section { .packages-section {
width: min(1180px, calc(100% - 3rem)); background-color: var(--bg-dark);
margin: auto; padding: 4rem 2rem;
padding-block: 5rem; font-family: var(--font-body);
overflow-wrap: anywhere; color: var(--text-lightgray);
}
.packages-header {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
gap: 3rem;
align-items: end;
margin-bottom: 3rem;
}
.packages-eyebrow,
.package-type {
color: #10b981;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.packages-header h1,
.detail-heading h1 {
margin: 0.75rem 0 1rem;
font-size: clamp(2rem, 4.5vw, 3.75rem);
font-weight: 600;
line-height: 1.15;
letter-spacing: -0.035em;
}
.packages-intro,
.package-desc,
.detail-text,
.detail-list,
.price-note,
.booking-notice {
color: #94a3b8;
}
.packages-intro {
margin: 0;
max-width: 40rem;
} }
.packages-container { .packages-container {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr));
gap: 1.5rem; gap: 2rem;
} max-width: 1200px;
.package-card, margin: 0 auto;
.package-checkout {
min-width: 0;
padding: 1.75rem;
border: 1px solid #f8fafc1f;
border-radius: 1.4rem;
background: #ffffff0b;
} }
.package-card { .package-card {
background-color: var(--bg-card);
border-radius: 12px;
padding: 2rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
position: relative;
border: 1px solid rgba(255, 255, 255, 0.05);
} }
.package-type-container { .package-type-container {
display: flex; display: flex;
flex-wrap: wrap; justify-content: space-between;
align-items: center; align-items: center;
gap: 0.75rem;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.package-type {
color: var(--text-muted);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.1em;
}
.package-highlight { .package-highlight {
border-radius: 999px; background-color: rgba(16, 185, 129, 0.1);
padding: 0.25rem 0.65rem; color: var(--accent-teal);
background: #10b98114; font-size: 0.7rem;
color: #34d399; padding: 0.25rem 0.75rem;
border: 1px solid #10b98133; border-radius: 9999px;
font-size: 0.65rem;
font-weight: 600; font-weight: 600;
letter-spacing: 0.05em; text-transform: uppercase;
} }
.package-title { .package-title {
margin: 0 0 1rem; font-family: var(--font-display);
color: #fff; font-size: 1.75rem;
font-size: 1.55rem; color: var(--text-white);
font-weight: 600; margin-bottom: 1rem;
line-height: 1.25; line-height: 1.2;
letter-spacing: -0.025em;
} }
.package-desc { .package-desc {
margin: 0 0 1.5rem; color: var(--text-lightgray);
} font-size: 0.95rem;
.package-details-box { line-height: 1.5;
padding-block: 1.5rem;
border-top: 1px solid #f8fafc1f;
flex: 1;
}
.detail-item + .detail-item {
margin-top: 1.25rem;
}
.detail-title {
margin: 0 0 0.5rem;
color: #f8fafc;
font-size: 1rem;
font-weight: 600;
}
.detail-text {
font-size: 0.9rem;
}
.package-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
width: 100%;
min-height: 3rem;
padding: 0.8rem 1.1rem;
border: 1px solid #94a3b840;
border-radius: 999px;
background: #ffffff06;
color: #f8fafc;
font: inherit;
font-size: 0.9rem;
font-weight: 600;
text-align: center;
text-decoration: none;
cursor: pointer;
}
.package-btn:hover {
border-color: #10b981;
background: #10b98114;
}
.package-btn.primary-cta {
background: #10b981;
border-color: #10b981;
color: #06130f;
}
.package-btn.primary-cta:hover {
background: #34d399;
}
.package-btn:disabled {
opacity: 0.65;
cursor: wait;
}
.packages-section :focus-visible {
outline: 3px solid #10b981;
outline-offset: 4px;
}
.packages-back {
display: inline-block;
margin-bottom: 2rem; margin-bottom: 2rem;
padding-block: 0.5rem;
color: #34d399;
text-decoration: none;
} }
.detail-heading {
max-width: 52rem; .package-details-box {
margin-bottom: 3rem; background-color: rgba(255, 255, 255, 0.03);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 2rem;
flex-grow: 1; /* Schiebt den Button immer ganz nach unten */
} }
.detail-grid {
display: grid; .detail-item {
grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr); margin-bottom: 1.25rem;
gap: 2.5rem;
align-items: start;
} }
.detail-content > section + section {
margin-top: 2.5rem; .detail-item:last-child {
margin-bottom: 0;
} }
.detail-content h2,
.package-checkout h2 { .detail-title {
margin: 0 0 1rem; color: var(--text-white);
font-size: 1.25rem;
}
.detail-list {
padding-left: 1.25rem;
}
.detail-list li {
margin-block: 0.65rem;
}
.detail-list li::marker {
color: #10b981;
}
.process-step {
margin-top: 1rem;
padding: 1.25rem;
border: 1px solid #f8fafc1f;
border-radius: 1rem;
background: #ffffff06;
}
.process-step strong {
display: block;
margin-bottom: 0.4rem;
}
.process-step p {
margin: 0;
}
.price-box {
margin-bottom: 1.5rem;
}
.price-amount {
display: block;
font-size: clamp(2rem, 4vw, 3rem);
font-weight: 600; font-weight: 600;
letter-spacing: -0.04em; font-size: 0.9rem;
color: #34d399;
}
.price-note,
.booking-notice {
font-size: 0.85rem;
}
.detail-box {
margin-bottom: 1.5rem;
}
.detail-box label {
display: block;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.variant-select {
width: 100%; .detail-text {
max-width: 100%; color: var(--text-muted);
min-width: 0; font-size: 0.85rem;
min-height: 3rem; line-height: 1.5;
padding: 0.65rem;
border: 1px solid #94a3b866;
border-radius: 0.75rem;
color: #f8fafc;
background: #0d0d1a;
font: inherit;
} }
/* Die Detailseite zeigt ihre eigene Übersicht; Booking liefert den Dialog. */
.packages-section #booking-root > .booking > h2, .package-btn {
.packages-section #booking-root .booking__package, display: block;
.packages-section #booking-root .booking__payment, text-align: center;
.packages-section #booking-root .booking__open { background-color: transparent;
display: none; color: var(--text-white);
border: 1px solid rgba(255, 255, 255, 0.2);
padding: 1rem;
border-radius: 8px;
text-decoration: none;
font-weight: 500;
transition: all 0.2s ease;
} }
.packages-section .booking__dialog {
background: #0d0d1a; .package-btn:hover {
color: #f8fafc; background-color: rgba(255, 255, 255, 0.05);
border-color: #94a3b840; border-color: var(--accent-teal);
} color: var(--accent-teal);
.packages-section .booking__dialog .booking__button:not(.booking__cancel) {
background: #10b981;
border-color: #10b981;
color: #06130f;
}
.packages-section .booking__dialog :focus-visible {
outline-color: #10b981;
}
@media (max-width: 60rem) {
.packages-container {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
}
.packages-header,
.detail-grid {
grid-template-columns: minmax(0, 1fr);
gap: 1.5rem;
}
}
@media (max-width: 30rem) {
.packages-section {
width: calc(100% - 2rem);
padding-block: 2.5rem;
}
.package-card,
.package-checkout {
padding: 1.25rem;
}
} }

View File

@ -619,44 +619,3 @@ test("Swagger-Kundendaten und Zustimmung werden validiert und dauerhaft gespeich
409, 409,
); );
}); });
test("Telefonnummern werden in beiden Anfrageformaten geprüft", async (t) => {
const { post, sent } = await setup(t);
for (const phone of [
"123",
"00000000000",
"abc",
"+999 123456789",
"0170 test 1234567",
]) {
for (const data of [
{ ...input, phone },
{
packageId: input.packageId,
acceptedTerms: true,
customer: {
firstName: "Test",
lastName: "Person",
email: input.email,
phone,
},
},
]) {
const result = await post(data);
assert.equal(result.status, 400, phone);
assert.ok(result.body.error.fields.phone);
}
}
assert.equal(sent.length, 0);
for (const phone of [
"",
"0170 1234567",
"+49 170 1234567",
"0049 170 1234567",
"030 123456",
"+44 20 7946 0958",
]) {
const result = await post({ ...input, phone });
assert.equal(result.status, 201, phone);
}
});