diff --git a/angebotsuebersicht.html b/angebotsuebersicht.html index 4cf1c87..06d0290 100644 --- a/angebotsuebersicht.html +++ b/angebotsuebersicht.html @@ -1,285 +1,34 @@ - - - - - - Ernährungs-Pakete – Tri-Hub - - - - - - - - - - - -
-
-
-
-
-
TRI-HUB · ERNÄHRUNG
-

Unsere Ernährungs-Pakete

-
-

- Wähle das Paket, das zu deinem aktuellen Stand passt. Von den Grundlagen bis zur Wettkampf-Vorbereitung. -

-
- -
-
-
-
- - - - - + + + + Ernährungs-Pakete – Tri-Hub + + + + + +
+
+
+

TRI-HUB · ERNÄHRUNG

+

Unsere Ernährungs-Pakete

+
+

+ Wähle das Paket, das zu deinem aktuellen Stand passt. Von + den Grundlagen bis zur Wettkampf-Vorbereitung. +

+
+
+
+ + + diff --git a/package-lock.json b/package-lock.json index 7e92738..eb86a80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "csv-parse": "^7.0.2", "csv-stringify": "^6.8.3", + "libphonenumber-js": "^1.13.14", "nodemailer": "^10.0.10" }, "devDependencies": { @@ -982,6 +983,12 @@ "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": { "version": "3.3.19", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", diff --git a/package.json b/package.json index d903bdd..cb17e0c 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "dependencies": { "csv-parse": "^7.0.2", "csv-stringify": "^6.8.3", + "libphonenumber-js": "^1.13.14", "nodemailer": "^10.0.10" } } diff --git a/paket-details.html b/paket-details.html index 9ec9e46..e3fabf7 100644 --- a/paket-details.html +++ b/paket-details.html @@ -1,372 +1,27 @@ - - - - - - - Paket-Details – Tri-Hub Ernährungsberatung - - - - - - - - - - - -
-
- -
-
- - - - - + + + + Paket-Details – Tri-Hub Ernährungsberatung + + + + + +
+ ← Zurück zur Angebotsübersicht +
+
+ + + diff --git a/src/features/angebote/angebote-entry.js b/src/features/angebote/angebote-entry.js index 43eb523..2cf63e5 100644 --- a/src/features/angebote/angebote-entry.js +++ b/src/features/angebote/angebote-entry.js @@ -1,5 +1,3 @@ -import "../../styles/styles.css"; -import "../../styles/packages.css"; import { renderAngebotsuebersicht } from "./angebotsuebersicht.js"; renderAngebotsuebersicht(); diff --git a/src/features/angebote/angebotsuebersicht.js b/src/features/angebote/angebotsuebersicht.js index c0ca328..57133ac 100644 --- a/src/features/angebote/angebotsuebersicht.js +++ b/src/features/angebote/angebotsuebersicht.js @@ -23,7 +23,7 @@ export function renderAngebotsuebersicht() { ${highlightHtml} -

${pkg.name}

+

${pkg.name}

${pkg.summary}

@@ -41,7 +41,7 @@ export function renderAngebotsuebersicht() {
- ${pkg.buttonText} + ${pkg.buttonText} `; diff --git a/src/features/angebote/paket-details.js b/src/features/angebote/paket-details.js index 7f7cccd..5c70cc2 100644 --- a/src/features/angebote/paket-details.js +++ b/src/features/angebote/paket-details.js @@ -1,199 +1,158 @@ 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", () => { const container = document.getElementById("package-detail-container"); if (!container) return; 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) { + if (!pkg || invalidVariant) { container.innerHTML = `
-

Paket nicht gefunden

-

Bitte wähle ein gültiges Angebot aus der Übersicht.

- - Zur Übersicht - -
- `; +

Paket nicht gefunden

+

Bitte wähle ein gültiges Paket und eine passende Variante aus.

+ Zur Übersicht + `; return; } + document.title = `${pkg.type} – Tri-Hub Ernährungsberatung`; const variantsHtml = pkg.variants?.length - ? ` -
-

Wähle deine Variante

- ${pkg.variants .map( (variant) => ` - - `, + `, ) .join("")} -
- ` + ` : ""; container.innerHTML = ` -
+
- - ${pkg.type} · ${pkg.durationLabel} - - ${ - pkg.badge - ? `${pkg.badge}` - : "" - } + ${pkg.type} · + ${pkg.badge ? `${pkg.badge}` : ""}
- -

${pkg.title}

+

${pkg.title}

${pkg.subtitle}

- -
- - ${pkg.price.toFixed(2)} € - - - inkl. ${pkg.taxRate}% MwSt. (${pkg.billingInterval}) - -
- - ${variantsHtml} - -
-
-

- Für wen ist dieses Paket ideal? -

+
+
+
+
+

Für wen ist dieses Paket ideal?

    - ${pkg.targetGroup - .map((item) => `
  • ${item}
  • `) - .join("")} + ${pkg.targetGroup.map((item) => `
  • ${item}
  • `).join("")}
-
- -
-

- Das ist im Paket enthalten -

+ +
+

Das ist im Paket enthalten

    - ${pkg.includedFeatures - .map((feature) => `
  • ${feature}
  • `) - .join("")} + ${pkg.includedFeatures.map((feature) => `
  • ${feature}
  • `).join("")}
-
- -
-

- So läuft deine Begleitung ab -

+ +
+

So läuft deine Begleitung ab

${pkg.processSteps .map( (step) => ` -
- ${step.step}: - - ${step.text} - -
- `, +
+ ${step.step} +

${step.text}

+
`, ) .join("")} -
+
+
+

Dein ${pkg.type.toLowerCase().replace(/^./, (letter) => letter.toUpperCase())}-Paket

