diff --git a/README.md b/README.md index 0ca7144..77a7c5b 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,12 @@ angezeigt. Der Ablauf bleibt eine Simulation ohne Zahlung oder verbindliche Beratung. Buchungen liegen lokal in `src/server/data/bookings.csv`. Diese Datei nicht vorab leer anlegen: Das Backend erzeugt sie bei der ersten Buchung selbst. +## Beraterkontakt testen + +Die [Kontakt-Testseite](http://localhost:5173/contact-test.html?berater-id=anna-mueller) +öffnet das neue Popup. Kundenprüfung, Mailpit-Versand, Profil-Einbindung und +Checkliste sind in [docs/contact.md](docs/contact.md) dokumentiert. + ## Prüfen ```bash diff --git a/contact-test.html b/contact-test.html new file mode 100644 index 0000000..9705d86 --- /dev/null +++ b/contact-test.html @@ -0,0 +1,26 @@ + + + + + + Berater kontaktieren – Tri-Hub Testseite + + +
+

TRI-HUB · KONTAKT-DEMO

+

Dein direkter Draht zur Beratung

+

+ +

+ Testseite für das spätere Beraterprofil. Nutze eine + E-Mail-Adresse aus einer vorhandenen Testbuchung. +

+
+ + + diff --git a/docs/contact-integration.md b/docs/contact-integration.md new file mode 100644 index 0000000..3040cb8 --- /dev/null +++ b/docs/contact-integration.md @@ -0,0 +1,98 @@ +# Übergabe an die KI der Beraterprofilseite + +Binde das vorhandene Kontakt-Popup in die Kontaktbuttons der Beraterprofile +ein. Übergib ausschließlich die Berater-ID als `advisorId`. Implementiere +kein eigenes Kontaktformular und keine separate E-Mail-Prüfung. + +## Relevante Dateien + +- `src/shared/berater-daten.json`: gemeinsame Datenquelle für Profile und Kontakt; + Array mit eindeutigen String-IDs, `name` und `email`. Die aktuellen Einträge + sind Demo-Daten. Eigene öffentliche Profilfelder dürfen ergänzt werden. +- `src/shared/berater.js`: `findAdvisor(advisorId)` liefert den passenden Eintrag. +- `src/features/contact/contact-dialog.js`: exportiert `openContactDialog({ advisorId })`. +- `src/features/contact/contact.css`: wird automatisch vom Popup-Modul importiert; + die Schriftdateien liegen unter `public/fonts/`. +- `src/features/contact/contact-entry.js` und `contact-test.html`: Beispielintegration. +- `src/server/services/contact-service.js`: prüft Kunden-E-Mail und Berater-ID, + lädt die Empfängeradresse serverseitig aus der JSON. +- `src/server/swagger.json`: API-Vertrag für `POST /api/contact`. + +## Datenvertrag + +```json +[ + { + "id": "anna-mueller", + "name": "Anna Müller", + "email": "anna.mueller@tri-hub.de" + } +] +``` + +Die Profilseite und das Popup müssen dieselben IDs verwenden. E-Mail-Adressen +werden explizit gepflegt und nicht aus Namen erzeugt. Sie müssen auf `@tri-hub.de` +enden. Keine vertraulichen Informationen in die JSON schreiben: Sie ist Teil +des Frontends. Nach Änderungen API neu starten und Frontend neu bauen. + +## Einzubindender Code + +Beispielbutton (ID aus dem jeweiligen Profil): + +```html + +``` + +Im JavaScript-Modul der Profilseite, einmalig registrieren: + +```js +// Importpfad relativ zu dieser Datei anpassen. +import { openContactDialog } from "./src/features/contact/contact-dialog.js"; + +document.addEventListener("click", (event) => { + const button = event.target.closest("button[data-berater-id]"); + if (!button) return; + openContactDialog({ advisorId: button.dataset.beraterId }); +}); +``` + +Bei dynamischer Erzeugung des Buttons die ID aus dem Profil setzen: + +```js +button.dataset.beraterId = profile.id; +``` + +Alternativ direkt am einzelnen Button binden (nicht zusätzlich zur Delegation): + +```js +button.addEventListener("click", () => { + openContactDialog({ advisorId: profile.id }); +}); +``` + +Falls die Profilseite selbst Daten aus der gemeinsamen Datei benötigt: + +```js +// Beispiel für ein Modul direkt unter src/; Pfad ggf. anpassen. +import berater from "./shared/berater-daten.json" with { type: "json" }; + +const profile = berater.find((entry) => entry.id === advisorId); +// profile.name und profile.email stehen bei bekannter ID zur Verfügung. +``` + +Das Popup ermittelt den Anzeigenamen selbst und sendet beim Absenden +`{ email, advisorId, reason, simulationAccepted }` an `/api/contact`. Die +Profilseite muss weder Name noch E-Mail an das Popup schicken. Kundenprüfung, +Simulation, Mailpit-Versand und Microsoft-Bookings-Weiterleitung sind bereits +implementiert. Unbekannte IDs sperren das Absenden. + +## Testen + +Mit laufendem Frontend, API und Mailpit: +`http://localhost:5173/contact-test.html?berater-id=anna-mueller`. +Für die Kundenadresse eine vorhandene Testbuchung verwenden. Die Prüfung +findet ausschließlich beim Absenden statt. Der Branch ist +`feature/terminbuchung-kontaktfeld`; die Kontaktdateien müssen im Arbeitsstand +der Profilseite vorhanden sein. diff --git a/docs/contact.md b/docs/contact.md new file mode 100644 index 0000000..4026dfa --- /dev/null +++ b/docs/contact.md @@ -0,0 +1,156 @@ +# Beraterkontakt (Simulation) + +## Testseite starten + +Wie bei der Buchung in drei Terminals starten: + +```sh +npm run dev:mail +npm run dev:server +npm run dev +``` + +Öffne . +Der Button öffnet ein modales Fenster. Gib eine E-Mail aus einer vorhandenen +Buchung in `src/server/data/bookings.csv` ein, wähle einen Kontaktgrund und +bestätige den Simulationshinweis. Beim Klick auf „Anfrage senden & Termin wählen“ +prüft der Server die E-Mail und versendet nur bei vorhandener Buchung. Bei Bedarf zuerst +über die bestehende Angebotsseite eine Testbuchung anlegen. + +Nach dem Absenden müssen zwei getrennte Nachrichten in + erscheinen: an die Kundenadresse und beispielsweise +`anna.mueller@tri-hub.de`. Der Browser wechselt anschließend zur vom Auftrag +vorgegebenen Microsoft-Bookings-Adresse. Es wird dort kein Termin automatisch +gebucht. Die Checkliste spricht von Microsoft Forms; umgesetzt ist der konkret +angegebene Bookings-Link. + +## Checkliste + +- [x] Kontaktgrund auswählbar: Coaching, Ernährungsplan, Wettkampfverpflegung, + Regeneration, Paketfragen, Termin und Sonstiges. +- [x] Getrennte Eingangsbestätigungen an Kunde und Berater, ausschließlich + simuliert über Mailpit, mit Kontaktgrund und Simulationshinweis. +- [x] Weiterleitung zur vorhandenen Microsoft-Bookings-Adresse nach Annahme + beider Nachrichten durch Mailpit; zusätzlicher Link als Rückfalloption. +- [x] Kontakt nur nach erfolgreichem E-Mail-Abgleich mit `bookings.csv`; + der Server prüft beim tatsächlichen Absenden erneut. + +## Einbindung in Beraterprofile + +Die Profilseite importiert `openContactDialog` aus +`src/features/contact/contact-dialog.js` und übergibt ausschließlich die ID: + +```js +import { openContactDialog } from "./src/features/contact/contact-dialog.js"; + +button.addEventListener("click", () => { + openContactDialog({ advisorId: profile.id }); +}); +``` + +Den Importpfad relativ zur einbindenden Datei anpassen. Das Modul bringt die +Popup-Styles und die lokalen DM-Sans-Schriftdateien mit. Die vollständige +Einbauanleitung für die Profilseite steht in [contact-integration.md](contact-integration.md). + +Die gemeinsame Datenquelle `src/shared/berater-daten.json` enthält ein Array +mit eindeutigen String-IDs und den Feldern `id`, `name`, `email`. Die beiden +vorhandenen Einträge sind Demo-Daten und können durch die tatsächlichen +Profildaten ersetzt werden. Zusätzliche Profilfelder sind möglich. Die Datei +wird im Frontend eingebunden: ausschließlich öffentliche Profildaten eintragen. + +`src/shared/berater.js` stellt `findAdvisor(advisorId)` bereit. Popup und Server +verwenden dieselbe Zuordnung. Der Server holt die Empfängeradresse ausschließlich +aus dieser JSON; vom Browser gesendete Namen oder Empfängeradressen werden +nicht übernommen. E-Mail-Adressen müssen gültig sein und auf `@tri-hub.de` +enden. Eine unbekannte ID wird abgelehnt und sperrt das Absenden im Popup. + +Die Testseite liest den URL-Parameter `berater-id`, zum Beispiel +`contact-test.html?berater-id=max-mustermann`. Ohne Parameter verwendet sie +`anna-mueller`. Nach Änderungen an der JSON den API-Server neu starten und für +das Deployment das Frontend neu bauen. + +Das native `dialog` hält den Tastaturfokus im Popup. Escape und Schließen +bringen ihn zum auslösenden Button zurück. Während einer Anfrage ist Schließen +kurz gesperrt, damit der Versand nicht unbemerkt weiterläuft. Das Fenster ist +auf schmalen Bildschirmen scrollbar. Die E-Mail wird bei jedem Absenden geprüft; +ein separater Prüfbutton ist nicht erforderlich. Es gibt keine zusätzlichen +Laufzeitabhängigkeiten. + +## API + +Der vollständige OpenAPI-Vertrag steht in `src/server/swagger.json`. +Beide Endpunkte erwarten POST mit `Content-Type: application/json`, maximal +8 KiB. Fehler enthalten `error.code` und `error.message`. + +| Endpunkt | Eingabe | Erfolg | +| ----------------------- | --------------------------------------------------- | ---------------------------------------------------------- | +| `/api/contact/validate` | `{ "email": "kunde@example.test" }` | 200: `{ "valid": true, "email": "kunde@example.test" }` | +| `/api/contact` | E-Mail, Berater-ID, Grund, Zustimmung (siehe unten) | 201: `simulated`, `emailStatus: "accepted"`, `redirectUrl` | + +```json +{ + "email": "kunde@example.test", + "advisorId": "anna-mueller", + "reason": "coaching", + "simulationAccepted": true +} +``` + +400 bedeutet ungültige Eingaben, 403 keine passende Buchung, 405 falsche Methode, +413 zu große Anfrage, 415 falscher Medientyp, 503 nicht lesbarer CSV-Speicher oder ungültige Beraterkontaktdaten. +502 bedeutet, dass mindestens eine Mailannahme fehlgeschlagen oder unklar ist; +der Browser leitet dann nicht weiter. 500 bezeichnet einen unerwarteten Fehler. +Die CSV wird ausschließlich gelesen und bleibt unverändert. Der separate +Validierungsendpunkt bleibt für API-Nutzer verfügbar; das Popup verwendet +ausschließlich `/api/contact` mit integrierter Kundenprüfung. + +## Grenzen der Simulation + +Der E-Mail-Abgleich ignoriert Groß-/Kleinschreibung und äußere Leerzeichen. +Er bestätigt einen Eintrag in der Buchungsdatei, nicht den Besitz des Postfachs. +Die Prüfantwort ist kein Anmeldetoken und gibt keine Buchungsdetails zurück. +Vor einem öffentlichen Produktiveinsatz wären eine Anmeldung oder Bestätigung +per E-Mail sowie ein Schutz vor automatisierten Adressabfragen erforderlich. + +Kontaktmails verwenden fest `127.0.0.1:1025`, unabhängig von einer eventuell +externen SMTP-Konfiguration des Buchungsablaufs. Mailpit muss deshalb auf +demselben Host laufen. Kunden- und Berateradresse sind simulierte Empfänger; +es erfolgt kein externer Versand. SMTP-Annahme ist keine reale Zustellbestätigung. + +Kontaktanfragen werden nicht dauerhaft gespeichert und haben keinen +Idempotenzschlüssel. Doppelklicks sind während des Versands gesperrt. Bei +Verbindungsabbrüchen oder Teilfehlern kann bereits eine Nachricht vorliegen: +vor manuellem Wiederholen Mailpit prüfen. Es gibt keinen automatischen Neuversand. + +## Prüfungen + +- `npm test`: API, CSV-Abgleich, erneute Validierung beim Absenden, ungültige + Eingaben, SMTP-Empfänger und Teilfehler; isolierte temporäre CSV-Dateien. +- `npm run test:browser`: vollständiger Ablauf einschließlich abgefangener + externer Weiterleitung, unbekannter Adresse, geänderter E-Mail, Versandfehler, + mobiler Darstellung, Fokus und Escape; zusätzlich bestehende Buchungstests. +- `npm run build` und `git diff --check`. +- Lokaler SMTP-Smoke-Test mit Mailpit: beide Empfänger und Simulationshinweis + in den empfangenen Nachrichten geprüft. + +Chromium muss für Playwright installiert sein (`npx playwright install chromium`). +In dieser Arbeitsumgebung liegt der Testbrowser unter `/tmp/trihub-playwright`; +hier mit `PLAYWRIGHT_BROWSERS_PATH=/tmp/trihub-playwright npm run test:browser` starten. +Der globale Formatcheck hat bereits bestehende Abweichungen in `src/main.js` +und `src/components/navigationsbar/README.md`; die Kontaktdateien sind formatiert. +Der bestehende Gesamt-Build meldet außerdem fehlende ältere Schriftdateien aus +dem globalen Stylesheet. Das Kontakt-Popup verwendet eigene lokale Schriftdateien. + +### Ergebnis dieser Umsetzung + +31 API-/Servicetests und alle vier Kontakt-Browsertests bestanden. +Build, lokale Mailpit-Prüfung und Formatierung der geänderten Dateien bestanden. +Die bestehenden Buchungs-Browsertests sind teilweise nicht mehr an das schon +vorhandene Buchungs-Popup angepasst: Sie suchen Formularfelder, ohne vorher +„Jetzt buchen“ zu klicken. Weitere Bestandsfälle erwarten einen nicht mehr vorhandenen Startseiten-Link +oder eine Seitennavigation, wo bereits ein Popup geöffnet wird. Beim ursprünglichen Gesamtlauf bestanden 7 von 15 Browsertests; die 8 Fehler +betrafen ausschließlich die unveränderten Buchungstests. Die gezielten +Kontakttests werden nach Anpassungen separat ausgeführt. +`git diff --check` ist für die eigenen Änderungen sauber; die zuvor bereits +geänderte `bookings.csv` erzeugt separat CRLF-Whitespace-Hinweise und wurde +von dieser Umsetzung nicht verändert. diff --git a/index.html b/index.html index 49a63e4..cb5146c 100644 --- a/index.html +++ b/index.html @@ -22,8 +22,8 @@ :root { --page-bg: #0d0d1a; --page-bg-2: #1a1a2e; - --green: #10b981; - --green-dark: #059669; + --brand: #53d5cd; + --brand-hover: #36b8b1; --white: #ffffff; --light: #f8fafc; --muted: #94a3b8; @@ -48,12 +48,12 @@ background: radial-gradient( circle at 12% 0%, - rgba(16, 185, 129, 0.09), + rgba(83, 213, 205, 0.09), transparent 30% ), radial-gradient( circle at 88% 26%, - rgba(16, 185, 129, 0.055), + rgba(83, 213, 205, 0.055), transparent 27% ), linear-gradient(145deg, var(--page-bg), var(--page-bg-2)); @@ -115,7 +115,7 @@ } .brand span { - color: var(--green); + color: var(--brand); } .nav-links { @@ -138,14 +138,14 @@ .nav-cta { padding: 11px 16px; - border: 1px solid rgba(16, 185, 129, 0.42); + border: 1px solid rgba(83, 213, 205, 0.42); border-radius: 999px; color: var(--white) !important; - background: rgba(16, 185, 129, 0.07); + background: rgba(83, 213, 205, 0.07); } .nav-cta:hover { - background: rgba(16, 185, 129, 0.14); + background: rgba(83, 213, 205, 0.14); } .container { @@ -155,7 +155,7 @@ .eyebrow { margin-bottom: 18px; - color: var(--green); + color: var(--brand); font-size: 0.72rem; font-weight: 700; letter-spacing: 0.18em; @@ -180,7 +180,7 @@ } .hero h1 .accent { - color: var(--green); + color: var(--brand); } .hero-lead { @@ -220,12 +220,12 @@ .button-primary { color: #06130f; - background: var(--green); - box-shadow: 0 14px 32px rgba(16, 185, 129, 0.18); + background: var(--brand); + box-shadow: 0 14px 32px rgba(83, 213, 205, 0.18); } .button-primary:hover { - background: #34d399; + background: #75e2dc; } .button-secondary { @@ -235,7 +235,7 @@ } .button-secondary:hover { - border-color: rgba(16, 185, 129, 0.55); + border-color: rgba(83, 213, 205, 0.55); } .hero-card { @@ -251,7 +251,7 @@ background: radial-gradient( circle at 72% 18%, - rgba(16, 185, 129, 0.2), + rgba(83, 213, 205, 0.2), transparent 32% ), linear-gradient( @@ -269,18 +269,18 @@ height: 230px; right: -75px; top: -75px; - border: 1px solid rgba(16, 185, 129, 0.24); + border: 1px solid rgba(83, 213, 205, 0.24); border-radius: 50%; box-shadow: - 0 0 0 34px rgba(16, 185, 129, 0.035), - 0 0 0 68px rgba(16, 185, 129, 0.02); + 0 0 0 34px rgba(83, 213, 205, 0.035), + 0 0 0 68px rgba(83, 213, 205, 0.02); } .hero-card-mark { position: absolute; top: 42px; left: 38px; - color: rgba(16, 185, 129, 0.88); + color: rgba(83, 213, 205, 0.88); font-size: 4.5rem; line-height: 1; } @@ -342,13 +342,13 @@ .topic:hover { transform: translateY(-4px); - border-color: rgba(16, 185, 129, 0.34); + border-color: rgba(83, 213, 205, 0.34); background: var(--panel-strong); } .topic-number { margin-bottom: 70px; - color: var(--green); + color: var(--brand); font-size: 0.75rem; font-weight: 700; letter-spacing: 0.14em; @@ -407,8 +407,8 @@ height: 10px; margin-top: 7px; border-radius: 50%; - background: var(--green); - box-shadow: 0 0 0 5px rgba(16, 185, 129, 0.08); + background: var(--brand); + box-shadow: 0 0 0 5px rgba(83, 213, 205, 0.08); } .review-section { @@ -504,8 +504,8 @@ input:focus, textarea:focus, select:focus { - border-color: rgba(16, 185, 129, 0.75); - box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.1); + border-color: rgba(83, 213, 205, 0.75); + box-shadow: 0 0 0 3px rgba(83, 213, 205, 0.1); } .rating-input { @@ -533,7 +533,7 @@ .rating-input label:hover, .rating-input label:hover ~ label, .rating-input input:checked ~ label { - color: var(--green); + color: var(--brand); } .consent { @@ -547,7 +547,7 @@ .consent input { margin-top: 4px; - accent-color: var(--green); + accent-color: var(--brand); } .review-form .button { @@ -560,7 +560,7 @@ .form-status { min-height: 20px; margin-top: 12px; - color: var(--green); + color: var(--brand); font-size: 0.8rem; } @@ -594,7 +594,7 @@ flex: 0 0 auto; border-radius: 50%; color: #06130f; - background: var(--green); + background: var(--brand); font-size: 0.72rem; font-weight: 800; } @@ -613,7 +613,7 @@ } .stars { - color: var(--green); + color: var(--brand); letter-spacing: 0.08em; white-space: nowrap; } diff --git a/public/fonts/DM-Sans-LICENSE.txt b/public/fonts/DM-Sans-LICENSE.txt new file mode 100644 index 0000000..917aaa6 --- /dev/null +++ b/public/fonts/DM-Sans-LICENSE.txt @@ -0,0 +1,93 @@ +Copyright 2014 The DM Sans Project Authors (https://github.com/googlefonts/dm-fonts) DMSans-Italic[opsz,wght].ttf: Copyright 2014 The DM Sans Project Authors (https://github.com/googlefonts/dm-fonts) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/public/fonts/dm-sans-latin-400-normal.woff2 b/public/fonts/dm-sans-latin-400-normal.woff2 new file mode 100644 index 0000000..0b8bc55 Binary files /dev/null and b/public/fonts/dm-sans-latin-400-normal.woff2 differ diff --git a/public/fonts/dm-sans-latin-500-normal.woff2 b/public/fonts/dm-sans-latin-500-normal.woff2 new file mode 100644 index 0000000..48e1612 Binary files /dev/null and b/public/fonts/dm-sans-latin-500-normal.woff2 differ diff --git a/public/fonts/dm-sans-latin-700-normal.woff2 b/public/fonts/dm-sans-latin-700-normal.woff2 new file mode 100644 index 0000000..26edc56 Binary files /dev/null and b/public/fonts/dm-sans-latin-700-normal.woff2 differ diff --git a/src/features/booking/booking-confirmation.css b/src/features/booking/booking-confirmation.css index 58d7eeb..0f20f8c 100644 --- a/src/features/booking/booking-confirmation.css +++ b/src/features/booking/booking-confirmation.css @@ -1,9 +1,10 @@ .confirmation { + --confirmation-accent: #53d5cd; margin: 0; min-height: 100vh; color: #f8fafc; background: - radial-gradient(ellipse at 5% 20%, #10b98114, transparent 45%), + radial-gradient(ellipse at 5% 20%, #53d5cd14, transparent 45%), radial-gradient(ellipse at 95% 45%, #1a1a2e, transparent 55%), #0d0d1a; font-family: Inter, system-ui, sans-serif; line-height: 1.65; @@ -21,7 +22,7 @@ text-decoration: none; } .confirmation a:focus-visible { - outline: 3px solid #10b981; + outline: 3px solid var(--confirmation-accent); outline-offset: 6px; } .confirmation__header { @@ -53,10 +54,9 @@ width: 3rem; height: 3rem; background: #f8fafc; - color: #087452; + color: var(--confirmation-accent); border-radius: 0.9rem; letter-spacing: -0.08em; - font-family: Georgia, serif; font-size: 1.3rem; } .confirmation__main { @@ -81,25 +81,25 @@ width: 1.6rem; height: 1.6rem; margin-right: 0.5rem; - border: 1px solid #10b98170; + border: 1px solid #53d5cd70; border-radius: 50%; - color: #10b981; + color: var(--confirmation-accent); } .confirmation h1 { margin: 1rem 0 1.5rem; - font-family: "Playfair Display", Georgia, serif; + font-family: inherit; font-weight: 500; font-size: clamp(2.5rem, 5vw, 4.5rem); line-height: 1.12; letter-spacing: -0.035em; } .confirmation h1 em { - color: #10b981; + color: var(--confirmation-accent); font-weight: inherit; } .confirmation h2 { margin: 0.5rem 0 1rem; - font-family: "Playfair Display", Georgia, serif; + font-family: inherit; font-weight: 500; font-size: 1.75rem; line-height: 1.3; @@ -114,7 +114,7 @@ .confirmation__simulation { color: #cbd5e1; padding-left: 1rem; - border-left: 2px solid #10b981; + border-left: 2px solid var(--confirmation-accent); font-size: 0.85rem; } .confirmation__grid { @@ -167,8 +167,8 @@ margin: 0.2rem 0 1rem; } .confirmation__mail { - border-color: #10b98140; - background: #10b98108; + border-color: #53d5cd40; + background: #53d5cd08; } .confirmation__mail h2 { font-family: inherit; @@ -193,7 +193,7 @@ content: "✓"; position: absolute; left: 0; - color: #10b981; + color: var(--confirmation-accent); } .confirmation__next { margin-top: 1.5rem; @@ -213,7 +213,7 @@ } .confirmation__steps li::before { content: "0" counter(steps); - color: #10b981; + color: var(--confirmation-accent); font-size: 0.8rem; } .confirmation__steps h3 { @@ -242,14 +242,14 @@ font-weight: 600; } .confirmation__button { - background: #10b981; + background: var(--confirmation-accent); color: #071a14 !important; } .confirmation__link { border: 1px solid #ffffff20; } .confirmation__button:hover { - background: #34d399; + background: #6de0d9; } .confirmation__link:hover { background: #ffffff08; diff --git a/src/features/booking/booking.css b/src/features/booking/booking.css index 2416eb9..c8f1ca9 100644 --- a/src/features/booking/booking.css +++ b/src/features/booking/booking.css @@ -53,8 +53,8 @@ border-style: dashed; } .booking__button { - color: #f7faf7; - background: #176448; + color: #061619; + background: #53d5cd; cursor: pointer; max-width: 100%; } @@ -69,7 +69,7 @@ outline-offset: 4px; } .booking__button:focus-visible { - outline-color: #18352b; + outline-color: #53d5cd; } .booking__errors, .booking__field-error { @@ -160,8 +160,8 @@ border-radius: 0.75rem; } .booking__dialog .booking__button { - background: #00b6c7; - border-color: #00b6c7; + background: #53d5cd; + border-color: #53d5cd; color: #061619; border-radius: 0.75rem; } @@ -188,5 +188,5 @@ .booking__dialog :focus-visible, .booking__dialog .booking__errors:focus, .booking__dialog .booking__result:focus { - outline-color: #00d6e8; + outline-color: #53d5cd; } diff --git a/src/features/contact/contact-dialog.js b/src/features/contact/contact-dialog.js new file mode 100644 index 0000000..c30192f --- /dev/null +++ b/src/features/contact/contact-dialog.js @@ -0,0 +1,126 @@ +import { findAdvisor } from "../../shared/berater.js"; +import { + contactReasons, + contactNotice, + contactBookingUrl, +} from "../../shared/contact.js"; +import "./contact.css"; + +// Auf Profilseiten: openContactDialog({ advisorId: profile.id }). +export function openContactDialog({ + advisorId, + navigate = (url) => window.location.assign(url), +} = {}) { + const advisor = findAdvisor(advisorId); + const returnFocus = document.activeElement; + const dialog = document.createElement("dialog"); + dialog.className = "contact-dialog"; + dialog.setAttribute("aria-labelledby", "contact-title"); + dialog.innerHTML = ` + +

