paket seiten angepasst und booking mit telefonvalidierung integriert

feature/angebote-booking-integration
Marvin Gemlin 2026-09-28 22:07:11 +00:00
parent 29ef00c6c3
commit 8fb2044a81
14 changed files with 520 additions and 825 deletions

View File

@ -1,285 +1,34 @@
<!doctype html> <!doctype html>
<html lang="de"> <html lang="de">
<head>
<head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="stylesheet" href="/src/styles/navigation.css" />
<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="preconnect" href="https://fonts.googleapis.com" /> <link rel="stylesheet" href="/src/styles/packages.css" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> </head>
<link <body class="packages-page">
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@500;600;700&display=swap"
rel="stylesheet" />
<style>
:root {
--page-bg: #0d0d1a;
--page-bg-2: #1a1a2e;
--green: #10b981;
--green-dark: #059669;
--white: #ffffff;
--light: #f8fafc;
--muted: #94a3b8;
--line: rgba(248, 250, 252, 0.12);
--panel: rgba(255, 255, 255, 0.045);
--panel-strong: rgba(255, 255, 255, 0.065);
--max: 1180px;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
min-width: 320px;
color: var(--light);
background:
radial-gradient(circle at 12% 0%, rgba(16, 185, 129, 0.09), transparent 30%),
radial-gradient(circle at 88% 26%, rgba(16, 185, 129, 0.055), transparent 27%),
linear-gradient(145deg, var(--page-bg), var(--page-bg-2));
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
line-height: 1.6;
}
a {
color: inherit;
text-decoration: none;
}
h1,
h2,
h3 {
margin: 0;
font-family: "Playfair Display", Georgia, sans-serif;
font-weight: 600;
letter-spacing: -0.025em;
line-height: 1.05;
}
p {
margin: 0;
}
.container {
width: min(var(--max), calc(100% - 40px));
margin: 0 auto;
}
.section {
padding: 96px 0;
}
.section-heading {
display: grid;
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
gap: 70px;
align-items: end;
margin-bottom: 48px;
}
.section-heading h1 {
font-size: clamp(2.4rem, 4.8vw, 4.5rem);
}
.section-intro {
max-width: 650px;
color: var(--muted);
line-height: 1.85;
}
.eyebrow {
margin-bottom: 18px;
color: var(--green);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
}
/* Package Cards matching the .topic design */
.packages-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr));
gap: 24px;
}
.package-card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 22px;
padding: 30px;
display: flex;
flex-direction: column;
transition: transform 0.2s ease, border-color 0.2s ease, background 0.2s ease;
}
.package-card:hover {
transform: translateY(-4px);
border-color: rgba(16, 185, 129, 0.34);
background: var(--panel-strong);
}
.package-type {
color: var(--green);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
margin-bottom: 12px;
}
.package-badge {
display: inline-block;
background: rgba(16, 185, 129, 0.08);
border: 1px solid rgba(16, 185, 129, 0.2);
color: var(--green);
font-size: 0.7rem;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-weight: 600;
text-transform: uppercase;
margin-bottom: 1rem;
width: fit-content;
}
.package-title {
font-size: 1.8rem;
margin-bottom: 0.75rem;
color: var(--white);
}
.package-desc {
color: var(--muted);
font-size: 0.95rem;
line-height: 1.6;
margin-bottom: 2rem;
flex-grow: 1;
}
.package-features {
margin-bottom: 2rem;
padding-top: 1.5rem;
border-top: 1px solid var(--line);
}
.feature-group {
margin-bottom: 1rem;
display: grid;
grid-template-columns: 100px 1fr;
gap: 12px;
}
.feature-group:last-child {
margin-bottom: 0;
}
.feature-group-title {
color: var(--white);
font-weight: 600;
font-size: 0.85rem;
}
.feature-group-text {
color: var(--muted);
font-size: 0.85rem;
line-height: 1.5;
}
/* Standardized Buttons */
.button {
display: inline-flex;
min-height: 48px;
align-items: center;
justify-content: center;
padding: 0 20px;
border-radius: 999px;
border: 1px solid transparent;
font-size: 0.9rem;
font-weight: 700;
transition: transform 0.2s ease, background 0.2s ease, border-color 0.2s ease;
cursor: pointer;
text-decoration: none;
}
.button:hover {
transform: translateY(-2px);
}
.button-secondary {
color: var(--light);
border-color: rgba(148, 163, 184, 0.25);
background: rgba(255, 255, 255, 0.025);
width: 100%;
}
.button-secondary:hover {
border-color: rgba(16, 185, 129, 0.55);
background: rgba(16, 185, 129, 0.05);
}
@media (max-width: 900px) {
.section-heading {
grid-template-columns: 1fr;
gap: 22px;
}
.packages-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 600px) {
.container {
width: min(var(--max), calc(100% - 28px));
}
.section {
padding: 68px 0;
}
.package-card {
padding: 24px;
}
.feature-group {
grid-template-columns: 1fr;
gap: 4px;
margin-bottom: 1.2rem;
}
}
</style>
</head>
<body>
<div id="navbar-placeholder"></div> <div id="navbar-placeholder"></div>
<main class="packages-section" id="main-content" tabindex="-1">
<main> <div class="packages-header">
<section class="section">
<div class="container">
<div class="section-heading">
<div> <div>
<div class="eyebrow">TRI-HUB · ERNÄHRUNG</div> <p class="packages-eyebrow">TRI-HUB · ERNÄHRUNG</p>
<h1>Unsere Ernährungs-Pakete</h1> <h1>Unsere Ernährungs-Pakete</h1>
</div> </div>
<p class="section-intro"> <p class="packages-intro">
Wähle das Paket, das zu deinem aktuellen Stand passt. Von den Grundlagen bis zur Wettkampf-Vorbereitung. Wähle das Paket, das zu deinem aktuellen Stand passt. Von
den Grundlagen bis zur Wettkampf-Vorbereitung.
</p> </p>
</div> </div>
<div class="packages-container" id="packages-container"></div>
<div class="packages-grid" id="packages-container"></div>
</div>
</section>
</main> </main>
<script
<script type="module" src="/src/components/navigationsbar/navigation.js"></script> type="module"
<script type="module" src="/src/features/angebote/angebote-entry.js"></script> src="/src/components/navigationsbar/navigation.js"
</body> ></script>
<script
type="module"
src="/src/features/angebote/angebote-entry.js"
></script>
</body>
</html> </html>

