feat(contact): Spitznamen mit Vornamen-Fallback im Kontakt-Popup anzeigen

Beraterdaten um getrennte Namensfelder ergänzen und den Anzeigenamen für Popup und Kontaktmails zentral bestimmen. Leere Spitznamen fallen auf den Vornamen zurück; Nachname und Kontaktieren-Zusatz entfallen in der Überschrift.

Integrationsdokumentation, API-Beispiele und Tests an den neuen Namensvertrag anpassen.
feature/beraterprofile
Marvin Gemlin 2026-09-29 15:45:13 +00:00
parent af88228d3b
commit 881ba21630
12 changed files with 80 additions and 39 deletions

View File

@ -45,7 +45,7 @@ nicht vorab leer anlegen: Das Backend erzeugt sie bei der ersten Buchung selbst.
## Beraterkontakt testen ## Beraterkontakt testen
Die [Kontakt-Testseite](http://localhost:5173/contact-test.html?berater-id=anna-mueller) Die [Kontakt-Testseite](http://localhost:5173/contact-test.html?berater-id=relindis-agethen)
öffnet das neue Popup. Kundenprüfung, Mailpit-Versand, Profil-Einbindung und öffnet das neue Popup. Kundenprüfung, Mailpit-Versand, Profil-Einbindung und
Checkliste sind in [docs/contact.md](docs/contact.md) dokumentiert. Checkliste sind in [docs/contact.md](docs/contact.md) dokumentiert.

View File

@ -7,8 +7,7 @@ kein eigenes Kontaktformular und keine separate E-Mail-Prüfung.
## Relevante Dateien ## Relevante Dateien
- `src/shared/berater-daten.json`: gemeinsame Datenquelle für Profile und Kontakt; - `src/shared/berater-daten.json`: gemeinsame Datenquelle für Profile und Kontakt;
Array mit eindeutigen String-IDs, `name` und `email`. Die aktuellen Einträge Array mit eindeutigen String-IDs, `vorname`, `nachname`, optionalem `spitzname` und `email`. Eigene öffentliche Profilfelder dürfen ergänzt werden.
sind Demo-Daten. Eigene öffentliche Profilfelder dürfen ergänzt werden.
- `src/shared/berater.js`: `findAdvisor(advisorId)` liefert den passenden Eintrag. - `src/shared/berater.js`: `findAdvisor(advisorId)` liefert den passenden Eintrag.
- `src/features/contact/contact-dialog.js`: exportiert `openContactDialog({ advisorId })`. - `src/features/contact/contact-dialog.js`: exportiert `openContactDialog({ advisorId })`.
- `src/features/contact/contact.css`: wird automatisch vom Popup-Modul importiert; - `src/features/contact/contact.css`: wird automatisch vom Popup-Modul importiert;
@ -23,9 +22,11 @@ kein eigenes Kontaktformular und keine separate E-Mail-Prüfung.
```json ```json
[ [
{ {
"id": "anna-mueller", "id": "relindis-agethen",
"name": "Anna Müller", "vorname": "Relindis",
"email": "anna.mueller@tri-hub.de" "nachname": "Agethen",
"spitzname": "Lilli",
"email": "relindis.agethen@tri-hub.de"
} }
] ]
``` ```
@ -40,7 +41,7 @@ des Frontends. Nach Änderungen API neu starten und Frontend neu bauen.
Beispielbutton (ID aus dem jeweiligen Profil): Beispielbutton (ID aus dem jeweiligen Profil):
```html ```html
<button type="button" data-berater-id="anna-mueller"> <button type="button" data-berater-id="relindis-agethen">
Berater kontaktieren Berater kontaktieren
</button> </button>
``` ```
@ -79,9 +80,13 @@ Falls die Profilseite selbst Daten aus der gemeinsamen Datei benötigt:
import berater from "./shared/berater-daten.json" with { type: "json" }; import berater from "./shared/berater-daten.json" with { type: "json" };
const profile = berater.find((entry) => entry.id === advisorId); const profile = berater.find((entry) => entry.id === advisorId);
// profile.name und profile.email stehen bei bekannter ID zur Verfügung. // profile.vorname, profile.nachname, profile.spitzname und profile.email sind verfügbar.
``` ```
Das Popup zeigt den getrimmten `spitzname`, falls nicht leer, sonst den
getrimmten `vorname`. `nachname` und das bisherige Feld `name` werden für die
Überschrift nicht verwendet. Es wird kein „kontaktieren“ angehängt.
Das Popup ermittelt den Anzeigenamen selbst und sendet beim Absenden Das Popup ermittelt den Anzeigenamen selbst und sendet beim Absenden
`{ email, advisorId, reason, simulationAccepted }` an `/api/contact`. Die `{ email, advisorId, reason, simulationAccepted }` an `/api/contact`. Die
Profilseite muss weder Name noch E-Mail an das Popup schicken. Kundenprüfung, Profilseite muss weder Name noch E-Mail an das Popup schicken. Kundenprüfung,
@ -91,7 +96,7 @@ implementiert. Unbekannte IDs sperren das Absenden.
## Testen ## Testen
Mit laufendem Frontend, API und Mailpit: Mit laufendem Frontend, API und Mailpit:
`http://localhost:5173/contact-test.html?berater-id=anna-mueller`. `http://localhost:5173/contact-test.html?berater-id=relindis-agethen`.
Für die Kundenadresse eine vorhandene Testbuchung verwenden. Die Prüfung Für die Kundenadresse eine vorhandene Testbuchung verwenden. Die Prüfung
findet ausschließlich beim Absenden statt. Der Branch ist findet ausschließlich beim Absenden statt. Der Branch ist
`feature/terminbuchung-kontaktfeld`; die Kontaktdateien müssen im Arbeitsstand `feature/terminbuchung-kontaktfeld`; die Kontaktdateien müssen im Arbeitsstand

View File

@ -10,7 +10,7 @@ npm run dev:server
npm run dev npm run dev
``` ```
Öffne <http://localhost:5173/contact-test.html?berater-id=anna-mueller>. Öffne <http://localhost:5173/contact-test.html?berater-id=relindis-agethen>.
Der Button öffnet ein modales Fenster. Gib eine E-Mail aus einer vorhandenen 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 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“ bestätige den Simulationshinweis. Beim Klick auf „Anfrage senden & Termin wählen“
@ -19,7 +19,7 @@ prüft der Server die E-Mail und versendet nur bei vorhandener Buchung. Bei Beda
Nach dem Absenden müssen zwei getrennte Nachrichten in Nach dem Absenden müssen zwei getrennte Nachrichten in
<http://localhost:8025> erscheinen: an die Kundenadresse und beispielsweise <http://localhost:8025> erscheinen: an die Kundenadresse und beispielsweise
`anna.mueller@tri-hub.de`. Der Browser wechselt anschließend zur vom Auftrag `relindis.agethen@tri-hub.de`. Der Browser wechselt anschließend zur vom Auftrag
vorgegebenen Microsoft-Bookings-Adresse. Es wird dort kein Termin automatisch vorgegebenen Microsoft-Bookings-Adresse. Es wird dort kein Termin automatisch
gebucht. Die Checkliste spricht von Microsoft Forms; umgesetzt ist der konkret gebucht. Die Checkliste spricht von Microsoft Forms; umgesetzt ist der konkret
angegebene Bookings-Link. angegebene Bookings-Link.
@ -53,9 +53,7 @@ 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). 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 Die gemeinsame Datenquelle `src/shared/berater-daten.json` enthält ein Array
mit eindeutigen String-IDs und den Feldern `id`, `name`, `email`. Die beiden mit eindeutigen String-IDs und den Feldern `id`, `vorname`, `nachname`, `spitzname`, `email`. Die vorhandenen Einträge enthalten die Beraterprofile. Zusätzliche Profilfelder sind möglich. Die Datei
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. wird im Frontend eingebunden: ausschließlich öffentliche Profildaten eintragen.
`src/shared/berater.js` stellt `findAdvisor(advisorId)` bereit. Popup und Server `src/shared/berater.js` stellt `findAdvisor(advisorId)` bereit. Popup und Server
@ -65,8 +63,8 @@ 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. enden. Eine unbekannte ID wird abgelehnt und sperrt das Absenden im Popup.
Die Testseite liest den URL-Parameter `berater-id`, zum Beispiel Die Testseite liest den URL-Parameter `berater-id`, zum Beispiel
`contact-test.html?berater-id=max-mustermann`. Ohne Parameter verwendet sie `contact-test.html?berater-id=maren-hoffmann`. Ohne Parameter verwendet sie
`anna-mueller`. Nach Änderungen an der JSON den API-Server neu starten und für `relindis-agethen`. Nach Änderungen an der JSON den API-Server neu starten und für
das Deployment das Frontend neu bauen. das Deployment das Frontend neu bauen.
Das native `dialog` hält den Tastaturfokus im Popup. Escape und Schließen Das native `dialog` hält den Tastaturfokus im Popup. Escape und Schließen
@ -90,7 +88,7 @@ Beide Endpunkte erwarten POST mit `Content-Type: application/json`, maximal
```json ```json
{ {
"email": "kunde@example.test", "email": "kunde@example.test",
"advisorId": "anna-mueller", "advisorId": "relindis-agethen",
"reason": "coaching", "reason": "coaching",
"simulationAccepted": true "simulationAccepted": true
} }

