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 @@
-Unsere Ernährungs-Pakete
-
- Hier stellen wir bald unseren Ablauf und unser Angebot vor. + Entdecke unsere Pakete für deine Ernährung im Alltag und + Sport.
${pkg.description}
+${pkg.summary}
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 = ` - - `; - 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),