7
package-lock.json generated
View File

@ -10,6 +10,7 @@
"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": {
@ -982,6 +983,12 @@
"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,6 +26,7 @@
"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,372 +1,27 @@
<!doctype html> <!doctype html>
<html lang="de"> <html lang="de">
<head>
<head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="stylesheet" href="/src/styles/navigation.css" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex" />
<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="stylesheet" href="/src/styles/packages.css" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> </head>
<link <body class="packages-page">
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@500;600;700&display=swap"
rel="stylesheet" />
<style>
:root {
--page-bg: #0d0d1a;
--page-bg-2: #1a1a2e;
--green: #10b981;
--green-dark: #059669;
--white: #ffffff;
--light: #f8fafc;
--muted: #94a3b8;
--line: rgba(248, 250, 252, 0.12);
--panel: rgba(255, 255, 255, 0.045);
--panel-strong: rgba(255, 255, 255, 0.065);
--max: 1180px;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
min-width: 320px;
color: var(--light);
background:
radial-gradient(circle at 12% 0%, rgba(16, 185, 129, 0.09), transparent 30%),
radial-gradient(circle at 88% 26%, rgba(16, 185, 129, 0.055), transparent 27%),
linear-gradient(145deg, var(--page-bg), var(--page-bg-2));
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
line-height: 1.6;
}
a {
color: inherit;
text-decoration: none;
}
h1,
h2,
h3 {
margin: 0;
font-family: "Playfair Display", Georgia, sans-serif;
font-weight: 600;
letter-spacing: -0.025em;
line-height: 1.05;
}
p {
margin: 0;
}
.container {
width: min(var(--max), calc(100% - 40px));
margin: 0 auto;
}
.section {
padding: 96px 0;
}
.breadcrumb {
padding: 2rem 0 1rem;
color: var(--muted);
font-size: 0.9rem;
}
.breadcrumb a {
color: var(--green);
transition: color 0.2s ease;
font-weight: 500;
}
.breadcrumb a:hover {
color: var(--white);
}
.detail-header {
padding: 2rem 0 3rem;
border-bottom: 1px solid var(--line);
margin-bottom: 3rem;
}
.detail-badge {
display: inline-block;
color: var(--green);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
margin-bottom: 1.5rem;
}
.detail-header h1 {
font-size: clamp(2.4rem, 4.8vw, 4.5rem);
margin-bottom: 1.2rem;
}
.detail-subtitle {
color: var(--muted);
font-size: 1.15rem;
line-height: 1.8;
max-width: 700px;
}
.detail-grid {
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: 5rem;
}
.detail-main {
max-width: 700px;
}
.price-box {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 22px;
padding: 2.5rem;
margin-bottom: 2.5rem;
}
.price-amount {
display: block;
font-family: "Playfair Display", Georgia, sans-serif;
font-size: 3.5rem;
font-weight: 700;
color: var(--green);
margin-bottom: 0.5rem;
line-height: 1;
}
.price-note {
display: block;
color: var(--muted);
font-size: 0.9rem;
}
.detail-section {
margin-bottom: 3.5rem;
}
.detail-section h2 {
font-family: "Inter", sans-serif;
font-size: 1.3rem;
margin-bottom: 1.5rem;
color: var(--white);
}
.detail-list {
list-style: none;
padding: 0;
margin: 0;
}
.detail-list li {
padding-left: 2rem;
margin-bottom: 1.2rem;
position: relative;
color: var(--muted);
line-height: 1.7;
font-size: 1rem;
}
.detail-list li:before {
content: "";
position: absolute;
left: 0;
top: 8px;
width: 8px;
height: 8px;
background: var(--green);
border-radius: 50%;
box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.1);
}
.process-step {
margin-bottom: 1.5rem;
padding: 1.5rem;
border: 1px solid var(--line);
border-radius: 16px;
background: var(--panel);
}
.process-step strong {
display: block;
color: var(--white);
font-weight: 600;
font-size: 1.05rem;
margin-bottom: 0.5rem;
}
.process-step p {
color: var(--muted);
font-size: 0.95rem;
line-height: 1.6;
}
.detail-sidebar {
display: flex;
flex-direction: column;
gap: 2rem;
}
.variant-box {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 16px;
padding: 2rem;
}
.variant-box label {
display: block;
color: var(--white);
font-weight: 600;
font-size: 0.9rem;
margin-bottom: 0.75rem;
}
/* Select matches the Input styles from ernaehrung.html */
.variant-box select {
width: 100%;
height: 48px;
padding: 0 14px;
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 12px;
outline: 0;
color: var(--white);
background: rgba(13, 13, 26, 0.78);
font: inherit;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
cursor: pointer;
}
.variant-box select:focus {
border-color: rgba(16, 185, 129, 0.75);
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.1);
}
.button {
display: inline-flex;
min-height: 48px;
align-items: center;
justify-content: center;
padding: 0 24px;
border-radius: 999px;
border: 1px solid transparent;
font-size: 0.95rem;
font-weight: 700;
cursor: pointer;
text-decoration: none;
transition: transform 0.2s ease, background 0.2s ease, border-color 0.2s ease;
}
.button:hover {
transform: translateY(-2px);
}
.button-primary {
color: #06130f;
background: var(--green);
box-shadow: 0 14px 32px rgba(16, 185, 129, 0.18);
}
.button-primary:hover {
background: #34d399;
}
.button-secondary {
color: var(--light);
border-color: rgba(148, 163, 184, 0.25);
background: rgba(255, 255, 255, 0.025);
}
.button-secondary:hover {
border-color: rgba(16, 185, 129, 0.55);
background: rgba(16, 185, 129, 0.08);
}
.button-full {
width: 100%;
min-height: 54px;
font-size: 1rem;
}
@media (max-width: 900px) {
.detail-grid {
grid-template-columns: 1fr;
gap: 3rem;
}
.detail-header h1 {
font-size: 2.8rem;
}
.section {
padding: 64px 0;
}
}
@media (max-width: 600px) {
.container {
width: min(var(--max), calc(100% - 28px));
}
.section {
padding: 48px 0;
}
.detail-header {
padding: 1rem 0 2rem;
margin-bottom: 2rem;
}
.detail-header h1 {
font-size: 2.2rem;
}
.detail-subtitle {
font-size: 1rem;
}
.price-amount {
font-size: 2.8rem;
}
.price-box {
padding: 1.5rem;
}
}
</style>
</head>
<body>
<div id="navbar-placeholder"></div> <div id="navbar-placeholder"></div>
<main class="packages-section" id="main-content" tabindex="-1">
<main id="main-content"> <a class="packages-back" href="/angebotsuebersicht.html"
<section class="section"> >← Zurück zur Angebotsübersicht</a
<div class="container"> >
<div class="breadcrumb">
<a href="/angebotsuebersicht.html">← Zurück zur Übersicht</a>
</div>
<div id="package-detail-container"></div> <div id="package-detail-container"></div>
</div>
</section>
</main> </main>
<script
<script type="module" src="/src/components/navigationsbar/navigation.js"></script> type="module"
<script type="module" src="/src/features/angebote/paket-details.js"></script> src="/src/components/navigationsbar/navigation.js"
</body> ></script>
<script
type="module"
src="/src/features/angebote/paket-details.js"
></script>
</body>
</html> </html>