View File

@ -1,4 +1,4 @@
import { findAdvisor } from "../../shared/berater.js"; import { findAdvisor, advisorDisplayName } from "../../shared/berater.js";
import { import {
contactReasons, contactReasons,
contactNotice, contactNotice,
@ -19,7 +19,7 @@ export function openContactDialog({
dialog.innerHTML = ` dialog.innerHTML = `
<button class="contact-close" type="button" aria-label="Kontaktfenster schließen">×</button> <button class="contact-close" type="button" aria-label="Kontaktfenster schließen">×</button>
<p class="contact-eyebrow">DEIN NÄCHSTER SCHRITT</p> <p class="contact-eyebrow">DEIN NÄCHSTER SCHRITT</p>
<h2 id="contact-title">Berater kontaktieren</h2> <h2 id="contact-title"></h2>
<p class="contact-advisor"></p> <p class="contact-advisor"></p>
<form class="contact-form"> <form class="contact-form">
<div><label for="contact-email">Deine Buchungs-E-Mail</label> <div><label for="contact-email">Deine Buchungs-E-Mail</label>
@ -33,8 +33,10 @@ export function openContactDialog({
<p class="contact-status" role="status" aria-live="polite"></p> <p class="contact-status" role="status" aria-live="polite"></p>
<button class="contact-submit" type="submit" disabled>Anfrage senden & Termin wählen</button> <button class="contact-submit" type="submit" disabled>Anfrage senden & Termin wählen</button>
</form>`; </form>`;
dialog.querySelector("#contact-title").textContent =
advisorDisplayName(advisor) || "Berater nicht gefunden";
dialog.querySelector(".contact-advisor").textContent = advisor dialog.querySelector(".contact-advisor").textContent = advisor
? `Deine Anfrage an ${advisor.name}` ? "Sende deine Kontaktanfrage."
: "Die Berater-ID fehlt oder ist unbekannt. Bitte öffne das Fenster über ein Beraterprofil."; : "Die Berater-ID fehlt oder ist unbekannt. Bitte öffne das Fenster über ein Beraterprofil.";
dialog.querySelector(".contact-consent span").textContent = contactNotice; dialog.querySelector(".contact-consent span").textContent = contactNotice;
const form = dialog.querySelector("form"); const form = dialog.querySelector("form");

View File

@ -1,12 +1,13 @@
import { openContactDialog } from "./contact-dialog.js"; import { openContactDialog } from "./contact-dialog.js";
import { findAdvisor } from "../../shared/berater.js"; import { findAdvisor, advisorDisplayName } from "../../shared/berater.js";
import "./contact-test.css"; import "./contact-test.css";
const advisorId = const advisorId =
new URLSearchParams(location.search).get("berater-id") ?? "anna-mueller"; new URLSearchParams(location.search).get("berater-id") ??
"relindis-agethen";
const advisor = findAdvisor(advisorId); const advisor = findAdvisor(advisorId);
document.querySelector("#advisor-name").textContent = advisor document.querySelector("#advisor-name").textContent = advisor
? `Dein Berater: ${advisor.name}` ? `Dein Berater: ${advisorDisplayName(advisor)}`
: "Unbekannte Berater-ID"; : "Unbekannte Berater-ID";
document document
.querySelector("#open-contact") .querySelector("#open-contact")

View File

@ -1,3 +1,4 @@
import { advisorDisplayName } from "../../shared/berater.js";
import { createMailTransport } from "./email-service.js"; import { createMailTransport } from "./email-service.js";
import { BookingError } from "./booking-service.js"; import { BookingError } from "./booking-service.js";
import { contactNotice } from "../../shared/contact.js"; import { contactNotice } from "../../shared/contact.js";
@ -17,11 +18,11 @@ export function createContactEmailService(makeTransport) {
[ [
{ {
to: email, to: email,
text: `Deine Kontaktanfrage an ${advisor.name} ist in der Simulation eingegangen.`, text: `Deine Kontaktanfrage an ${advisorDisplayName(advisor)} ist in der Simulation eingegangen.`,
}, },
{ {
to: advisor.email, to: advisor.email,
text: `Hallo ${advisor.name}, eine simulierte Kontaktanfrage von ${email} ist eingegangen.`, text: `Hallo ${advisorDisplayName(advisor)}, eine simulierte Kontaktanfrage von ${email} ist eingegangen.`,
}, },
].map(async ({ to, text }) => { ].map(async ({ to, text }) => {
const result = await transport.sendMail({ const result = await transport.sendMail({

View File

@ -12,8 +12,8 @@ function advisorDetails(advisorId) {
); );
} }
if ( if (
typeof advisor.name !== "string" || typeof advisor.vorname !== "string" ||
!advisor.name.trim() || !advisor.vorname.trim() ||
typeof advisor.email !== "string" || typeof advisor.email !== "string" ||
advisor.email.length > 254 || advisor.email.length > 254 ||
!emailPattern.test(advisor.email) || !emailPattern.test(advisor.email) ||

View File

@ -385,7 +385,7 @@
}, },
"advisorId": { "advisorId": {
"type": "string", "type": "string",
"example": "anna-mueller", "example": "relindis-agethen",
"description": "ID aus src/shared/berater-daten.json. Name und E-Mail werden serverseitig ausschließlich dort nachgeschlagen." "description": "ID aus src/shared/berater-daten.json. Name und E-Mail werden serverseitig ausschließlich dort nachgeschlagen."
} }
} }

View File

@ -16,7 +16,10 @@
"Leistungssport-Perspektive", "Leistungssport-Perspektive",
"Wettkampforientierte Entwicklung" "Wettkampforientierte Entwicklung"
], ],
"bildquelle": "" "bildquelle": "",
"vorname": "Relindis",
"nachname": "Agethen",
"spitzname": "Lilli"
}, },
{ {
"id": "maren-hoffmann", "id": "maren-hoffmann",
@ -33,6 +36,9 @@
"Wettkampfverpflegung", "Wettkampfverpflegung",
"Alltagstaugliche Ernährungsroutinen" "Alltagstaugliche Ernährungsroutinen"
], ],
"bildquelle": "" "bildquelle": "",
"vorname": "Maren",
"nachname": "Hoffmann",
"spitzname": ""
} }
] ]

View File

@ -5,3 +5,7 @@ export function findAdvisor(advisorId) {
? berater.find((entry) => entry.id === advisorId) ? berater.find((entry) => entry.id === advisorId)
: undefined; : undefined;
} }
export function advisorDisplayName(advisor) {
return advisor?.spitzname?.trim() || advisor?.vorname?.trim() || "";
}

View File

@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
import { contactBookingUrl } from "../../shared/contact.js"; import { contactBookingUrl } from "../../shared/contact.js";
async function open(page) { async function open(page) {
await page.goto("/contact-test.html?berater-id=max-mustermann"); await page.goto("/contact-test.html?berater-id=maren-hoffmann");
await page await page
.getByRole("button", { name: "Berater kontaktieren", exact: true }) .getByRole("button", { name: "Berater kontaktieren", exact: true })
.click(); .click();
@ -20,7 +20,7 @@ test("Popup prüft E-Mail beim Absenden und leitet nur bekannte Kunden weiter",
}); });
expect(booking.ok()).toBeTruthy(); expect(booking.ok()).toBeTruthy();
await open(page); await open(page);
await expect(page.getByRole("dialog")).toContainText("Max Mustermann"); await expect(page.getByRole("dialog")).toContainText("Maren");
const submit = page.getByRole("button", { name: "Anfrage senden" }); const submit = page.getByRole("button", { name: "Anfrage senden" });
await expect( await expect(
page.getByRole("button", { name: "E-Mail prüfen" }), page.getByRole("button", { name: "E-Mail prüfen" }),
@ -42,7 +42,7 @@ test("Popup prüft E-Mail beim Absenden und leitet nur bekannte Kunden weiter",
request.url().endsWith("/api/contact"), request.url().endsWith("/api/contact"),
); );
await submit.click(); await submit.click();
expect((await sentRequest).postDataJSON().advisorId).toBe("max-mustermann"); expect((await sentRequest).postDataJSON().advisorId).toBe("maren-hoffmann");
await expect(page).toHaveURL(contactBookingUrl); await expect(page).toHaveURL(contactBookingUrl);
}); });

View File

@ -44,7 +44,7 @@ async function fixture(t, options = {}) {
} }
const input = { const input = {
email: "kunde@example.test", email: "kunde@example.test",
advisorId: "anna-mueller", advisorId: "relindis-agethen",
reason: "coaching", reason: "coaching",
simulationAccepted: true, simulationAccepted: true,
}; };
@ -65,14 +65,14 @@ test("CSV-Abgleich normalisiert E-Mail; Versand erhält Berater und festen Redir
assert.equal(result.status, 201); assert.equal(result.status, 201);
assert.equal(result.body.redirectUrl, contactBookingUrl); assert.equal(result.body.redirectUrl, contactBookingUrl);
assert.equal(result.body.simulated, true); assert.equal(result.body.simulated, true);
assert.equal(f.sent[0].advisor.email, "anna.mueller@tri-hub.de"); assert.equal(f.sent[0].advisor.email, "relindis.agethen@tri-hub.de");
assert.equal(f.sent[0].advisor.name, "Anna Müller"); assert.equal(f.sent[0].advisor.vorname, "Relindis");
const second = await f.post("/api/contact", { const second = await f.post("/api/contact", {
...input, ...input,
advisorId: "max-mustermann", advisorId: "maren-hoffmann",
}); });
assert.equal(second.status, 201); assert.equal(second.status, 201);
assert.equal(f.sent[1].advisor.email, "max.mustermann@tri-hub.de"); assert.equal(f.sent[1].advisor.email, "maren.hoffmann@tri-hub.de");
assert.deepEqual(await f.storage.readAll(), before); assert.deepEqual(await f.storage.readAll(), before);
}); });
@ -147,7 +147,7 @@ test("Zwei getrennte simulierte Bestätigungen; Teilfehler verhindern Erfolg", a
assert.equal((await f.post("/api/contact", input)).status, 201); assert.equal((await f.post("/api/contact", input)).status, 201);
assert.deepEqual( assert.deepEqual(
messages.map((m) => m.to), messages.map((m) => m.to),
[input.email, "anna.mueller@tri-hub.de"], [input.email, "relindis.agethen@tri-hub.de"],
); );
for (const message of messages) { for (const message of messages) {
assert.match(message.text, /Simulation/); assert.match(message.text, /Simulation/);
@ -163,3 +163,27 @@ test("Zwei getrennte simulierte Bestätigungen; Teilfehler verhindern Erfolg", a
assert.equal(result.status, 502); assert.equal(result.status, 502);
assert.equal(result.body.redirectUrl, undefined); assert.equal(result.body.redirectUrl, undefined);
}); });
test("Anzeigename bevorzugt Spitzname, sonst Vorname, ohne Nachnamen", async () => {
const { advisorDisplayName } = await import("../shared/berater.js");
assert.equal(
advisorDisplayName({
spitzname: " Lilli ",
vorname: "Relindis",
nachname: "Agethen",
}),
"Lilli",
);
assert.equal(
advisorDisplayName({
spitzname: " ",
vorname: "Maren",
nachname: "Hoffmann",
}),
"Maren",
);
assert.equal(
advisorDisplayName({ vorname: "Maren", nachname: "Hoffmann" }),
"Maren",
);
});