DEIN NÄCHSTER SCHRITT

+

Berater kontaktieren

+

+
+
+ +

Verwende die E-Mail-Adresse, mit der du dein Paket gebucht hast.

+
+
+ +
+ +

+ +
`; + dialog.querySelector(".contact-advisor").textContent = advisor + ? `Deine Anfrage an ${advisor.name}` + : "Die Berater-ID fehlt oder ist unbekannt. Bitte öffne das Fenster über ein Beraterprofil."; + dialog.querySelector(".contact-consent span").textContent = contactNotice; + const form = dialog.querySelector("form"); + const email = form.elements.email; + const reason = form.elements.reason; + for (const [value, label] of Object.entries(contactReasons)) { + reason.add(new Option(label, value)); + } + const status = dialog.querySelector(".contact-status"); + const submit = dialog.querySelector(".contact-submit"); + const close = dialog.querySelector(".contact-close"); + let busy = false; + let sent = false; + function update() { + submit.disabled = busy || sent || !advisor; + close.disabled = busy; + email.disabled = busy || sent; + reason.disabled = busy || sent; + form.elements.simulationAccepted.disabled = busy || sent; + } + async function post(path, input) { + const response = await fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + const result = await response.json(); + if (!response.ok) + throw new Error( + result.error?.message || "Die Anfrage ist fehlgeschlagen.", + ); + return result; + } + email.addEventListener("input", () => { + status.textContent = ""; + }); + form.addEventListener("submit", async (event) => { + event.preventDefault(); + if (submit.disabled || !form.reportValidity()) return; + const input = { + email: email.value, + advisorId, + reason: reason.value, + simulationAccepted: form.elements.simulationAccepted.checked, + }; + busy = true; + update(); + status.textContent = + "E-Mail wird geprüft und simulierte Anfrage gesendet …"; + try { + const result = await post("/api/contact", input); + if (result.redirectUrl !== contactBookingUrl) + throw new Error("Die Weiterleitungsadresse ist ungültig."); + sent = true; + status.textContent = + "Beide Bestätigungen wurden in Mailpit angenommen. Microsoft Bookings wird geöffnet. "; + const link = document.createElement("a"); + link.href = contactBookingUrl; + link.textContent = "Weiter zur Terminwahl"; + status.append(link); + navigate(contactBookingUrl); + } catch (error) { + status.textContent = `${error.message} Bei einem Verbindungsfehler bitte vor erneutem Senden Mailpit prüfen.`; + } finally { + busy = false; + update(); + } + }); + close.addEventListener("click", () => dialog.close()); + dialog.addEventListener("cancel", (event) => { + if (busy) event.preventDefault(); + }); + const previousOverflow = document.body.style.overflow; + dialog.addEventListener( + "close", + () => { + document.body.style.overflow = previousOverflow; + dialog.remove(); + returnFocus?.focus(); + }, + { once: true }, + ); + document.body.append(dialog); + document.body.style.overflow = "hidden"; + update(); + dialog.showModal(); + email.focus(); + return dialog; +} diff --git a/src/features/contact/contact-entry.js b/src/features/contact/contact-entry.js new file mode 100644 index 0000000..c8b4da8 --- /dev/null +++ b/src/features/contact/contact-entry.js @@ -0,0 +1,13 @@ +import { openContactDialog } from "./contact-dialog.js"; +import { findAdvisor } from "../../shared/berater.js"; +import "./contact-test.css"; + +const advisorId = + new URLSearchParams(location.search).get("berater-id") ?? "anna-mueller"; +const advisor = findAdvisor(advisorId); +document.querySelector("#advisor-name").textContent = advisor + ? `Dein Berater: ${advisor.name}` + : "Unbekannte Berater-ID"; +document + .querySelector("#open-contact") + .addEventListener("click", () => openContactDialog({ advisorId })); diff --git a/src/features/contact/contact-test.css b/src/features/contact/contact-test.css new file mode 100644 index 0000000..18607f8 --- /dev/null +++ b/src/features/contact/contact-test.css @@ -0,0 +1,29 @@ +body { + margin: 0; + background: oklch(13% 0.022 223); + color: oklch(96% 0.008 210); + font: + 16px/1.5 "DM Sans", + sans-serif; +} +.contact-test { + max-width: 680px; + margin: 12vh auto; + padding: 24px; +} +.contact-test > p { + color: oklch(72% 0.015 214); +} +.contact-test button { + padding: 14px 24px; + border: 0; + border-radius: 6px; + background: white; + color: oklch(20% 0.03 220); + font: inherit; + cursor: pointer; +} +.contact-test button:focus-visible { + outline: 3px solid oklch(70% 0.12 205 / 0.45); + outline-offset: 4px; +} diff --git a/src/features/contact/contact.css b/src/features/contact/contact.css new file mode 100644 index 0000000..1e53103 --- /dev/null +++ b/src/features/contact/contact.css @@ -0,0 +1,152 @@ +@font-face { + font-family: "DM Sans"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("/fonts/dm-sans-latin-400-normal.woff2") format("woff2"); +} +@font-face { + font-family: "DM Sans"; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url("/fonts/dm-sans-latin-500-normal.woff2") format("woff2"); +} +@font-face { + font-family: "DM Sans"; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url("/fonts/dm-sans-latin-700-normal.woff2") format("woff2"); +} +.contact-dialog { + --contact-foreground: oklch(96% 0.008 210); + --contact-accent: oklch(70% 0.12 205); + box-sizing: border-box; + width: min(586px, calc(100% - 32px)); + max-height: calc(100dvh - 32px); + margin: auto; + padding: 32px; + border: 1px solid oklch(100% 0 0 / 0.1); + border-radius: 1.1rem; + background: oklch(17% 0.024 221); + color: var(--contact-foreground); + font: + 400 16px/1.5 "DM Sans", + sans-serif; + -webkit-font-smoothing: antialiased; + box-shadow: 0 24px 80px #0008; +} +.contact-dialog::backdrop { + background: #000a; + backdrop-filter: blur(4px); +} +.contact-dialog * { + box-sizing: border-box; +} +.contact-dialog h2 { + margin: 8px 32px 8px 0; + font-size: 28px; + line-height: 1.25; +} +.contact-dialog p { + margin: 0; +} +.contact-dialog .contact-eyebrow { + color: var(--contact-accent); + font-size: 12px; + letter-spacing: 0.12em; +} +.contact-advisor, +.contact-help, +.contact-consent { + color: oklch(72% 0.015 214); +} +.contact-form { + margin-top: 32px; + display: grid; + gap: 24px; +} +.contact-form label { + display: block; + margin-bottom: 8px; +} +.contact-form input:not([type="checkbox"]), +.contact-form select { + width: 100%; + min-height: 48px; + padding: 10px 12px; + border: 1px solid oklch(100% 0 0 / 0.12); + border-radius: 6px; + background: oklch(13% 0.022 223); + color: inherit; + font: inherit; + color-scheme: dark; +} +.contact-dialog button { + min-height: 44px; + padding: 10px 16px; + border: 0; + border-radius: 6px; + font: + 500 14px/1.5 "DM Sans", + sans-serif; + cursor: pointer; +} +.contact-dialog .contact-close { + float: right; + padding: 0; + width: 44px; + background: transparent; + color: inherit; + font-size: 28px; +} +.contact-submit { + width: 100%; + background: white; + color: oklch(20% 0.03 220); +} +.contact-submit:hover:not(:disabled) { + background: var(--contact-accent); +} + +.contact-dialog :disabled { + opacity: 0.5; + cursor: not-allowed; +} +.contact-dialog :focus-visible { + outline: 3px solid oklch(70% 0.12 205 / 0.45); + outline-offset: 3px; +} +.contact-dialog .contact-help { + margin-top: 8px; + font-size: 14px; +} +.contact-form .contact-consent { + display: flex; + align-items: flex-start; + gap: 12px; + margin: 0; + font-size: 14px; +} +.contact-consent input { + flex-shrink: 0; + width: 20px; + height: 20px; + margin: 2px 0 0; + accent-color: var(--contact-accent); +} +.contact-status { + overflow-wrap: anywhere; +} +.contact-status:empty { + display: none; +} +.contact-status a { + color: var(--contact-accent); +} +@media (max-width: 480px) { + .contact-dialog { + padding: 24px 20px; + } +} diff --git a/src/server/index.js b/src/server/index.js index b9abd1c..f3ead04 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -1,3 +1,6 @@ +import { createContactService } from "./services/contact-service.js"; +import { createContactEmailService } from "./services/contact-email.js"; +import { handleContact } from "./routes/contact.js"; import { createServer } from "node:http"; import { fileURLToPath, pathToFileURL } from "node:url"; import { packages } from "../shared/packages.js"; @@ -16,6 +19,7 @@ export function createApp({ ), details, sendConfirmation, + sendContact, } = {}) { const ids = new Set(); for (const entry of catalog) { @@ -42,8 +46,20 @@ export function createApp({ packages: catalog, sendConfirmation: sendConfirmation ?? createEmailService(), }); + const contact = createContactService({ + storage, + sendContact: sendContact ?? createContactEmailService(), + }); return createServer(async (request, response) => { const path = new URL(request.url, "http://localhost").pathname; + if (path === "/api/contact" || path === "/api/contact/validate") { + return handleContact( + request, + response, + contact, + path.endsWith("/validate"), + ); + } if (path === "/api/angebote" && request.method === "GET") { return json(response, 200, { packages: catalog.map(({ id, name, summary }) => ({ diff --git a/src/server/routes/bookings.js b/src/server/routes/bookings.js index 9209502..5618720 100644 --- a/src/server/routes/bookings.js +++ b/src/server/routes/bookings.js @@ -9,7 +9,7 @@ export function json(response, status, body) { response.end(JSON.stringify(body)); } -async function readJson(request) { +export async function readJson(request) { if ( request.headers["content-type"]?.split(";")[0].trim() !== "application/json" diff --git a/src/server/routes/contact.js b/src/server/routes/contact.js new file mode 100644 index 0000000..464e240 --- /dev/null +++ b/src/server/routes/contact.js @@ -0,0 +1,38 @@ +import { BookingError } from "../services/booking-service.js"; +import { json, readJson } from "./bookings.js"; + +export async function handleContact(request, response, service, validateOnly) { + if (request.method !== "POST") { + response.setHeader("Allow", "POST"); + return json(response, 405, { + error: { + code: "METHOD_NOT_ALLOWED", + message: "Bitte POST verwenden.", + }, + }); + } + try { + const input = await readJson(request); + const result = await (validateOnly + ? service.validate(input) + : service.submit(input)); + return json(response, validateOnly ? 200 : 201, result); + } catch (error) { + return json( + response, + error instanceof BookingError ? error.status : 500, + { + error: { + code: + error instanceof BookingError + ? error.code + : "INTERNAL_ERROR", + message: + error instanceof BookingError + ? error.message + : "Die Kontaktanfrage konnte nicht verarbeitet werden.", + }, + }, + ); + } +} diff --git a/src/server/services/booking-service.js b/src/server/services/booking-service.js index 5ecf2e2..4d04ec0 100644 --- a/src/server/services/booking-service.js +++ b/src/server/services/booking-service.js @@ -17,7 +17,7 @@ export class BookingError extends Error { const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; // Übliche unquotierte E-Mail-Adressen; einzelne Domainlabels maximal 63 Zeichen. -const emailPattern = +export const emailPattern = /^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/i; function validateInput(input, key) { diff --git a/src/server/services/contact-email.js b/src/server/services/contact-email.js new file mode 100644 index 0000000..6186150 --- /dev/null +++ b/src/server/services/contact-email.js @@ -0,0 +1,51 @@ +import { createMailTransport } from "./email-service.js"; +import { BookingError } from "./booking-service.js"; +import { contactNotice } from "../../shared/contact.js"; + +export function createContactEmailService(makeTransport) { + // Dieser Ablauf bleibt auch bei extern konfiguriertem Buchungs-SMTP lokal. + const { transport, from } = createMailTransport( + { + SMTP_HOST: "127.0.0.1", + SMTP_PORT: "1025", + SMTP_FROM: "Tri-Hub Simulation ", + }, + makeTransport, + ); + return async ({ email, advisor, reason }) => { + const results = await Promise.allSettled( + [ + { + to: email, + text: `Deine Kontaktanfrage an ${advisor.name} ist in der Simulation eingegangen.`, + }, + { + to: advisor.email, + text: `Hallo ${advisor.name}, eine simulierte Kontaktanfrage von ${email} ist eingegangen.`, + }, + ].map(async ({ to, text }) => { + const result = await transport.sendMail({ + from, + to, + subject: + "Eingangsbestätigung – Tri-Hub Kontakt (Simulation)", + text: `${text}\n\nKontaktgrund: ${reason}\n\n${contactNotice}`, + }); + if ( + !result.accepted?.some( + (address) => address.toLowerCase() === to.toLowerCase(), + ) + ) { + throw new Error("SMTP hat den Empfänger nicht angenommen."); + } + }), + ); + if (results.some((result) => result.status === "rejected")) { + throw new BookingError( + 502, + "CONTACT_MAIL_FAILED", + "Nicht beide Bestätigungen konnten bestätigt werden. Bitte Mailpit prüfen, bevor du erneut sendest; eine Nachricht kann bereits eingegangen sein.", + ); + } + }; +} diff --git a/src/server/services/contact-service.js b/src/server/services/contact-service.js new file mode 100644 index 0000000..ea8ec82 --- /dev/null +++ b/src/server/services/contact-service.js @@ -0,0 +1,108 @@ +import { findAdvisor } from "../../shared/berater.js"; +import { BookingError, emailPattern } from "./booking-service.js"; +import { contactReasons, contactBookingUrl } from "../../shared/contact.js"; + +function advisorDetails(advisorId) { + const advisor = findAdvisor(advisorId); + if (!advisor) { + throw new BookingError( + 400, + "INVALID_ADVISOR", + "Bitte eine gültige Berater-ID übergeben.", + ); + } + if ( + typeof advisor.name !== "string" || + !advisor.name.trim() || + typeof advisor.email !== "string" || + advisor.email.length > 254 || + !emailPattern.test(advisor.email) || + advisor.email.split("@")[0].length > 64 || + !advisor.email.toLowerCase().endsWith("@tri-hub.de") + ) { + throw new BookingError( + 503, + "ADVISOR_UNAVAILABLE", + "Die Kontaktdaten dieses Beraters sind nicht verfügbar.", + ); + } + return { ...advisor }; +} + +export function createContactService({ storage, sendContact }) { + async function validate(input) { + if ( + !input || + typeof input !== "object" || + Array.isArray(input) || + typeof input.email !== "string" || + input.email.length > 254 + ) { + throw new BookingError( + 400, + "INVALID_EMAIL", + "Bitte eine gültige E-Mail-Adresse eingeben.", + ); + } + const email = input.email.trim().toLowerCase(); + if (!emailPattern.test(email) || email.split("@")[0].length > 64) { + throw new BookingError( + 400, + "INVALID_EMAIL", + "Bitte eine gültige E-Mail-Adresse eingeben.", + ); + } + let records; + try { + records = await storage.readAll(); + } catch { + throw new BookingError( + 503, + "STORAGE_UNAVAILABLE", + "Die Kundendaten können gerade nicht geprüft werden.", + ); + } + if (!records.some((row) => row.email?.trim().toLowerCase() === email)) { + throw new BookingError( + 403, + "CUSTOMER_NOT_FOUND", + "Zu dieser E-Mail-Adresse liegt keine Buchung vor. Bitte verwende die Adresse deiner Buchung.", + ); + } + return { email, valid: true }; + } + return { + validate, + async submit(input) { + const { email } = await validate(input); + const advisor = advisorDetails(input.advisorId); + if ( + typeof input.reason !== "string" || + !Object.hasOwn(contactReasons, input.reason) + ) { + throw new BookingError( + 400, + "INVALID_REASON", + "Bitte einen Kontaktgrund auswählen.", + ); + } + if (input.simulationAccepted !== true) { + throw new BookingError( + 400, + "SIMULATION_REQUIRED", + "Bitte den Simulationshinweis bestätigen.", + ); + } + await sendContact({ + email, + advisor, + reason: contactReasons[input.reason], + }); + return { + simulated: true, + emailStatus: "accepted", + redirectUrl: contactBookingUrl, + }; + }, + }; +} diff --git a/src/server/services/email-service.js b/src/server/services/email-service.js index a3415ec..be715c8 100644 --- a/src/server/services/email-service.js +++ b/src/server/services/email-service.js @@ -2,7 +2,7 @@ import nodemailer from "nodemailer"; import { orderSummaryRows } from "../../shared/order-summary.js"; import { paymentNotice } from "../../shared/booking-copy.js"; -export function createEmailService( +export function createMailTransport( env = process.env, makeTransport = nodemailer.createTransport, ) { @@ -34,6 +34,14 @@ export function createEmailService( disableUrlAccess: true, }); const from = env.SMTP_FROM || "Tri-Hub "; + return { transport, from }; +} + +export function createEmailService( + env = process.env, + makeTransport = nodemailer.createTransport, +) { + const { transport, from } = createMailTransport(env, makeTransport); return async function sendConfirmation(booking) { const order = booking.bookedPackage; const phone = booking.phone || booking.customer?.phone; diff --git a/src/server/swagger.json b/src/server/swagger.json index 7ba05eb..f703ecf 100644 --- a/src/server/swagger.json +++ b/src/server/swagger.json @@ -2,8 +2,8 @@ "openapi": "3.0.3", "info": { "title": "Tri-Hub Ernährungsberatung & Booking API", - "description": "API-Spezifikation für die Angebotsübersicht (US2.1), die Paket-Detailansicht und den Buchungsservice (US2.4 Paket buchen).", - "version": "1.1.0" + "description": "API-Spezifikation für die Angebotsübersicht (US2.1), die Paket-Detailansicht und den Buchungsservice (US2.4 Paket buchen). Kontakt-Simulation mit CSV-Kundenvalidierung und Mailpit.", + "version": "1.2.0" }, "servers": [ { @@ -224,6 +224,288 @@ } ] } + }, + "/contact/validate": { + "post": { + "tags": ["Contact"], + "summary": "Kunden-E-Mail prüfen", + "description": "Vergleicht normalisierte E-Mail mit bookings.csv. Kein Nachweis des Postfachbesitzes.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["email"], + "properties": { + "email": { + "type": "string", + "format": "email", + "maxLength": 254 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Kunde in CSV gefunden", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["valid", "email"], + "properties": { + "valid": { + "type": "boolean", + "enum": [true] + }, + "email": { + "type": "string", + "format": "email", + "maxLength": 254 + } + } + } + } + } + }, + "400": { + "description": "Ungültige Eingabe oder JSON", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "403": { + "description": "Keine Buchung zur E-Mail", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "405": { + "description": "Nur POST erlaubt", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "413": { + "description": "Mehr als 8 KiB", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "415": { + "description": "Content-Type muss application/json sein", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "503": { + "description": "CSV nicht lesbar", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "500": { + "description": "Unerwarteter Fehler", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + } + } + } + }, + "/contact": { + "post": { + "tags": ["Contact"], + "summary": "Beraterkontakt simulieren", + "description": "Vergleicht normalisierte E-Mail mit bookings.csv. Kein Nachweis des Postfachbesitzes. Prüft CSV bei jedem Absenden erneut. Versand ausschließlich an lokales Mailpit (127.0.0.1:1025), zwei getrennte Bestätigungen. Kein automatischer Neuversand, keine persistente Kontaktablage.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "email", + "advisorId", + "reason", + "simulationAccepted" + ], + "properties": { + "email": { + "type": "string", + "format": "email", + "maxLength": 254 + }, + "reason": { + "type": "string", + "enum": [ + "coaching", + "nutrition", + "competition", + "recovery", + "package", + "appointment", + "other" + ] + }, + "simulationAccepted": { + "type": "boolean", + "enum": [true] + }, + "advisorId": { + "type": "string", + "example": "anna-mueller", + "description": "ID aus src/shared/berater-daten.json. Name und E-Mail werden serverseitig ausschließlich dort nachgeschlagen." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Beide simulierten E-Mails von Mailpit angenommen; Weiterleitung möglich", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "simulated", + "emailStatus", + "redirectUrl" + ], + "properties": { + "simulated": { + "type": "boolean", + "enum": [true] + }, + "emailStatus": { + "type": "string", + "enum": ["accepted"] + }, + "redirectUrl": { + "type": "string", + "format": "uri", + "description": "Feste Microsoft-Bookings-Adresse; keine benutzerdefinierten Redirects." + } + } + } + } + } + }, + "400": { + "description": "Ungültige Eingabe oder JSON", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "403": { + "description": "Keine Buchung zur E-Mail", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "405": { + "description": "Nur POST erlaubt", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "413": { + "description": "Mehr als 8 KiB", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "415": { + "description": "Content-Type muss application/json sein", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "503": { + "description": "CSV nicht lesbar oder Beraterkontaktdaten ungültig", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "500": { + "description": "Unerwarteter Fehler", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "502": { + "description": "Mindestens eine Mailannahme fehlgeschlagen oder unklar; vor Wiederholung Mailpit prüfen", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + } + } + } } }, "components": { @@ -676,6 +958,24 @@ } } } + }, + "ContactError": { + "type": "object", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } } } } diff --git a/src/shared/berater-daten.json b/src/shared/berater-daten.json index e150614..5c5f236 100644 --- a/src/shared/berater-daten.json +++ b/src/shared/berater-daten.json @@ -1,12 +1,12 @@ [ - { - "id": "anna-mueller", - "name": "Anna Müller", - "email": "anna.mueller@tri-hub.de", - "titel": "Ernährungsberaterin", - "biographie": "Anna begleitet Ausdauerathletinnen und -athleten dabei, eine alltagstaugliche Ernährungsweise zu entwickeln. Im Mittelpunkt stehen individuelle Bedürfnisse, praktikable Routinen und eine gute Versorgung rund um Training und Wettkampf.", - "qualifikationen": ["Ernährungsberatung", "Sporternährung"], - "schwerpunkte": ["Sporternährung", "Wettkampfverpflegung", "Hydration"], - "bildquelle": "" - } + { + "id": "anna-mueller", + "name": "Anna Müller", + "email": "anna.mueller@tri-hub.de", + "titel": "Ernährungsberaterin", + "biographie": "Anna begleitet Ausdauerathletinnen und -athleten dabei, eine alltagstaugliche Ernährungsweise zu entwickeln. Im Mittelpunkt stehen individuelle Bedürfnisse, praktikable Routinen und eine gute Versorgung rund um Training und Wettkampf.", + "qualifikationen": ["Ernährungsberatung", "Sporternährung"], + "schwerpunkte": ["Sporternährung", "Wettkampfverpflegung", "Hydration"], + "bildquelle": "" + } ] diff --git a/src/shared/berater.js b/src/shared/berater.js new file mode 100644 index 0000000..606e86f --- /dev/null +++ b/src/shared/berater.js @@ -0,0 +1,7 @@ +import berater from "./berater-daten.json" with { type: "json" }; + +export function findAdvisor(advisorId) { + return typeof advisorId === "string" + ? berater.find((entry) => entry.id === advisorId) + : undefined; +} diff --git a/src/shared/contact.js b/src/shared/contact.js new file mode 100644 index 0000000..41ae552 --- /dev/null +++ b/src/shared/contact.js @@ -0,0 +1,15 @@ +export const contactReasons = { + coaching: "Persönliches Coaching und Erstgespräch", + nutrition: "Ernährungsplan und Anpassungen", + competition: "Wettkampfverpflegung und Hydration", + recovery: "Regeneration und Verträglichkeit", + package: "Fragen zum gebuchten Paket", + appointment: "Termin vereinbaren oder ändern", + other: "Sonstiges Anliegen", +}; + +export const contactNotice = + "Dies ist eine Simulation des Kontaktprozesses. Die Bestätigungen werden nur im lokalen Mailpit gesammelt. Es wird kein Berater tatsächlich kontaktiert. Die Weiterleitung öffnet die externe Microsoft-Bookings-Seite; dort wird hierdurch kein Termin gebucht."; + +export const contactBookingUrl = + "https://bookings.cloud.microsoft/bookwithme/user/b055625c7e5b4bf3866230ffa066536b%40Tri-hub.de/meetingtype/qkJP99nqg0a_Na5Z-gY9WQ2?anonymous&ismsaljsauthenabled"; diff --git a/src/styles/navigation.css b/src/styles/navigation.css index a67d028..e22d54c 100644 --- a/src/styles/navigation.css +++ b/src/styles/navigation.css @@ -5,7 +5,7 @@ :root { --nav-bg: #0d0d1a; --nav-border: rgba(248, 250, 252, 0.08); - --nav-green: #10b981; + --nav-accent: #53d5cd; --nav-white: #ffffff; --nav-light: #f8fafc; --nav-muted: #94a3b8; @@ -33,7 +33,7 @@ .navbar__inner { width: 100%; - min-height: 76px; + min-height: 96px; padding: 0 20px; display: flex; align-items: center; @@ -52,9 +52,13 @@ } .navbar__logo-img { - height: 40px; - width: auto; + width: 72px; + height: 72px; + padding: 5px; display: block; + object-fit: contain; + background: #fff; + border-radius: 16px; } .navbar__brand-text { @@ -119,7 +123,7 @@ } .navbar__link--home:hover { - border-color: rgba(16, 185, 129, 0.55); + border-color: rgba(83, 213, 205, 0.55); color: var(--nav-white); } @@ -127,7 +131,7 @@ display: inline-flex; align-items: center; justify-content: center; - color: #22d3ee; + color: var(--nav-accent); } .navbar__home-icon svg { @@ -142,7 +146,7 @@ .navbar a:focus-visible, .navbar button:focus-visible { - outline: 3px solid var(--nav-green); + outline: 3px solid var(--nav-accent); outline-offset: 2px; border-radius: 4px; } @@ -184,7 +188,7 @@ .navbar__nav { position: absolute; - top: 76px; + top: 96px; left: 0; right: 0; background: var(--nav-bg); @@ -217,5 +221,5 @@ /* Platz für die fixierte Navbar */ body { - padding-top: 76px; + padding-top: 96px; } diff --git a/src/styles/packages.css b/src/styles/packages.css index dd8c95b..c058b4b 100644 --- a/src/styles/packages.css +++ b/src/styles/packages.css @@ -3,8 +3,8 @@ min-height: 100vh; color: #f8fafc; background: - radial-gradient(circle at 12% 0%, #10b98117, transparent 30%), - radial-gradient(circle at 88% 26%, #10b9810e, transparent 27%), + radial-gradient(circle at 12% 0%, #53d5cd17, transparent 30%), + radial-gradient(circle at 88% 26%, #53d5cd0e, transparent 27%), linear-gradient(145deg, #0d0d1a, #1a1a2e); font-family: Inter, system-ui, sans-serif; line-height: 1.6; @@ -29,7 +29,7 @@ } .packages-eyebrow, .package-type { - color: #10b981; + color: #53d5cd; font-size: 0.75rem; font-weight: 600; letter-spacing: 0.14em; @@ -82,9 +82,9 @@ .package-highlight { border-radius: 999px; padding: 0.25rem 0.65rem; - background: #10b98114; - color: #34d399; - border: 1px solid #10b98133; + background: #53d5cd14; + color: #75e2dc; + border: 1px solid #53d5cd33; font-size: 0.65rem; font-weight: 600; letter-spacing: 0.05em; @@ -137,30 +137,30 @@ cursor: pointer; } .package-btn:hover { - border-color: #10b981; - background: #10b98114; + border-color: #53d5cd; + background: #53d5cd14; } .package-btn.primary-cta { - background: #10b981; - border-color: #10b981; + background: #53d5cd; + border-color: #53d5cd; color: #06130f; } .package-btn.primary-cta:hover { - background: #34d399; + background: #75e2dc; } .package-btn:disabled { opacity: 0.65; cursor: wait; } .packages-section :focus-visible { - outline: 3px solid #10b981; + outline: 3px solid #53d5cd; outline-offset: 4px; } .packages-back { display: inline-block; margin-bottom: 2rem; padding-block: 0.5rem; - color: #34d399; + color: #75e2dc; text-decoration: none; } .detail-heading { @@ -188,7 +188,7 @@ margin-block: 0.65rem; } .detail-list li::marker { - color: #10b981; + color: #53d5cd; } .process-step { margin-top: 1rem; @@ -212,7 +212,7 @@ font-size: clamp(2rem, 4vw, 3rem); font-weight: 600; letter-spacing: -0.04em; - color: #34d399; + color: #75e2dc; } .price-note, .booking-notice { @@ -250,12 +250,12 @@ border-color: #94a3b840; } .packages-section .booking__dialog .booking__button:not(.booking__cancel) { - background: #10b981; - border-color: #10b981; + background: #53d5cd; + border-color: #53d5cd; color: #06130f; } .packages-section .booking__dialog :focus-visible { - outline-color: #10b981; + outline-color: #53d5cd; } @media (max-width: 60rem) { .packages-container { diff --git a/src/styles/styles.css b/src/styles/styles.css index 697e903..f4ae5cf 100644 --- a/src/styles/styles.css +++ b/src/styles/styles.css @@ -333,16 +333,16 @@ --color-yellow-300: oklch(90.5% 0.182 98.111); --color-yellow-500: oklch(79.5% 0.184 86.047); --color-yellow-700: oklch(55.4% 0.135 66.442); - --color-green-100: oklch(96.2% 0.044 156.743); - --color-green-200: oklch(92.5% 0.084 155.995); - --color-green-300: oklch(87.1% 0.15 154.449); - --color-green-400: oklch(79.2% 0.209 151.711); - --color-green-500: oklch(72.3% 0.219 149.579); - --color-green-600: oklch(62.7% 0.194 149.214); - --color-green-700: oklch(52.7% 0.154 150.069); - --color-green-800: oklch(44.8% 0.119 151.328); - --color-emerald-100: oklch(95% 0.052 163.051); - --color-emerald-400: oklch(76.5% 0.177 163.223); + --color-green-100: #e6fbf9; + --color-green-200: #cef7f4; + --color-green-300: #a5eee9; + --color-green-400: #78e2dc; + --color-green-500: #53d5cd; + --color-green-600: #36b8b1; + --color-green-700: #298f8b; + --color-green-800: #1e706d; + --color-emerald-100: #e6fbf9; + --color-emerald-400: #53d5cd; --color-teal-500: oklch(70.4% 0.14 182.503); --color-teal-600: oklch(60% 0.118 184.704); --color-cyan-100: oklch(95.6% 0.045 203.388); @@ -2483,7 +2483,7 @@ } } .border-emerald-400\/20 { - border-color: #00d29433; + border-color: #53d5cd33; } @supports (color: color-mix(in lab, red, red)) { .border-emerald-400\/20 { @@ -2495,7 +2495,7 @@ } } .border-emerald-400\/30 { - border-color: #00d2944d; + border-color: #53d5cd4d; } @supports (color: color-mix(in lab, red, red)) { .border-emerald-400\/30 { @@ -2540,7 +2540,7 @@ border-color: var(--color-green-500); } .border-green-500\/20 { - border-color: #00c75833; + border-color: #53d5cd33; } @supports (color: color-mix(in lab, red, red)) { .border-green-500\/20 { @@ -2552,7 +2552,7 @@ } } .border-green-500\/30 { - border-color: #00c7584d; + border-color: #53d5cd4d; } @supports (color: color-mix(in lab, red, red)) { .border-green-500\/30 { @@ -3109,7 +3109,7 @@ } } .bg-emerald-400\/10 { - background-color: #00d2941a; + background-color: #53d5cd1a; } @supports (color: color-mix(in lab, red, red)) { .bg-emerald-400\/10 { @@ -3164,7 +3164,7 @@ background-color: var(--color-green-100); } .bg-green-500\/10 { - background-color: #00c7581a; + background-color: #53d5cd1a; } @supports (color: color-mix(in lab, red, red)) { .bg-green-500\/10 { @@ -3176,7 +3176,7 @@ } } .bg-green-500\/20 { - background-color: #00c75833; + background-color: #53d5cd33; } @supports (color: color-mix(in lab, red, red)) { .bg-green-500\/20 { @@ -8681,8 +8681,8 @@ --ink-strong: oklch(20% 0.03 220); --surface-elevated: oklch(18% 0.026 221); --surface-soft: oklch(23% 0.028 219); - --accent-strong: oklch(70% 0.12 205); - --accent-muted: oklch(58% 0.09 205); + --accent-strong: #53d5cd; + --accent-muted: #36b8b1; --accent-warm: oklch(84% 0.16 87); --background: oklch(13% 0.022 223); --foreground: oklch(96% 0.008 210); @@ -8690,7 +8690,7 @@ --card-foreground: oklch(95% 0.008 210); --popover: oklch(17% 0.024 221); --popover-foreground: oklch(95% 0.008 210); - --primary: oklch(70% 0.12 205); + --primary: #53d5cd; --primary-foreground: oklch(18% 0.02 220); --secondary: oklch(24% 0.026 220); --secondary-foreground: oklch(90% 0.01 210); @@ -8710,7 +8710,7 @@ --chart-5: oklch(63% 0.09 177); --sidebar: oklch(15% 0.02 221); --sidebar-foreground: oklch(95% 0.008 210); - --sidebar-primary: oklch(70% 0.12 205); + --sidebar-primary: #53d5cd; --sidebar-primary-foreground: oklch(18% 0.02 220); --sidebar-accent: oklch(23% 0.026 219); --sidebar-accent-foreground: oklch(95% 0.008 210); diff --git a/src/tests/browser/contact.spec.js b/src/tests/browser/contact.spec.js new file mode 100644 index 0000000..e869afe --- /dev/null +++ b/src/tests/browser/contact.spec.js @@ -0,0 +1,98 @@ +import { test, expect } from "@playwright/test"; +import { randomUUID } from "node:crypto"; +import { contactBookingUrl } from "../../shared/contact.js"; + +async function open(page) { + await page.goto("/contact-test.html?berater-id=max-mustermann"); + await page + .getByRole("button", { name: "Berater kontaktieren", exact: true }) + .click(); +} + +test("Popup prüft E-Mail beim Absenden und leitet nur bekannte Kunden weiter", async ({ + page, + request, +}) => { + const email = `kontakt-${randomUUID()}@example.test`; + const booking = await request.post("/api/bookings", { + headers: { "Idempotency-Key": randomUUID() }, + data: { packageId: "ernaehrung-starter", name: "Testkunde", email }, + }); + expect(booking.ok()).toBeTruthy(); + await open(page); + await expect(page.getByRole("dialog")).toContainText("Max Mustermann"); + const submit = page.getByRole("button", { name: "Anfrage senden" }); + await expect( + page.getByRole("button", { name: "E-Mail prüfen" }), + ).toHaveCount(0); + await expect(submit).toBeEnabled(); + await page + .getByLabel("Deine Buchungs-E-Mail") + .fill("unbekannt@example.test"); + await page.getByLabel("Grund für den Kontakt").selectOption("nutrition"); + await page.getByRole("checkbox").check(); + await submit.click(); + await expect(page.getByRole("status")).toContainText("keine Buchung"); + await expect(page.getByRole("dialog")).toBeVisible(); + await page.getByLabel("Deine Buchungs-E-Mail").fill(email); + await page.route("https://bookings.cloud.microsoft/**", (route) => + route.fulfill({ body: "Microsoft Bookings (Test)" }), + ); + const sentRequest = page.waitForRequest((request) => + request.url().endsWith("/api/contact"), + ); + await submit.click(); + expect((await sentRequest).postDataJSON().advisorId).toBe("max-mustermann"); + await expect(page).toHaveURL(contactBookingUrl); +}); + +test("Mobiles Popup: Fokus, Escape, Rückkehr zum Auslöser und keine Überbreite", async ({ + page, +}) => { + await page.setViewportSize({ width: 375, height: 667 }); + await open(page); + await expect(page.getByLabel("Deine Buchungs-E-Mail")).toBeFocused(); + const box = await page.getByRole("dialog").boundingBox(); + expect(box.x).toBeGreaterThanOrEqual(0); + expect(box.x + box.width).toBeLessThanOrEqual(375); + await page.keyboard.press("Shift+Tab"); + await expect( + page.getByRole("button", { name: "Kontaktfenster schließen" }), + ).toBeFocused(); + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect( + page.getByRole("button", { name: "Berater kontaktieren", exact: true }), + ).toBeFocused(); +}); + +test("Versandfehler zeigt Meldung und bleibt im Popup", async ({ page }) => { + await page.route("**/api/contact", (route) => + route.fulfill({ + status: 502, + json: { error: { message: "Mailpit nicht erreichbar." } }, + }), + ); + await open(page); + await page.getByLabel("Deine Buchungs-E-Mail").fill("kunde@example.test"); + await page.getByLabel("Grund für den Kontakt").selectOption("package"); + await page.getByRole("checkbox").check(); + await page.getByRole("button", { name: "Anfrage senden" }).click(); + await expect(page.getByRole("status")).toContainText( + "Mailpit nicht erreichbar", + ); + await expect(page.getByRole("dialog")).toBeVisible(); +}); + +test("Unbekannte Berater-ID sperrt das Absenden", async ({ page }) => { + await page.goto("/contact-test.html?berater-id=unbekannt"); + await page + .getByRole("button", { name: "Berater kontaktieren", exact: true }) + .click(); + await expect(page.getByRole("dialog")).toContainText( + "Berater-ID fehlt oder ist unbekannt", + ); + await expect( + page.getByRole("button", { name: "Anfrage senden" }), + ).toBeDisabled(); +}); diff --git a/src/tests/contact.test.js b/src/tests/contact.test.js new file mode 100644 index 0000000..63e3a97 --- /dev/null +++ b/src/tests/contact.test.js @@ -0,0 +1,165 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createApp } from "../server/index.js"; +import { createCsvStorage } from "../server/services/csv-storage.js"; +import { createContactEmailService } from "../server/services/contact-email.js"; +import { contactBookingUrl } from "../shared/contact.js"; + +async function fixture(t, options = {}) { + const dir = await mkdtemp(join(tmpdir(), "trihub-contact-")); + const storage = createCsvStorage(join(dir, "bookings.csv")); + await storage.writeAll([ + { + bookingId: "TH-000001", + email: "kunde@example.test", + name: "Testkunde", + }, + ]); + const sent = []; + const app = createApp({ + storage, + sendConfirmation: async () => {}, + sendContact: async (data) => sent.push(data), + ...options, + }); + await new Promise((resolve) => app.listen(0, "127.0.0.1", resolve)); + t.after(async () => { + app.closeAllConnections(); + await new Promise((resolve) => app.close(resolve)); + await rm(dir, { recursive: true, force: true }); + }); + const base = `http://127.0.0.1:${app.address().port}`; + const post = async (path, input) => { + const response = await fetch(base + path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + return { status: response.status, body: await response.json() }; + }; + return { post, sent, storage, base }; +} +const input = { + email: "kunde@example.test", + advisorId: "anna-mueller", + reason: "coaching", + simulationAccepted: true, +}; + +test("CSV-Abgleich normalisiert E-Mail; Versand erhält Berater und festen Redirect", async (t) => { + const f = await fixture(t); + const before = await f.storage.readAll(); + const validation = await f.post("/api/contact/validate", { + email: " KUNDE@example.test ", + }); + assert.equal(validation.status, 200); + assert.equal(validation.body.email, input.email); + const result = await f.post("/api/contact", { + ...input, + advisor: "Manipuliert", + advisorEmail: "falsch@example.test", + }); + assert.equal(result.status, 201); + assert.equal(result.body.redirectUrl, contactBookingUrl); + assert.equal(result.body.simulated, true); + assert.equal(f.sent[0].advisor.email, "anna.mueller@tri-hub.de"); + assert.equal(f.sent[0].advisor.name, "Anna Müller"); + const second = await f.post("/api/contact", { + ...input, + advisorId: "max-mustermann", + }); + assert.equal(second.status, 201); + assert.equal(f.sent[1].advisor.email, "max.mustermann@tri-hub.de"); + assert.deepEqual(await f.storage.readAll(), before); +}); + +test("Ungültige und unbekannte Kunden, manipulierte Berater, Gründe und fehlende Zustimmung senden nichts", async (t) => { + const f = await fixture(t); + for (const [data, status] of [ + [null, 400], + [{ ...input, email: "bad" }, 400], + [{ ...input, email: "unknown@example.test" }, 403], + [{ ...input, advisorId: "unbekannt" }, 400], + [{ ...input, advisorId: null }, 400], + [{ ...input, reason: "__proto__" }, 400], + [{ ...input, simulationAccepted: false }, 400], + ]) + assert.equal((await f.post("/api/contact", data)).status, status); + assert.equal(f.sent.length, 0); +}); + +test("Absenden prüft CSV erneut; Speicherfehler bleiben geschlossen", async (t) => { + const f = await fixture(t); + assert.equal((await f.post("/api/contact/validate", input)).status, 200); + await f.storage.writeAll([]); + assert.equal((await f.post("/api/contact", input)).status, 403); + const broken = await fixture(t, { + storage: { + readAll() { + throw new Error("unavailable"); + }, + }, + }); + assert.equal((await broken.post("/api/contact", input)).status, 503); + assert.equal(broken.sent.length, 0); +}); + +test("HTTP-Vertrag weist falsche Methode, ungültiges JSON und große Requests zurück", async (t) => { + const f = await fixture(t); + assert.equal((await fetch(f.base + "/api/contact")).status, 405); + assert.equal( + (await fetch(f.base + "/api/contact", { method: "POST", body: "x" })) + .status, + 415, + ); + assert.equal( + ( + await fetch(f.base + "/api/contact", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{", + }) + ).status, + 400, + ); + assert.equal( + (await f.post("/api/contact", { email: "x".repeat(9000) })).status, + 413, + ); +}); + +test("Zwei getrennte simulierte Bestätigungen; Teilfehler verhindern Erfolg", async (t) => { + const messages = []; + const sender = createContactEmailService((config) => { + assert.equal(config.host, "127.0.0.1"); + assert.equal(config.port, 1025); + return { + async sendMail(message) { + messages.push(message); + return { accepted: [message.to] }; + }, + }; + }); + const f = await fixture(t, { sendContact: sender }); + assert.equal((await f.post("/api/contact", input)).status, 201); + assert.deepEqual( + messages.map((m) => m.to), + [input.email, "anna.mueller@tri-hub.de"], + ); + for (const message of messages) { + assert.match(message.text, /Simulation/); + assert.match(message.text, /Persönliches Coaching/); + } + const failing = createContactEmailService(() => ({ + async sendMail(message) { + return { accepted: message.to === input.email ? [message.to] : [] }; + }, + })); + const failure = await fixture(t, { sendContact: failing }); + const result = await failure.post("/api/contact", input); + assert.equal(result.status, 502); + assert.equal(result.body.redirectUrl, undefined); +}); diff --git a/src/tests/helpers/browser-server.js b/src/tests/helpers/browser-server.js index f83d189..e9805ce 100644 --- a/src/tests/helpers/browser-server.js +++ b/src/tests/helpers/browser-server.js @@ -11,6 +11,7 @@ const server = createApp({ catalog: packages, storage: createCsvStorage(join(directory, "bookings.csv")), sendConfirmation: async () => {}, + sendContact: async () => {}, }); server.listen(3000, "127.0.0.1"); async function stop() { diff --git a/vite.config.js b/vite.config.js index a849d45..a0e5234 100644 --- a/vite.config.js +++ b/vite.config.js @@ -19,6 +19,9 @@ export default defineConfig({ build: { rollupOptions: { input: { + contactTest: fileURLToPath( + new URL("./contact-test.html", import.meta.url), + ), angebote: fileURLToPath( new URL("./angebotsuebersicht.html", import.meta.url), ),