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
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
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
- `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.
Array mit eindeutigen String-IDs, `vorname`, `nachname`, optionalem `spitzname` und `email`. 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;
@ -23,9 +22,11 @@ kein eigenes Kontaktformular und keine separate E-Mail-Prüfung.
```json
[
{
"id": "anna-mueller",
"name": "Anna Müller",
"email": "anna.mueller@tri-hub.de"
"id": "relindis-agethen",
"vorname": "Relindis",
"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):
```html
<button type="button" data-berater-id="anna-mueller">
<button type="button" data-berater-id="relindis-agethen">
Berater kontaktieren
</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" };
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
`{ email, advisorId, reason, simulationAccepted }` an `/api/contact`. Die
Profilseite muss weder Name noch E-Mail an das Popup schicken. Kundenprüfung,
@ -91,7 +96,7 @@ implementiert. Unbekannte IDs sperren das Absenden.
## Testen
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
findet ausschließlich beim Absenden statt. Der Branch ist
`feature/terminbuchung-kontaktfeld`; die Kontaktdateien müssen im Arbeitsstand

View File

@ -10,7 +10,7 @@ npm run dev:server
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
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“
@ -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
<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
gebucht. Die Checkliste spricht von Microsoft Forms; umgesetzt ist der konkret
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).
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
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
wird im Frontend eingebunden: ausschließlich öffentliche Profildaten eintragen.
`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.
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
`contact-test.html?berater-id=maren-hoffmann`. Ohne Parameter verwendet sie
`relindis-agethen`. 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
@ -90,7 +88,7 @@ Beide Endpunkte erwarten POST mit `Content-Type: application/json`, maximal
```json
{
"email": "kunde@example.test",
"advisorId": "anna-mueller",
"advisorId": "relindis-agethen",
"reason": "coaching",
"simulationAccepted": true
}

View File

@ -1,4 +1,4 @@
import { findAdvisor } from "../../shared/berater.js";
import { findAdvisor, advisorDisplayName } from "../../shared/berater.js";
import {
contactReasons,
contactNotice,
@ -19,7 +19,7 @@ export function openContactDialog({
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>
<h2 id="contact-title"></h2>
<p class="contact-advisor"></p>
<form class="contact-form">
<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>
<button class="contact-submit" type="submit" disabled>Anfrage senden & Termin wählen</button>
</form>`;
dialog.querySelector("#contact-title").textContent =
advisorDisplayName(advisor) || "Berater nicht gefunden";
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.";
dialog.querySelector(".contact-consent span").textContent = contactNotice;
const form = dialog.querySelector("form");

View File

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

View File

@ -1,3 +1,4 @@
import { advisorDisplayName } from "../../shared/berater.js";
import { createMailTransport } from "./email-service.js";
import { BookingError } from "./booking-service.js";
import { contactNotice } from "../../shared/contact.js";
@ -17,11 +18,11 @@ export function createContactEmailService(makeTransport) {
[
{
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,
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 }) => {
const result = await transport.sendMail({

View File

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

View File

@ -385,7 +385,7 @@
},
"advisorId": {
"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."
}
}

View File

@ -16,7 +16,10 @@
"Leistungssport-Perspektive",
"Wettkampforientierte Entwicklung"
],
"bildquelle": ""
"bildquelle": "",
"vorname": "Relindis",
"nachname": "Agethen",
"spitzname": "Lilli"
},
{
"id": "maren-hoffmann",
@ -33,6 +36,9 @@
"Wettkampfverpflegung",
"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)
: 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";
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
.getByRole("button", { name: "Berater kontaktieren", exact: true })
.click();
@ -20,7 +20,7 @@ test("Popup prüft E-Mail beim Absenden und leitet nur bekannte Kunden weiter",
});
expect(booking.ok()).toBeTruthy();
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" });
await expect(
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"),
);
await submit.click();
expect((await sentRequest).postDataJSON().advisorId).toBe("max-mustermann");
expect((await sentRequest).postDataJSON().advisorId).toBe("maren-hoffmann");
await expect(page).toHaveURL(contactBookingUrl);
});

View File

@ -44,7 +44,7 @@ async function fixture(t, options = {}) {
}
const input = {
email: "kunde@example.test",
advisorId: "anna-mueller",
advisorId: "relindis-agethen",
reason: "coaching",
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.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");
assert.equal(f.sent[0].advisor.email, "relindis.agethen@tri-hub.de");
assert.equal(f.sent[0].advisor.vorname, "Relindis");
const second = await f.post("/api/contact", {
...input,
advisorId: "max-mustermann",
advisorId: "maren-hoffmann",
});
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);
});
@ -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.deepEqual(
messages.map((m) => m.to),
[input.email, "anna.mueller@tri-hub.de"],
[input.email, "relindis.agethen@tri-hub.de"],
);
for (const message of messages) {
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.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",
);
});