View File

@ -1,5 +1,3 @@
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>
<h3 class="package-title">${pkg.name}</h3> <h2 class="package-title">${pkg.name}</h2>
<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}</a> <a href="/paket-details.html?id=${pkg.id}" class="package-btn">${pkg.buttonText} <span aria-hidden="true">→</span></a>
</div> </div>
`; `;

View File

@ -1,199 +1,158 @@
import { packagesDetails } from "../../shared/packagesdetails.js"; import { packagesDetails } from "../../shared/packagesdetails.js";
import { mountBookingPage } from "../booking/booking-page.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 packageId = params.get("id"); const pkg = packagesDetails.find((item) => item.id === params.get("id"));
const requestedVariant = params.get("variant");
const invalidVariant =
requestedVariant &&
!pkg?.variants?.some(
(variant) => variant.variantId === requestedVariant,
);
const pkg = packagesDetails.find((item) => item.id === packageId); if (!pkg || invalidVariant) {
if (!pkg) {
container.innerHTML = ` container.innerHTML = `
<div class="package-card"> <div class="package-card">
<h2>Paket nicht gefunden</h2> <h1>Paket nicht gefunden</h1>
<p>Bitte wähle ein gültiges Angebot aus der Übersicht.</p> <p>Bitte wähle ein gültiges Paket und eine passende Variante aus.</p>
<a href="angebotsuebersicht.html" class="package-btn"> <a href="/angebotsuebersicht.html" class="package-btn">Zur Übersicht</a>
Zur Übersicht </div>`;
</a>
</div>
`;
return; return;
} }
document.title = `${pkg.type} – Tri-Hub Ernährungsberatung`;
const variantsHtml = pkg.variants?.length 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"
aria-label="Paketvariante"
>
${pkg.variants ${pkg.variants
.map( .map(
(variant) => ` (variant) => `
<option <option value="${variant.variantId}">
value="${variant.variantId}" ${variant.label} – ${formatMoney(variant.price, pkg.currency)}
data-price="${variant.price}" </option>`,
>
${variant.label} – ${variant.price.toFixed(2)} €
</option>
`,
) )
.join("")} .join("")}
</select> </select>
</div> </div>`
`
: ""; : "";
container.innerHTML = ` container.innerHTML = `
<div class="package-card detail-view"> <header class="detail-heading">
<div class="package-type-container"> <div class="package-type-container">
<span class="package-type"> <span class="package-type">${pkg.type} · <span id="package-duration"></span></span>
${pkg.type} · ${pkg.durationLabel} ${pkg.badge ? `<span class="package-highlight">${pkg.badge}</span>` : ""}
</span>
${
pkg.badge
? `<span class="package-highlight">${pkg.badge}</span>`
: ""
}
</div> </div>
<h1>${pkg.title}</h1>
<h1 class="package-title">${pkg.title}</h1>
<p class="package-desc">${pkg.subtitle}</p> <p class="package-desc">${pkg.subtitle}</p>
</header>
<div class="price-box"> <div class="detail-grid">
<span class="price-amount" id="displayed-price"> <div class="detail-content">
${pkg.price.toFixed(2)} € <section aria-labelledby="target-heading">
</span> <h2 id="target-heading">Für wen ist dieses Paket ideal?</h2>
<span class="price-note">
inkl. ${pkg.taxRate}% MwSt. (${pkg.billingInterval})
</span>
</div>
${variantsHtml}
<div class="package-details-box">
<div class="detail-item">
<h3 class="detail-title">
Für wen ist dieses Paket ideal?
</h3>
<ul class="detail-list"> <ul class="detail-list">
${pkg.targetGroup ${pkg.targetGroup.map((item) => `<li>${item}</li>`).join("")}
.map((item) => `<li>${item}</li>`)
.join("")}
</ul> </ul>
</div> </section>
<section aria-labelledby="features-heading">
<div class="detail-item"> <h2 id="features-heading">Das ist im Paket enthalten</h2>
<h3 class="detail-title">
Das ist im Paket enthalten
</h3>
<ul class="detail-list"> <ul class="detail-list">
${pkg.includedFeatures ${pkg.includedFeatures.map((feature) => `<li>${feature}</li>`).join("")}
.map((feature) => `<li>${feature}</li>`)
.join("")}
</ul> </ul>
</div> </section>
<section aria-labelledby="steps-heading">
<div class="detail-item"> <h2 id="steps-heading">So läuft deine Begleitung ab</h2>
<h3 class="detail-title">
So läuft deine Begleitung ab
</h3>
${pkg.processSteps ${pkg.processSteps
.map( .map(
(step) => ` (step) => `
<div class="process-step"> <div class="process-step">
<strong>${step.step}:</strong> <strong>${step.step}</strong>
<span class="detail-text"> <p class="detail-text">${step.text}</p>
${step.text} </div>`,
</span>
</div>
`,
) )
.join("")} .join("")}
</section>
</div> </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> </div>
${variantsHtml}
<button <button id="proceed-to-booking-btn" class="package-btn primary-cta" type="button" aria-haspopup="dialog" aria-expanded="false" disabled>
id="proceed-to-booking-btn" Buchungsformular wird geladen …
class="package-btn primary-cta"
type="button"
aria-haspopup="dialog"
>
Jetzt buchen
</button> </button>
<p class="booking-notice" id="booking-notice"></p>
<p>
Dieser Buchungsablauf ist eine Simulation. Es erfolgt keine
Abbuchung und keine verbindliche Beratungsbuchung.
</p>
<p id="booking-status" role="status"></p>
<div id="booking-root"></div> <div id="booking-root"></div>
</div> </section>
`; </div>`;
const variantSelect = document.getElementById("variant-select");
const displayedPrice = document.getElementById("displayed-price");
const bookingBtn = document.getElementById("proceed-to-booking-btn");
const bookingRoot = document.getElementById("booking-root");
const bookingStatus = document.getElementById("booking-status");
container.querySelector("#booking-notice").textContent = paymentNotice;
const variantSelect = container.querySelector("#variant-select");
if (variantSelect && requestedVariant)
variantSelect.value = requestedVariant;
const bookingBtn = container.querySelector("#proceed-to-booking-btn");
const bookingRoot = container.querySelector("#booking-root");
let cleanupBooking; let cleanupBooking;
let activeDialog;
function prepareBooking() { function prepareBooking() {
cleanupBooking?.(); cleanupBooking?.();
bookingStatus.textContent = ""; activeDialog = null;
bookingBtn.disabled = true;
bookingBtn.textContent = "Buchungsformular wird geladen …";
bookingBtn.setAttribute("aria-expanded", "false");
bookingBtn.removeAttribute("aria-controls");
const variant = pkg.variants?.find(
(item) => item.variantId === variantSelect?.value,
);
const duration = variant
? `${variant.durationWeeks} Wochen Begleitung`
: pkg.durationLabel;
container.querySelector("#displayed-price").textContent = formatMoney(
variant?.price ?? pkg.price,
pkg.currency,
);
container.querySelector("#package-duration").textContent = duration;
container.querySelector("#selected-duration").textContent = duration;
cleanupBooking = mountBookingPage(bookingRoot, { cleanupBooking = mountBookingPage(bookingRoot, {
packageId: pkg.id, packageId: pkg.id,
variantId: variantSelect?.value, variantId: variantSelect?.value,
}); });
} }
function updatePrice() { // Booking lädt die Paketvorschau asynchron, auch bei einem erneuten Versuch.
if (!variantSelect) return; const observer = new MutationObserver(() => {
const dialog = bookingRoot.querySelector(".booking__dialog");
const selectedOption = variantSelect.selectedOptions[0]; if (!dialog || dialog === activeDialog) return;
const price = Number(selectedOption.dataset.price); activeDialog = dialog;
bookingBtn.disabled = false;
displayedPrice.textContent = `${price.toFixed(2)} €`; bookingBtn.textContent = "Jetzt buchen";
} bookingBtn.setAttribute("aria-controls", dialog.id);
dialog.addEventListener("close", () => {
updatePrice(); bookingBtn.setAttribute("aria-expanded", "false");
prepareBooking(); bookingBtn.focus();
variantSelect?.addEventListener("change", () => {
updatePrice();
prepareBooking();
}); });
});
observer.observe(bookingRoot, { childList: true, subtree: true });
prepareBooking();
variantSelect?.addEventListener("change", prepareBooking);
bookingBtn.addEventListener("click", () => { bookingBtn.addEventListener("click", () => {
const dialog = bookingRoot.querySelector(".booking__dialog"); if (!activeDialog || activeDialog.open) return;
activeDialog.showModal();
if (!dialog) { bookingBtn.setAttribute("aria-expanded", "true");
bookingStatus.textContent = const result = activeDialog.querySelector(
"Das Buchungsformular ist noch nicht verfügbar. Bitte beachte den Ladehinweis im Buchungsbereich."; ".booking__result:not([hidden])",
return;
}
if (dialog.open) return;
bookingStatus.textContent = "";
dialog.addEventListener(
"close",
() => {
bookingBtn.focus();
},
{ once: true },
); );
(result || activeDialog.querySelector('[name="name"]')).focus();
dialog.showModal();
dialog.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 = `/booking.html?${params}`; select("confirmation-retry").href = `/paket-details.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,3 +1,7 @@
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";
@ -284,12 +288,8 @@ 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 ( if (!isValidBookingPhone(phone.value))
phone.value.length > 50 || fields.phone = phoneError;
/[\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,3 +1,7 @@
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";
@ -112,16 +116,12 @@ function validateInput(input, key) {
) { ) {
fields.email = "Bitte eine gültige E-Mail-Adresse eingeben."; fields.email = "Bitte eine gültige E-Mail-Adresse eingeben.";
} }
if (!customer && input.phone !== undefined) { const phone = customer ? customer.phone : input.phone;
if ( if (phone !== undefined) {
typeof input.phone !== "string" || if (!isValidBookingPhone(phone)) {
input.phone.length > 50 || fields.phone = phoneError;
/[\x00-\x1f\x7f]/.test(input.phone) } else if (!customer && phone.trim()) {
) { 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 bei Übergabe von name und email, ohne Steuerzeichen. Bei customer gilt customer.phone.", "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.",
"example": "+49 170 1234567" "example": "+49 170 1234567"
}, },
"customer": { "customer": {
@ -575,6 +575,8 @@
}, },
"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

@ -0,0 +1,14 @@
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,10 +1,279 @@
#booking-root .booking__open { .packages-page {
margin: 0;
min-height: 100vh;
color: #f8fafc;
background:
radial-gradient(circle at 12% 0%, #10b98117, transparent 30%),
radial-gradient(circle at 88% 26%, #10b9810e, transparent 27%),
linear-gradient(145deg, #0d0d1a, #1a1a2e);
font-family: Inter, system-ui, sans-serif;
line-height: 1.6;
color-scheme: dark;
}
.packages-section,
.packages-section * {
box-sizing: border-box;
}
.packages-section {
width: min(1180px, calc(100% - 3rem));
margin: auto;
padding-block: 5rem;
overflow-wrap: anywhere;
}
.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 {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1.5rem;
}
.package-card,
.package-checkout {
min-width: 0;
padding: 1.75rem;
border: 1px solid #f8fafc1f;
border-radius: 1.4rem;
background: #ffffff0b;
}
.package-card {
display: flex;
flex-direction: column;
}
.package-type-container {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
.package-highlight {
border-radius: 999px;
padding: 0.25rem 0.65rem;
background: #10b98114;
color: #34d399;
border: 1px solid #10b98133;
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.05em;
}
.package-title {
margin: 0 0 1rem;
color: #fff;
font-size: 1.55rem;
font-weight: 600;
line-height: 1.25;
letter-spacing: -0.025em;
}
.package-desc {
margin: 0 0 1.5rem;
}
.package-details-box {
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;
padding-block: 0.5rem;
color: #34d399;
text-decoration: none;
}
.detail-heading {
max-width: 52rem;
margin-bottom: 3rem;
}
.detail-grid {
display: grid;
grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr);
gap: 2.5rem;
align-items: start;
}
.detail-content > section + section {
margin-top: 2.5rem;
}
.detail-content h2,
.package-checkout h2 {
margin: 0 0 1rem;
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;
letter-spacing: -0.04em;
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;
}
.variant-select {
width: 100%;
max-width: 100%;
min-width: 0;
min-height: 3rem;
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,
.packages-section #booking-root .booking__package,
.packages-section #booking-root .booking__payment,
.packages-section #booking-root .booking__open {
display: none; display: none;
} }
/* Zusätzliche Booking-Übersicht ausblenden, Dialog erhalten. */ .packages-section .booking__dialog {
#booking-root > .booking > h2, background: #0d0d1a;
#booking-root .booking__package, color: #f8fafc;
#booking-root .booking__payment, border-color: #94a3b840;
#booking-root .booking__open { }
display: none; .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,3 +619,44 @@ 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);
}
});