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

-
- - -
- -
-
+ + + + + + Ernährungs-Pakete – Tri-Hub + + +
Zur Startseite
+
+

Unsere Ernährungs-Pakete

+
+
+ + + 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}

@@ -47,7 +47,7 @@ export function renderAngebotsuebersicht() {
- ${pkg.buttonText} + ${pkg.buttonText}
`; 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),