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
parent
af88228d3b
commit
881ba21630
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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) ||
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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() || "";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue