Angebotsübersicht und Buchung verbinden, Mailpit einrichten

feature/angebote-booking-integration-detailseiten
Marvin Gemlin 2026-09-25 11:53:07 +00:00
parent 762f21dfa1
commit 4ed3ab54e8
18 changed files with 259 additions and 92 deletions

View File

@ -0,0 +1,5 @@
# Version bewusst festhalten, damit alle dieselbe Mailpit-Version verwenden.
FROM axllent/mailpit:v1.31.2 AS mailpit
FROM mcr.microsoft.com/devcontainers/javascript-node:24-bookworm
COPY --from=mailpit /mailpit /usr/local/bin/mailpit

View File

@ -1,15 +1,18 @@
{
"name": "Ernaehrungsberatung",
"image": "mcr.microsoft.com/devcontainers/javascript-node:24-bookworm",
"remoteUser": "node",
"forwardPorts": [5173],
"forwardPorts": [5173, 8025],
"portsAttributes": {
"5173": {
"label": "Webseite",
"onAutoForward": "notify"
},
"8025": {
"label": "Mailpit – Testmails",
"onAutoForward": "notify"
}
},
"postCreateCommand": "if [ -f package-lock.json ]; then npm ci; else npm install; fi",
"postCreateCommand": "bash .devcontainer/setup.sh",
"customizations": {
"vscode": {
"extensions": [
@ -17,5 +20,8 @@
"EditorConfig.EditorConfig"
]
}
},
"build": {
"dockerfile": "Dockerfile"
}
}

View File

@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
npm ci
if [ ! -e .env ]; then
cp .env.example .env
fi
# Buchungsdatei nicht vorab anlegen: Das Backend erstellt sie bei der ersten Buchung.
echo "Bereit. In drei Terminals starten: npm run dev:mail, npm run dev:server, npm run dev"

59
README.md 100644
View File

@ -0,0 +1,59 @@
# Tri-Hub Ernährungsberatung
## Im Dev Container starten
1. Repository klonen und in VS Code „Dev Containers: Reopen in Container“ wählen.
Bei einem bereits vorhandenen Container einmal „Dev Containers: Rebuild Container“
ausführen, damit Mailpit installiert wird.
2. Die Einrichtung installiert automatisch die npm-Abhängigkeiten und erstellt
`.env` aus `.env.example`, sofern noch keine `.env` vorhanden ist.
3. In drei getrennten Terminals in dieser Reihenfolge starten:
```bash
npm run dev:mail
```
```bash
npm run dev:server
```
```bash
npm run dev
```
Startseite: http://localhost:5173 — über „Beratung kennenlernen“ zur
Angebotsübersicht und von dort zum Buchungsformular des gewählten Pakets.
Direkt: http://localhost:5173/angebotsuebersicht.html
Mailansicht: http://localhost:8025. Beide Web-Ports werden vom Dev Container
weitergeleitet. Mailpit läuft im selben Container auf SMTP-Port 1025 und fängt
Testmails lokal ab, ohne sie extern zu versenden.
Die Container-Konfiguration installiert Mailpit v1.31.2 aus dem
[offiziellen Docker-Image](https://mailpit.axllent.org/docs/install/docker/).
Die Binärdatei, lokale Mails, `.env` und Buchungsdaten werden nicht committed.
Mailpit verwendet ohne Datenbankpfad einen temporären Speicher; Testmails müssen
nach einem Neustart nicht erhalten bleiben.
Ohne Dev Container: Node.js 24 und Mailpit installieren, `npm ci` ausführen und
`.env.example` nach `.env` kopieren, falls `.env` fehlt. Danach dieselben drei
Startbefehle verwenden.
Ohne Mailpit wird die Buchung gespeichert, aber der fehlgeschlagene Mailversand
angezeigt. Der Ablauf bleibt eine Simulation ohne Zahlung oder verbindliche
Beratung. Buchungen liegen lokal in `src/server/data/bookings.csv`. Diese Datei
nicht vorab leer anlegen: Das Backend erzeugt sie bei der ersten Buchung selbst.
## Prüfen
```bash
npm test
npm run build
npm run test:browser
```
Die Browsertests starten eigene Server auf 3000 und 5173; diese Ports vorher
freihalten. Sie verwenden eine temporäre CSV und simulierten Mailversand.
Falls Chromium fehlt: `npx playwright install chromium`.
Der Build enthält Startseite, Angebotsübersicht und Buchungsseite. Für ein
Deployment zusätzlich das Node-Backend betreiben und `/api` dorthin weiterleiten.

View File

@ -1,11 +1,19 @@
<section class="packages-section" id="angebotsuebersicht-section">
<div class="packages-header">
<!-- Hier könnte noch eine Überschrift hin, falls gewünscht -->
<h2>Unsere Ernährungs-Pakete</h2>
</div>
<!-- Das ist der Container, den unser JS mit den Karten füllt -->
<div class="packages-container" id="packages-container">
<!-- Karten werden hier dynamisch von angebotsuebersicht.js injiziert -->
</div>
</section>
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Ernährungs-Pakete – Tri-Hub</title>
</head>
<body>
<header class="container"><a href="/">Zur Startseite</a></header>
<main class="packages-section" id="angebotsuebersicht-section">
<div class="packages-header"><h1>Unsere Ernährungs-Pakete</h1></div>
<div class="packages-container" id="packages-container"></div>
</main>
<script
type="module"
src="/src/features/angebote/angebote-entry.js"
></script>
</body>
</html>

View File

@ -33,14 +33,16 @@ cp .env.example .env
Eine vorhandene `.env` vorher prüfen und nicht überschreiben. Die Beispielwerte
aktivieren das klar gekennzeichnete Testpaket und verwenden nur einen Mailfänger.
Ohne `.env` bleibt der Demo-Modus ausgeschaltet und der echte Paketkatalog leer.
Ohne `.env` bleibt der Demo-Modus ausgeschaltet; die drei regulären Pakete bleiben verfügbar.
Einmalig [Mailpit installieren](https://mailpit.axllent.org/docs/install/).
Mailpit ist über `.devcontainer/Dockerfile` bereits Bestandteil neuer Dev Container.
Bestehende Container einmal neu bauen. Außerhalb davon einmalig
[Mailpit installieren](https://mailpit.axllent.org/docs/install/).
Es läuft als einzelne Binärdatei auch direkt im Dev Container. Kein Docker im
Container erforderlich. Mailpit ohne Weiterleitung an echte SMTP-Server starten:
```bash
mailpit --listen 127.0.0.1:8025 --smtp 127.0.0.1:1025 --disable-version-check
npm run dev:mail
```
In Terminal 2 das Backend starten:
@ -56,8 +58,8 @@ npm run dev
```
Öffnen: <http://localhost:5173/booking.html#/buchen/demo-booking>.
Mailfänger: <http://localhost:8025>. Im Dev Container bei Bedarf Port 8025 über
VS Codes Ansicht „Ports“ weiterleiten. Port 5173 ist bereits vorkonfiguriert.
Mailfänger: <http://localhost:8025>. Im Dev Container sind die Ports 8025 und 5173
für die Weiterleitung vorkonfiguriert.
Port 3000 muss für den Browser nicht weitergeleitet werden: Vite vermittelt `/api`.
Diese Beschreibung setzt voraus, dass Mailpit im selben Container läuft.
@ -92,7 +94,9 @@ Die `.env.example` enthält nur Testwerte und keine Geheimnisse.
## Paketdaten und spätere Router-Anbindung
Person 3 pflegt `packages` in **`src/shared/packages.js`**.
Das derzeit leere Array enthält später ausschließlich abgestimmte Angebote:
Das Array enthält die drei Angebote Starter, Standard und Premium. Übersicht und
Buchung verwenden gemeinsam `id`, `name` und `summary`; zusätzliche Felder
beschreiben die Darstellung der Angebotskarten.
```js
// Nur Schema-Beispiel, kein verbindliches Angebot:
@ -127,7 +131,7 @@ cleanup();
Das Modul bringt seine begrenzten `.booking`-Styles mit. Farben und Schrift
orientieren sich am vorhandenen Basis-CSS. Gemeinsame Designvariablen und eine
Button-Komponente gibt es bisher nicht. Layout und Navigation bleiben Aufgabe
der anderen Teammitglieder. „Zur Angebotsseite“ zeigt vorerst auf `/#beratung`.
der anderen Teammitglieder. „Zur Angebotsseite“ führt zu `/angebotsuebersicht.html`.
## API-Beispiele
@ -336,7 +340,7 @@ Binärdatei außerhalb des Repositorys.
## Offene Punkte und gemeinsame Dateien
Offen: echte Paketdaten, Anschluss an den gemeinsamen Router, abschließendes
Die Angebotsübersicht ist als eigene HTML-Seite angebunden. Offen: Anschluss an einen gemeinsamen Router, abschließendes
Teamdesign, reale Zustellprüfung und Screenreader-Abnahme. Vor öffentlichem
Betrieb sind außerdem Betriebsfragen wie Zugriff auf CSV/Backups, Aufbewahrung
und Schutz des öffentlichen Buchungsendpunkts vor automatisiertem Massenversand
@ -347,7 +351,7 @@ Gemeinsame Änderungen: `package.json` und Lockdatei (Abhängigkeiten/Startbefeh
`.prettierignore` (private Daten/Testergebnisse ausschließen). Neu hinzugefügt:
`vite.config.js` (Proxy, Dateizugriffsschutz, zwei HTML-Einstiegspunkte),
`src/shared/packages.js` (gemeinsamer Paketvertrag), `.env.example`, `booking.html`
und `playwright.config.js`. Die Dev-Container-Konfiguration bleibt unverändert.
und `playwright.config.js`. Die Dev-Container-Konfiguration installiert Mailpit und leitet dessen Web-Port weiter.
Technische Referenzen: [CSV-Parser](https://csv.js.org/parse/api/sync/),
[Playwright-Testserver](https://playwright.dev/docs/test-webserver).

View File

@ -20,12 +20,13 @@
Individuelle 1:1-Beratung für deinen Alltag und deine
sportlichen Ziele.
</p>
<a href="#beratung">Beratung kennenlernen</a>
<a href="/angebotsuebersicht.html">Beratung kennenlernen</a>
<section id="beratung" aria-labelledby="beratung-heading">
<h2 id="beratung-heading">Deine persönliche Beratung</h2>
<p>
Hier stellen wir bald unseren Ablauf und unser Angebot vor.
Entdecke unsere Pakete für deine Ernährung im Alltag und
Sport.
</p>
</section>
</main>

View File

@ -14,8 +14,9 @@
"format:check": "prettier --check .",
"dev:server": "node --env-file-if-exists=.env --watch src/server/index.js",
"start:server": "node --env-file-if-exists=.env src/server/index.js",
"test": "node --test src/tests/bookings.test.js",
"test:browser": "playwright test"
"test": "node --test src/tests/*.test.js",
"test:browser": "playwright test",
"dev:mail": "mailpit --listen 127.0.0.1:8025 --smtp 127.0.0.1:1025 --disable-version-check"
},
"devDependencies": {
"@playwright/test": "^1.63.0",

View File

@ -0,0 +1,5 @@
import "../../styles/styles.css";
import "../../styles/packages.css";
import { renderAngebotsuebersicht } from "./angebotsuebersicht.js";
renderAngebotsuebersicht();

View File

@ -28,8 +28,8 @@ export function renderAngebotsuebersicht() {
${highlightHtml}
</div>
<h3 class="package-title">${pkg.title}</h3>
<p class="package-desc">${pkg.description}</p>
<h3 class="package-title">${pkg.name}</h3>
<p class="package-desc">${pkg.summary}</p>
<div class="package-details-box">
<div class="detail-item">
@ -47,7 +47,7 @@ export function renderAngebotsuebersicht() {
</div>
<!-- HIER ist der wichtige Link für deinen Kollegen: -->
<a href="booking.html?id=${pkg.id}" class="package-btn">${pkg.buttonText}</a>
<a href="/booking.html#/buchen/${encodeURIComponent(pkg.id)}" class="package-btn">${pkg.buttonText}</a>
</div>
`;

View File

@ -9,7 +9,7 @@ export function mountBookingPage(root, { packageId } = {}) {
const { signal } = controller;
root.innerHTML = `
<div class="booking">
<a href="/#beratung">Zur Angebotsseite</a>
<a href="/angebotsuebersicht.html">Zur Angebotsseite</a>
<h1>Paket buchen</h1>
<p class="booking__intro" role="status">Paket wird geladen …</p>
<div class="booking__content"></div>

View File

@ -2,8 +2,8 @@ export const packages = [
{
id: "ernaehrung-starter",
type: "STARTER",
title: "Für den sicheren Einstieg in die Sporternährung",
description:
name: "Für den sicheren Einstieg in die Sporternährung",
summary:
"Für Menschen 50+, die mit Triathlon beginnen und ihre Ernährung an die neue Belastung anpassen möchten.",
fokus: "Grundlagen der Makronährstoffe, Hydration im Training und Gewichtsmanagement.",
anamnese:
@ -16,8 +16,8 @@ export const packages = [
{
id: "ernaehrung-standard",
type: "STANDARD",
title: "Für Best Ager mit ersten Erfahrungen und klaren Zielen",
description:
name: "Für Best Ager mit ersten Erfahrungen und klaren Zielen",
summary:
"Für Athletinnen und Athleten, die ihre Ernährung für die Kurzdistanz und im Alltag gezielt weiterentwickeln wollen.",
fokus: "Carb-Loading, Pacing-Strategien für den Magen und Timing der Nahrungsaufnahme.",
anamnese:
@ -30,8 +30,8 @@ export const packages = [
{
id: "ernaehrung-premium",
type: "PREMIUM",
title: "Für maximale Individualität mit klarem Fokus",
description:
name: "Für maximale Individualität mit klarem Fokus",
summary:
"Für Menschen, die sich eine besonders enge Coaching-Beziehung und laufende Anpassung ihrer Ernährung wünschen.",
fokus: "Individuelle Stoffwechselanalyse, Mikronährstoff-Optimierung und Raceday-Nutrition.",
anamnese:
@ -42,3 +42,13 @@ export const packages = [
highlight: null,
},
];
// Nur für lokale Demonstrationen und isolierte Tests.
export const demoPackages = [
{
id: "demo-booking",
name: "1:1-Ernährungsberatung",
summary: "Testpaket für den simulierten Buchungsablauf.",
testOnly: true,
},
];

View File

@ -19,7 +19,7 @@
.packages-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr));
gap: 2rem;
max-width: 1200px;
margin: 0 auto;

View File

@ -1,36 +1,30 @@
export async function renderAngebotsuebersicht() {
const container = document.getElementById("packages-container");
if (!container) return;
import test from "node:test";
import assert from "node:assert/strict";
import { createApp } from "../server/index.js";
import { packages } from "../shared/packages.js";
try {
// 1. Daten vom Backend des Kollegen abfragen
const response = await fetch("/api/angebote"); // Die genaue URL musst du den Kollegen fragen
const backendPackages = await response.json();
container.innerHTML = "";
// 2. Mit den echten Daten die Karten generieren
backendPackages.forEach((pkg) => {
const highlightHtml = pkg.highlight
? `<span class="package-highlight">${pkg.highlight}</span>`
: "";
const cardHtml = `
<div class="package-card">
<div class="package-type-container">
<span class="package-type">${pkg.type}</span>
${highlightHtml}
</div>
<h3 class="package-title">${pkg.title}</h3>
<p class="package-desc">${pkg.description}</p>
<!-- ... Rest des HTMLs wie gehabt ... -->
<a href="buchung.html?id=${pkg.id}" class="package-btn">${pkg.buttonText}</a>
</div>
`;
container.innerHTML += cardHtml;
});
} catch (error) {
console.error("Fehler beim Laden der Angebote vom Backend:", error);
container.innerHTML = "<p>Angebote konnten nicht geladen werden.</p>";
}
}
test("API liefert für jedes angezeigte Angebot dieselbe ID, Namen und Beschreibung", async (t) => {
const app = createApp({ sendConfirmation: async () => {} });
await new Promise((resolve) => app.listen(0, "127.0.0.1", resolve));
t.after(
() =>
new Promise((resolve) => {
app.closeAllConnections();
app.close(resolve);
}),
);
const response = await fetch(
`http://127.0.0.1:${app.address().port}/api/angebote`,
);
assert.equal(response.status, 200);
const body = await response.json();
assert.deepEqual(
body.packages,
packages.map(({ id, name, summary }) => ({
id,
name,
summary,
testOnly: false,
})),
);
});

View File

@ -1,5 +1,7 @@
import { test, expect } from "@playwright/test";
import { packages } from "../../shared/packages.js";
const pageUrl = "/booking.html#/buchen/demo-booking";
async function fill(page) {
await page
@ -179,3 +181,53 @@ test("Vite liefert Serverdateien und Umgebungsdateien nicht aus", async ({
expect([403, 404]).toContain(response.status());
}
});
for (const pkg of packages) {
test(`Von der Startseite zur Buchung: ${pkg.type}`, async ({ page }) => {
await page.goto("/");
await page.getByRole("link", { name: "Beratung kennenlernen" }).click();
await expect(page.locator(".package-card")).toHaveCount(
packages.length,
);
await page.getByRole("link", { name: pkg.buttonText }).click();
await expect(page).toHaveURL(
new RegExp(`booking\\.html#/buchen/${pkg.id}$`),
);
await expect(
page.getByRole("heading", { name: pkg.name, exact: true }),
).toBeVisible();
await expect(page.locator(".booking__summary")).toHaveText(pkg.summary);
await fill(page);
const responsePromise = page.waitForResponse(
(response) =>
response.url().endsWith("/api/bookings") &&
response.request().method() === "POST",
);
await page
.getByRole("button", { name: "Paket buchen", exact: true })
.click();
const response = await responsePromise;
expect(response.status()).toBe(201);
const booking = await response.json();
expect(booking.packageId).toBe(pkg.id);
expect(booking.packageName).toBe(pkg.name);
await expect(page.locator(".booking__result")).toContainText(
booking.bookingId,
);
await page.getByRole("link", { name: "Zur Angebotsseite" }).click();
await expect(page.locator(".package-card")).toHaveCount(
packages.length,
);
});
}
test("Angebotsübersicht passt auf ein schmales Display", async ({ page }) => {
await page.setViewportSize({ width: 320, height: 640 });
await page.goto("/angebotsuebersicht.html");
await expect(page.locator(".package-card")).toHaveCount(packages.length);
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
),
).toBe(true);
});

View File

@ -4,11 +4,11 @@ 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 { demoPackages } from "../../shared/packages.js";
import { packages, demoPackages } from "../../shared/packages.js";
const directory = await mkdtemp(join(tmpdir(), "trihub-browser-"));
const server = createApp({
catalog: demoPackages,
catalog: [...packages, ...demoPackages],
storage: createCsvStorage(join(directory, "bookings.csv")),
sendConfirmation: async () => {},
});

View File

@ -1,19 +1,28 @@
import { packages } from "../shared/packages.js";
import test from "node:test";
import assert from "node:assert/strict";
import { packages, demoPackages } from "../shared/packages.js";
import { createApp } from "../server/index.js";
describe("Angebots-Daten (packages.js)", () => {
test("sollte ein Array mit Angeboten exportieren", () => {
expect(Array.isArray(nutritionPackages)).toBe(true);
expect(nutritionPackages.length).toBeGreaterThan(0);
});
test("jedes Paket muss eine ID und einen Titel für das Routing besitzen", () => {
nutritionPackages.forEach((pkg) => {
expect(pkg).toHaveProperty("id");
expect(pkg).toHaveProperty("title");
expect(pkg).toHaveProperty("buttonText");
// Prüft, ob die ID das korrekte Format für die URLs hat
expect(typeof pkg.id).toBe("string");
expect(pkg.id.length).toBeGreaterThan(0);
});
});
test("Angebote erfüllen den gemeinsamen Vertrag von Übersicht und Buchung", () => {
assert.ok(packages.length > 0);
assert.doesNotThrow(() =>
createApp({
catalog: [...packages, ...demoPackages],
sendConfirmation: async () => {},
}),
);
for (const pkg of packages) {
for (const field of [
"name",
"summary",
"type",
"fokus",
"anamnese",
"begleitung",
"buttonText",
]) {
assert.equal(typeof pkg[field], "string");
assert.ok(pkg[field].trim());
}
}
});

View File

@ -19,6 +19,9 @@ export default defineConfig({
build: {
rollupOptions: {
input: {
angebote: fileURLToPath(
new URL("./angebotsuebersicht.html", import.meta.url),
),
main: fileURLToPath(new URL("./index.html", import.meta.url)),
booking: fileURLToPath(
new URL("./booking.html", import.meta.url),