feat(contact): Beraterkontakt mit ID-Zuordnung und Kundenvalidierung
Kontakt-Popup mit serverseitigem CSV-Abgleich, Mailpit-Bestätigungen und Weiterleitung zu Microsoft Bookings ergänzen. Berater über gemeinsame JSON-Daten zuordnen sowie Swagger, Integration, Testseite und automatisierte Tests dokumentieren.feature/beraterprofile
parent
55e4904482
commit
5553d117d6
|
|
@ -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
|
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.
|
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
|
## Prüfen
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Berater kontaktieren – Tri-Hub Testseite</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="contact-test">
|
||||||
|
<p>TRI-HUB · KONTAKT-DEMO</p>
|
||||||
|
<h1>Dein direkter Draht zur Beratung</h1>
|
||||||
|
<p id="advisor-name"></p>
|
||||||
|
<button id="open-contact" type="button">
|
||||||
|
Berater kontaktieren
|
||||||
|
</button>
|
||||||
|
<p>
|
||||||
|
Testseite für das spätere Beraterprofil. Nutze eine
|
||||||
|
E-Mail-Adresse aus einer vorhandenen Testbuchung.
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
<script
|
||||||
|
type="module"
|
||||||
|
src="/src/features/contact/contact-entry.js"
|
||||||
|
></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -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
|
||||||
|
<button type="button" data-berater-id="anna-mueller">
|
||||||
|
Berater kontaktieren
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
@ -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 <http://localhost:5173/contact-test.html?berater-id=anna-mueller>.
|
||||||
|
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
|
||||||
|
<http://localhost:8025> 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.
|
||||||
|
|
@ -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.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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 = `
|
||||||
|
<button class="contact-close" type="button" aria-label="Kontaktfenster schließen">×</button>
|
||||||
|
<p class="contact-eyebrow">DEIN NÄCHSTER SCHRITT</p>
|
||||||
|
<h2 id="contact-title">Berater kontaktieren</h2>
|
||||||
|
<p class="contact-advisor"></p>
|
||||||
|
<form class="contact-form">
|
||||||
|
<div><label for="contact-email">Deine Buchungs-E-Mail</label>
|
||||||
|
<input id="contact-email" name="email" type="email" autocomplete="email" maxlength="254" required aria-describedby="contact-email-help">
|
||||||
|
<p id="contact-email-help" class="contact-help">Verwende die E-Mail-Adresse, mit der du dein Paket gebucht hast.</p>
|
||||||
|
</div>
|
||||||
|
<div><label for="contact-reason">Grund für den Kontakt</label>
|
||||||
|
<select id="contact-reason" name="reason" required><option value="">Bitte auswählen</option></select>
|
||||||
|
</div>
|
||||||
|
<label class="contact-consent"><input name="simulationAccepted" type="checkbox" required><span></span></label>
|
||||||
|
<p class="contact-status" role="status" aria-live="polite"></p>
|
||||||
|
<button class="contact-submit" type="submit" disabled>Anfrage senden & Termin wählen</button>
|
||||||
|
</form>`;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -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 }));
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 { createServer } from "node:http";
|
||||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
import { packages } from "../shared/packages.js";
|
import { packages } from "../shared/packages.js";
|
||||||
|
|
@ -16,6 +19,7 @@ export function createApp({
|
||||||
),
|
),
|
||||||
details,
|
details,
|
||||||
sendConfirmation,
|
sendConfirmation,
|
||||||
|
sendContact,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const ids = new Set();
|
const ids = new Set();
|
||||||
for (const entry of catalog) {
|
for (const entry of catalog) {
|
||||||
|
|
@ -42,8 +46,20 @@ export function createApp({
|
||||||
packages: catalog,
|
packages: catalog,
|
||||||
sendConfirmation: sendConfirmation ?? createEmailService(),
|
sendConfirmation: sendConfirmation ?? createEmailService(),
|
||||||
});
|
});
|
||||||
|
const contact = createContactService({
|
||||||
|
storage,
|
||||||
|
sendContact: sendContact ?? createContactEmailService(),
|
||||||
|
});
|
||||||
return createServer(async (request, response) => {
|
return createServer(async (request, response) => {
|
||||||
const path = new URL(request.url, "http://localhost").pathname;
|
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") {
|
if (path === "/api/angebote" && request.method === "GET") {
|
||||||
return json(response, 200, {
|
return json(response, 200, {
|
||||||
packages: catalog.map(({ id, name, summary }) => ({
|
packages: catalog.map(({ id, name, summary }) => ({
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ export function json(response, status, body) {
|
||||||
response.end(JSON.stringify(body));
|
response.end(JSON.stringify(body));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readJson(request) {
|
export async function readJson(request) {
|
||||||
if (
|
if (
|
||||||
request.headers["content-type"]?.split(";")[0].trim() !==
|
request.headers["content-type"]?.split(";")[0].trim() !==
|
||||||
"application/json"
|
"application/json"
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,7 +17,7 @@ export class BookingError extends Error {
|
||||||
const uuidPattern =
|
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;
|
/^[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.
|
// Ü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;
|
/^[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) {
|
function validateInput(input, key) {
|
||||||
|
|
|
||||||
|
|
@ -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 <kontakt@tri-hub.de>",
|
||||||
|
},
|
||||||
|
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.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@ import nodemailer from "nodemailer";
|
||||||
import { orderSummaryRows } from "../../shared/order-summary.js";
|
import { orderSummaryRows } from "../../shared/order-summary.js";
|
||||||
import { paymentNotice } from "../../shared/booking-copy.js";
|
import { paymentNotice } from "../../shared/booking-copy.js";
|
||||||
|
|
||||||
export function createEmailService(
|
export function createMailTransport(
|
||||||
env = process.env,
|
env = process.env,
|
||||||
makeTransport = nodemailer.createTransport,
|
makeTransport = nodemailer.createTransport,
|
||||||
) {
|
) {
|
||||||
|
|
@ -34,6 +34,14 @@ export function createEmailService(
|
||||||
disableUrlAccess: true,
|
disableUrlAccess: true,
|
||||||
});
|
});
|
||||||
const from = env.SMTP_FROM || "Tri-Hub <buchung@example.test>";
|
const from = env.SMTP_FROM || "Tri-Hub <buchung@example.test>";
|
||||||
|
return { transport, from };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEmailService(
|
||||||
|
env = process.env,
|
||||||
|
makeTransport = nodemailer.createTransport,
|
||||||
|
) {
|
||||||
|
const { transport, from } = createMailTransport(env, makeTransport);
|
||||||
return async function sendConfirmation(booking) {
|
return async function sendConfirmation(booking) {
|
||||||
const order = booking.bookedPackage;
|
const order = booking.bookedPackage;
|
||||||
const phone = booking.phone || booking.customer?.phone;
|
const phone = booking.phone || booking.customer?.phone;
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
"openapi": "3.0.3",
|
"openapi": "3.0.3",
|
||||||
"info": {
|
"info": {
|
||||||
"title": "Tri-Hub Ernährungsberatung & Booking API",
|
"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).",
|
"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.1.0"
|
"version": "1.2.0"
|
||||||
},
|
},
|
||||||
"servers": [
|
"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": {
|
"components": {
|
||||||
|
|
@ -676,6 +958,24 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"ContactError": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["error"],
|
||||||
|
"properties": {
|
||||||
|
"error": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["code", "message"],
|
||||||
|
"properties": {
|
||||||
|
"code": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "anna-mueller",
|
||||||
|
"name": "Anna Müller",
|
||||||
|
"email": "anna.mueller@tri-hub.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "max-mustermann",
|
||||||
|
"name": "Max Mustermann",
|
||||||
|
"email": "max.mustermann@tri-hub.de"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
@ -11,6 +11,7 @@ const server = createApp({
|
||||||
catalog: packages,
|
catalog: packages,
|
||||||
storage: createCsvStorage(join(directory, "bookings.csv")),
|
storage: createCsvStorage(join(directory, "bookings.csv")),
|
||||||
sendConfirmation: async () => {},
|
sendConfirmation: async () => {},
|
||||||
|
sendContact: async () => {},
|
||||||
});
|
});
|
||||||
server.listen(3000, "127.0.0.1");
|
server.listen(3000, "127.0.0.1");
|
||||||
async function stop() {
|
async function stop() {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,9 @@ export default defineConfig({
|
||||||
build: {
|
build: {
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: {
|
input: {
|
||||||
|
contactTest: fileURLToPath(
|
||||||
|
new URL("./contact-test.html", import.meta.url),
|
||||||
|
),
|
||||||
angebote: fileURLToPath(
|
angebote: fileURLToPath(
|
||||||
new URL("./angebotsuebersicht.html", import.meta.url),
|
new URL("./angebotsuebersicht.html", import.meta.url),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue