From 4ed3ab54e816b6e6089f2991baa73099d54591a8 Mon Sep 17 00:00:00 2001
From: Marvin Gemlin
Date: Fri, 25 Sep 2026 11:53:07 +0000
Subject: [PATCH] =?UTF-8?q?Angebots=C3=BCbersicht=20und=20Buchung=20verbin?=
=?UTF-8?q?den,=20Mailpit=20einrichten?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.devcontainer/Dockerfile | 5 ++
.devcontainer/devcontainer.json | 12 +++-
.devcontainer/setup.sh | 10 ++++
README.md | 59 +++++++++++++++++++
angebotsuebersicht.html | 30 ++++++----
docs/booking.md | 22 ++++---
index.html | 5 +-
package.json | 5 +-
src/features/angebote/angebote-entry.js | 5 ++
src/features/angebote/angebotsuebersicht.js | 6 +-
src/features/booking/booking-page.js | 2 +-
src/shared/packages.js | 22 +++++--
src/styles/packages.css | 2 +-
src/tests/angebotsuebersicht.test.js | 64 ++++++++++-----------
src/tests/browser/booking.spec.js | 52 +++++++++++++++++
src/tests/helpers/browser-server.js | 4 +-
src/tests/packages.test.js | 43 ++++++++------
vite.config.js | 3 +
18 files changed, 259 insertions(+), 92 deletions(-)
create mode 100644 .devcontainer/Dockerfile
create mode 100644 .devcontainer/setup.sh
create mode 100644 README.md
create mode 100644 src/features/angebote/angebote-entry.js
diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
new file mode 100644
index 0000000..460e349
--- /dev/null
+++ b/.devcontainer/Dockerfile
@@ -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
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index c2df1da..1f24ae9 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -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"
}
}
diff --git a/.devcontainer/setup.sh b/.devcontainer/setup.sh
new file mode 100644
index 0000000..0afa5c9
--- /dev/null
+++ b/.devcontainer/setup.sh
@@ -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"
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..0ca7144
--- /dev/null
+++ b/README.md
@@ -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.
diff --git a/angebotsuebersicht.html b/angebotsuebersicht.html
index 58bbf85..517d32f 100644
--- a/angebotsuebersicht.html
+++ b/angebotsuebersicht.html
@@ -1,11 +1,19 @@
-
+
+
+
+
+
+ Ernährungs-Pakete – Tri-Hub
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/booking.md b/docs/booking.md
index 5f0541d..9dd1d1a 100644
--- a/docs/booking.md
+++ b/docs/booking.md
@@ -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: .
-Mailfänger: . Im Dev Container bei Bedarf Port 8025 über
-VS Codes Ansicht „Ports“ weiterleiten. Port 5173 ist bereits vorkonfiguriert.
+Mailfänger: . 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).
diff --git a/index.html b/index.html
index dc976fa..a750b86 100644
--- a/index.html
+++ b/index.html
@@ -20,12 +20,13 @@
Individuelle 1:1-Beratung für deinen Alltag und deine
sportlichen Ziele.
- Beratung kennenlernen
+ Beratung kennenlernen
Deine persönliche Beratung
- Hier stellen wir bald unseren Ablauf und unser Angebot vor.
+ Entdecke unsere Pakete für deine Ernährung im Alltag und
+ Sport.
diff --git a/package.json b/package.json
index 2af1f0a..d903bdd 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/features/angebote/angebote-entry.js b/src/features/angebote/angebote-entry.js
new file mode 100644
index 0000000..43eb523
--- /dev/null
+++ b/src/features/angebote/angebote-entry.js
@@ -0,0 +1,5 @@
+import "../../styles/styles.css";
+import "../../styles/packages.css";
+import { renderAngebotsuebersicht } from "./angebotsuebersicht.js";
+
+renderAngebotsuebersicht();
diff --git a/src/features/angebote/angebotsuebersicht.js b/src/features/angebote/angebotsuebersicht.js
index 765dd62..02f7852 100644
--- a/src/features/angebote/angebotsuebersicht.js
+++ b/src/features/angebote/angebotsuebersicht.js
@@ -28,8 +28,8 @@ export function renderAngebotsuebersicht() {
${highlightHtml}
- ${pkg.title}
- ${pkg.description}
+ ${pkg.name}
+ ${pkg.summary}
`;
diff --git a/src/features/booking/booking-page.js b/src/features/booking/booking-page.js
index e15a656..420d238 100644
--- a/src/features/booking/booking-page.js
+++ b/src/features/booking/booking-page.js
@@ -9,7 +9,7 @@ export function mountBookingPage(root, { packageId } = {}) {
const { signal } = controller;
root.innerHTML = `
-
Zur Angebotsseite
+
Zur Angebotsseite
Paket buchen
Paket wird geladen …
diff --git a/src/shared/packages.js b/src/shared/packages.js
index 76dac1a..7615755 100644
--- a/src/shared/packages.js
+++ b/src/shared/packages.js
@@ -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,
+ },
+];
diff --git a/src/styles/packages.css b/src/styles/packages.css
index d02cbe3..da50358 100644
--- a/src/styles/packages.css
+++ b/src/styles/packages.css
@@ -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;
diff --git a/src/tests/angebotsuebersicht.test.js b/src/tests/angebotsuebersicht.test.js
index 08dfee6..3a8a51c 100644
--- a/src/tests/angebotsuebersicht.test.js
+++ b/src/tests/angebotsuebersicht.test.js
@@ -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
- ? `
${pkg.highlight}`
- : "";
-
- const cardHtml = `
-
-
- ${pkg.type}
- ${highlightHtml}
-
-
${pkg.title}
-
${pkg.description}
-
-
${pkg.buttonText}
-
- `;
- container.innerHTML += cardHtml;
- });
- } catch (error) {
- console.error("Fehler beim Laden der Angebote vom Backend:", error);
- container.innerHTML = "
Angebote konnten nicht geladen werden.
";
- }
-}
+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,
+ })),
+ );
+});
diff --git a/src/tests/browser/booking.spec.js b/src/tests/browser/booking.spec.js
index c9d584d..551c803 100644
--- a/src/tests/browser/booking.spec.js
+++ b/src/tests/browser/booking.spec.js
@@ -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);
+});
diff --git a/src/tests/helpers/browser-server.js b/src/tests/helpers/browser-server.js
index f9f02d8..395967c 100644
--- a/src/tests/helpers/browser-server.js
+++ b/src/tests/helpers/browser-server.js
@@ -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 () => {},
});
diff --git a/src/tests/packages.test.js b/src/tests/packages.test.js
index d957691..01fcae2 100644
--- a/src/tests/packages.test.js
+++ b/src/tests/packages.test.js
@@ -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());
+ }
+ }
});
diff --git a/vite.config.js b/vite.config.js
index 665b58f..58bccce 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -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),