+
+ + inkl. ${pkg.taxRate} % MwSt. · ${pkg.billingInterval} +

+
+ ${variantsHtml} + +

+
+
+
`; - - -

- Dieser Buchungsablauf ist eine Simulation. Es erfolgt keine - Abbuchung und keine verbindliche Beratungsbuchung. -

- -

-
- - `; - - 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 activeDialog; function prepareBooking() { 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, { packageId: pkg.id, variantId: variantSelect?.value, }); } - function updatePrice() { - if (!variantSelect) return; - - const selectedOption = variantSelect.selectedOptions[0]; - const price = Number(selectedOption.dataset.price); - - displayedPrice.textContent = `${price.toFixed(2)} €`; - } - - updatePrice(); - prepareBooking(); - - variantSelect?.addEventListener("change", () => { - updatePrice(); - prepareBooking(); + // Booking lädt die Paketvorschau asynchron, auch bei einem erneuten Versuch. + 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", () => { - const dialog = bookingRoot.querySelector(".booking__dialog"); - - if (!dialog) { - bookingStatus.textContent = - "Das Buchungsformular ist noch nicht verfügbar. Bitte beachte den Ladehinweis im Buchungsbereich."; - return; - } - - if (dialog.open) return; - - bookingStatus.textContent = ""; - - dialog.addEventListener( - "close", - () => { - bookingBtn.focus(); - }, - { once: true }, + if (!activeDialog || activeDialog.open) return; + activeDialog.showModal(); + bookingBtn.setAttribute("aria-expanded", "true"); + const result = activeDialog.querySelector( + ".booking__result:not([hidden])", ); - - dialog.showModal(); - dialog.querySelector('[name="name"]').focus(); + (result || activeDialog.querySelector('[name="name"]')).focus(); }); }); diff --git a/src/features/booking/booking-confirmation.js b/src/features/booking/booking-confirmation.js index 5e67a8b..7311709 100644 --- a/src/features/booking/booking-confirmation.js +++ b/src/features/booking/booking-confirmation.js @@ -90,7 +90,7 @@ if ( if (body.emailStatus === "pending" && body.packageId) { const params = new URLSearchParams({ id: body.packageId }); 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-empty").hidden = true; diff --git a/src/features/booking/booking-page.js b/src/features/booking/booking-page.js index 4d97d52..2366c50 100644 --- a/src/features/booking/booking-page.js +++ b/src/features/booking/booking-page.js @@ -1,3 +1,7 @@ +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"; @@ -284,12 +288,8 @@ export function mountBookingPage( fields.email = "Bitte eine gültige E-Mail-Adresse eingeben."; phone.value = phone.value.trim(); - if ( - phone.value.length > 50 || - /[\x00-\x1f\x7f]/.test(phone.value) - ) - fields.phone = - "Bitte höchstens 50 Zeichen ohne Zeilenumbrüche eingeben."; + if (!isValidBookingPhone(phone.value)) + fields.phone = phoneError; if (Object.keys(fields).length) { showError( "Bitte die markierten Angaben prüfen.", diff --git a/src/server/services/booking-service.js b/src/server/services/booking-service.js index f62c6d3..5ecf2e2 100644 --- a/src/server/services/booking-service.js +++ b/src/server/services/booking-service.js @@ -1,3 +1,7 @@ +import { + isValidBookingPhone, + phoneError, +} from "../../shared/phone-validation.js"; import { createHash } from "node:crypto"; import { packagesDetails } from "../../shared/packagesdetails.js"; @@ -112,16 +116,12 @@ function validateInput(input, key) { ) { fields.email = "Bitte eine gültige E-Mail-Adresse eingeben."; } - if (!customer && input.phone !== undefined) { - if ( - typeof input.phone !== "string" || - input.phone.length > 50 || - /[\x00-\x1f\x7f]/.test(input.phone) - ) { - fields.phone = - "Bitte höchstens 50 Zeichen ohne Zeilenumbrüche eingeben."; - } else if (input.phone.trim()) { - result.phone = input.phone.trim(); + const phone = customer ? customer.phone : input.phone; + if (phone !== undefined) { + if (!isValidBookingPhone(phone)) { + fields.phone = phoneError; + } else if (!customer && phone.trim()) { + result.phone = phone.trim(); } } if (Object.keys(fields).length) { diff --git a/src/server/swagger.json b/src/server/swagger.json index 409c41d..7ba05eb 100644 --- a/src/server/swagger.json +++ b/src/server/swagger.json @@ -553,7 +553,7 @@ "phone": { "type": "string", "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" }, "customer": { @@ -575,6 +575,8 @@ }, "phone": { "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" }, "ageGroup": { diff --git a/src/shared/phone-validation.js b/src/shared/phone-validation.js new file mode 100644 index 0000000..8958dcf --- /dev/null +++ b/src/shared/phone-validation.js @@ -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"); +} diff --git a/src/styles/packages.css b/src/styles/packages.css index c40e4f5..dd8c95b 100644 --- a/src/styles/packages.css +++ b/src/styles/packages.css @@ -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; } -/* Zusätzliche Booking-Übersicht ausblenden, Dialog erhalten. */ -#booking-root > .booking > h2, -#booking-root .booking__package, -#booking-root .booking__payment, -#booking-root .booking__open { - display: none; +.packages-section .booking__dialog { + background: #0d0d1a; + color: #f8fafc; + border-color: #94a3b840; +} +.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; + } } diff --git a/src/tests/bookings.test.js b/src/tests/bookings.test.js index e3b510d..2be862c 100644 --- a/src/tests/bookings.test.js +++ b/src/tests/bookings.test.js @@ -619,3 +619,44 @@ test("Swagger-Kundendaten und Zustimmung werden validiert und dauerhaft gespeich 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); + } +});