6 Commits
Author SHA1 Message Date
maro 32e37d277f Add GitHub Actions build and deploy workflows
CI / test (push) Canceled after 0s
CI / build-image (push) Canceled after 0s
2026-09-16 19:40:40 +02:00
maro d0864e2911 Integrate LiveKit SFU and harden booking workflow 2026-09-16 19:38:28 +02:00
maro f1cd07b3ad CI/CD: build the Docker image once on main, promote it on release tags
CI / test (push) Failing after 11m55s
CI / build-image (push) Skipped
Previously release.yml fully rebuilt and re-ran lint/typecheck/test/build
from source on every tag push, duplicating what ci.yml had already just
verified on main. Now ci.yml builds and pushes the image (tagged by commit
SHA) once, right after its checks pass, but only for pushes to main.
release.yml no longer builds anything: it pulls that exact image, retags
it as the release version + latest, and deploys it.

This guarantees the deployed image is byte-for-byte what CI verified, and
turns "tag a commit that was never pushed to main or hasn't finished CI"
into a loud, immediate failure (no image to promote) instead of a rebuild
that silently skips the checks.
2026-09-06 22:24:07 +02:00
maro 526a908c80 Redesign: warmer, more editorial visual pass; remove stray em dashes
CI / test (push) Successful in 3m42s
Release / test (push) Successful in 3m44s
Release / build-and-deploy (push) Successful in 8m59s
Replaces em dashes with en dashes across all hardcoded copy, emails, and
seed content, then reworks the site's visual language so it reads as
deliberately designed rather than assembled from generic components:

- New ArrowLink component replaces ad hoc "Mehr erfahren ->" links and
  ghost buttons; one shared FormField (TextField/TextAreaField) replaces
  7 near-duplicate field styles across every form
- Navbar loses its glassmorphism blur for a solid warm background, gets a
  subtle active-link underline and a softer, warmer mega-menu
- PageHeader supports per-page blob tone/position and a compact mode
  instead of an identical band on every interior page
- Homepage: the core offer is featured large instead of four equal cards,
  the philosophy section gets a single calmer accent blob, blog teasers
  now show their cover image like the real /aktuelles page does
- Booking calendar and widget: 44px touch targets throughout (month nav,
  day cells, time slots, cancel action), a visible "today" ring, and
  responsive padding so the calendar isn't cramped on small screens
- Quieter, warmer empty states on /termine and /aktuelles

No functional changes: all interaction logic (nav menus, forms, booking,
CMS data fetching) is untouched, only markup and styling.
2026-09-06 22:00:28 +02:00
maro 65b6eeac51 Add privacy-friendly analytics and cookie consent, integrated into the CMS
Adds a first-party analytics system with no third-party trackers and no
persistent per-visitor identifiers: events are grouped into sessions via a
daily-rotating pseudonymous hash (IP+UA+host, HMAC-salted, never stored raw).

- New "Analytics" section in the Payload admin, gated by a canViewAnalytics
  permission on Users, following the existing custom-admin-view pattern
  used by the calendar feature
- Sessions, page views, unique visitors, top/entry/exit pages, events,
  device/browser breakdown, date range filter with period comparison
- Raw events and daily aggregate rollups as their own collections, with
  configurable retention and a cron endpoint that rolls up before purging
- Cookie consent banner (necessary/preferences/statistics) with categories
  and copy editable from a new CMS global; a track() helper that is a
  complete no-op without statistics consent, so nothing is ever sent
  before a visitor opts in
- track() calls wired into the booking flow, contact form, and navigation
2026-09-06 21:59:59 +02:00
maro 82c4261a81 Fix security and correctness issues found in full-codebase audit
- Restrict meetingPassword field so it isn't leaked via the public REST/GraphQL API
- Fix booking slot computation to use Europe/Berlin time instead of the server's UTC clock
- Reset email verification when a customer changes their email address
- Fix ICS calendar injection via unescaped carriage returns in event descriptions
- Bind WebRTC meeting kicks to a persistent client id so they survive a rejoin
- Move the meeting signaling token out of the WebSocket query string into the first message
- Prevent duplicate signaling connections from leaking when the same participant reconnects
- Use the rightmost X-Forwarded-For entry for rate limiting (single trusted reverse proxy)
- Use constant-time comparison for the cron endpoint's bearer token
- Remove SVG from allowed Media upload types (stored-XSS risk, unused anyway)
- Add baseline security headers and a CSP to next.config.ts
- Fix CI/CD script injection via unsanitized git ref names in release.yml
2026-09-06 21:59:39 +02:00
124 changed files with 4803 additions and 1585 deletions
+13 -7
View File
@@ -25,6 +25,11 @@ PAYLOAD_SECRET=replace-with-a-long-random-secret
# Generate with: openssl rand -hex 32
MEETING_SESSION_SECRET=replace-with-a-long-random-secret
# Salts the daily-rotating, non-reversible visitor hash used for Analytics
# session grouping (kept separate from PAYLOAD_SECRET on purpose, see
# lib/analytics/salt.ts). Generate with: openssl rand -hex 32
ANALYTICS_SALT_SECRET=replace-with-a-long-random-secret
# Sent as "Authorization: Bearer <value>" by the external cron job that
# triggers /api/cron/event-reminders. Generate with: openssl rand -hex 32
CRON_SECRET=replace-with-a-long-random-secret
@@ -46,13 +51,14 @@ SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM="ANOUMA <support@anouma.org>"
# ── WebRTC (STUN/TURN) ──────────────────────────────────────────────────
# P2P works with just STUN for most networks. Add a TURN server later for
# restrictive NATs/firewalls — no code changes needed, just set these.
STUN_SERVER=stun:stun.l.google.com:19302
TURN_SERVER=
TURN_USERNAME=
TURN_PASSWORD=
# ── LiveKit SFU / embedded TURN ─────────────────────────────────────────
# Public HTTPS endpoint of the self-hosted LiveKit server. The app converts
# https:// to wss:// for browsers and uses the same endpoint for its API.
# Put a trusted TLS reverse proxy in front of LiveKit (normally on :7880).
LIVEKIT_URL=https://livekit.example.com
# Keep these values private. setup.sh generates them automatically.
LIVEKIT_API_KEY=LKreplace-with-setup-secret
LIVEKIT_API_SECRET=replace-with-setup-secret
# ── SEO ──────────────────────────────────────────────────────────────────
# Optional Google Search Console domain-verification token (Search Console →
+68
View File
@@ -23,6 +23,7 @@ jobs:
DATABASE_URI: postgresql://ci:ci@localhost:5432/ci
PAYLOAD_SECRET: ci-only-not-a-real-secret
MEETING_SESSION_SECRET: ci-only-not-a-real-secret
ANALYTICS_SALT_SECRET: ci-only-not-a-real-secret
CRON_SECRET: ci-only-not-a-real-secret
NEXT_PUBLIC_SERVER_URL: http://localhost:3000
steps:
@@ -48,3 +49,70 @@ jobs:
env:
NODE_ENV: production
run: npm run build
# Builds once per commit that lands on main and has passed the checks
# above, and pushes it to the registry tagged by commit SHA. release.yml
# (triggered by a version tag) never rebuilds — it only promotes this
# exact, already-tested image to the release tag + `latest` and deploys
# it. This guarantees byte-for-byte that what gets deployed is what CI
# verified, and makes tagging a commit that never passed CI on main fail
# loudly (no image to promote) instead of silently rebuilding from
# source and skipping the checks.
build-image:
needs: test
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
# The anouma-runner runs as a Docker container itself (gitea/runner:3),
# with the host's docker.sock bind-mounted in but no `docker` CLI binary
# of its own — a job with no `container:` here runs directly inside that
# minimal runner image and gets "docker: command not found".
#
# A plain `docker:27-cli` image (Alpine, no Node.js) doesn't work either:
# actions/checkout@v4 is a JS action and needs a Node runtime in the job
# container, which that image doesn't have — it fails at Checkout with
# exit 127. Using a Node image as the base guarantees Node is already
# there (same reason the `test` job's node:22-alpine works), and the
# Docker CLI is added via apt. The runner auto-mounts the same host
# docker.sock into this container (sibling containers — see
# container.docker_host in the runner's config.yaml), so `docker
# build`/`push` operate on the host's real Docker daemon. release.yml's
# promote-and-deploy job uses the same setup, for the same reasons.
container:
image: node:22-bookworm-slim
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Docker CLI
run: |
apt-get update
apt-get install -y --no-install-recommends docker.io
rm -rf /var/lib/apt/lists/*
- name: Determine image name
id: image
run: |
echo "registry=${REGISTRY:-git.maro.run}" >> "$GITHUB_OUTPUT"
echo "repo=${REGISTRY_REPO:-maro/anouma}" >> "$GITHUB_OUTPUT"
env:
REGISTRY: git.maro.run
REGISTRY_REPO: maro/anouma
- name: Log in to registry
env:
REGISTRY_IMAGE: ${{ steps.image.outputs.registry }}
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_IMAGE" -u "$REGISTRY_USERNAME" --password-stdin
- name: Build image
env:
IMAGE: ${{ steps.image.outputs.registry }}/${{ steps.image.outputs.repo }}
# Tagged by commit SHA only — `latest` is reserved for whatever was
# last actually released (see release.yml), not the newest main build.
run: docker build -t "$IMAGE:$GITHUB_SHA" .
- name: Push image
env:
IMAGE: ${{ steps.image.outputs.registry }}/${{ steps.image.outputs.repo }}
run: docker push "$IMAGE:$GITHUB_SHA"
+49 -54
View File
@@ -6,55 +6,23 @@ on:
- 'v*'
jobs:
test:
# Deliberately does NOT rebuild or re-run lint/typecheck/test/build: the
# image for this exact commit was already built and pushed by ci.yml's
# build-image job when it landed on main (which itself only runs after
# the `test` job passes). This job just promotes that already-verified
# image to the release tag + `latest` and deploys it — if the tagged
# commit was never pushed to main (or CI hasn't finished yet), the
# "Pull the tested image" step below fails loudly instead of silently
# rebuilding from source and skipping the checks.
promote-and-deploy:
runs-on: ubuntu-latest
container:
image: node:22-alpine
# NODE_ENV is deliberately NOT set at job level: `npm ci` treats a
# production NODE_ENV as `--omit=dev`, which would skip eslint/
# typescript/etc. and break Lint/Typecheck/Test. It's scoped to the
# Build step below instead.
env:
NEXT_TELEMETRY_DISABLED: "1"
DATABASE_URI: postgresql://ci:ci@localhost:5432/ci
PAYLOAD_SECRET: ci-only-not-a-real-secret
MEETING_SESSION_SECRET: ci-only-not-a-real-secret
CRON_SECRET: ci-only-not-a-real-secret
NEXT_PUBLIC_SERVER_URL: http://localhost:3000
steps:
- uses: actions/checkout@v4
- run: apk add --no-cache libc6-compat
- run: npm ci
- run: npm run lint
- run: npx next typegen && npx tsc --noEmit
- run: npm test
- run: npm run build
env:
NODE_ENV: production
build-and-deploy:
needs: test
runs-on: ubuntu-latest
# The anouma-runner runs as a Docker container itself (gitea/runner:3),
# with the host's docker.sock bind-mounted in but no `docker` CLI binary
# of its own — a job with no `container:` here runs directly inside that
# minimal runner image and gets "docker: command not found".
#
# A plain `docker:27-cli` image (Alpine, no Node.js) doesn't work either:
# actions/checkout@v4 is a JS action and needs a Node runtime in the job
# container, which that image doesn't have — it failed at Checkout with
# exit 127. Using a Node image as the base guarantees Node is already
# there (same reason the `test` job's node:22-alpine works), and the
# Docker CLI + ssh client are added via apt. The runner auto-mounts the
# same host docker.sock into this container (sibling containers — see
# container.docker_host in the runner's config.yaml), so `docker
# build`/`push` operate on the host's real Docker daemon.
# Node-based image (not the bare runner, not a Docker-only image) for
# the same reasons as ci.yml's build-image job: the runner needs a
# Docker CLI added via apt (it has none of its own), and actions/checkout
# needs a Node runtime that a plain docker:*-cli image doesn't have.
container:
image: node:22-bookworm-slim
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Docker CLI and ssh client
run: |
apt-get update
@@ -67,27 +35,54 @@ jobs:
# so step commands use the same $GITHUB_OUTPUT / $GITHUB_ENV
# mechanism (the `gitea.*` vs `github.*` expression contexts are
# just aliases of each other for `${{ }}` templating).
#
# The tag name (github.ref_name / $GITHUB_REF_NAME) is
# attacker-influenced: anyone with tag-push rights controls it, and
# git allows shell metacharacters in tag names. It's read here via
# the automatic $GITHUB_REF_NAME env var rather than `${{ github.ref_name }}`
# so its value reaches bash as data (argv/envp), never spliced into
# the script text — `${{ }}` expressions are substituted into the
# YAML before bash ever sees the script, which is what makes
# interpolating attacker-controlled values that way a script-injection
# vector (arbitrary code execution in a job that holds the registry
# and production deploy credentials).
run: |
echo "registry=${REGISTRY:-git.maro.run}" >> "$GITHUB_OUTPUT"
echo "repo=${REGISTRY_REPO:-maro/anouma}" >> "$GITHUB_OUTPUT"
echo "version=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
echo "version=$GITHUB_REF_NAME" >> "$GITHUB_OUTPUT"
env:
REGISTRY: git.maro.run
REGISTRY_REPO: maro/anouma
- name: Log in to registry
run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "${{ steps.image.outputs.registry }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
env:
REGISTRY_IMAGE: ${{ steps.image.outputs.registry }}
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_IMAGE" -u "$REGISTRY_USERNAME" --password-stdin
- name: Build image
- name: Pull the tested image for this commit
env:
IMAGE: ${{ steps.image.outputs.registry }}/${{ steps.image.outputs.repo }}
run: |
IMAGE="${{ steps.image.outputs.registry }}/${{ steps.image.outputs.repo }}"
VERSION="${{ steps.image.outputs.version }}"
docker build -t "$IMAGE:$VERSION" -t "$IMAGE:latest" .
docker pull "$IMAGE:$GITHUB_SHA" || {
echo "::error::No image found for commit $GITHUB_SHA. This tag must point at a commit that was already pushed to main and built by ci.yml's build-image job — push to main first (and wait for CI to finish) before tagging a release." >&2
exit 1
}
- name: Push image
- name: Promote image to the release tag and latest
env:
IMAGE: ${{ steps.image.outputs.registry }}/${{ steps.image.outputs.repo }}
VERSION: ${{ steps.image.outputs.version }}
run: |
docker tag "$IMAGE:$GITHUB_SHA" "$IMAGE:$VERSION"
docker tag "$IMAGE:$GITHUB_SHA" "$IMAGE:latest"
- name: Push promoted tags
env:
IMAGE: ${{ steps.image.outputs.registry }}/${{ steps.image.outputs.repo }}
VERSION: ${{ steps.image.outputs.version }}
run: |
IMAGE="${{ steps.image.outputs.registry }}/${{ steps.image.outputs.repo }}"
VERSION="${{ steps.image.outputs.version }}"
docker push "$IMAGE:$VERSION"
docker push "$IMAGE:latest"
+63
View File
@@ -0,0 +1,63 @@
name: CI
on:
push:
branches: ["**"]
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
env:
NEXT_TELEMETRY_DISABLED: "1"
DATABASE_URI: postgresql://ci:ci@localhost:5432/ci
PAYLOAD_SECRET: ci-only-not-a-real-secret
MEETING_SESSION_SECRET: ci-only-not-a-real-secret
ANALYTICS_SALT_SECRET: ci-only-not-a-real-secret
CRON_SECRET: ci-only-not-a-real-secret
NEXT_PUBLIC_SERVER_URL: http://localhost:3000
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Use Node.js 22
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Typecheck
run: npx next typegen && npx tsc --noEmit
- name: Test
run: npm test
- name: Build
env:
NODE_ENV: production
run: npm run build
build-image:
needs: test
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
permissions:
contents: read
env:
IMAGE: git.maro.run/maro/anouma
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: git.maro.run
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build image
run: docker build -t "$IMAGE:$GITHUB_SHA" .
- name: Push image
run: docker push "$IMAGE:$GITHUB_SHA"
+55
View File
@@ -0,0 +1,55 @@
name: Release
on:
push:
tags: ["v*.*.*"]
permissions:
contents: read
jobs:
promote-and-deploy:
runs-on: ubuntu-latest
env:
IMAGE: git.maro.run/maro/anouma
steps:
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: git.maro.run
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Pull tested image
run: docker pull "$IMAGE:$GITHUB_SHA"
- name: Promote release image
env:
VERSION: ${{ github.ref_name }}
run: |
docker tag "$IMAGE:$GITHUB_SHA" "$IMAGE:$VERSION"
docker tag "$IMAGE:$GITHUB_SHA" "$IMAGE:latest"
docker push "$IMAGE:$VERSION"
docker push "$IMAGE:latest"
- name: Deploy to production
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_HOST_KEY: ${{ secrets.DEPLOY_HOST_KEY }}
VERSION: ${{ github.ref_name }}
shell: bash
run: |
set -euo pipefail
umask 077
key_file="$(mktemp)"
known_hosts_file="$(mktemp)"
trap 'rm -f "$key_file" "$known_hosts_file"' EXIT
printf '%s\n' "$DEPLOY_SSH_KEY" > "$key_file"
chmod 600 "$key_file"
if [ -n "${DEPLOY_HOST_KEY:-}" ]; then
printf '%s\n' "$DEPLOY_HOST_KEY" > "$known_hosts_file"
else
ssh-keyscan -p "${DEPLOY_PORT:-22}" "$DEPLOY_HOST" > "$known_hosts_file" 2>/dev/null
fi
ssh -i "$key_file" -o UserKnownHostsFile="$known_hosts_file" -p "${DEPLOY_PORT:-22}" \
"${DEPLOY_USER}@${DEPLOY_HOST}" "deploy ${VERSION}"
+59 -27
View File
@@ -3,12 +3,18 @@
Production deployment is fully CI/CD-driven via Gitea Actions. There is no `update.sh` and the production server never builds anything — it only ever pulls a tested image from the registry.
```text
Developer → git push → Gitea → Gitea Actions
→ Tests → Docker Build → Registry Push
→ Release (git tag vX.Y.Z) → SSH → deploy.sh
→ docker compose pull → migrate → up -d → Healthcheck → LIVE
Developer → git push main → Gitea Actions (CI)
→ Tests → Docker Build → Registry Push (tagged by commit SHA)
→ Release (git tag vX.Y.Z) → promote that exact image to vX.Y.Z + latest
→ SSH → deploy.sh → docker compose pull → migrate → up -d → Healthcheck → LIVE
```
Building and testing happen exactly once, on `main` — tagging a release never
rebuilds or re-tests, it only promotes the already-verified image for that
commit. Tagging a commit that hasn't been pushed to `main` (or whose CI run
hasn't finished yet) fails the release with a clear error instead of quietly
building from source and skipping the checks.
## One-time server setup
```bash
@@ -64,15 +70,17 @@ Gitea Actions takes it from there (see below). Once it's live, seed the original
Runs on every push and PR: `npm ci` → lint → typecheck → test → build, on the `ubuntu-latest`-labelled runner. No database is required — every Payload-backed route in this app is `force-dynamic`, so a production build never touches Postgres. (There's no automated test suite yet — `npm test` is an honest placeholder; add a real one, e.g. vitest, whenever the project needs it, and it'll run here automatically.)
On top of that, every push to `main` that passes the checks above also builds the Docker image and pushes it to the registry tagged by commit SHA (`git.maro.run/maro/anouma:<sha>`) — this is the one and only build of that commit; nothing later rebuilds it.
### Release (`.gitea/workflows/release.yml`)
Triggered by pushing a tag matching `v*` (e.g. `v1.2.0`):
1. Re-runs the same quality gates as CI.
2. Builds the Docker image and pushes `git.maro.run/maro/anouma:vX.Y.Z` **and** `:latest`.
1. Pulls the image CI already built and pushed for that commit's SHA — fails immediately with a clear error if it isn't there (the tagged commit was never on `main`, or CI for it hasn't finished yet).
2. Retags that same image as `git.maro.run/maro/anouma:vX.Y.Z` **and** `:latest`, and pushes both.
3. SSHes into the production server as `anouma-deploy` and runs `deploy.sh deploy vX.Y.Z`.
Production always runs a specific version tag — `latest` is pushed for convenience/reference only, `deploy.sh` never uses it.
Production always runs a specific version tag — `latest` is pushed for convenience/reference only (and always points at the most recently *released* build, not just the newest commit on `main`), `deploy.sh` never uses it.
## `deploy.sh` (server-side, `/opt/anouma/deploy.sh`)
@@ -122,26 +130,49 @@ In Nginx Proxy Manager, add a Proxy Host:
| Scheme | `http` |
| Forward Host | `app` (the Compose service name) |
| Forward Port | `3000` |
| **Websockets Support** | **enabled** — required for the video-call signaling (`/ws/signaling`) |
| **Websockets Support** | **enabled** — required for the LiveKit signal connection |
## WebRTC: signaling, STUN, TURN
Für LiveKit legst du zusätzlich einen zweiten Proxy Host an:
| Field | Value |
| --- | --- |
| Domain | z. B. `livekit.anouma.org` |
| Scheme | `http` |
| Forward Host | der Server, auf dem LiveKit läuft |
| Forward Port | `7880` |
| **Websockets Support** | **enabled** |
In DNS muss die LiveKit-Domain auf denselben Server zeigen. Das Zertifikat
muss von einer öffentlichen CA stammen; selbst signierte Zertifikate werden
von Browsern für LiveKit nicht akzeptiert.
## WebRTC: LiveKit SFU und eingebettetes TURN
```
Client A Client B
│ │
│ WebSocket (wss://…/ws/signaling) │
▼ ▼
Signaling server (in the "app" container)
│
▼
relays only small JSON offer/answer/ICE
messages — never touches audio/video/screen
Client A ═══════════ P2P WebRTC ═══════════ Client B
Audio / Video / Screen
Client A ── WebRTC ──┐
├── LiveKit SFU ── WebRTC ── Client B
Client C ── WebRTC ──┘ │
└── embedded TURN fallback
```
STUN is on by default (`STUN_SERVER` in `.env`). Add a TURN server for restrictive NATs/firewalls by setting `TURN_SERVER`/`TURN_USERNAME`/`TURN_PASSWORD` and redeploying — no code changes needed.
`docker-compose.yml` startet LiveKit mit Host-Networking, dem UDP-Medienbereich
`50000–60000`, dem TCP-Fallback `7881` und eingebettetem TURN/UDP auf `3478`.
Die API-Keys werden von `setup.sh` generiert und nur an den LiveKit-Container
und die serverseitige Token-Route gegeben. Für den Browser ist ausschließlich
`LIVEKIT_URL` als `wss://`-Endpoint sichtbar.
Firewall/NAT-Freigaben auf dem Server:
```text
TCP 443 LiveKit über den TLS-Reverse-Proxy
TCP 7881 LiveKit-TCP-Fallback
UDP 3478 eingebettetes TURN
UDP 50000-60000 LiveKit-Medien
```
Für genau einen parallelen kleinen Call ist kein Redis und keine GPU nötig.
Bei mehreren LiveKit-Instanzen oder deutlich höherer Last kommt Redis bzw. ein
Load-Balancer dazu.
## Environment variables
@@ -150,10 +181,10 @@ See `.env.example` for the full, commented list. `setup.sh` writes the real file
| Variable | Purpose |
| --- | --- |
| `DATABASE_URI`, `POSTGRES_*` | Postgres connection |
| `PAYLOAD_SECRET`, `MEETING_SESSION_SECRET`, `CRON_SECRET` | Auto-generated, never overwritten afterwards |
| `PAYLOAD_SECRET`, `MEETING_SESSION_SECRET`, `ANALYTICS_SALT_SECRET`, `CRON_SECRET` | Auto-generated, never overwritten afterwards |
| `NEXT_PUBLIC_SERVER_URL` | Public URL of the site |
| `SMTP_*` | The existing ANOUMA mail system |
| `STUN_SERVER`, `TURN_SERVER`, `TURN_USERNAME`, `TURN_PASSWORD` | WebRTC ICE servers |
| `LIVEKIT_URL`, `LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET` | eigener LiveKit-SFU und serverseitige Token-Erzeugung |
| `DOCKER_NETWORK`, `NPM_NETWORK` | Docker network names |
| `BACKUP_RETENTION_DAYS` | Deploy-backup retention |
| `REGISTRY`, `REGISTRY_REPO`, `REGISTRY_USERNAME`, `REGISTRY_PASSWORD` | Where `deploy.sh` pulls the image from |
@@ -174,7 +205,7 @@ Neither `setup.sh` nor `deploy.sh` ever runs `docker system prune`, `docker volu
- **app**: `GET /api/health` (built into the image's `HEALTHCHECK`) — checks that the app can actually query Postgres, not just that the process is listening.
- **postgres**: `pg_isready`.
`docker compose ps` (from `/opt/anouma`) shows both statuses.
`docker compose ps` (from `/opt/anouma`) shows the app, Postgres and LiveKit services.
## Security
@@ -190,8 +221,9 @@ Neither `setup.sh` nor `deploy.sh` ever runs `docker system prune`, `docker volu
| Symptom | Check |
| --- | --- |
| `setup.sh` fails at "Datenbank wurde nicht rechtzeitig healthy" | `docker compose logs postgres` (from `/opt/anouma`) — usually a bad `POSTGRES_PASSWORD`/`DATABASE_URI` mismatch if you hand-edited `.env` |
| Release workflow fails at "Pull the tested image" | The tagged commit was never pushed to `main`, or `main`'s CI/build-image run for it hasn't finished yet — push to `main`, wait for CI to go green, then tag |
| Release workflow fails at "Deploy to production" | Check the Gitea secrets are all set correctly, and that the server's SSH port is reachable from the runner |
| App container unhealthy after a deploy | `ssh` directly to the server (not via the restricted key) and run `docker compose logs app` from `/opt/anouma` |
| Video calls connect but no audio/video | Likely a restrictive NAT — configure a TURN server (see above) |
| Reverse proxy shows a 502/connection reset on the call page | Websockets Support isn't enabled on the Nginx Proxy Manager proxy host |
| Video calls connect but no audio/video | Check LiveKit logs, the UDP 3478/50000–60000 firewall rules and the public `LIVEKIT_URL` |
| Browser cannot connect to LiveKit | Check the LiveKit proxy host, trusted TLS certificate and that Websockets Support is enabled |
| Need to redeploy the same version | `git tag -f vX.Y.Z && git push -f origin vX.Y.Z`, or push a new patch tag |
+2 -2
View File
@@ -1,5 +1,5 @@
# Multi-stage build for the ANOUMA app (Next.js + Payload CMS + WebRTC
# signaling, all served by the custom server.ts — see next.config.ts for why
# Multi-stage build for the ANOUMA app (Next.js + Payload CMS + LiveKit SFU
# integration, all served by the custom server.ts — see next.config.ts for why
# this can't use Next's "standalone" output). DATABASE_URI/PAYLOAD_SECRET/etc.
# are only needed at *runtime*, not at build time (see docker-compose.yml and
# .env.example) — this image builds without a database connection.
+6 -6
View File
@@ -1,6 +1,6 @@
# Anouma
Die Website von Anouma — Next.js (App Router) mit einem eingebauten [Payload CMS](https://payloadcms.com) (PostgreSQL) für Termine, Beiträge, Angebote und Seiteninhalte, plus Online-Termine mit P2P-Video-Call (WebRTC), Anmeldungen und E-Mail-Erinnerungen.
Die Website von Anouma — Next.js (App Router) mit einem eingebauten [Payload CMS](https://payloadcms.com) (PostgreSQL) für Termine, Beiträge, Angebote und Seiteninhalte, plus Online-Termine mit selbst gehostetem [LiveKit](https://livekit.io) SFU-Video-Call, Anmeldungen und E-Mail-Erinnerungen.
## Projektstruktur
@@ -10,11 +10,11 @@ Die Website von Anouma — Next.js (App Router) mit einem eingebauten [Payload C
- `app/global-not-found.tsx` — statische 404-Seite (siehe unten, warum sie nötig ist)
- `collections/`, `globals/`, `access/`, `fields/`, `payload.config.ts` — die CMS-Konfiguration
- `lib/payload/` — Local-API-Zugriffe für die öffentliche Website (mit `React.cache` pro Request memoisiert)
- `lib/meeting/` — Meeting-Passwort, Zeitfenster/Status, signierte Beitritts-Tokens, WebSocket-Signaling, Reminder-Logik
- `lib/meeting/` — Meeting-Passwort, Zeitfenster/Status, signierte Beitritts-Tokens, LiveKit-SFU-Token und Reminder-Logik
- `lib/email/` — SMTP-Versand (nodemailer) und das E-Mail-Template für Reminder
- `lib/texte.ts` — die ursprünglichen Anouma-Texte, nur noch als Seed-Quelle verwendet
- `scripts/seed.ts` — überträgt die vorhandenen Inhalte ins CMS
- `server.ts` — eigener Node-Server (statt `next start`), weil daran der WebRTC-Signaling-WebSocket hängt
- `server.ts` — eigener Node-Server (statt `next start`), passend zur bestehenden Payload-Konfiguration
Zwei bzw. drei Root-Layouts (`(frontend)`, `(payload)`, `(call)`) bedeuten: Next.js kann daraus keine einzelne 404-Seite komponieren, deshalb gibt es `app/global-not-found.tsx` (siehe `experimental.globalNotFound` in `next.config.ts`).
@@ -24,8 +24,8 @@ Zwei bzw. drei Root-Layouts (`(frontend)`, `(payload)`, `(call)`) bedeuten: Next
- Öffentliche Beitrittsseite: `/termine/[slug]/beitreten` (Name + Passwort, beide Pflicht) → bei Erfolg `/termine/[slug]/call`.
- Ist die aufrufende Person im selben Browser als Admin eingeloggt, wird sie automatisch als **Host** erkannt (kein Passwort nötig, größeres Beitritts-Zeitfenster, exklusive Steuerung: Bildschirmfreigabe, Teilnehmer entfernen).
- Die Zeitfenster (wie früh Host/Teilnehmer beitreten dürfen, wie lange das Meeting nach Ende offen bleibt) sind zentral im CMS unter **Video-Termin-Einstellungen** konfigurierbar.
- Server-seitige Validierung: `/api/meetings/[slug]/join` prüft Termin, Zeitfenster und Passwort und stellt danach erst ein kurzlebiges, signiertes Sitzungs-Token aus (`MEETING_SESSION_SECRET`). Das WebSocket-Signaling (`/ws/signaling`, siehe `server.ts`) prüft dieses Token erneut, bevor jemand einem Raum beitreten darf.
- Reines P2P-WebRTC: Der Server relayt nur kleine Signaling-Nachrichten (Angebot/Antwort/ICE), niemals Audio/Video/Bildschirmfreigabe. Es wird nichts aufgezeichnet oder gespeichert.
- Server-seitige Validierung: `/api/meetings/[slug]/join` prüft Termin, Zeitfenster und Passwort und stellt danach erst ein kurzlebiges, signiertes Sitzungs-Token aus (`MEETING_SESSION_SECRET`). Ein zweiter Endpoint tauscht dieses App-Token gegen ein kurzlebiges LiveKit-Token aus.
- LiveKit läuft als SFU auf dem eigenen Server: Audio, Video und Bildschirm werden nicht zwischen allen Browsern vermasht, sondern einmal zum SFU gesendet und gezielt an die anderen Teilnehmer verteilt. Der Server zeichnet nichts auf oder speichert Medien.
- Teilnehmer-Anmeldungen laufen über die Collection **Anmeldungen** (`event-registrations`), öffentlich erreichbar über `/api/meetings/[slug]/register`.
### E-Mail-Erinnerungen
@@ -59,7 +59,7 @@ git tag v1.2.0 && git push origin v1.2.0
Gitea Actions testet, baut das Image, pusht es in die Registry und deployt es per SSH auf den Server — inklusive Backup, Healthcheck und automatischem Rollback bei Fehlschlag. Es gibt kein separates Update-Script.
Für alle Details (Architektur, Gitea Secrets, CI/Release-Workflows, NPM-Reverse-Proxy-Einrichtung, Environment-Variablen, WebRTC/STUN/TURN, Backups, Rollback, Security) siehe **[DEPLOYMENT.md](./DEPLOYMENT.md)**.
Für alle Details (Architektur, Gitea Secrets, CI/Release-Workflows, Reverse-Proxy- und LiveKit-Einrichtung, Environment-Variablen, Backups, Rollback, Security) siehe **[DEPLOYMENT.md](./DEPLOYMENT.md)**.
## Lokale Entwicklung (ohne Docker für die App)
+11
View File
@@ -13,6 +13,17 @@ export const isAdmin: Access = ({ req: { user } }) =>
export const isAdminFieldLevel: FieldAccess = ({ req: { user } }) =>
user?.collection === "users" && user.role === "admin";
/**
* Admins always see Analytics; any other "users" account additionally needs
* its own `canViewAnalytics` flag (see collections/Users.ts) — this is the
* granular permission the single "admin" role doesn't otherwise provide.
* Used both as a Payload Access/FieldAccess function and called directly
* (with a minimal `{ req: { user } }` shape) from the custom admin nav-link
* and view components, which Payload does not gate via collection access.
*/
export const canViewAnalytics: Access = ({ req: { user } }) =>
user?.collection === "users" && (user.role === "admin" || user.canViewAnalytics === true);
/**
* Admins can read every document (including drafts); everyone else may only
* read documents whose Payload draft/publish status is "published".
+4 -3
View File
@@ -55,9 +55,10 @@ export default async function AktuellesPage() {
<Section tone="plain">
{posts.length === 0 ? (
<p className="text-lg text-anouma-plum">
Hier erscheinen bald die ersten Neuigkeiten — schau gerne wieder vorbei.
</p>
<div className="rounded-3xl bg-anouma-cream-light p-8 text-center sm:p-10">
<p className="font-serif text-xl font-medium text-anouma-plum">Hier erscheinen bald die ersten Neuigkeiten</p>
<p className="mt-2 text-base leading-relaxed text-anouma-plum/90">Schau gerne wieder vorbei.</p>
</div>
) : (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{posts.map((post, i) => (
+2 -2
View File
@@ -19,7 +19,7 @@ import { siteConfig } from "@/lib/site";
export const dynamic = "force-dynamic";
const FALLBACK_DESCRIPTION =
"Prozessbegleitung, Doula-Begleitung, Kindergruppen und Singkreise — die Angebote von Anouma im Überblick.";
"Prozessbegleitung, Doula-Begleitung, Kindergruppen und Singkreise – die Angebote von Anouma im Überblick.";
export async function generateMetadata(): Promise<Metadata> {
const [intro, seoSettings] = await Promise.all([getAngeboteIntroGlobal(), getSEOSettingsGlobal()]);
@@ -170,7 +170,7 @@ export default async function AngebotePage() {
<CTA
title="Unsicher, welcher Raum passt?"
lead="Melde dich gerne für ein unverbindliches Kennenlerngespräch — gemeinsam schauen wir, was dein Anliegen gerade braucht."
lead="Melde dich gerne für ein unverbindliches Kennenlerngespräch – gemeinsam schauen wir, was dein Anliegen gerade braucht."
/>
</>
);
+1 -1
View File
@@ -53,7 +53,7 @@ export default async function SingkreisePage() {
<CTA
title="Deine Stimme erklingen lassen"
lead="Ob offen für alle, für Schwangere oder für Mamas mit Baby — melde dich, wenn du einen Kreis besuchen möchtest."
lead="Ob offen für alle, für Schwangere oder für Mamas mit Baby – melde dich, wenn du einen Kreis besuchen möchtest."
/>
</>
);
@@ -21,6 +21,7 @@ export default async function VerifyEmailPage({ params }: Args) {
eyebrow="Konto"
title={verified ? "E-Mail-Adresse bestätigt" : "Bestätigung fehlgeschlagen"}
crumbs={[{ title: "E-Mail bestätigen" }]}
compact
/>
<Section tone="plain">
<div className="mx-auto max-w-md text-center">
+4 -4
View File
@@ -21,7 +21,7 @@ export default async function DatenschutzPage() {
const contact = await getContactGlobal();
return (
<>
<PageHeader title="Datenschutzerklärung" crumbs={[{ title: "Datenschutz" }]} />
<PageHeader title="Datenschutzerklärung" crumbs={[{ title: "Datenschutz" }]} compact />
<Section tone="plain">
<div className="mx-auto max-w-2xl space-y-8">
<PlaceholderNote>
@@ -33,9 +33,9 @@ export default async function DatenschutzPage() {
<div>
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Verantwortliche Stelle</h2>
<p className="mt-4 leading-relaxed text-anouma-plum">
[Platzhalter — Vor- und Nachname]
[Platzhalter – Vor- und Nachname]
<br />
[Platzhalter — Anschrift]
[Platzhalter – Anschrift]
<br />
E-Mail: {contact.email}
</p>
@@ -48,7 +48,7 @@ export default async function DatenschutzPage() {
<p className="mt-4 leading-relaxed text-anouma-plum">
Beim Besuch dieser Website werden aus technischen Gründen automatisch Informationen
erfasst, die dein Browser übermittelt (z. B. IP-Adresse, Datum und Uhrzeit des
Zugriffs). [Platzhalter — Details zum verwendeten Hosting-Anbieter ergänzen.]
Zugriffs). [Platzhalter – Details zum verwendeten Hosting-Anbieter ergänzen.]
</p>
</div>
+3 -3
View File
@@ -10,7 +10,7 @@ import { JsonLd, webPageJsonLd } from "@/lib/seo/jsonld";
export const dynamic = "force-dynamic";
const DESCRIPTION = "Bildeindrücke aus der Arbeit von Anouma — Natur, Gemeinschaft und gemeinsame Räume.";
const DESCRIPTION = "Bildeindrücke aus der Arbeit von Anouma – Natur, Gemeinschaft und gemeinsame Räume.";
export async function generateMetadata(): Promise<Metadata> {
return buildMetadata({ title: "Impressionen", description: DESCRIPTION, path: "/impressionen" });
@@ -36,11 +36,11 @@ export default async function ImpressionenPage() {
<PageHeader
eyebrow="Impressionen"
title="Einblicke in gemeinsame Räume"
lead="Diese Galerie ist als Platzhalter-System angelegt — echte Fotografien lassen sich hier später direkt einsetzen, ohne das Layout zu verändern."
lead="Diese Galerie ist als Platzhalter-System angelegt – echte Fotografien lassen sich hier später direkt einsetzen, ohne das Layout zu verändern."
crumbs={[{ title: "Impressionen" }]}
/>
<Section tone="plain">
<div className="grid auto-rows-[14rem] grid-cols-2 gap-4 sm:grid-cols-4">
<div className="grid auto-rows-[10rem] grid-cols-2 gap-4 sm:auto-rows-[14rem] sm:grid-cols-4">
{gallery.map((item, i) => (
<Reveal key={item.label} delay={Math.min(i * 0.04, 0.3)} className={item.span}>
<ImagePlaceholder mood={item.mood} label={item.label} shape="soft" className="h-full w-full" />
+6 -6
View File
@@ -23,7 +23,7 @@ export default async function ImpressumPage() {
const contact = await getContactGlobal();
return (
<>
<PageHeader title="Impressum" crumbs={[{ title: "Impressum" }]} />
<PageHeader title="Impressum" crumbs={[{ title: "Impressum" }]} compact />
<Section tone="plain">
<div className="mx-auto max-w-2xl space-y-8">
<PlaceholderNote>
@@ -35,11 +35,11 @@ export default async function ImpressumPage() {
<div>
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Angaben gemäß § 5 TMG</h2>
<p className="mt-4 leading-relaxed text-anouma-plum">
[Platzhalter — Vor- und Nachname]
[Platzhalter – Vor- und Nachname]
<br />
[Platzhalter — Straße und Hausnummer]
[Platzhalter – Straße und Hausnummer]
<br />
[Platzhalter — PLZ und Ort]
[Platzhalter – PLZ und Ort]
</p>
</div>
@@ -61,7 +61,7 @@ export default async function ImpressumPage() {
Umsatzsteuer-Identifikationsnummer
</h2>
<p className="mt-4 leading-relaxed text-anouma-plum">
[Platzhalter — sofern vorhanden, gemäß § 27 a Umsatzsteuergesetz]
[Platzhalter – sofern vorhanden, gemäß § 27 a Umsatzsteuergesetz]
</p>
</div>
@@ -70,7 +70,7 @@ export default async function ImpressumPage() {
Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV
</h2>
<p className="mt-4 leading-relaxed text-anouma-plum">
[Platzhalter — Vor- und Nachname, Anschrift wie oben]
[Platzhalter – Vor- und Nachname, Anschrift wie oben]
</p>
</div>
+3
View File
@@ -50,6 +50,9 @@ export default async function KontaktPage() {
title={contact.title || "Ich freue mich, von dir zu hören"}
lead={contact.lead}
crumbs={[{ title: "Kontakt" }]}
blobTone="sage"
blobPosition="top-left"
compact
/>
<Section tone="plain">
+3 -1
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { getCurrentCustomer } from "@/lib/auth/customer";
import { getCustomerBookings } from "@/lib/booking/queries";
import { PersonalCalendar } from "@/components/booking/PersonalCalendar";
import { getMeetingSettingsGlobal } from "@/lib/payload/globals";
export const metadata: Metadata = { title: "Mein Kalender" };
@@ -10,12 +11,13 @@ export default async function KontoKalenderPage() {
if (!customer) return null;
const bookings = await getCustomerBookings(customer.id);
const meetingSettings = await getMeetingSettingsGlobal();
return (
<div>
<h2 className="font-serif text-xl font-medium text-anouma-plum">Mein Kalender</h2>
<div className="mt-6">
<PersonalCalendar bookings={bookings} />
<PersonalCalendar bookings={bookings} meetingSettings={meetingSettings} />
</div>
</div>
);
+2 -2
View File
@@ -44,12 +44,12 @@ export default async function KontoLayout({ children }: { children: ReactNode })
<Link
key={item.href}
href={item.href}
className="whitespace-nowrap rounded-full px-4 py-2.5 text-sm font-medium text-anouma-plum transition-colors hover:bg-white lg:rounded-2xl"
className="flex min-h-11 items-center whitespace-nowrap rounded-full px-4 text-sm font-medium text-anouma-plum transition-colors hover:bg-white lg:rounded-2xl"
>
{item.title}
</Link>
))}
<LogoutButton className="whitespace-nowrap rounded-full px-4 py-2.5 text-left text-sm font-medium text-anouma-plum/70 transition-colors hover:bg-white lg:rounded-2xl" />
<LogoutButton className="flex min-h-11 items-center whitespace-nowrap rounded-full px-4 text-left text-sm font-medium text-anouma-plum/70 transition-colors hover:bg-white lg:rounded-2xl" />
</nav>
<div className="min-w-0">{children}</div>
</div>
+6 -5
View File
@@ -1,8 +1,10 @@
import type { Metadata } from "next";
import Link from "next/link";
import { getCurrentCustomer } from "@/lib/auth/customer";
import { getCustomerBookings } from "@/lib/booking/queries";
import { BookingCard } from "@/components/booking/BookingCard";
import { ArrowLink } from "@/components/ArrowLink";
import { getMeetingSettingsGlobal } from "@/lib/payload/globals";
import { getBookingMeetingStatus } from "@/lib/booking/meetingAccess";
export const metadata: Metadata = { title: "Mein Konto" };
@@ -11,6 +13,7 @@ export default async function KontoOverviewPage() {
if (!customer) return null; // Layout already redirects; keeps TS happy.
const bookings = await getCustomerBookings(customer.id);
const meetingSettings = await getMeetingSettingsGlobal();
const pending = bookings.filter((b) => b.status === "pending");
const confirmed = bookings.filter((b) => b.status === "confirmed" && new Date(b.date) >= new Date());
const past = bookings.filter((b) => new Date(b.date) < new Date() || b.status === "rejected" || b.status === "cancelled");
@@ -53,7 +56,7 @@ export default async function KontoOverviewPage() {
) : (
<div className="mt-4 grid gap-4 sm:grid-cols-2">
{confirmed.map((b) => (
<BookingCard key={b.id} booking={b} />
<BookingCard key={b.id} booking={b} meetingStatus={getBookingMeetingStatus(b, meetingSettings)} />
))}
</div>
)}
@@ -61,9 +64,7 @@ export default async function KontoOverviewPage() {
{past.length > 0 && (
<div>
<Link href="/konto/termine" className="text-sm font-medium text-anouma-mauve-dark underline underline-offset-4">
Alle Termine (inkl. vergangene) ansehen →
</Link>
<ArrowLink href="/konto/termine">Alle Termine (inkl. vergangene) ansehen</ArrowLink>
</div>
)}
</div>
+1 -1
View File
@@ -14,7 +14,7 @@ export default async function KontoProfilPage() {
<div className="mt-6 rounded-3xl bg-white p-6">
<ProfileForm id={customer.id} name={customer.name} phone={customer.phone ?? ""} />
<p className="mt-6 text-xs text-anouma-plum/60">
E-Mail: {customer.email} — für eine Änderung der E-Mail-Adresse melde dich bitte direkt bei Anna.
E-Mail: {customer.email} – für eine Änderung der E-Mail-Adresse melde dich bitte direkt bei Anna.
</p>
</div>
</div>
+4 -1
View File
@@ -2,6 +2,8 @@ import type { Metadata } from "next";
import { getCurrentCustomer } from "@/lib/auth/customer";
import { getCustomerBookings } from "@/lib/booking/queries";
import { BookingCard } from "@/components/booking/BookingCard";
import { getMeetingSettingsGlobal } from "@/lib/payload/globals";
import { getBookingMeetingStatus } from "@/lib/booking/meetingAccess";
export const metadata: Metadata = { title: "Meine Termine" };
@@ -10,6 +12,7 @@ export default async function KontoTerminePage() {
if (!customer) return null;
const bookings = await getCustomerBookings(customer.id);
const meetingSettings = await getMeetingSettingsGlobal();
return (
<div>
@@ -21,7 +24,7 @@ export default async function KontoTerminePage() {
) : (
<div className="mt-4 grid gap-4 sm:grid-cols-2">
{bookings.map((b) => (
<BookingCard key={b.id} booking={b} />
<BookingCard key={b.id} booking={b} meetingStatus={getBookingMeetingStatus(b, meetingSettings)} />
))}
</div>
)}
+13 -3
View File
@@ -2,10 +2,11 @@ import type { Metadata } from "next";
import { Cormorant_Garamond, Inter } from "next/font/google";
import { Navbar } from "@/components/Navbar";
import { Footer } from "@/components/Footer";
import { Analytics } from "@/components/Analytics";
import { siteConfig } from "@/lib/site";
import { getOffers } from "@/lib/payload/content";
import { getCurrentCustomer } from "@/lib/auth/customer";
import { getBookingSettingsGlobal, getContactGlobal, getSEOSettingsGlobal } from "@/lib/payload/globals";
import { getBookingSettingsGlobal, getContactGlobal, getCookieSettingsGlobal, getSEOSettingsGlobal } from "@/lib/payload/globals";
import { getSiteUrl } from "@/lib/seo/config";
import { JsonLd, organizationOrLocalBusinessJsonLd, websiteJsonLd } from "@/lib/seo/jsonld";
import "./globals.css";
@@ -30,7 +31,7 @@ export const metadata: Metadata = {
metadataBase: new URL(siteConfig.domain),
title: {
default: siteConfig.title,
template: `%s — ${siteConfig.name}`,
template: `%s – ${siteConfig.name}`,
},
description: siteConfig.description,
openGraph: {
@@ -51,13 +52,14 @@ export const metadata: Metadata = {
};
export default async function RootLayout({ children }: LayoutProps<"/">) {
const [offers, customer, seoSettings, bookingSettings, contact, siteUrl] = await Promise.all([
const [offers, customer, seoSettings, bookingSettings, contact, siteUrl, cookieSettings] = await Promise.all([
getOffers(),
getCurrentCustomer(),
getSEOSettingsGlobal(),
getBookingSettingsGlobal(),
getContactGlobal(),
getSiteUrl(),
getCookieSettingsGlobal(),
]);
return (
@@ -84,6 +86,14 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
{children}
</main>
<Footer />
<Analytics
consentVersion={cookieSettings.consentVersion}
preferencesEnabled={cookieSettings.preferences?.enabled !== false}
statisticsEnabled={cookieSettings.statistics?.enabled !== false}
necessary={{ label: cookieSettings.necessary?.label ?? "Notwendig", description: cookieSettings.necessary?.description ?? "" }}
preferences={{ label: cookieSettings.preferences?.label ?? "Präferenzen", description: cookieSettings.preferences?.description ?? "" }}
statistics={{ label: cookieSettings.statistics?.label ?? "Statistik", description: cookieSettings.statistics?.description ?? "" }}
/>
</body>
</html>
);
+1 -1
View File
@@ -20,7 +20,7 @@ export default async function LoginPage() {
return (
<>
<PageHeader eyebrow="Konto" title="Anmelden" crumbs={[{ title: "Anmelden" }]} />
<PageHeader eyebrow="Konto" title="Anmelden" crumbs={[{ title: "Anmelden" }]} compact />
<Section tone="plain">
<div className="mx-auto max-w-md">
<Suspense>
+80 -59
View File
@@ -2,9 +2,11 @@ import type { Metadata } from "next";
import Link from "next/link";
import { Hero } from "@/components/Hero";
import { Button } from "@/components/Button";
import { ArrowLink } from "@/components/ArrowLink";
import { Section, SectionHeading } from "@/components/Section";
import { AngebotCard } from "@/components/AngebotCard";
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
import { OrganicBlob } from "@/components/OrganicBlob";
import { RichText } from "@/components/RichText";
import { CTA } from "@/components/CTA";
import { Reveal } from "@/components/Reveal";
@@ -29,7 +31,7 @@ export async function generateMetadata(): Promise<Metadata> {
fallbackDescription: () =>
home.heroSupporting ||
seoSettings.defaultDescription ||
"Prozessbegleitung, Doula-Begleitung, Kindergruppen und Singkreise — Räume für Verbindung mit dir selbst, miteinander und mit der Natur.",
"Prozessbegleitung, Doula-Begleitung, Kindergruppen und Singkreise – Räume für Verbindung mit dir selbst, miteinander und mit der Natur.",
fallbackImage: home.heroImage,
defaultOgImage: seoSettings.defaultOgImage,
});
@@ -53,6 +55,8 @@ export default async function Home() {
getUpcomingEvents(3),
]);
const groups = groupOffersByCategory(offers);
const featuredGroup = groups.find((g) => g.category === "prozessbegleitung");
const remainingGroups = featuredGroup ? groups.filter((g) => g !== featuredGroup) : groups;
const siteUrl = await getSiteUrl();
return (
@@ -73,9 +77,9 @@ export default async function Home() {
actions={
<>
<Button href="/termin-buchen">Termin buchen</Button>
<Button href="/angebote" variant="secondary">
<ArrowLink href="/angebote" size="base" className="self-center">
Angebote entdecken
</Button>
</ArrowLink>
</>
}
>
@@ -89,24 +93,39 @@ export default async function Home() {
<SectionHeading
eyebrow="Angebote"
title="Räume, die zu deinem Weg passen"
lead="Von persönlicher Prozessbegleitung über Doula-Begleitung bis zu Kinder- und Singkreisen — jedes Angebot ist ein eigener, geschützter Raum."
lead="Von persönlicher Prozessbegleitung über Doula-Begleitung bis zu Kinder- und Singkreisen – jedes Angebot ist ein eigener, geschützter Raum."
/>
<div className="mt-14 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
{groups.map((group, i) => {
const isSingle = group.offers.length === 1;
const first = group.offers[0];
return (
<Reveal key={group.category} delay={i * 0.08}>
<AngebotCard
href={isSingle ? `/angebote/${first.slug}` : group.href}
title={group.label}
tagline={isSingle ? first.shortDescription : `${group.offers.length} Angebote`}
mood={moodForOffer(first)}
imageSrc={mediaUrl(first.image, "card")}
/>
</Reveal>
);
})}
<div className="mt-14 space-y-6">
{/* Prozessbegleitung ist das Kernangebot – bekommt deshalb den größeren, ersten Platz statt eines gleichwertigen 4er-Rasters. */}
{featuredGroup && (
<Reveal>
<AngebotCard
href={featuredGroup.offers.length === 1 ? `/angebote/${featuredGroup.offers[0].slug}` : featuredGroup.href}
title={featuredGroup.label}
tagline={featuredGroup.offers.length === 1 ? featuredGroup.offers[0].shortDescription : `${featuredGroup.offers.length} Angebote`}
mood={moodForOffer(featuredGroup.offers[0])}
imageSrc={mediaUrl(featuredGroup.offers[0].image, "card")}
size="large"
/>
</Reveal>
)}
<div className="grid gap-6 sm:grid-cols-3">
{remainingGroups.map((group, i) => {
const isSingle = group.offers.length === 1;
const first = group.offers[0];
return (
<Reveal key={group.category} delay={i * 0.08}>
<AngebotCard
href={isSingle ? `/angebote/${first.slug}` : group.href}
title={group.label}
tagline={isSingle ? first.shortDescription : `${group.offers.length} Angebote`}
mood={moodForOffer(first)}
imageSrc={mediaUrl(first.image, "card")}
/>
</Reveal>
);
})}
</div>
</div>
</Section>
@@ -141,9 +160,7 @@ export default async function Home() {
</div>
)}
<div className="mt-8">
<Button href="/ueber-mich" variant="ghost">
Mehr über mich lesen →
</Button>
<ArrowLink href="/ueber-mich">Mehr über mich lesen</ArrowLink>
</div>
</Reveal>
</div>
@@ -151,24 +168,27 @@ export default async function Home() {
)}
{home.values && home.values.length > 0 && (
<Section tone="mauve">
<SectionHeading
eyebrow={home.valuesEyebrow ?? undefined}
title={home.valuesTitle || "Philosophie"}
align="center"
className="mx-auto text-anouma-cream-light [&_h2]:text-anouma-cream-light [&_p]:text-anouma-cream-light/90"
/>
<div className="mt-16 grid gap-10 sm:grid-cols-3">
{home.values.map((wert, i) => (
<Reveal key={wert.id ?? wert.title} delay={i * 0.1} className="text-center">
<p className="font-serif text-sm font-medium uppercase tracking-[0.2em] text-white/85">
{wert.title}
</p>
<p className="mt-4 text-balance font-serif text-2xl leading-snug italic">
„{wert.quote}“
</p>
</Reveal>
))}
<Section tone="mauve" className="relative overflow-hidden">
<OrganicBlob tone="sage" className="-left-24 top-1/2 h-96 w-96 -translate-y-1/2 opacity-20" />
<div className="relative">
<SectionHeading
eyebrow={home.valuesEyebrow ?? undefined}
title={home.valuesTitle || "Philosophie"}
align="center"
className="mx-auto text-anouma-cream-light [&_h2]:text-anouma-cream-light [&_p]:text-anouma-cream-light/90"
/>
<div className="mt-16 grid gap-10 sm:grid-cols-3">
{home.values.map((wert, i) => (
<Reveal key={wert.id ?? wert.title} delay={i * 0.1} className="text-center">
<p className="font-serif text-sm font-medium uppercase tracking-[0.2em] text-white/85">
{wert.title}
</p>
<p className="mt-4 text-balance font-serif text-2xl leading-snug italic">
„{wert.quote}“
</p>
</Reveal>
))}
</div>
</div>
</Section>
)}
@@ -177,9 +197,7 @@ export default async function Home() {
<Section tone="warm">
<div className="flex flex-wrap items-end justify-between gap-6">
<SectionHeading eyebrow="Termine" title="Aktuelle Termine" className="mb-0" />
<Button href="/termine" variant="ghost">
Alle Termine →
</Button>
<ArrowLink href="/termine">Alle Termine</ArrowLink>
</div>
<div className="mt-12 grid gap-6 sm:grid-cols-3">
{events.map((event, i) => (
@@ -195,23 +213,31 @@ export default async function Home() {
<Section tone="cream">
<div className="flex flex-wrap items-end justify-between gap-6">
<SectionHeading eyebrow="Aktuelles" title="Neuigkeiten und Inspiration" className="mb-0" />
<Button href="/aktuelles" variant="ghost">
Alle Neuigkeiten →
</Button>
<ArrowLink href="/aktuelles">Alle Neuigkeiten</ArrowLink>
</div>
<div className="mt-12 grid gap-6 sm:grid-cols-3">
{posts.map((post, i) => (
<Reveal key={post.slug} delay={i * 0.08}>
<Link href={`/aktuelles/${post.slug}`} className="block rounded-3xl bg-background p-7">
<p className="text-xs font-medium uppercase tracking-[0.18em] text-anouma-plum">
<Link href={`/aktuelles/${post.slug}`} className="group block">
<div className="relative aspect-[4/3] w-full overflow-hidden rounded-3xl">
<ImagePlaceholder
mood="sand"
label={post.title}
src={mediaUrl(post.coverImage, "card")}
alt={mediaAlt(post.coverImage) ?? post.title}
shape="soft"
className="h-full w-full transition-transform duration-700 group-hover:scale-[1.03]"
/>
</div>
<p className="mt-4 text-xs font-medium uppercase tracking-[0.18em] text-anouma-plum/70">
{new Date(post.publishDate ?? post.createdAt).toLocaleDateString("de-DE", {
day: "2-digit",
month: "long",
year: "numeric",
})}
</p>
<h3 className="mt-3 font-serif text-xl font-medium text-anouma-plum">{post.title}</h3>
<p className="mt-3 text-sm leading-relaxed text-anouma-plum">{post.teaser}</p>
<h3 className="mt-2 font-serif text-xl font-medium text-anouma-plum">{post.title}</h3>
<p className="mt-2 text-sm leading-relaxed text-anouma-plum/90">{post.teaser}</p>
</Link>
</Reveal>
))}
@@ -228,13 +254,8 @@ export default async function Home() {
</Reveal>
))}
</div>
<div className="mt-10 text-center">
<Link
href="/impressionen"
className="text-sm font-medium text-anouma-mauve-dark underline underline-offset-4"
>
Alle Impressionen ansehen
</Link>
<div className="mt-10 flex justify-center">
<ArrowLink href="/impressionen">Alle Impressionen ansehen</ArrowLink>
</div>
</Section>
@@ -242,7 +263,7 @@ export default async function Home() {
title={home.ctaTitle || "Kennenlernen & Termine vereinbaren"}
lead={
home.ctaLead ||
"Ich freue mich, von dir zu hören — schreib mir oder buche direkt ein unverbindliches Kennenlerngespräch."
"Ich freue mich, von dir zu hören – schreib mir oder buche direkt ein unverbindliches Kennenlerngespräch."
}
/>
</>
+1
View File
@@ -24,6 +24,7 @@ export default async function RegisterPage() {
title="Konto erstellen"
lead="Mit einem Konto kannst du Termine anfragen und behältst alle deine Buchungen im Blick."
crumbs={[{ title: "Konto erstellen" }]}
compact
/>
<Section tone="plain">
<div className="mx-auto max-w-md">
+1 -1
View File
@@ -98,7 +98,7 @@ export default async function TerminBuchenPage() {
title="Allgemeine Anfrage"
lead={
bookableOffers.length > 0
? "Für einen konkreten Termin wähle oben ein Angebot — hier kannst du auch einfach eine allgemeine Nachricht schreiben."
? "Für einen konkreten Termin wähle oben ein Angebot – hier kannst du auch einfach eine allgemeine Nachricht schreiben."
: undefined
}
align="left"
@@ -5,6 +5,7 @@ import { JoinForm } from "@/components/meeting/JoinForm";
import { OrganicBlob } from "@/components/OrganicBlob";
import { getEventForJoin } from "@/lib/payload/content";
import { combineDateAndTime } from "@/lib/meeting/status";
import { formatBerlinDate, formatBerlinTime } from "@/lib/calendar/timezone";
export const dynamic = "force-dynamic";
@@ -21,8 +22,8 @@ export default async function BeitretenPage({ params }: Args) {
if (!event || !event.isOnline) notFound();
const start = combineDateAndTime(event.date, event.startTime);
const dateLabel = start.toLocaleDateString("de-DE", { day: "2-digit", month: "long" });
const timeLabel = start.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
const dateLabel = formatBerlinDate(start, { day: "2-digit", month: "long" });
const timeLabel = formatBerlinTime(start, { hour: "2-digit", minute: "2-digit" });
return (
<section className="relative flex min-h-[80vh] items-center overflow-hidden py-20">
+3 -1
View File
@@ -76,7 +76,7 @@ export default async function EventDetailPage({ params }: Args) {
const { user } = await payload.auth({ headers: await headers() });
// Must check the collection, not just presence — a logged-in customer
// (collection "customers") must never be treated as host.
const isHost = user?.collection === "users";
const isHost = user?.collection === "users" && user.role === "admin";
const settings = await payload.findGlobal({ slug: "meeting-settings" });
const start = combineDateAndTime(event.date, event.startTime);
const end = event.endTime
@@ -126,6 +126,8 @@ export default async function EventDetailPage({ params }: Args) {
eyebrow={categoryLabel}
title={event.title}
crumbs={[{ title: "Termine", href: "/termine" }, { title: event.title }]}
blobTone="mauve"
blobPosition="top-left"
/>
<Section tone="plain">
<div className="grid gap-14 lg:grid-cols-[1fr_0.8fr]">
+15 -5
View File
@@ -3,6 +3,7 @@ import { PageHeader } from "@/components/PageHeader";
import { Section } from "@/components/Section";
import { Reveal } from "@/components/Reveal";
import { EventTeaserCard } from "@/components/EventTeaserCard";
import { ArrowLink } from "@/components/ArrowLink";
import { getAllEvents } from "@/lib/payload/content";
import { buildMetadata } from "@/lib/seo/metadata";
import { getSiteUrl } from "@/lib/seo/config";
@@ -20,7 +21,9 @@ export default async function TerminePage() {
const events = await getAllEvents();
const today = new Date();
today.setHours(0, 0, 0, 0);
const upcoming = events.filter((e) => new Date(e.date) >= today).reverse();
const upcoming = events
.filter((e) => new Date(e.date) >= today)
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
const past = events.filter((e) => new Date(e.date) < today);
const siteUrl = await getSiteUrl();
@@ -32,14 +35,21 @@ export default async function TerminePage() {
title="Aktuelle Termine"
lead="Ein Überblick über anstehende Kreise, Begleitungen und Veranstaltungen."
crumbs={[{ title: "Termine" }]}
blobTone="mauve"
blobPosition="top-left"
/>
<Section tone="plain">
{upcoming.length === 0 ? (
<p className="text-lg text-anouma-plum">
Aktuell sind keine Termine geplant — schau gerne bald wieder vorbei oder melde dich
direkt über die <a href="/kontakt" className="underline underline-offset-4">Kontaktseite</a>.
</p>
<div className="rounded-3xl bg-anouma-cream-light p-8 text-center sm:p-10">
<p className="font-serif text-xl font-medium text-anouma-plum">Aktuell sind keine Termine geplant</p>
<p className="mt-2 text-base leading-relaxed text-anouma-plum/90">
Schau gerne bald wieder vorbei, oder melde dich direkt – ich freue mich von dir zu hören.
</p>
<div className="mt-5 flex justify-center">
<ArrowLink href="/kontakt">Zur Kontaktseite</ArrowLink>
</div>
</div>
) : (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{upcoming.map((event, i) => (
@@ -0,0 +1,56 @@
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
import { createMeetingToken } from "@/lib/meeting/token";
export const dynamic = "force-dynamic";
type Body = { eventSlug?: unknown };
/** Creates a host session for an online event from the authenticated CRM. */
export async function POST(request: Request) {
let body: Body;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
}
const eventSlug = typeof body.eventSlug === "string" ? body.eventSlug.trim() : "";
if (!eventSlug) return NextResponse.json({ error: "Online-Termin fehlt." }, { status: 400 });
try {
const payload = await getPayload({ config });
const { user } = await payload.auth({ headers: request.headers });
if (!user || user.collection !== "users" || user.role !== "admin") {
return NextResponse.json({ error: "Nicht autorisiert." }, { status: 401 });
}
const { docs } = await payload.find({
collection: "events",
where: { slug: { equals: eventSlug } },
limit: 1,
});
const event = docs[0];
if (!event || !event.isOnline) {
return NextResponse.json({ error: "Dieser Termin ist kein Online-Termin." }, { status: 404 });
}
const { token } = createMeetingToken({
eventSlug,
name: typeof user.name === "string" && user.name.trim() ? user.name.trim() : "Host",
role: "host",
});
// Keep the token in the URL fragment: browsers do not send fragments to
// the server, so the temporary host credential does not enter request or
// reverse-proxy logs. The CRM copies this test link without navigating.
const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || new URL(request.url).origin;
const testUrl = `${baseUrl.replace(/\/$/, "")}/termine/${encodeURIComponent(eventSlug)}/call#token=${encodeURIComponent(token)}`;
return NextResponse.json({ testUrl });
} catch (error) {
console.error("admin meeting start failed", error);
return NextResponse.json({ error: "Der Video-Call konnte nicht gestartet werden." }, { status: 500 });
}
}
@@ -0,0 +1,97 @@
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
import { checkRateLimit, getClientIp } from "@/lib/auth/rateLimit";
import { visitorHash } from "@/lib/analytics/salt";
import { classifyBrowser, classifyDevice } from "@/lib/analytics/userAgent";
import { resolveSessionId } from "@/lib/analytics/session";
export const dynamic = "force-dynamic";
const MAX_PATH_LENGTH = 512;
const MAX_EVENT_NAME_LENGTH = 100;
function normalizePath(value: unknown): string | null {
if (typeof value !== "string" || !value.startsWith("/") || value.length > MAX_PATH_LENGTH) return null;
return value;
}
function normalizeEventName(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (!trimmed || trimmed.length > MAX_EVENT_NAME_LENGTH) return null;
return trimmed;
}
/** Hostname only (never path/query) — see collections/AnalyticsEvents.ts. */
function referrerHostFrom(referrer: unknown): string | undefined {
if (typeof referrer !== "string" || !referrer) return undefined;
try {
return new URL(referrer).hostname || undefined;
} catch {
return undefined;
}
}
/**
* Public tracking beacon, called by lib/analytics/client.ts only after the
* visitor has granted statistics consent — but the client's own claim is
* never trusted alone: this route re-checks the CookieSettings global's
* `statistics.enabled` flag server-side before writing anything, so a
* global kill-switch (or a stale/tampered client) can never bypass it.
*
* navigator.sendBeacon() can't reliably set a JSON Content-Type across
* browsers, so this accepts both application/json and a raw text body.
*/
export async function POST(request: Request) {
const ip = getClientIp(request);
if (!checkRateLimit(`analytics:${ip}`, { max: 120, windowMs: 60_000 })) {
return new NextResponse(null, { status: 204 });
}
let body: { eventName?: unknown; path?: unknown; referrer?: unknown };
try {
const raw = await request.text();
body = JSON.parse(raw);
} catch {
return new NextResponse(null, { status: 204 });
}
const path = normalizePath(body.path);
const eventName = normalizeEventName(body.eventName);
if (!path || !eventName) {
return new NextResponse(null, { status: 204 });
}
try {
const payload = await getPayload({ config });
const cookieSettings = await payload.findGlobal({ slug: "cookie-settings" });
if (cookieSettings.statistics?.enabled === false) {
return new NextResponse(null, { status: 204 });
}
const userAgent = request.headers.get("user-agent") ?? "";
const host = request.headers.get("host") ?? "";
const hash = visitorHash({ ip, userAgent, host });
const sessionId = await resolveSessionId(payload, hash);
await payload.create({
collection: "analytics-events",
data: {
sessionId,
visitorHash: hash,
path,
eventName,
referrerHost: referrerHostFrom(body.referrer),
device: classifyDevice(userAgent),
browser: classifyBrowser(userAgent),
},
});
return new NextResponse(null, { status: 204 });
} catch (error) {
console.error("analytics track failed", error);
return new NextResponse(null, { status: 204 });
}
}
+5 -7
View File
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
import { getAvailableSlots } from "@/lib/booking/slots";
import { berlinDayBounds } from "@/lib/calendar/timezone";
export const dynamic = "force-dynamic";
@@ -59,16 +60,13 @@ export async function POST(request: Request) {
// Re-validate the slot is actually free right now (defense against a
// stale slot list) — the hard, race-safe check still runs again when
// Anna confirms (see collections/BookingRequests.ts).
const dayStart = new Date(start);
dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(start);
dayEnd.setHours(23, 59, 59, 999);
const dayBounds = berlinDayBounds(start);
const slotsByDay = await getAvailableSlots(payload, {
durationMinutes: offer.durationMinutes,
from: dayStart,
to: dayEnd,
from: dayBounds.start,
to: dayBounds.end,
});
const key = dayStart.toISOString().slice(0, 10);
const key = dayBounds.key;
const isStillFree = (slotsByDay.get(key) ?? []).some((s) => s.start.getTime() === start.getTime());
if (!isStillFree) {
return NextResponse.json({ error: "Dieser Termin ist leider nicht mehr verfügbar." }, { status: 409 });
+12 -2
View File
@@ -29,11 +29,21 @@ export async function GET(request: Request) {
return NextResponse.json({ error: "Dieses Angebot ist nicht buchbar." }, { status: 404 });
}
const from = fromParam ? new Date(fromParam) : new Date();
const from = fromParam ? new Date(`${fromParam}T00:00:00`) : new Date();
if (Number.isNaN(from.getTime())) {
return NextResponse.json({ error: "Ungültiges Startdatum." }, { status: 400 });
}
from.setHours(0, 0, 0, 0);
const to = toParam ? new Date(toParam) : new Date(from.getTime() + 21 * 24 * 60 * 60 * 1000);
const to = toParam ? new Date(`${toParam}T00:00:00`) : new Date(from.getTime() + 21 * 24 * 60 * 60 * 1000);
if (Number.isNaN(to.getTime())) {
return NextResponse.json({ error: "Ungültiges Enddatum." }, { status: 400 });
}
to.setHours(23, 59, 59, 999);
if (to < from) {
return NextResponse.json({ error: "Das Enddatum muss nach dem Startdatum liegen." }, { status: 400 });
}
// Cap the range so this can't be abused to run an expensive scan.
const maxRangeMs = 62 * 24 * 60 * 60 * 1000;
if (to.getTime() - from.getTime() > maxRangeMs) {
@@ -0,0 +1,41 @@
import { timingSafeEqual } from "node:crypto";
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
import { runAnalyticsCleanup } from "@/lib/analytics/cleanup";
export const dynamic = "force-dynamic";
function safeEqual(a: string, b: string): boolean {
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
if (bufA.length !== bufB.length) return false;
return timingSafeEqual(bufA, bufB);
}
/**
* Intended to be called by an external cron job once daily, e.g.:
* curl -H "Authorization: Bearer $CRON_SECRET" https://anouma.org/api/cron/analytics-cleanup
* (a second, separate cron entry from /api/cron/event-reminders — reuses
* the same CRON_SECRET, no new secret needed)
*/
export async function GET(request: Request) {
const secret = process.env.CRON_SECRET;
if (!secret) {
return NextResponse.json({ error: "CRON_SECRET is not configured" }, { status: 500 });
}
const authHeader = request.headers.get("authorization");
if (!authHeader || !safeEqual(authHeader, `Bearer ${secret}`)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const payload = await getPayload({ config });
const result = await runAnalyticsCleanup(payload);
return NextResponse.json({ ok: true, ...result });
} catch (error) {
console.error("analytics-cleanup cron failed", error);
return NextResponse.json({ error: "Internal error" }, { status: 500 });
}
}
@@ -1,8 +1,16 @@
import { timingSafeEqual } from "node:crypto";
import { NextResponse } from "next/server";
import { runEventReminders } from "@/lib/meeting/reminders";
export const dynamic = "force-dynamic";
function safeEqual(a: string, b: string): boolean {
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
if (bufA.length !== bufB.length) return false;
return timingSafeEqual(bufA, bufB);
}
/**
* Intended to be called by an external cron job every few minutes, e.g.:
* curl -H "Authorization: Bearer $CRON_SECRET" https://anouma.org/api/cron/event-reminders
@@ -14,7 +22,7 @@ export async function GET(request: Request) {
}
const authHeader = request.headers.get("authorization");
if (authHeader !== `Bearer ${secret}`) {
if (!authHeader || !safeEqual(authHeader, `Bearer ${secret}`)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
@@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
import { createLiveKitToken, getLiveKitClientUrl } from "@/lib/meeting/livekit";
import { isMeetingParticipantKicked } from "@/lib/meeting/kicks";
import { verifyMeetingToken } from "@/lib/meeting/token";
export const dynamic = "force-dynamic";
type Args = { params: Promise<{ slug: string }> };
/** Exchanges the app's short-lived meeting credential for a LiveKit token. */
export async function POST(request: Request, { params }: Args) {
const { slug } = await params;
const authorization = request.headers.get("authorization") ?? "";
const appToken = authorization.startsWith("Bearer ") ? authorization.slice(7) : "";
const meetingToken = verifyMeetingToken(appToken);
if (!meetingToken || meetingToken.eventSlug !== slug) {
return NextResponse.json({ error: "Die Meeting-Sitzung ist ungültig." }, { status: 401 });
}
if (isMeetingParticipantKicked(slug, meetingToken.participantId, meetingToken.clientId)) {
return NextResponse.json({ error: "Du wurdest aus diesem Meeting entfernt." }, { status: 403 });
}
try {
const payload = await getPayload({ config });
const { docs } = await payload.find({ collection: "events", where: { slug: { equals: slug } }, limit: 1 });
if (!docs[0]?.isOnline) return NextResponse.json({ error: "Dieser Online-Termin wurde nicht gefunden." }, { status: 404 });
const participantToken = await createLiveKitToken({
eventSlug: slug,
participantId: meetingToken.participantId,
name: meetingToken.name,
role: meetingToken.role,
clientId: meetingToken.clientId,
});
return NextResponse.json({ serverUrl: getLiveKitClientUrl(), participantToken });
} catch (error) {
console.error("meeting LiveKit connect failed", error);
return NextResponse.json({ error: "Der Video-Server ist gerade nicht erreichbar." }, { status: 503 });
}
}
@@ -4,7 +4,8 @@ import { getPayload } from "payload";
import config from "@payload-config";
import { createMeetingToken } from "@/lib/meeting/token";
import { canJoinMeeting, combineDateAndTime, getMeetingStatus } from "@/lib/meeting/status";
import { getIceServers } from "@/lib/meeting/iceServers";
import { createLiveKitToken, getLiveKitClientUrl } from "@/lib/meeting/livekit";
import { isMeetingParticipantKicked } from "@/lib/meeting/kicks";
export const dynamic = "force-dynamic";
@@ -20,7 +21,7 @@ type Args = { params: Promise<{ slug: string }> };
export async function POST(request: Request, { params }: Args) {
const { slug } = await params;
let body: { name?: unknown; password?: unknown };
let body: { name?: unknown; password?: unknown; clientId?: unknown };
try {
body = await request.json();
} catch {
@@ -29,9 +30,13 @@ export async function POST(request: Request, { params }: Args) {
const name = typeof body.name === "string" ? body.name.trim() : "";
const password = typeof body.password === "string" ? body.password.trim() : "";
if (!name) {
const clientId = typeof body.clientId === "string" ? body.clientId.trim() : undefined;
if (!name || name.length > 120) {
return NextResponse.json({ error: "Bitte gib deinen Namen ein." }, { status: 400 });
}
if (password.length > 200) {
return NextResponse.json({ error: "Das Meeting-Passwort ist ungültig." }, { status: 400 });
}
try {
const payload = await getPayload({ config });
@@ -49,7 +54,7 @@ export async function POST(request: Request, { params }: Args) {
const { user } = await payload.auth({ headers: request.headers });
// Must check the collection, not just presence — a logged-in customer
// (collection "customers") must never be treated as host.
const isHost = user?.collection === "users";
const isHost = user?.collection === "users" && user.role === "admin";
if (!isHost) {
if (!password) {
@@ -85,6 +90,21 @@ export async function POST(request: Request, { params }: Args) {
eventSlug: slug,
name,
role: isHost ? "host" : "participant",
// Only meaningful for participants — a host authenticates via a real
// session already, so there's no need to anchor a kick to it (and
// hosts can't be kicked in the first place.
clientId: isHost ? undefined : clientId,
});
if (!isHost && isMeetingParticipantKicked(slug, participantId, clientId)) {
return NextResponse.json({ error: "Du wurdest aus diesem Meeting entfernt." }, { status: 403 });
}
const participantToken = await createLiveKitToken({
eventSlug: slug,
participantId,
name,
role: isHost ? "host" : "participant",
clientId: isHost ? undefined : clientId,
});
return NextResponse.json({
@@ -92,7 +112,8 @@ export async function POST(request: Request, { params }: Args) {
participantId,
role: isHost ? "host" : "participant",
eventTitle: event.title,
iceServers: getIceServers(),
participantToken,
serverUrl: getLiveKitClientUrl(),
});
} catch (error) {
console.error("meeting join failed", error);
@@ -0,0 +1,50 @@
import { NextResponse } from "next/server";
import { getLiveKitRoomName, getLiveKitRoomService } from "@/lib/meeting/livekit";
import { markMeetingParticipantKicked } from "@/lib/meeting/kicks";
import { verifyMeetingToken } from "@/lib/meeting/token";
export const dynamic = "force-dynamic";
type Args = { params: Promise<{ slug: string }> };
export async function POST(request: Request, { params }: Args) {
const { slug } = await params;
const authorization = request.headers.get("authorization") ?? "";
const appToken = authorization.startsWith("Bearer ") ? authorization.slice(7) : "";
const host = verifyMeetingToken(appToken);
if (!host || host.eventSlug !== slug || host.role !== "host") {
return NextResponse.json({ error: "Nur der Host darf Teilnehmer entfernen." }, { status: 403 });
}
let body: { participantId?: unknown };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
}
const participantId = typeof body.participantId === "string" ? body.participantId.trim() : "";
if (!participantId || participantId.length > 100 || participantId === host.participantId) {
return NextResponse.json({ error: "Ungültiger Teilnehmer." }, { status: 400 });
}
try {
const roomService = getLiveKitRoomService();
const room = getLiveKitRoomName(slug);
const participant = (await roomService.listParticipants(room)).find((item) => item.identity === participantId);
if (!participant) return NextResponse.json({ error: "Teilnehmer nicht gefunden." }, { status: 404 });
let clientId: string | undefined;
try {
const metadata = JSON.parse(participant.metadata || "{}") as { clientId?: unknown };
if (typeof metadata.clientId === "string" && metadata.clientId.length <= 100) clientId = metadata.clientId;
} catch {
// Metadata is optional; the participant identity is still revoked below.
}
markMeetingParticipantKicked(slug, participantId, clientId);
await roomService.removeParticipant(room, participantId);
return NextResponse.json({ ok: true });
} catch (error) {
console.error("meeting participant kick failed", error);
return NextResponse.json({ error: "Der Teilnehmer konnte nicht entfernt werden." }, { status: 503 });
}
}
@@ -1,14 +1,40 @@
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
import { checkRateLimit, getClientIp } from "@/lib/auth/rateLimit";
export const dynamic = "force-dynamic";
// Registration capacity is checked before the insert. Serialize registrations
// for the same event so two simultaneous requests cannot both observe the
// same free seat in this single-process deployment.
const registrationLocks = new Map<string, Promise<void>>();
async function withEventRegistrationLock<T>(eventId: string, action: () => Promise<T>): Promise<T> {
const previous = registrationLocks.get(eventId) ?? Promise.resolve();
let release!: () => void;
const current = new Promise<void>((resolve) => {
release = resolve;
});
registrationLocks.set(eventId, current);
await previous;
try {
return await action();
} finally {
release();
if (registrationLocks.get(eventId) === current) registrationLocks.delete(eventId);
}
}
type Args = { params: Promise<{ slug: string }> };
export async function POST(request: Request, { params }: Args) {
const { slug } = await params;
if (!checkRateLimit(`event-registration:ip:${getClientIp(request)}`, { max: 20, windowMs: 60 * 60 * 1000 })) {
return NextResponse.json({ error: "Zu viele Anmeldungen. Bitte versuche es später erneut." }, { status: 429 });
}
let body: { name?: unknown; email?: unknown };
try {
body = await request.json();
@@ -17,8 +43,8 @@ export async function POST(request: Request, { params }: Args) {
}
const name = typeof body.name === "string" ? body.name.trim() : "";
const email = typeof body.email === "string" ? body.email.trim() : "";
if (!name || !email || !email.includes("@")) {
const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : "";
if (!name || name.length > 120 || !email || email.length > 254 || !email.includes("@")) {
return NextResponse.json({ error: "Bitte gib deinen Namen und eine gültige E-Mail-Adresse an." }, { status: 400 });
}
@@ -31,41 +57,46 @@ export async function POST(request: Request, { params }: Args) {
limit: 1,
});
const event = docs[0];
if (!event) {
if (!event || event._status !== "published" || !event.registrationRequired) {
return NextResponse.json({ error: "Dieser Termin wurde nicht gefunden." }, { status: 404 });
}
const { docs: existingForEmail } = await payload.find({
collection: "event-registrations",
where: {
and: [
{ event: { equals: event.id } },
{ email: { equals: email } },
{ status: { equals: "registered" } },
],
},
limit: 1,
});
if (existingForEmail[0]) {
return NextResponse.json({ ok: true, alreadyRegistered: true });
if (!checkRateLimit(`event-registration:email:${event.id}:${email}`, { max: 3, windowMs: 60 * 60 * 1000 })) {
return NextResponse.json({ error: "Zu viele Anmeldeversuche für diese Adresse. Bitte versuche es später erneut." }, { status: 429 });
}
if (event.maxParticipants) {
const { totalDocs } = await payload.count({
return await withEventRegistrationLock(String(event.id), async () => {
const { docs: existingForEmail } = await payload.find({
collection: "event-registrations",
where: { and: [{ event: { equals: event.id } }, { status: { equals: "registered" } }] },
where: {
and: [
{ event: { equals: event.id } },
{ email: { equals: email } },
{ status: { equals: "registered" } },
],
},
limit: 1,
});
if (totalDocs >= event.maxParticipants) {
return NextResponse.json({ error: "Dieser Termin ist bereits ausgebucht." }, { status: 409 });
if (existingForEmail[0]) {
return NextResponse.json({ ok: true, alreadyRegistered: true });
}
}
await payload.create({
collection: "event-registrations",
data: { event: event.id, name, email, status: "registered" },
if (event.maxParticipants) {
const { totalDocs } = await payload.count({
collection: "event-registrations",
where: { and: [{ event: { equals: event.id } }, { status: { equals: "registered" } }] },
});
if (totalDocs >= event.maxParticipants) {
return NextResponse.json({ error: "Dieser Termin ist bereits ausgebucht." }, { status: 409 });
}
}
await payload.create({
collection: "event-registrations",
data: { event: event.id, name, email, status: "registered" },
});
return NextResponse.json({ ok: true });
});
return NextResponse.json({ ok: true });
} catch (error) {
console.error("event registration failed", error);
return NextResponse.json({ error: "Die Anmeldung ist gerade nicht möglich. Bitte versuche es später erneut." }, { status: 500 });
+52
View File
@@ -0,0 +1,52 @@
import type { CollectionConfig } from "payload";
import { canViewAnalytics } from "@/access";
/**
* One row per calendar day, upserted by the cleanup cron
* (lib/analytics/cleanup.ts) from raw analytics-events BEFORE those raw rows
* age out. This is what lets aggregated history survive well beyond the raw
* retention window (default 90 days raw vs. 12 months aggregated — see
* globals/AnalyticsSettings.ts) without keeping individual events around.
*
* Not currently read by the main Analytics dashboard filters (those all
* stay within raw retention by design — see the implementation plan); this
* exists so long-term aggregated history isn't silently lost once raw
* events are purged, and is available for a future longer-range view.
*/
export const AnalyticsDailyStats: CollectionConfig = {
slug: "analytics-daily-stats",
labels: {
singular: "Analytics-Tagesstatistik",
plural: "Analytics-Tagesstatistiken",
},
admin: {
hidden: true,
},
access: {
create: () => false,
read: canViewAnalytics,
update: () => false,
delete: () => false,
},
timestamps: true,
fields: [
{
name: "date",
type: "date",
required: true,
unique: true,
index: true,
admin: { date: { pickerAppearance: "dayOnly" } },
},
{ name: "sessions", type: "number", required: true, defaultValue: 0 },
{ name: "pageViews", type: "number", required: true, defaultValue: 0 },
{ name: "uniqueVisitors", type: "number", required: true, defaultValue: 0 },
// Each a { [key: string]: number } map, e.g. eventCounts.booking_started.
{ name: "eventCounts", type: "json" },
{ name: "deviceCounts", type: "json" },
{ name: "browserCounts", type: "json" },
{ name: "topPaths", type: "json" },
{ name: "entryPaths", type: "json" },
{ name: "exitPaths", type: "json" },
],
};
+92
View File
@@ -0,0 +1,92 @@
import type { CollectionConfig } from "payload";
import { canViewAnalytics } from "@/access";
/**
* One row per tracked event (page_view, booking_started, ...). Deliberately
* append-only and never writable via the public/admin API — the only writer
* is app/(payload)/api/analytics/track/route.ts, which uses the trusted
* Local API (overrideAccess: true by default) to insert rows despite
* `create` being locked to `() => false` here. The only deleter is the
* cleanup cron (lib/analytics/cleanup.ts), same mechanism.
*
* No name/email/IP/UA is ever stored — see lib/analytics/salt.ts for how
* `visitorHash` is derived without persisting anything identifying.
*/
export const AnalyticsEvents: CollectionConfig = {
slug: "analytics-events",
labels: {
singular: "Analytics-Event",
plural: "Analytics-Events",
},
admin: {
// High-volume, low-signal raw data — not meant for manual browsing.
// The Analytics dashboard (a custom admin view) reads it programmatically.
hidden: true,
},
access: {
create: () => false,
read: canViewAnalytics,
update: () => false,
delete: () => false,
},
timestamps: true,
indexes: [{ fields: ["visitorHash", "createdAt"] }, { fields: ["createdAt"] }],
fields: [
{
name: "sessionId",
type: "text",
required: true,
index: true,
},
{
name: "visitorHash",
type: "text",
required: true,
// Daily-rotating pseudonymous hash (see lib/analytics/salt.ts) — never
// a raw IP/UA, never stable across days, never exposed to the client.
},
{
name: "path",
type: "text",
required: true,
index: true,
},
{
name: "eventName",
type: "text",
required: true,
index: true,
// Deliberately free-text, not a select/enum: new event names (see
// lib/analytics/events.ts) never require a schema migration.
defaultValue: "page_view",
},
{
name: "referrerHost",
type: "text",
// Hostname only (never a full URL/query string). document.referrer is
// fixed for a tab's whole lifetime (it doesn't change on client-side
// route transitions), so this naturally reflects how the visitor
// originally arrived, sent unchanged with every event in the session.
},
{
name: "device",
type: "select",
options: [
{ label: "Mobile", value: "mobile" },
{ label: "Desktop", value: "desktop" },
{ label: "Tablet", value: "tablet" },
],
},
{
name: "browser",
type: "select",
options: [
{ label: "Chrome", value: "chrome" },
{ label: "Firefox", value: "firefox" },
{ label: "Safari", value: "safari" },
{ label: "Edge", value: "edge" },
{ label: "Sonstige", value: "other" },
],
},
],
};
+7 -5
View File
@@ -12,6 +12,7 @@ import {
} from "@/lib/email/bookingTemplates";
import { sendEmail } from "@/lib/email/sendBookingEmails";
import type { Customer, Event, Offer } from "@/payload-types";
import { berlinDateKey, berlinDayBounds, formatBerlinDate, formatBerlinTime } from "@/lib/calendar/timezone";
export const APPOINTMENT_TYPES = [
{ label: "Vor Ort", value: "onsite" },
@@ -26,15 +27,15 @@ export const BOOKING_STATUSES = [
] as const;
function dateKey(iso: string): string {
return new Date(iso).toISOString().slice(0, 10);
return berlinDateKey(new Date(iso));
}
function fmtDate(iso: string): string {
return new Date(iso).toLocaleDateString("de-DE", { day: "2-digit", month: "long", year: "numeric" });
return formatBerlinDate(new Date(iso), { day: "2-digit", month: "long", year: "numeric" });
}
function fmtTime(iso: string): string {
return new Date(iso).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
return formatBerlinTime(new Date(iso), { hour: "2-digit", minute: "2-digit" });
}
const appointmentTypeLabel = (value: string) =>
@@ -70,8 +71,9 @@ const handleConfirmation: CollectionBeforeChangeHook = async ({ data, originalDo
await acquireBookingDayLock(req, dateKey(date));
const dayStart = new Date(new Date(date).setHours(0, 0, 0, 0)).toISOString();
const dayEnd = new Date(new Date(date).setHours(23, 59, 59, 999)).toISOString();
const dayBounds = berlinDayBounds(new Date(date));
const dayStart = dayBounds.start.toISOString();
const dayEnd = dayBounds.end.toISOString();
const conflictClauses: Where[] = [
{ status: { equals: "confirmed" } },
{ date: { greater_than_equal: dayStart } },
+29 -14
View File
@@ -1,24 +1,40 @@
import type { CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionConfig } from "payload";
import { isAdminFieldLevel } from "@/access";
import { isAdmin, isAdminFieldLevel } from "@/access";
import { EMAIL_VERIFICATION_TTL_MS, generateVerificationToken, hashVerificationToken, verificationExpiryISO } from "@/lib/auth/verification";
import { verificationEmail } from "@/lib/email/authTemplates";
import { sendEmail } from "@/lib/email/sendBookingEmails";
const isSelfOrAdmin = ({ req }: { req: { user?: { collection?: string; id?: unknown } | null } }) => {
const user = req.user;
// Routes through the shared isAdmin check (role === "admin", not just
// collection === "users") so this stays correct if a non-admin role is ever
// added to Users — see the comment on isAdmin in access/index.ts.
const isSelfOrAdmin: typeof isAdmin = (args) => {
const user = args.req.user;
if (!user) return false;
if (user.collection === "users") return true;
if (user.collection === "users") return isAdmin(args);
if (user.collection === "customers") return { id: { equals: user.id } };
return false;
};
// Runs on every new customer account: issues a random, single-use,
// time-limited email-verification token. Only its hash is ever persisted
// (see the emailVerificationTokenHash field below) — the plaintext exists
// only in req.context for the afterChange hook below to email out, and is
// never written to the database or logged.
const issueVerificationToken: CollectionBeforeChangeHook = ({ data, operation, req }) => {
if (operation !== "create") return data;
// Runs on every new customer account, and again whenever an existing
// customer changes their email address: issues a random, single-use,
// time-limited email-verification token and resets emailVerified to false.
// Only the token's hash is ever persisted (see emailVerificationTokenHash
// below) — the plaintext exists only in req.context for the afterChange
// hook below to email out, and is never written to the database or logged.
//
// The email-change case matters because `update` access on this collection
// is isSelfOrAdmin and the auto-added `email` field carries no field-level
// access of its own — without this, a customer could verify a throwaway
// address once and then repoint `email` at an address they don't own while
// keeping emailVerified: true (which gates booking-confirmation emails).
const issueVerificationToken: CollectionBeforeChangeHook = ({ data, originalDoc, operation, req }) => {
const emailChanged =
operation === "update" &&
typeof data.email === "string" &&
typeof originalDoc?.email === "string" &&
data.email !== originalDoc.email;
if (operation !== "create" && !emailChanged) return data;
const token = generateVerificationToken();
data.emailVerified = false;
data.emailVerificationTokenHash = hashVerificationToken(token);
@@ -27,8 +43,7 @@ const issueVerificationToken: CollectionBeforeChangeHook = ({ data, operation, r
return data;
};
const sendVerificationEmail: CollectionAfterChangeHook = async ({ doc, operation, req }) => {
if (operation !== "create") return doc;
const sendVerificationEmail: CollectionAfterChangeHook = async ({ doc, req }) => {
const token = req.context.pendingVerificationToken;
if (typeof token !== "string") return doc;
try {
@@ -69,7 +84,7 @@ export const Customers: CollectionConfig = {
create: () => true,
read: isSelfOrAdmin,
update: isSelfOrAdmin,
delete: ({ req }) => req.user?.collection === "users",
delete: isAdmin,
},
hooks: {
beforeChange: [issueVerificationToken],
+5 -2
View File
@@ -14,8 +14,11 @@ export const EventRegistrations: CollectionConfig = {
description: "Anmeldungen zu Terminen — wird beim öffentlichen Anmeldeformular befüllt.",
},
access: {
// Visitors register themselves through the public event page.
create: () => true,
// Public registrations must go through /api/meetings/[slug]/register,
// which verifies publication/registrationRequired, normalizes the email,
// and enforces the capacity check. The Local API used by that route
// intentionally overrides this access rule after those checks.
create: () => false,
read: isAdmin,
update: isAdmin,
delete: isAdmin,
+8 -1
View File
@@ -1,5 +1,5 @@
import type { CollectionBeforeChangeHook, CollectionConfig } from "payload";
import { isAdmin, publishedOrAdmin } from "@/access";
import { isAdmin, isAdminFieldLevel, publishedOrAdmin } from "@/access";
import { slugField } from "@/fields/slug";
import { seoFields } from "@/fields/seo";
import { generateMeetingPassword } from "@/lib/meeting/password";
@@ -162,6 +162,13 @@ export const Events: CollectionConfig = {
name: "meetingPassword",
type: "text",
label: "Meeting-Passwort",
// Events are publicly readable (publishedOrAdmin) via the REST/
// GraphQL API, so without this the plaintext password would be
// returned to any visitor fetching the event — defeating the
// password check in app/(payload)/api/meetings/[slug]/join. Only
// exposed to admins there; the join route reads it via the Local
// API, which bypasses field access by default.
access: { read: isAdminFieldLevel },
admin: {
readOnly: true,
condition: (data) => Boolean(data?.isOnline),
+4 -1
View File
@@ -24,7 +24,10 @@ export const Media: CollectionConfig = {
},
upload: {
staticDir: path.resolve(dirname, "..", "media"),
mimeTypes: ["image/jpeg", "image/png", "image/webp", "image/svg+xml"],
// SVG deliberately excluded: it can carry inline <script>/event-handler
// payloads that execute if the raw file is ever opened directly (not via
// <img>) — not currently needed by any part of the site.
mimeTypes: ["image/jpeg", "image/png", "image/webp"],
imageSizes: [
{ name: "thumbnail", width: 400, position: "centre" },
{ name: "card", width: 800, position: "centre" },
+5 -1
View File
@@ -2,7 +2,11 @@ import type { CollectionConfig } from "payload";
import { isAdmin } from "@/access";
function normalizePath(value: string): string {
const trimmed = value.trim();
// Lowercased to match the slugs it points at/from (fields/slug.ts always
// lowercases via slugify) — without this, "/Angebote/Doula" and
// "/angebote/doula" count as different values to the `unique` constraint
// on fromPath, letting near-duplicate redirects coexist.
const trimmed = value.trim().toLowerCase();
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
return withSlash.length > 1 ? withSlash.replace(/\/+$/, "") : withSlash;
}
+16
View File
@@ -45,6 +45,22 @@ export const Users: CollectionConfig = {
update: isAdminFieldLevel,
},
},
{
name: "canViewAnalytics",
type: "checkbox",
label: "Analytics sichtbar",
defaultValue: true,
// Admins always have access regardless of this flag (see
// access/index.ts's canViewAnalytics) — this only matters once a
// non-admin role exists, letting an admin grant/revoke Analytics
// visibility per account without changing anyone's role.
access: {
update: isAdminFieldLevel,
},
admin: {
description: "Steuert, ob dieser Zugang das Analytics-Dashboard im Admin-Bereich sehen kann.",
},
},
{
name: "calendarFeedTokenHash",
type: "text",
+153
View File
@@ -0,0 +1,153 @@
"use client";
import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { getConsent, hasCurrentConsentDecision, setAnalyticsConfig, setConsent, track } from "@/lib/analytics/client";
type CategoryText = { label: string; description: string };
type AnalyticsProps = {
consentVersion: string;
preferencesEnabled: boolean;
statisticsEnabled: boolean;
necessary: CategoryText;
preferences: CategoryText;
statistics: CategoryText;
};
const buttonBase =
"inline-flex items-center justify-center rounded-full px-6 py-3 text-sm font-medium tracking-wide transition-colors duration-300 focus-visible:outline-2 focus-visible:outline-offset-4";
/**
* Mounted once in app/(frontend)/layout.tsx. Combines two independent jobs:
* (1) the cookie-consent banner, and (2) automatic page_view tracking on
* every client-side route change. Both only ever act through track()/
* setAnalyticsConfig() from lib/analytics/client.ts — no network call is
* possible from this component before a visitor has actually consented.
*/
export function Analytics({ consentVersion, preferencesEnabled, statisticsEnabled, necessary, preferences, statistics }: AnalyticsProps) {
// Plain assignment during render (not an effect): must be set before the
// page_view effect below ever fires, on every render including the first.
setAnalyticsConfig({ statisticsEnabled, consentVersion });
const pathname = usePathname();
const [showBanner, setShowBanner] = useState(false);
const [expanded, setExpanded] = useState(false);
const [prefChoice, setPrefChoice] = useState(false);
const [statChoice, setStatChoice] = useState(false);
useEffect(() => {
if (!hasCurrentConsentDecision()) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setShowBanner(true);
const existing = getConsent();
setPrefChoice(existing?.preferences ?? false);
setStatChoice(existing?.statistics ?? false);
}
}, [consentVersion]);
useEffect(() => {
track("page_view", { path: pathname });
}, [pathname]);
function acceptAll() {
setConsent({ preferences: preferencesEnabled, statistics: statisticsEnabled }, consentVersion);
setShowBanner(false);
}
function acceptNecessaryOnly() {
setConsent({ preferences: false, statistics: false }, consentVersion);
setShowBanner(false);
}
function saveSelection() {
setConsent({ preferences: preferencesEnabled && prefChoice, statistics: statisticsEnabled && statChoice }, consentVersion);
setShowBanner(false);
}
if (!showBanner) return null;
return (
<div
role="dialog"
aria-modal="false"
aria-label="Cookie-Einstellungen"
className="fixed inset-x-0 bottom-0 z-[90] border-t border-anouma-taupe/30 bg-anouma-cream-light px-6 py-6 shadow-[0_-4px_24px_rgba(0,0,0,0.08)] sm:px-8"
>
<div className="mx-auto flex max-w-4xl flex-col gap-4">
<div>
<h2 className="font-serif text-xl font-medium text-anouma-plum">Cookie-Einstellungen</h2>
<p className="mt-2 text-sm leading-relaxed text-anouma-plum/90">
Wir nutzen Cookies, um die Website technisch bereitzustellen{statisticsEnabled ? " und, mit deiner Zustimmung, datensparsame Statistiken zu erheben" : ""}. Du
kannst deine Auswahl jederzeit ändern.
</p>
</div>
{expanded && (
<div className="grid gap-3 rounded-2xl border border-anouma-taupe/30 bg-white/60 p-4 sm:grid-cols-3">
<label className="flex items-start gap-2 text-sm text-anouma-plum opacity-70">
<input type="checkbox" checked disabled className="mt-1" />
<span>
<strong className="block">{necessary.label}</strong>
{necessary.description}
</span>
</label>
{preferencesEnabled && (
<label className="flex items-start gap-2 text-sm text-anouma-plum">
<input
type="checkbox"
checked={prefChoice}
onChange={(e) => setPrefChoice(e.target.checked)}
className="mt-1"
/>
<span>
<strong className="block">{preferences.label}</strong>
{preferences.description}
</span>
</label>
)}
{statisticsEnabled && (
<label className="flex items-start gap-2 text-sm text-anouma-plum">
<input
type="checkbox"
checked={statChoice}
onChange={(e) => setStatChoice(e.target.checked)}
className="mt-1"
/>
<span>
<strong className="block">{statistics.label}</strong>
{statistics.description}
</span>
</label>
)}
</div>
)}
<div className="flex flex-wrap items-center gap-3">
<button type="button" onClick={acceptAll} className={`${buttonBase} bg-anouma-mauve-dark text-white hover:bg-anouma-plum`}>
Alle akzeptieren
</button>
<button
type="button"
onClick={acceptNecessaryOnly}
className={`${buttonBase} border border-anouma-mauve-dark/40 text-anouma-plum hover:bg-anouma-mauve-dark hover:text-white`}
>
Nur notwendige
</button>
{!expanded && (
<button type="button" onClick={() => setExpanded(true)} className="text-sm text-anouma-plum underline underline-offset-4">
Einstellungen anpassen
</button>
)}
{expanded && (
<button type="button" onClick={saveSelection} className={`${buttonBase} border border-anouma-mauve-dark/40 text-anouma-plum hover:bg-anouma-mauve-dark hover:text-white`}>
Auswahl speichern
</button>
)}
</div>
</div>
</div>
);
}
+2 -3
View File
@@ -1,5 +1,6 @@
import Link from "next/link";
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
import { ArrowIcon } from "@/components/ArrowIcon";
import type { ImageMood } from "@/lib/angebote";
type AngebotCardProps = {
@@ -42,9 +43,7 @@ export function AngebotCard({
<p className="mt-2 text-sm leading-relaxed text-white/90">{tagline}</p>
<span className="mt-4 inline-flex items-center gap-1.5 text-sm font-medium text-white/95">
Mehr erfahren
<svg width="14" height="10" viewBox="0 0 14 10" fill="none" aria-hidden="true" className="transition-transform duration-300 group-hover:translate-x-1">
<path d="M1 5h11.5M8 1l4.5 4L8 9" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<ArrowIcon />
</span>
</div>
</div>
+15
View File
@@ -0,0 +1,15 @@
/** The small forward-nudging arrow shared by ArrowLink and the "Mehr erfahren" affordance inside AngebotCard/EventTeaserCard (which can't use ArrowLink directly since those are already whole-tile links, and nesting an anchor inside an anchor is invalid). Purely visual — animates on the parent's `group-hover`. */
export function ArrowIcon({ className = "" }: { className?: string }) {
return (
<svg
width="14"
height="10"
viewBox="0 0 14 10"
fill="none"
aria-hidden="true"
className={`shrink-0 transition-transform duration-300 group-hover:translate-x-1 ${className}`}
>
<path d="M1 5h11.5M8 1l4.5 4L8 9" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
+43
View File
@@ -0,0 +1,43 @@
import Link from "next/link";
import type { ReactNode } from "react";
import { ArrowIcon } from "@/components/ArrowIcon";
type Tone = "mauve" | "plum" | "white";
type Size = "sm" | "base";
const tones: Record<Tone, string> = {
mauve: "text-anouma-mauve-dark",
plum: "text-anouma-plum",
white: "text-white",
};
const sizes: Record<Size, string> = {
sm: "gap-1.5 text-sm",
base: "gap-2 text-base",
};
type ArrowLinkProps = {
href: string;
children: ReactNode;
tone?: Tone;
size?: Size;
className?: string;
};
/**
* The site's one secondary-action pattern: a text link with a small arrow
* that nudges forward on hover. Used instead of a pill button wherever the
* action isn't the single primary one on screen — keeps most CTAs feeling
* like an invitation to read on rather than another button to click.
*/
export function ArrowLink({ href, children, tone = "mauve", size = "sm", className = "" }: ArrowLinkProps) {
return (
<Link
href={href}
className={`group inline-flex items-center font-medium ${tones[tone]} ${sizes[size]} ${className}`}
>
{children}
<ArrowIcon />
</Link>
);
}
+9 -22
View File
@@ -1,9 +1,8 @@
"use client";
import { useState, type FormEvent } from "react";
const fieldClass =
"w-full rounded-2xl border border-anouma-taupe/30 bg-background px-5 py-3.5 text-base text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none";
import { track } from "@/lib/analytics/client";
import { TextField, TextAreaField } from "@/components/FormField";
type ContactFormProps = {
toEmail: string;
@@ -31,31 +30,19 @@ export function ContactForm({
const message = String(data.get("message") ?? "");
const subject = encodeURIComponent(`${subjectPrefix} von ${name}`);
const body = encodeURIComponent(`${message}\n\n— ${name} (${email})`);
const body = encodeURIComponent(`${message}\n\n– ${name} (${email})`);
window.location.href = `mailto:${toEmail}?subject=${subject}&body=${body}`;
// Event only — never the form's name/email/message content, see
// lib/analytics/events.ts and the data-minimization requirement.
track("contact_clicked");
setSent(true);
}
return (
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor="name" className="mb-2 block text-sm font-medium text-anouma-plum">
Name
</label>
<input id="name" name="name" type="text" required className={fieldClass} autoComplete="name" />
</div>
<div>
<label htmlFor="email" className="mb-2 block text-sm font-medium text-anouma-plum">
E-Mail
</label>
<input id="email" name="email" type="email" required className={fieldClass} autoComplete="email" />
</div>
<div>
<label htmlFor="message" className="mb-2 block text-sm font-medium text-anouma-plum">
Deine Nachricht
</label>
<textarea id="message" name="message" required rows={5} className={fieldClass} />
</div>
<TextField label="Name" id="name" name="name" type="text" required autoComplete="name" />
<TextField label="E-Mail" id="email" name="email" type="email" required autoComplete="email" />
<TextAreaField label="Deine Nachricht" id="message" name="message" required rows={5} />
<button
type="submit"
className="inline-flex items-center justify-center rounded-full bg-anouma-mauve-dark px-7 py-3.5 text-sm font-medium tracking-wide text-white transition-colors duration-300 hover:bg-anouma-plum"
+2 -16
View File
@@ -1,6 +1,7 @@
import Link from "next/link";
import type { Event } from "@/payload-types";
import { formatEventDateParts, formatTimeRange } from "@/lib/format";
import { ArrowIcon } from "@/components/ArrowIcon";
export function EventTeaserCard({ event }: { event: Event }) {
const { day, month } = formatEventDateParts(event.date);
@@ -21,22 +22,7 @@ export function EventTeaserCard({ event }: { event: Event }) {
{event.location && <p className="text-sm text-anouma-plum/80">{event.location}</p>}
<span className="mt-2 inline-flex items-center gap-1.5 text-sm font-medium text-anouma-mauve-dark">
Mehr erfahren
<svg
width="12"
height="9"
viewBox="0 0 14 10"
fill="none"
aria-hidden="true"
className="transition-transform duration-300 group-hover:translate-x-1"
>
<path
d="M1 5h11.5M8 1l4.5 4L8 9"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<ArrowIcon />
</span>
</div>
</Link>
+1 -1
View File
@@ -16,7 +16,7 @@ export async function Footer() {
{siteConfig.name.toUpperCase()}
</Link>
<p className="mt-4 max-w-xs text-sm leading-relaxed text-anouma-cream-light/90">
Räume für Verbindung — mit dir selbst, miteinander und mit der Natur.
Räume für Verbindung – mit dir selbst, miteinander und mit der Natur.
</p>
</div>
+62
View File
@@ -0,0 +1,62 @@
import type { InputHTMLAttributes, ReactNode, TextareaHTMLAttributes } from "react";
/**
* The one field style used by every form on the site (previously each form
* declared its own near-identical `fieldClass` constant with small drifts in
* padding/size/background — see ContactForm, auth/*, meeting/* history).
* Functionality is untouched: both components just spread the native
* input/textarea props through, so controlled and uncontrolled forms,
* validation attributes, autoComplete, etc. all keep working exactly as
* before.
*/
export const fieldClass =
"w-full rounded-2xl border border-anouma-taupe/30 bg-white px-5 py-3.5 text-base text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none";
function FieldLabel({ label, htmlFor, hidden = false }: { label: string; htmlFor: string; hidden?: boolean }) {
return (
<label htmlFor={htmlFor} className={hidden ? "sr-only" : "mb-2 block text-sm font-medium text-anouma-plum"}>
{label}
</label>
);
}
type TextFieldProps = InputHTMLAttributes<HTMLInputElement> & {
label: string;
id: string;
/** Keeps the label for screen readers only — for compact inline fields identified by a placeholder instead (e.g. ResendVerificationButton's email prompt). */
hideLabel?: boolean;
wrapperClassName?: string;
};
export function TextField({ label, id, hideLabel = false, className = "", wrapperClassName = "", ...inputProps }: TextFieldProps) {
return (
<div className={wrapperClassName}>
<FieldLabel label={label} htmlFor={id} hidden={hideLabel} />
<input id={id} className={`${fieldClass} ${className}`} {...inputProps} />
</div>
);
}
type TextAreaFieldProps = TextareaHTMLAttributes<HTMLTextAreaElement> & {
label: string;
id: string;
wrapperClassName?: string;
};
export function TextAreaField({ label, id, className = "", wrapperClassName = "", ...textareaProps }: TextAreaFieldProps) {
return (
<div className={wrapperClassName}>
<FieldLabel label={label} htmlFor={id} />
<textarea id={id} className={`${fieldClass} ${className}`} {...textareaProps} />
</div>
);
}
/** Shared error-message treatment (`role="alert"`) used below every form field/submit button. */
export function FormError({ children, className = "" }: { children: ReactNode; className?: string }) {
return (
<p role="alert" className={`text-sm text-red-700 ${className}`}>
{children}
</p>
);
}
+1 -1
View File
@@ -40,7 +40,7 @@ export function Hero({ eyebrow, title, children, actions, mood = "rose", imageSr
<div className="relative mx-auto aspect-[4/5] w-full max-w-md animate-fade-in [animation-delay:200ms] lg:max-w-none">
<ImagePlaceholder
mood={mood}
label="Anouma — warme, naturverbundene Begleitung"
label="Anouma – warme, naturverbundene Begleitung"
src={imageSrc}
alt={imageAlt}
priority
+57 -48
View File
@@ -6,6 +6,8 @@ import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { groupOffersByCategory } from "@/lib/angebote";
import { mainNav, ctaNav, siteConfig } from "@/lib/site";
import { track } from "@/lib/analytics/client";
import { OrganicBlob } from "@/components/OrganicBlob";
import type { Offer } from "@/payload-types";
export function Navbar({ offers, customerName }: { offers: Offer[]; customerName?: string | null }) {
@@ -63,7 +65,7 @@ export function Navbar({ offers, customerName }: { offers: Offer[]; customerName
}
return (
<header className="sticky top-0 z-50 border-b border-anouma-taupe/15 bg-background/85 backdrop-blur-md">
<header className="sticky top-0 z-50 border-b border-anouma-taupe/15 bg-background">
<div className="mx-auto flex h-20 max-w-6xl items-center justify-between px-6 sm:px-8 lg:px-12">
<Link
href="/"
@@ -108,52 +110,55 @@ export function Navbar({ offers, customerName }: { offers: Offer[]; customerName
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 8 }}
transition={{ duration: 0.22, ease: "easeOut" }}
className="absolute left-1/2 top-full z-50 mt-3 w-[min(640px,92vw)] -translate-x-1/2 rounded-3xl border border-anouma-taupe/15 bg-background p-8 shadow-xl shadow-anouma-plum/10"
className="absolute left-1/2 top-full z-50 mt-3 w-[min(640px,92vw)] -translate-x-1/2 overflow-hidden rounded-3xl border border-anouma-taupe/15 bg-background p-8 shadow-lg shadow-anouma-plum/10"
>
<div className="grid grid-cols-2 gap-8 sm:grid-cols-4">
{groups.map((group) => {
const isSingle = group.offers.length === 1;
const groupHref = isSingle
? `/angebote/${group.offers[0].slug}`
: group.href;
return (
<div key={group.category}>
<Link
href={groupHref}
className="font-serif text-lg font-medium text-anouma-plum hover:text-anouma-mauve-dark"
>
{group.label}
</Link>
{!isSingle && (
<ul className="mt-3 space-y-2">
{group.offers.map((offer) => (
<li key={offer.slug}>
<Link
href={`/angebote/${offer.slug}`}
className="text-sm text-anouma-plum transition-colors hover:text-anouma-mauve-dark"
>
{offer.title}
</Link>
</li>
))}
</ul>
)}
{isSingle && (
<p className="mt-3 text-sm leading-relaxed text-anouma-plum">
{group.offers[0].shortDescription}
</p>
)}
</div>
);
})}
</div>
<div className="mt-7 border-t border-anouma-taupe/15 pt-5">
<Link
href="/angebote"
className="text-sm font-medium text-anouma-mauve-dark underline underline-offset-4"
>
Alle Angebote im Überblick
</Link>
<OrganicBlob tone="cream" className="-right-16 -top-16 h-52 w-52 opacity-30" />
<div className="relative">
<div className="grid grid-cols-2 gap-8 sm:grid-cols-4">
{groups.map((group) => {
const isSingle = group.offers.length === 1;
const groupHref = isSingle
? `/angebote/${group.offers[0].slug}`
: group.href;
return (
<div key={group.category}>
<Link
href={groupHref}
className="font-serif text-lg font-medium text-anouma-plum hover:text-anouma-mauve-dark"
>
{group.label}
</Link>
{!isSingle && (
<ul className="mt-3 space-y-2">
{group.offers.map((offer) => (
<li key={offer.slug}>
<Link
href={`/angebote/${offer.slug}`}
className="text-sm text-anouma-plum transition-colors hover:text-anouma-mauve-dark"
>
{offer.title}
</Link>
</li>
))}
</ul>
)}
{isSingle && (
<p className="mt-3 text-sm leading-relaxed text-anouma-plum">
{group.offers[0].shortDescription}
</p>
)}
</div>
);
})}
</div>
<div className="mt-7 border-t border-anouma-taupe/15 pt-5">
<Link
href="/angebote"
className="text-sm font-medium text-anouma-mauve-dark underline underline-offset-4"
>
Alle Angebote im Überblick
</Link>
</div>
</div>
</motion.div>
)}
@@ -163,8 +168,9 @@ export function Navbar({ offers, customerName }: { offers: Offer[]; customerName
<Link
key={item.href}
href={item.href}
className={`text-[15px] font-medium transition-colors hover:text-anouma-mauve-dark ${
pathname === item.href ? "text-anouma-mauve-dark" : "text-anouma-plum/90"
onClick={() => track("navigation_clicked", { path: item.href })}
className={`relative pb-1 text-[15px] font-medium transition-colors after:absolute after:bottom-0 after:left-0 after:h-px after:bg-anouma-mauve-dark after:transition-all after:duration-300 after:content-[''] hover:text-anouma-mauve-dark hover:after:w-full ${
pathname === item.href ? "text-anouma-mauve-dark after:w-full" : "text-anouma-plum/90 after:w-0"
}`}
>
{item.title}
@@ -182,6 +188,7 @@ export function Navbar({ offers, customerName }: { offers: Offer[]; customerName
</Link>
<Link
href={ctaNav.href}
onClick={() => track("navigation_clicked", { path: ctaNav.href })}
className="rounded-full bg-anouma-mauve-dark px-6 py-2.5 text-sm font-medium text-white transition-colors hover:bg-anouma-plum"
>
{ctaNav.title}
@@ -300,6 +307,7 @@ export function Navbar({ offers, customerName }: { offers: Offer[]; customerName
<Link
key={item.href}
href={item.href}
onClick={() => track("navigation_clicked", { path: item.href })}
className="border-b border-anouma-taupe/10 py-4 text-lg font-medium text-anouma-plum"
>
{item.title}
@@ -314,6 +322,7 @@ export function Navbar({ offers, customerName }: { offers: Offer[]; customerName
</Link>
<Link
href={ctaNav.href}
onClick={() => track("navigation_clicked", { path: ctaNav.href })}
className="mt-6 rounded-full bg-anouma-mauve-dark px-6 py-4 text-center text-base font-medium text-white"
>
{ctaNav.title}
+22 -3
View File
@@ -9,10 +9,23 @@ type PageHeaderProps = {
title: string;
lead?: ReactNode;
crumbs?: Crumb[];
/** Varies the header's decorative blob so every interior page doesn't open with the exact same rose-top-right treatment. */
blobTone?: "rose" | "sage" | "cream" | "mauve";
blobPosition?: "top-right" | "top-left";
/** Shorter vertical padding for pages that don't need much ceremony (e.g. Kontakt). */
compact?: boolean;
};
/** Renders the visual breadcrumb trail AND its matching BreadcrumbList JSON-LD from the same `crumbs` — every page that already passes `crumbs` gets structured breadcrumb data for free. */
export async function PageHeader({ eyebrow, title, lead, crumbs }: PageHeaderProps) {
export async function PageHeader({
eyebrow,
title,
lead,
crumbs,
blobTone = "rose",
blobPosition = "top-right",
compact = false,
}: PageHeaderProps) {
const breadcrumbData = crumbs?.length
? await (async () => {
const siteUrl = await getSiteUrl();
@@ -24,9 +37,15 @@ export async function PageHeader({ eyebrow, title, lead, crumbs }: PageHeaderPro
})()
: null;
const blobPositionClass = blobPosition === "top-left" ? "-left-20 -top-20" : "-right-20 -top-20";
return (
<section className="relative overflow-hidden border-b border-anouma-taupe/10 bg-anouma-cream-light pb-16 pt-14 sm:pb-20 sm:pt-20">
<OrganicBlob tone="rose" className="-right-20 -top-20 h-72 w-72" />
<section
className={`relative overflow-hidden border-b border-anouma-taupe/10 bg-anouma-cream-light ${
compact ? "pb-10 pt-10 sm:pb-12 sm:pt-12" : "pb-16 pt-14 sm:pb-20 sm:pt-20"
}`}
>
<OrganicBlob tone={blobTone} className={`${blobPositionClass} h-72 w-72`} />
<div className="relative mx-auto max-w-6xl px-6 sm:px-8 lg:px-12">
{breadcrumbData && <JsonLd data={breadcrumbData} />}
{crumbs && <Breadcrumbs items={crumbs} />}
+1 -1
View File
@@ -4,7 +4,7 @@ import type { ReactNode } from "react";
export function PlaceholderNote({ children }: { children: ReactNode }) {
return (
<div className="rounded-2xl border border-dashed border-anouma-dustyrose/50 bg-anouma-rose-pale/20 px-5 py-4 text-sm leading-relaxed text-anouma-plum">
<span className="font-medium">Platzhalter — noch zu ergänzen:</span> {children}
<span className="font-medium">Platzhalter – noch zu ergänzen:</span> {children}
</div>
);
}
@@ -0,0 +1,188 @@
.page {
padding: 24px;
max-width: 1100px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: #1f1f1f;
}
.title {
font-size: 22px;
font-weight: 600;
margin: 0 0 4px;
}
.subtitle {
color: #666;
font-size: 14px;
margin: 0 0 24px;
}
.filterBar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 24px;
}
.rangeLink {
display: inline-flex;
align-items: center;
padding: 6px 14px;
border-radius: 999px;
border: 1px solid #e2e2e2;
color: #333;
text-decoration: none;
font-size: 13px;
font-weight: 500;
}
.rangeLink:hover {
background: #f5f5f5;
}
.rangeLinkActive {
background: #8b616d;
border-color: #8b616d;
color: #fff;
}
.customForm {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #555;
}
.customForm input[type="date"] {
border: 1px solid #e2e2e2;
border-radius: 6px;
padding: 5px 8px;
font-size: 13px;
}
.customForm button {
border: 1px solid #e2e2e2;
border-radius: 999px;
padding: 6px 14px;
font-size: 13px;
background: #fff;
cursor: pointer;
}
.compareToggle {
margin-left: auto;
font-size: 13px;
}
.compareToggle a {
color: #8b616d;
text-decoration: none;
}
.compareToggle a:hover {
text-decoration: underline;
}
.tiles {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
margin-bottom: 28px;
}
.tile {
border: 1px solid #e6e6e6;
border-radius: 12px;
padding: 14px 16px;
background: #fafafa;
}
.tileLabel {
font-size: 12px;
color: #888;
margin: 0 0 6px;
}
.tileValue {
font-size: 24px;
font-weight: 700;
margin: 0;
}
.tileDelta {
font-size: 12px;
font-weight: 600;
margin-top: 4px;
}
.deltaUp {
color: #3a4f30;
}
.deltaDown {
color: #7a2020;
}
.deltaFlat {
color: #888;
}
.chartCard {
border: 1px solid #e6e6e6;
border-radius: 12px;
padding: 18px;
margin-bottom: 28px;
}
.sectionTitle {
font-size: 15px;
font-weight: 600;
margin: 0 0 12px;
}
.tableGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 20px;
margin-bottom: 28px;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.table th {
text-align: left;
font-size: 11px;
text-transform: uppercase;
color: #888;
padding: 4px 8px;
border-bottom: 1px solid #e6e6e6;
}
.table td {
padding: 6px 8px;
border-bottom: 1px solid #f0f0f0;
}
.table tr:last-child td {
border-bottom: none;
}
.tableEmpty {
color: #888;
font-size: 13px;
padding: 8px;
}
.privacyPanel {
border: 1px solid #e6e6e6;
border-radius: 12px;
padding: 18px;
background: #f7f4f1;
font-size: 13px;
color: #444;
}
.privacyPanel h3 {
margin: 0 0 8px;
font-size: 14px;
color: #1f1f1f;
}
.privacyPanel ul {
margin: 8px 0;
padding-left: 18px;
}
.privacyPanel p {
margin: 6px 0;
}
+196
View File
@@ -0,0 +1,196 @@
import type { AdminViewServerProps } from "payload";
import { canViewAnalytics } from "@/access";
import { RANGE_OPTIONS, resolveRange } from "@/lib/analytics/range";
import { getDashboardComparison, percentDelta, type DashboardStats } from "@/lib/analytics/dashboardQueries";
import { topEntries } from "@/lib/analytics/aggregate";
import { analyticsEventLabel } from "@/lib/analytics/events";
import { AnalyticsChart } from "./AnalyticsChart";
import styles from "./AdminAnalyticsView.module.css";
const DEVICE_LABELS: Record<string, string> = { mobile: "Mobile", desktop: "Desktop", tablet: "Tablet" };
const BROWSER_LABELS: Record<string, string> = { chrome: "Chrome", firefox: "Firefox", safari: "Safari", edge: "Edge", other: "Sonstige" };
function fmtInt(n: number): string {
return n.toLocaleString("de-DE");
}
function DeltaBadge({ delta }: { delta: number | null }) {
if (delta === null) return null;
const cls = delta > 0 ? styles.deltaUp : delta < 0 ? styles.deltaDown : styles.deltaFlat;
const arrow = delta > 0 ? "↑" : delta < 0 ? "↓" : "→";
return (
<p className={`${styles.tileDelta} ${cls}`}>
{arrow} {Math.abs(delta).toLocaleString("de-DE")}%
</p>
);
}
function Tile({ label, value, delta }: { label: string; value: number; delta: number | null | undefined }) {
return (
<div className={styles.tile}>
<p className={styles.tileLabel}>{label}</p>
<p className={styles.tileValue}>{fmtInt(value)}</p>
{delta !== undefined && <DeltaBadge delta={delta} />}
</div>
);
}
function EntryTable({ title, entries }: { title: string; entries: [string, number][] }) {
return (
<div>
<h2 className={styles.sectionTitle}>{title}</h2>
{entries.length === 0 ? (
<p className={styles.tableEmpty}>Keine Daten in diesem Zeitraum.</p>
) : (
<table className={styles.table}>
<tbody>
{entries.map(([key, count]) => (
<tr key={key}>
<td>{key}</td>
<td style={{ textAlign: "right", fontWeight: 600 }}>{fmtInt(count)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}
function buildHref(base: URLSearchParams, overrides: Record<string, string | null>): string {
const params = new URLSearchParams(base);
for (const [key, value] of Object.entries(overrides)) {
if (value === null) params.delete(key);
else params.set(key, value);
}
const qs = params.toString();
return qs ? `?${qs}` : "?";
}
export async function AdminAnalyticsView({ initPageResult }: AdminViewServerProps) {
const req = initPageResult.req;
if (!canViewAnalytics({ req })) {
return (
<div className={styles.page}>
<h1 className={styles.title}>Analytics</h1>
<p className={styles.subtitle}>Du hast keine Berechtigung, diesen Bereich zu sehen.</p>
</div>
);
}
const settings = await req.payload.findGlobal({ slug: "analytics-settings" });
const maxDaysBack = settings.rawRetentionDays ?? 90;
const searchParams = req.searchParams;
const compareEnabled = searchParams.get("compare") === "1";
const { key: activeRange, range, compareRange } = resolveRange(searchParams, maxDaysBack);
const { current, previous }: { current: DashboardStats; previous: DashboardStats } = await getDashboardComparison(
req.payload,
range,
compareRange,
);
const delta = (metric: keyof Pick<DashboardStats, "sessions" | "pageViews" | "uniqueVisitors" | "avgPagesPerSession">) =>
compareEnabled ? percentDelta(current[metric], previous[metric]) : undefined;
const eventDelta = (name: string) =>
compareEnabled ? percentDelta(current.eventCounts[name] ?? 0, previous.eventCounts[name] ?? 0) : undefined;
const fromParam = range.from.toISOString().slice(0, 10);
const toParam = range.to.toISOString().slice(0, 10);
return (
<div className={styles.page}>
<h1 className={styles.title}>Analytics</h1>
<p className={styles.subtitle}>Datensparsame Reichweitenmessung — keine personenbezogenen Profile. Rohdaten werden {maxDaysBack} Tage aufbewahrt.</p>
<div className={styles.filterBar}>
{RANGE_OPTIONS.map((option) => (
<a
key={option.key}
href={buildHref(searchParams, { range: option.key, from: null, to: null })}
className={`${styles.rangeLink} ${activeRange === option.key ? styles.rangeLinkActive : ""}`}
>
{option.label}
</a>
))}
<form method="get" className={styles.customForm}>
<input type="hidden" name="range" value="custom" />
{compareEnabled && <input type="hidden" name="compare" value="1" />}
<input type="date" name="from" defaultValue={fromParam} max={toParam} />
<span>bis</span>
<input type="date" name="to" defaultValue={toParam} max={new Date().toISOString().slice(0, 10)} />
<button type="submit">Anwenden</button>
</form>
<div className={styles.compareToggle}>
<a href={buildHref(searchParams, { compare: compareEnabled ? null : "1" })}>
{compareEnabled ? "Vergleich ausblenden" : "Mit Vorperiode vergleichen"}
</a>
</div>
</div>
{RANGE_OPTIONS.some((o) => typeof o.days === "number" && o.days > maxDaysBack) && (
<p style={{ color: "#888", fontSize: 12, marginTop: -16, marginBottom: 20 }}>
Zeiträume sind auf die konfigurierte Rohdaten-Aufbewahrung von {maxDaysBack} Tagen begrenzt (siehe „Analytics-Einstellungen“).
</p>
)}
<div className={styles.tiles}>
<Tile label="Sessions" value={current.sessions} delta={delta("sessions")} />
<Tile label="Seitenaufrufe" value={current.pageViews} delta={delta("pageViews")} />
<Tile label="Eindeutige Besucher" value={current.uniqueVisitors} delta={delta("uniqueVisitors")} />
<Tile label="Ø Seiten / Session" value={current.avgPagesPerSession} delta={delta("avgPagesPerSession")} />
<Tile label="Buchungsstarts" value={current.eventCounts.booking_started ?? 0} delta={eventDelta("booking_started")} />
<Tile label="Buchungen abgeschlossen" value={current.eventCounts.booking_completed ?? 0} delta={eventDelta("booking_completed")} />
</div>
<div className={styles.chartCard}>
<h2 className={styles.sectionTitle}>Seitenaufrufe &amp; Sessions</h2>
<AnalyticsChart series={current.series} />
</div>
<div className={styles.tableGrid}>
<EntryTable title="Beliebteste Seiten" entries={topEntries(current.topPaths)} />
<EntryTable title="Einstiegsseiten" entries={topEntries(current.entryPaths)} />
<EntryTable title="Ausstiegsseiten" entries={topEntries(current.exitPaths)} />
<EntryTable
title="Events"
entries={topEntries(current.eventCounts).map(([name, count]) => [analyticsEventLabel(name), count])}
/>
<EntryTable
title="Geräte"
entries={topEntries(current.deviceCounts).map(([key, count]) => [DEVICE_LABELS[key] ?? key, count])}
/>
<EntryTable
title="Browser"
entries={topEntries(current.browserCounts).map(([key, count]) => [BROWSER_LABELS[key] ?? key, count])}
/>
</div>
<div className={styles.privacyPanel}>
<h3>Analytics &amp; Datenschutz</h3>
<p>Erhobene Daten:</p>
<ul>
<li>Seitenaufrufe und Events (siehe „Events“-Tabelle)</li>
<li>Gerätetyp (Mobile/Desktop/Tablet)</li>
<li>Browser-Familie</li>
<li>Referrer-Hostname (nur die Domain, keine vollständige URL)</li>
</ul>
<p>
Keine Namen, E-Mail-Adressen, Telefonnummern, vollständigen IP-Adressen oder Formularinhalte. Kein Fingerprinting, keine
dauerhaften individuellen Nutzerprofile — Besucher:innen werden über einen täglich rotierenden, nicht zurückverfolgbaren Hash
gruppiert.
</p>
<p>
<strong>Aufbewahrung:</strong> {settings.rawRetentionDays} Tage für Rohdaten, {settings.aggregateRetentionMonths} Monate für
aggregierte Tagesstatistiken (konfigurierbar unter „Analytics-Einstellungen“).
</p>
<p style={{ fontStyle: "italic", color: "#777" }}>
Rechtliche Einordnung (z. B. ob dies für eure Website ausreichend ist) ist keine technische Aussage dieses Panels — bitte im
Zweifel rechtlich prüfen lassen, wie auch beim Impressum/Datenschutztext der Website.
</p>
</div>
</div>
);
}
@@ -155,13 +155,21 @@
padding: 6px 10px;
border-radius: 8px;
background: #f7f4f1;
text-decoration: none;
color: #1f1f1f;
font-size: 13px;
}
.entry:hover {
background: #efe7e1;
}
.entryLink {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
color: inherit;
text-decoration: none;
}
.badge {
font-size: 10px;
+31 -16
View File
@@ -1,6 +1,8 @@
import type { AdminViewServerProps } from "payload";
import { getAdminCalendarItems, type AdminCalendarItem } from "@/lib/booking/adminCalendar";
import { berlinParts } from "@/lib/calendar/timezone";
import { CalendarFeedPanel } from "./CalendarFeedPanel";
import { AdminCallButton } from "./AdminCallButton";
import styles from "./AdminCalendarView.module.css";
const WEEKDAY_LABELS = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
@@ -27,7 +29,8 @@ const BADGE_CLASS: Record<string, string> = {
};
function dateKey(d: Date) {
return d.toISOString().slice(0, 10);
const { year, month, day } = berlinParts(d);
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
function parseMonthParam(value: string | null): Date {
if (value && /^\d{4}-\d{2}$/.test(value)) {
@@ -40,7 +43,11 @@ function monthParam(d: Date) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
}
function fmtTime(iso: string) {
return new Date(iso).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
return new Date(iso).toLocaleTimeString("de-DE", {
timeZone: "Europe/Berlin",
hour: "2-digit",
minute: "2-digit",
});
}
export async function AdminCalendarView({ initPageResult }: AdminViewServerProps) {
@@ -48,7 +55,9 @@ export async function AdminCalendarView({ initPageResult }: AdminViewServerProps
const searchParams = req.searchParams;
const month = parseMonthParam(searchParams.get("month"));
const selectedDayParam = searchParams.get("day");
const selectedDay = selectedDayParam ? new Date(`${selectedDayParam}T00:00:00`) : null;
// Noon UTC stays on the same Berlin calendar day even when the server runs
// in UTC and the selected date is around a DST transition.
const selectedDay = selectedDayParam ? new Date(`${selectedDayParam}T12:00:00Z`) : null;
const gridStart = new Date(month.getFullYear(), month.getMonth(), 1);
const firstWeekday = (gridStart.getDay() + 6) % 7;
@@ -82,7 +91,10 @@ export async function AdminCalendarView({ initPageResult }: AdminViewServerProps
const monthLabel = month.toLocaleDateString("de-DE", { month: "long", year: "numeric" });
const dayItems = selectedDay ? (itemsByDay.get(dateKey(selectedDay)) ?? []) : [];
const hours = Array.from({ length: 13 }, (_, i) => i + 8); // 08:00–20:00
const startHours = dayItems.length ? dayItems.map((item) => berlinParts(new Date(item.startTime)).hour) : [];
const firstHour = Math.min(8, ...startHours);
const lastHour = Math.max(20, ...startHours);
const hours = Array.from({ length: lastHour - firstHour + 1 }, (_, i) => i + firstHour);
return (
<div className={styles.page}>
@@ -156,23 +168,26 @@ export async function AdminCalendarView({ initPageResult }: AdminViewServerProps
{selectedDay &&
dayItems.length > 0 &&
hours.map((hour) => {
const hourEntries = dayItems.filter((item) => new Date(item.startTime).getHours() === hour);
const hourEntries = dayItems.filter((item) => berlinParts(new Date(item.startTime)).hour === hour);
return (
<div key={hour} className={styles.hourRow}>
<div className={styles.hourLabel}>{String(hour).padStart(2, "0")}:00</div>
<div className={styles.hourContent}>
{hourEntries.map((entry) => (
<a key={entry.id} href={entry.href} className={styles.entry}>
<span className={`${styles.badge} ${BADGE_CLASS[entry.status] ?? ""}`}>
{STATUS_LABEL[entry.status] ?? entry.status}
</span>
<strong>{entry.title}</strong>
<span>
{fmtTime(entry.startTime)}–{fmtTime(entry.endTime)}
</span>
{entry.subtitle && <span>· {entry.subtitle}</span>}
{entry.appointmentType && <span>· {entry.appointmentType === "online" ? "Online" : "Vor Ort"}</span>}
</a>
<div key={entry.id} className={styles.entry}>
<a href={entry.href} className={styles.entryLink}>
<span className={`${styles.badge} ${BADGE_CLASS[entry.status] ?? ""}`}>
{STATUS_LABEL[entry.status] ?? entry.status}
</span>
<strong>{entry.title}</strong>
<span>
{fmtTime(entry.startTime)}–{fmtTime(entry.endTime)}
</span>
{entry.subtitle && <span>· {entry.subtitle}</span>}
{entry.appointmentType && <span>· {entry.appointmentType === "online" ? "Online" : "Vor Ort"}</span>}
</a>
{entry.callEventSlug && <AdminCallButton eventSlug={entry.callEventSlug} />}
</div>
))}
</div>
</div>
+64
View File
@@ -0,0 +1,64 @@
"use client";
import { useState } from "react";
export function AdminCallButton({ eventSlug }: { eventSlug: string }) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [url, setUrl] = useState<string | null>(null);
async function startCall() {
setLoading(true);
setError(null);
setCopied(false);
try {
const response = await fetch("/api/admin/meetings/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ eventSlug }),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
setError(data.error || "Der Video-Call konnte nicht gestartet werden.");
return;
}
setUrl(data.testUrl);
try {
await navigator.clipboard.writeText(data.testUrl);
setCopied(true);
} catch {
setError("Der Link wurde erzeugt. Bitte kopiere ihn aus dem Feld.");
}
} catch {
setError("Verbindung fehlgeschlagen.");
} finally {
setLoading(false);
}
}
return (
<span className="inline-flex items-center gap-2">
<button
type="button"
onClick={startCall}
disabled={loading}
className="rounded-full bg-anouma-mauve-dark px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-anouma-plum disabled:cursor-wait disabled:opacity-60"
>
{loading ? "Test-Link wird erzeugt …" : copied ? "Test-Link kopiert" : "Call testen"}
</button>
{url && !copied && (
<input
type="text"
readOnly
value={url}
aria-label="Test-Link"
onFocus={(event) => event.currentTarget.select()}
className="w-56 rounded border border-anouma-taupe/30 bg-white px-2 py-1 text-xs text-anouma-plum"
/>
)}
{error && <span className="text-xs text-red-700">{error}</span>}
</span>
);
}
+35
View File
@@ -0,0 +1,35 @@
"use client";
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import type { DailyPoint } from "@/lib/analytics/dashboardQueries";
const ACCENT = "#8b616d"; // matches the existing admin CSS modules' accent color
const ACCENT_MUTED = "#c9a8ae";
function fmtDay(iso: string): string {
return new Date(`${iso}T00:00:00Z`).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit" });
}
export function AnalyticsChart({ series }: { series: DailyPoint[] }) {
if (series.length === 0) {
return <p style={{ color: "#888", fontSize: 14 }}>Noch keine Daten in diesem Zeitraum.</p>;
}
const data = series.map((p) => ({ ...p, label: fmtDay(p.date) }));
return (
<ResponsiveContainer width="100%" height={260}>
<LineChart data={data} margin={{ top: 8, right: 16, bottom: 0, left: 0 }}>
<CartesianGrid stroke="#eee" vertical={false} />
<XAxis dataKey="label" tick={{ fontSize: 12, fill: "#888" }} axisLine={{ stroke: "#e2e2e2" }} tickLine={false} />
<YAxis tick={{ fontSize: 12, fill: "#888" }} axisLine={false} tickLine={false} allowDecimals={false} width={36} />
<Tooltip
contentStyle={{ borderRadius: 8, border: "1px solid #e2e2e2", fontSize: 13 }}
labelFormatter={(label) => `Tag: ${label}`}
/>
<Line type="monotone" dataKey="pageViews" name="Seitenaufrufe" stroke={ACCENT} strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="sessions" name="Sessions" stroke={ACCENT_MUTED} strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
);
}
+30
View File
@@ -0,0 +1,30 @@
import Link from "next/link";
import type { ServerProps } from "payload";
import { canViewAnalytics } from "@/access";
export function AnalyticsNavLink({ user }: ServerProps) {
// Payload's custom views/nav links have no declarative `access` option —
// this component must gate itself (see also AdminAnalyticsView.tsx, which
// re-checks the same thing for direct navigation to /admin/analytics).
if (!canViewAnalytics({ req: { user } } as Parameters<typeof canViewAnalytics>[0])) return null;
return (
<div style={{ padding: "0 8px 8px" }}>
<Link
href="/admin/analytics"
style={{
display: "block",
padding: "8px 12px",
borderRadius: 6,
fontSize: 13,
fontWeight: 600,
color: "#8b616d",
textDecoration: "none",
border: "1px solid #e2e2e2",
}}
>
📊 Analytics
</Link>
</div>
);
}
+4 -20
View File
@@ -3,9 +3,7 @@
import { useState, type FormEvent } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { ResendVerificationButton } from "./ResendVerificationButton";
const fieldClass =
"w-full rounded-2xl border border-anouma-taupe/30 bg-white px-5 py-3.5 text-base text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none";
import { TextField, FormError } from "@/components/FormField";
export function LoginForm() {
const router = useRouter();
@@ -44,23 +42,9 @@ export function LoginForm() {
return (
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor="email" className="mb-2 block text-sm font-medium text-anouma-plum">
E-Mail
</label>
<input id="email" name="email" type="email" required className={fieldClass} autoComplete="email" />
</div>
<div>
<label htmlFor="password" className="mb-2 block text-sm font-medium text-anouma-plum">
Passwort
</label>
<input id="password" name="password" type="password" required className={fieldClass} autoComplete="current-password" />
</div>
{error && (
<p role="alert" className="text-sm text-red-700">
{error}
</p>
)}
<TextField label="E-Mail" id="email" name="email" type="email" required autoComplete="email" />
<TextField label="Passwort" id="password" name="password" type="password" required autoComplete="current-password" />
{error && <FormError>{error}</FormError>}
<button
type="submit"
disabled={loading}
+3 -15
View File
@@ -2,9 +2,7 @@
import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
const fieldClass =
"w-full rounded-2xl border border-anouma-taupe/30 bg-white px-5 py-3.5 text-base text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none";
import { TextField } from "@/components/FormField";
export function ProfileForm({ id, name, phone }: { id: number; name: string; phone: string }) {
const router = useRouter();
@@ -35,18 +33,8 @@ export function ProfileForm({ id, name, phone }: { id: number; name: string; pho
return (
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor="name" className="mb-2 block text-sm font-medium text-anouma-plum">
Name
</label>
<input id="name" name="name" type="text" required defaultValue={name} className={fieldClass} />
</div>
<div>
<label htmlFor="phone" className="mb-2 block text-sm font-medium text-anouma-plum">
Telefonnummer (optional)
</label>
<input id="phone" name="phone" type="tel" defaultValue={phone} className={fieldClass} />
</div>
<TextField label="Name" id="name" name="name" type="text" required defaultValue={name} />
<TextField label="Telefonnummer (optional)" id="phone" name="phone" type="tel" defaultValue={phone} />
<button
type="submit"
disabled={status === "saving"}
+13 -34
View File
@@ -2,9 +2,7 @@
import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
const fieldClass =
"w-full rounded-2xl border border-anouma-taupe/30 bg-white px-5 py-3.5 text-base text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none";
import { TextField, FormError } from "@/components/FormField";
export function RegisterForm() {
const router = useRouter();
@@ -54,37 +52,18 @@ export function RegisterForm() {
return (
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor="name" className="mb-2 block text-sm font-medium text-anouma-plum">
Name
</label>
<input id="name" name="name" type="text" required className={fieldClass} autoComplete="name" />
</div>
<div>
<label htmlFor="email" className="mb-2 block text-sm font-medium text-anouma-plum">
E-Mail
</label>
<input id="email" name="email" type="email" required className={fieldClass} autoComplete="email" />
</div>
<div>
<label htmlFor="password" className="mb-2 block text-sm font-medium text-anouma-plum">
Passwort
</label>
<input
id="password"
name="password"
type="password"
required
minLength={8}
className={fieldClass}
autoComplete="new-password"
/>
</div>
{error && (
<p role="alert" className="text-sm text-red-700">
{error}
</p>
)}
<TextField label="Name" id="name" name="name" type="text" required autoComplete="name" />
<TextField label="E-Mail" id="email" name="email" type="email" required autoComplete="email" />
<TextField
label="Passwort"
id="password"
name="password"
type="password"
required
minLength={8}
autoComplete="new-password"
/>
{error && <FormError>{error}</FormError>}
<button
type="submit"
disabled={loading}
+8 -6
View File
@@ -1,9 +1,7 @@
"use client";
import { useState, type FormEvent } from "react";
const fieldClass =
"w-full rounded-2xl border border-anouma-taupe/30 bg-white px-4 py-2.5 text-sm text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none";
import { useId, useState, type FormEvent } from "react";
import { TextField } from "@/components/FormField";
async function callResend(email?: string): Promise<{ ok: boolean; error?: string }> {
try {
@@ -44,6 +42,7 @@ export function ResendVerificationButton({
const [open, setOpen] = useState(false);
const [status, setStatus] = useState<"idle" | "sending" | "sent" | "error">("idle");
const [error, setError] = useState<string | null>(null);
const emailFieldId = useId();
async function resendForSession() {
setStatus("sending");
@@ -108,12 +107,15 @@ export function ResendVerificationButton({
<p className="text-sm text-anouma-olive">{GENERIC_SENT_MESSAGE}</p>
) : (
<form onSubmit={handleEmailSubmit} className="mt-2 flex flex-wrap items-start gap-2">
<input
<TextField
label="Deine E-Mail-Adresse"
hideLabel
id={emailFieldId}
type="email"
name="email"
required
placeholder="Deine E-Mail-Adresse"
className={`${fieldClass} max-w-xs flex-1`}
wrapperClassName="max-w-xs flex-1"
/>
<button
type="submit"
+16 -18
View File
@@ -4,15 +4,10 @@ import { useState } from "react";
import { useRouter } from "next/navigation";
import { BookingStatusBadge, resolveDisplayStatus } from "./BookingStatusBadge";
import type { BookingRequest, Event, Offer } from "@/payload-types";
import { formatBerlinDate, formatBerlinTime } from "@/lib/calendar/timezone";
import type { MeetingStatus } from "@/lib/meeting/status";
function fmtDate(iso: string) {
return new Date(iso).toLocaleDateString("de-DE", { weekday: "long", day: "2-digit", month: "long", year: "numeric" });
}
function fmtTime(iso: string) {
return new Date(iso).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
}
export function BookingCard({ booking }: { booking: BookingRequest }) {
export function BookingCard({ booking, meetingStatus }: { booking: BookingRequest; meetingStatus?: MeetingStatus | null }) {
const router = useRouter();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -46,7 +41,7 @@ export function BookingCard({ booking }: { booking: BookingRequest }) {
<div>
<h3 className="font-serif text-xl font-medium text-anouma-plum">{offer?.title ?? "Termin"}</h3>
<p className="mt-1 text-sm text-anouma-plum/80">
{fmtDate(booking.date)} · {fmtTime(booking.startTime)} – {fmtTime(booking.endTime)}
{formatBerlinDate(new Date(booking.date), { weekday: "long", day: "2-digit", month: "long", year: "numeric" })} · {formatBerlinTime(new Date(booking.startTime), { hour: "2-digit", minute: "2-digit" })} – {formatBerlinTime(new Date(booking.endTime), { hour: "2-digit", minute: "2-digit" })}
</p>
</div>
<BookingStatusBadge status={displayStatus} />
@@ -62,8 +57,8 @@ export function BookingCard({ booking }: { booking: BookingRequest }) {
<div className="mt-4 rounded-2xl bg-anouma-cream-light p-4">
<p className="text-xs font-medium uppercase tracking-wide text-anouma-plum/70">Alternativer Termin</p>
<p className="mt-1 text-base text-anouma-plum">
{fmtDate(booking.proposedAlternative.date!)} · {fmtTime(booking.proposedAlternative.startTime!)} –{" "}
{fmtTime(booking.proposedAlternative.endTime!)}
{formatBerlinDate(new Date(booking.proposedAlternative.date!), { weekday: "long", day: "2-digit", month: "long", year: "numeric" })} · {formatBerlinTime(new Date(booking.proposedAlternative.startTime!), { hour: "2-digit", minute: "2-digit" })} –{" "}
{formatBerlinTime(new Date(booking.proposedAlternative.endTime!), { hour: "2-digit", minute: "2-digit" })}
</p>
<div className="mt-3 flex flex-wrap gap-3">
<button
@@ -88,16 +83,19 @@ export function BookingCard({ booking }: { booking: BookingRequest }) {
{booking.status === "confirmed" && (
<div className="mt-4 space-y-3">
{booking.appointmentType === "online" && event && (
<a
href={`/termine/${event.slug}/beitreten`}
className="inline-flex rounded-full bg-anouma-mauve-dark px-5 py-2.5 text-sm font-medium text-white hover:bg-anouma-plum"
>
{booking.appointmentType === "online" && event && meetingStatus !== "ended" && meetingStatus !== "scheduled" && (
<a href={`/termine/${event.slug}/beitreten`} className="inline-flex rounded-full bg-anouma-mauve-dark px-5 py-2.5 text-sm font-medium text-white hover:bg-anouma-plum">
Video-Call betreten
</a>
)}
{booking.appointmentType === "online" && event && meetingStatus === "scheduled" && (
<p className="text-sm text-anouma-plum/75">Der Video-Call wird 10 Minuten vor Beginn freigeschaltet.</p>
)}
{booking.appointmentType === "online" && event && meetingStatus === "ended" && (
<p className="text-sm text-anouma-plum/75">Dieser Video-Call ist bereits beendet.</p>
)}
{booking.appointmentType === "onsite" && (
<p className="text-sm text-anouma-plum/80">Vor Ort — Details siehe Bestätigungs-E-Mail.</p>
<p className="text-sm text-anouma-plum/80">Vor Ort – Details siehe Bestätigungs-E-Mail.</p>
)}
</div>
)}
@@ -107,7 +105,7 @@ export function BookingCard({ booking }: { booking: BookingRequest }) {
type="button"
disabled={busy}
onClick={() => callAction(`/api/booking/${booking.id}/cancel`)}
className="mt-4 text-sm font-medium text-anouma-plum/60 underline underline-offset-4 hover:text-red-700"
className="-ml-2 mt-4 inline-flex min-h-11 items-center rounded-full px-2 text-sm font-medium text-anouma-plum/60 underline underline-offset-4 transition-colors hover:bg-anouma-cream-light hover:text-red-700"
>
Termin stornieren
</button>
+18 -25
View File
@@ -3,19 +3,18 @@
import { useEffect, useState } from "react";
import { MonthCalendar, type CalendarMarker } from "./MonthCalendar";
import { ResendVerificationButton } from "@/components/auth/ResendVerificationButton";
import { TextAreaField, FormError } from "@/components/FormField";
import { track } from "@/lib/analytics/client";
import { berlinDateKey, formatBerlinDate, formatBerlinTime } from "@/lib/calendar/timezone";
type SlotsResponse = { durationMinutes: number; slots: Record<string, { start: string; end: string }[]> };
function dateKey(d: Date) {
return d.toISOString().slice(0, 10);
return berlinDateKey(d);
}
function fmtTime(iso: string) {
return new Date(iso).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
}
function fmtDate(iso: string) {
return new Date(iso).toLocaleDateString("de-DE", { weekday: "long", day: "2-digit", month: "long" });
}
const fmtTime = (iso: string) => formatBerlinTime(new Date(iso), { hour: "2-digit", minute: "2-digit" });
const fmtDate = (iso: string) => formatBerlinDate(new Date(iso), { weekday: "long", day: "2-digit", month: "long" });
export function BookingWidget({
offerSlug,
@@ -116,6 +115,8 @@ export function BookingWidget({
return;
}
setResult({ ok: true });
// Event only — never the offer/date/message content.
track("booking_started");
} catch {
setResult({ ok: false, error: "Verbindung fehlgeschlagen." });
setSubmitting(false);
@@ -144,7 +145,7 @@ export function BookingWidget({
key={slot.start}
type="button"
onClick={() => setSelectedSlot(slot.start)}
className={`rounded-full border px-4 py-2 text-sm font-medium transition-colors ${
className={`inline-flex min-h-11 items-center justify-center rounded-full border px-4 text-sm font-medium transition-colors ${
selectedSlot === slot.start
? "border-anouma-mauve-dark bg-anouma-mauve-dark text-white"
: "border-anouma-taupe/40 text-anouma-plum hover:border-anouma-mauve-dark"
@@ -189,24 +190,16 @@ export function BookingWidget({
</div>
</fieldset>
<div className="mt-4">
<label htmlFor="booking-message" className="mb-1.5 block text-sm font-medium text-anouma-plum">
Nachricht (optional)
</label>
<textarea
id="booking-message"
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={3}
className="w-full rounded-2xl border border-anouma-taupe/30 bg-white px-4 py-3 text-sm text-anouma-plum focus:border-anouma-mauve-dark focus:outline-none"
/>
</div>
<TextAreaField
label="Nachricht (optional)"
id="booking-message"
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={3}
wrapperClassName="mt-4"
/>
{result?.error && (
<p role="alert" className="mt-3 text-sm text-red-700">
{result.error}
</p>
)}
{result?.error && <FormError className="mt-3">{result.error}</FormError>}
<button
type="button"
+8 -7
View File
@@ -1,6 +1,7 @@
"use client";
import { useMemo, useState } from "react";
import { berlinDateKey, formatBerlinDate } from "@/lib/calendar/timezone";
export type CalendarMarker = {
date: Date;
@@ -23,7 +24,7 @@ function startOfMonth(d: Date) {
return new Date(d.getFullYear(), d.getMonth(), 1);
}
function dateKey(d: Date) {
return d.toISOString().slice(0, 10);
return berlinDateKey(d);
}
export function MonthCalendar({
@@ -62,17 +63,17 @@ export function MonthCalendar({
return result;
}, [month]);
const monthLabel = month.toLocaleDateString("de-DE", { month: "long", year: "numeric" });
const monthLabel = formatBerlinDate(month, { month: "long", year: "numeric" });
const today = dateKey(new Date());
return (
<div className="rounded-3xl bg-white p-6">
<div className="rounded-3xl bg-white p-4 sm:p-6">
<div className="flex items-center justify-between">
<button
type="button"
onClick={() => setMonth(new Date(month.getFullYear(), month.getMonth() - 1, 1))}
aria-label="Vorheriger Monat"
className="flex h-9 w-9 items-center justify-center rounded-full text-anouma-plum hover:bg-anouma-cream-light"
className="flex h-11 w-11 items-center justify-center rounded-full text-anouma-plum hover:bg-anouma-cream-light"
>
‹
</button>
@@ -81,7 +82,7 @@ export function MonthCalendar({
type="button"
onClick={() => setMonth(new Date(month.getFullYear(), month.getMonth() + 1, 1))}
aria-label="Nächster Monat"
className="flex h-9 w-9 items-center justify-center rounded-full text-anouma-plum hover:bg-anouma-cream-light"
className="flex h-11 w-11 items-center justify-center rounded-full text-anouma-plum hover:bg-anouma-cream-light"
>
›
</button>
@@ -106,9 +107,9 @@ export function MonthCalendar({
key={j}
type="button"
onClick={() => onSelectDay?.(day)}
className={`flex aspect-square flex-col items-center justify-center rounded-xl text-sm transition-colors ${
className={`flex aspect-square min-h-11 flex-col items-center justify-center rounded-xl text-sm transition-colors ${
isSelected ? "bg-anouma-mauve-dark text-white" : "text-anouma-plum hover:bg-anouma-cream-light"
} ${key === today && !isSelected ? "font-semibold" : ""}`}
} ${key === today && !isSelected ? "font-semibold ring-1 ring-inset ring-anouma-mauve-dark/50" : ""}`}
>
<span>{day.getDate()}</span>
{dayMarkers.length > 0 && (
+6 -8
View File
@@ -4,13 +4,11 @@ import { useState } from "react";
import { MonthCalendar, type CalendarMarker } from "./MonthCalendar";
import { BookingCard } from "./BookingCard";
import { resolveDisplayStatus } from "./BookingStatusBadge";
import type { BookingRequest } from "@/payload-types";
import type { BookingRequest, MeetingSetting } from "@/payload-types";
import { berlinDateKey } from "@/lib/calendar/timezone";
import { getBookingMeetingStatus } from "@/lib/booking/meetingAccess";
function dateKey(d: Date) {
return d.toISOString().slice(0, 10);
}
export function PersonalCalendar({ bookings }: { bookings: BookingRequest[] }) {
export function PersonalCalendar({ bookings, meetingSettings }: { bookings: BookingRequest[]; meetingSettings: MeetingSetting }) {
const [selectedDay, setSelectedDay] = useState<Date | null>(null);
const markers: CalendarMarker[] = bookings.map((b) => ({
@@ -19,7 +17,7 @@ export function PersonalCalendar({ bookings }: { bookings: BookingRequest[] }) {
}));
const selectedBookings = selectedDay
? bookings.filter((b) => dateKey(new Date(b.date)) === dateKey(selectedDay))
? bookings.filter((b) => berlinDateKey(new Date(b.date)) === berlinDateKey(selectedDay))
: [];
return (
@@ -30,7 +28,7 @@ export function PersonalCalendar({ bookings }: { bookings: BookingRequest[] }) {
selectedBookings.length > 0 ? (
<div className="space-y-4">
{selectedBookings.map((b) => (
<BookingCard key={b.id} booking={b} />
<BookingCard key={b.id} booking={b} meetingStatus={getBookingMeetingStatus(b, meetingSettings)} />
))}
</div>
) : (
+256 -398
View File
@@ -1,460 +1,318 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { VideoTile } from "./VideoTile";
import { DisconnectReason, Room, RoomEvent, Track, type LocalTrack, type Participant } from "livekit-client";
import { PreJoinScreen } from "./PreJoinScreen";
import { SfuVideoTile } from "./SfuVideoTile";
import { MEETING_SESSION_KEY, type StoredMeetingSession } from "./JoinForm";
import type { ClientToServerMessage, ParticipantSummary, ServerToClientMessage } from "@/lib/meeting/protocol";
const DEFAULT_ICE_SERVERS: RTCIceServer[] = [{ urls: "stun:stun.l.google.com:19302" }];
type Phase = "loading" | "prejoin" | "connecting" | "connected" | "kicked" | "ended" | "error";
type LayoutMode = "grid" | "speaker" | "screen";
type RemoteEntry = ParticipantSummary & { stream: MediaStream | null };
type ParticipantView = {
identity: string;
name: string;
role: "host" | "participant";
cameraTrack: Track | null;
microphoneTrack: Track | null;
screenTrack: Track | null;
speaking: boolean;
microphoneEnabled: boolean;
cameraEnabled: boolean;
};
type Phase = "loading" | "connecting" | "connected" | "kicked" | "ended" | "error";
function participantRole(participant: Participant, fallback: "host" | "participant"): "host" | "participant" {
try {
const metadata = JSON.parse(participant.metadata || "{}") as { role?: unknown };
if (metadata.role === "host" || metadata.role === "participant") return metadata.role;
} catch {
// A malformed metadata value cannot grant host privileges.
}
return fallback;
}
function snapshotParticipant(
participant: Participant,
activeSpeakers: Set<string>,
fallbackRole: "host" | "participant",
fallbackName: string,
): ParticipantView {
return {
identity: participant.identity,
name: participant.name || fallbackName,
role: participantRole(participant, fallbackRole),
cameraTrack: participant.getTrackPublication(Track.Source.Camera)?.track ?? null,
microphoneTrack: participant.getTrackPublication(Track.Source.Microphone)?.track ?? null,
screenTrack: participant.getTrackPublication(Track.Source.ScreenShare)?.track ?? null,
speaking: activeSpeakers.has(participant.identity) || participant.isSpeaking,
microphoneEnabled: participant.isMicrophoneEnabled,
cameraEnabled: participant.isCameraEnabled,
};
}
export function CallRoom({ slug }: { slug: string }) {
const router = useRouter();
const [session, setSession] = useState<StoredMeetingSession | null>(null);
const [phase, setPhase] = useState<Phase>("loading");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [remotes, setRemotes] = useState<Map<string, RemoteEntry>>(new Map());
const [localStream, setLocalStream] = useState<MediaStream | null>(null);
const [participants, setParticipants] = useState<ParticipantView[]>([]);
const [layout, setLayout] = useState<LayoutMode>("grid");
const [activeSpeakerId, setActiveSpeakerId] = useState<string | null>(null);
const [showParticipants, setShowParticipants] = useState(false);
const [sharingScreen, setSharingScreen] = useState(false);
const [micOn, setMicOn] = useState(true);
const [camOn, setCamOn] = useState(true);
const [sharingScreen, setSharingScreen] = useState(false);
const [showParticipants, setShowParticipants] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
const pcsRef = useRef<Map<string, RTCPeerConnection>>(new Map());
const localStreamRef = useRef<MediaStream | null>(null);
const screenStreamRef = useRef<MediaStream | null>(null);
const sessionRef = useRef<StoredMeetingSession | null>(null);
const roomRef = useRef<Room | null>(null);
const localTracksRef = useRef<LocalTrack[]>([]);
const activeSpeakersRef = useRef<Set<string>>(new Set());
const intentionalCloseRef = useRef(false);
const send = useCallback((message: ClientToServerMessage) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(message));
}
}, []);
const closePeer = useCallback((participantId: string) => {
pcsRef.current.get(participantId)?.close();
pcsRef.current.delete(participantId);
setRemotes((prev) => {
const next = new Map(prev);
next.delete(participantId);
return next;
});
}, []);
const createPeerConnection = useCallback(
(participantId: string, name: string, role: ParticipantSummary["role"], isInitiator: boolean) => {
const pc = new RTCPeerConnection({
iceServers: sessionRef.current?.iceServers?.length ? sessionRef.current.iceServers : DEFAULT_ICE_SERVERS,
});
pcsRef.current.set(participantId, pc);
localStreamRef.current?.getTracks().forEach((track) => {
pc.addTrack(track, localStreamRef.current!);
});
pc.ontrack = (event) => {
setRemotes((prev) => {
const next = new Map(prev);
const existing = next.get(participantId);
next.set(participantId, { participantId, name, role, stream: event.streams[0] ?? existing?.stream ?? null });
return next;
});
};
pc.onicecandidate = (event) => {
if (event.candidate) {
send({ type: "ice-candidate", to: participantId, payload: event.candidate.toJSON() });
}
};
setRemotes((prev) => {
const next = new Map(prev);
next.set(participantId, { participantId, name, role, stream: next.get(participantId)?.stream ?? null });
return next;
});
if (isInitiator) {
pc.createOffer()
.then((offer) => pc.setLocalDescription(offer).then(() => offer))
.then((offer) => send({ type: "offer", to: participantId, payload: offer }))
.catch(() => setErrorMessage("Verbindung zu einem Teilnehmer ist fehlgeschlagen."));
}
return pc;
},
[send],
);
const cleanup = useCallback(() => {
wsRef.current?.close();
wsRef.current = null;
pcsRef.current.forEach((pc) => pc.close());
pcsRef.current.clear();
localStreamRef.current?.getTracks().forEach((t) => t.stop());
localStreamRef.current = null;
screenStreamRef.current?.getTracks().forEach((t) => t.stop());
screenStreamRef.current = null;
}, []);
// Load session + acquire local media + open signaling connection. This
// reads sessionStorage and opens external connections, so it belongs in an
// effect; the early setState calls below are unavoidable for the
// missing/invalid-session error paths (no server-renderable equivalent).
useEffect(() => {
const raw = sessionStorage.getItem(MEETING_SESSION_KEY);
if (!raw) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setPhase("error");
setErrorMessage("Keine aktive Meeting-Sitzung gefunden.");
const hashToken = new URLSearchParams(window.location.hash.replace(/^#/, "")).get("token");
const rawSession = hashToken ? JSON.stringify({ token: hashToken }) : raw;
if (!rawSession) {
queueMicrotask(() => {
setPhase("error");
setErrorMessage("Keine aktive Meeting-Sitzung gefunden.");
});
return;
}
let parsed: StoredMeetingSession;
try {
parsed = JSON.parse(raw);
const candidate = JSON.parse(rawSession) as Partial<StoredMeetingSession>;
const parsed: StoredMeetingSession = {
token: typeof candidate.token === "string" ? candidate.token : "",
participantToken: candidate.participantToken,
serverUrl: candidate.serverUrl,
participantId: typeof candidate.participantId === "string" ? candidate.participantId : "",
role: candidate.role === "participant" ? "participant" : "host",
name: typeof candidate.name === "string" && candidate.name ? candidate.name : "Host",
eventTitle: typeof candidate.eventTitle === "string" && candidate.eventTitle ? candidate.eventTitle : "Video-Call",
eventSlug: typeof candidate.eventSlug === "string" && candidate.eventSlug ? candidate.eventSlug : slug,
};
if (!parsed.token || parsed.eventSlug !== slug) throw new Error("invalid session");
queueMicrotask(() => {
setSession(parsed);
setPhase("prejoin");
});
} catch {
setPhase("error");
setErrorMessage("Keine aktive Meeting-Sitzung gefunden.");
return;
}
if (parsed.eventSlug !== slug) {
setPhase("error");
setErrorMessage("Diese Sitzung gehört zu einem anderen Termin.");
return;
}
setSession(parsed);
sessionRef.current = parsed;
setPhase("connecting");
let cancelled = false;
(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
if (cancelled) {
stream.getTracks().forEach((t) => t.stop());
return;
}
localStreamRef.current = stream;
setLocalStream(stream);
} catch {
// Continue without local media — participant can still watch/listen to others.
setErrorMessage("Kamera/Mikrofon konnten nicht aktiviert werden. Du kannst trotzdem teilnehmen.");
}
if (cancelled) return;
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(`${protocol}://${window.location.host}/ws/signaling?token=${encodeURIComponent(parsed.token)}`);
wsRef.current = ws;
ws.onmessage = (event) => {
const message: ServerToClientMessage = JSON.parse(event.data);
handleServerMessage(message);
};
ws.onclose = () => {
setPhase((p) => (p === "kicked" || p === "ended" ? p : "ended"));
};
ws.onerror = () => setErrorMessage("Verbindung zum Meeting-Server fehlgeschlagen.");
})();
function handleServerMessage(message: ServerToClientMessage) {
const me = sessionRef.current;
if (!me) return;
switch (message.type) {
case "welcome": {
setPhase("connected");
for (const p of message.participants) {
createPeerConnection(p.participantId, p.name, p.role, true);
}
break;
}
case "peer-joined": {
createPeerConnection(message.participantId, message.name, message.role, false);
break;
}
case "peer-left": {
closePeer(message.participantId);
break;
}
case "offer": {
const pc =
pcsRef.current.get(message.from) ??
createPeerConnection(message.from, remotesLookupName(message.from), "participant", false);
pc.setRemoteDescription(new RTCSessionDescription(message.payload))
.then(() => pc.createAnswer())
.then((answer) => pc.setLocalDescription(answer).then(() => answer))
.then((answer) => send({ type: "answer", to: message.from, payload: answer }))
.catch(() => setErrorMessage("Verbindung zu einem Teilnehmer ist fehlgeschlagen."));
break;
}
case "answer": {
pcsRef.current.get(message.from)?.setRemoteDescription(new RTCSessionDescription(message.payload));
break;
}
case "ice-candidate": {
pcsRef.current.get(message.from)?.addIceCandidate(new RTCIceCandidate(message.payload)).catch(() => {});
break;
}
case "kicked": {
setPhase("kicked");
cleanup();
sessionStorage.removeItem(MEETING_SESSION_KEY);
break;
}
}
}
function remotesLookupName(participantId: string): string {
return remotes.get(participantId)?.name ?? "Teilnehmer:in";
queueMicrotask(() => {
setPhase("error");
setErrorMessage("Keine aktive Meeting-Sitzung gefunden.");
});
}
return () => {
cancelled = true;
cleanup();
intentionalCloseRef.current = true;
roomRef.current?.disconnect();
roomRef.current = null;
localTracksRef.current.forEach((track) => track.stop());
localTracksRef.current = [];
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [slug]);
function toggleMic() {
localStreamRef.current?.getAudioTracks().forEach((t) => (t.enabled = !micOn));
setMicOn((v) => !v);
function refreshParticipants(room: Room) {
const local = snapshotParticipant(room.localParticipant, activeSpeakersRef.current, session?.role ?? "participant", session?.name ?? "Du");
const remote = [...room.remoteParticipants.values()].map((participant) =>
snapshotParticipant(participant, activeSpeakersRef.current, "participant", "Teilnehmer:in"),
);
setParticipants([local, ...remote]);
}
function toggleCam() {
localStreamRef.current?.getVideoTracks().forEach((t) => (t.enabled = !camOn));
setCamOn((v) => !v);
async function connectRoom(previewTracks: LocalTrack[]) {
if (!session) return;
setPhase("connecting");
setErrorMessage(null);
intentionalCloseRef.current = false;
localTracksRef.current = previewTracks;
try {
let serverUrl = session.serverUrl;
let participantToken = session.participantToken;
if (!serverUrl || !participantToken) {
const response = await fetch(`/api/meetings/${encodeURIComponent(slug)}/connect`, {
method: "POST",
headers: { Authorization: `Bearer ${session.token}` },
credentials: "include",
});
const data = (await response.json().catch(() => ({}))) as { serverUrl?: string; participantToken?: string; error?: string };
if (!response.ok || !data.serverUrl || !data.participantToken) throw new Error(data.error || "Video-Server nicht erreichbar");
serverUrl = data.serverUrl;
participantToken = data.participantToken;
}
const room = new Room({ adaptiveStream: true, dynacast: true });
roomRef.current = room;
const refresh = () => refreshParticipants(room);
room.on(RoomEvent.Connected, refresh);
room.on(RoomEvent.ParticipantConnected, refresh);
room.on(RoomEvent.ParticipantDisconnected, refresh);
room.on(RoomEvent.TrackPublished, refresh);
room.on(RoomEvent.TrackUnpublished, refresh);
room.on(RoomEvent.TrackSubscribed, refresh);
room.on(RoomEvent.TrackUnsubscribed, refresh);
room.on(RoomEvent.TrackMuted, refresh);
room.on(RoomEvent.TrackUnmuted, refresh);
room.on(RoomEvent.LocalTrackPublished, refresh);
room.on(RoomEvent.LocalTrackUnpublished, refresh);
room.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
activeSpeakersRef.current = new Set(speakers.map((speaker) => speaker.identity));
setActiveSpeakerId(speakers[0]?.identity ?? null);
refresh();
});
room.on(RoomEvent.Reconnecting, () => setPhase("connecting"));
room.on(RoomEvent.Reconnected, () => {
setPhase("connected");
refresh();
});
room.on(RoomEvent.Disconnected, (reason) => {
if (reason === DisconnectReason.PARTICIPANT_REMOVED) {
localTracksRef.current.forEach((track) => track.stop());
localTracksRef.current = [];
sessionStorage.removeItem(MEETING_SESSION_KEY);
setPhase("kicked");
} else if (!intentionalCloseRef.current) {
setPhase("ended");
}
});
room.on(RoomEvent.MediaDevicesError, () => setErrorMessage("Kamera oder Mikrofon konnten nicht verwendet werden."));
await room.connect(serverUrl, participantToken);
for (const track of previewTracks) {
await room.localParticipant.publishTrack(track, {
source: track.kind === Track.Kind.Audio ? Track.Source.Microphone : Track.Source.Camera,
});
}
refresh();
setMicOn(previewTracks.some((track) => track.kind === Track.Kind.Audio && track.mediaStreamTrack.enabled));
setCamOn(previewTracks.some((track) => track.kind === Track.Kind.Video && track.mediaStreamTrack.enabled));
setPhase("connected");
} catch (error) {
localTracksRef.current.forEach((track) => track.stop());
localTracksRef.current = [];
intentionalCloseRef.current = true;
roomRef.current?.disconnect();
roomRef.current = null;
setPhase("error");
setErrorMessage(error instanceof Error ? error.message : "Der Video-Call konnte nicht geöffnet werden.");
}
}
async function toggleMic() {
const next = !micOn;
await roomRef.current?.localParticipant.setMicrophoneEnabled(next);
setMicOn(next);
}
async function toggleCamera() {
const next = !camOn;
await roomRef.current?.localParticipant.setCameraEnabled(next);
setCamOn(next);
}
async function toggleScreenShare() {
if (sharingScreen) {
stopScreenShare();
return;
}
const next = !sharingScreen;
try {
const screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true });
screenStreamRef.current = screenStream;
const screenTrack = screenStream.getVideoTracks()[0];
screenTrack.onended = () => stopScreenShare();
pcsRef.current.forEach((pc) => {
const sender = pc.getSenders().find((s) => s.track?.kind === "video");
sender?.replaceTrack(screenTrack);
});
setSharingScreen(true);
await roomRef.current?.localParticipant.setScreenShareEnabled(next);
setSharingScreen(next);
} catch {
// User cancelled the share dialog — nothing to do.
setErrorMessage("Die Bildschirmfreigabe konnte nicht gestartet werden.");
}
}
function stopScreenShare() {
screenStreamRef.current?.getTracks().forEach((t) => t.stop());
screenStreamRef.current = null;
const cameraTrack = localStreamRef.current?.getVideoTracks()[0] ?? null;
pcsRef.current.forEach((pc) => {
const sender = pc.getSenders().find((s) => s.track?.kind === "video");
sender?.replaceTrack(cameraTrack);
async function kickParticipant(identity: string) {
if (!session) return;
const response = await fetch(`/api/meetings/${encodeURIComponent(slug)}/kick`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${session.token}` },
body: JSON.stringify({ participantId: identity }),
credentials: "include",
});
setSharingScreen(false);
}
function kickParticipant(participantId: string) {
send({ type: "kick", targetParticipantId: participantId });
if (!response.ok) {
const data = (await response.json().catch(() => ({}))) as { error?: string };
setErrorMessage(data.error || "Teilnehmer konnte nicht entfernt werden.");
}
}
function leaveMeeting() {
cleanup();
intentionalCloseRef.current = true;
roomRef.current?.disconnect();
roomRef.current = null;
localTracksRef.current.forEach((track) => track.stop());
localTracksRef.current = [];
sessionStorage.removeItem(MEETING_SESSION_KEY);
router.push(`/termine/${slug}`);
}
if (phase === "loading" || phase === "connecting") {
return (
<div className="flex min-h-[70vh] items-center justify-center bg-neutral-900 text-white">
<p className="text-sm text-white/70">Verbindung wird aufgebaut …</p>
</div>
);
}
if (phase === "loading") return <div className="flex min-h-screen items-center justify-center bg-neutral-950 text-sm text-white/60">Meeting wird geladen …</div>;
if (phase === "prejoin" && session) return <PreJoinScreen onContinue={connectRoom} />;
if (phase === "connecting") return <div className="flex min-h-screen items-center justify-center bg-neutral-950 text-sm text-white/65">Verbindung wird aufgebaut …</div>;
if (phase === "error" || !session) {
return (
<div className="flex min-h-[70vh] flex-col items-center justify-center gap-4 bg-neutral-900 px-6 text-center text-white">
<p className="text-lg">{errorMessage || "Dieses Meeting konnte nicht geöffnet werden."}</p>
<a
href={`/termine/${slug}/beitreten`}
className="rounded-full bg-anouma-mauve-dark px-6 py-3 text-sm font-medium hover:bg-anouma-plum"
>
Zurück zur Beitrittsseite
</a>
</div>
);
return <div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-neutral-950 px-6 text-center text-white"><p>{errorMessage || "Dieses Meeting konnte nicht geöffnet werden."}</p><a href={`/termine/${slug}/beitreten`} className="rounded-full bg-anouma-mauve-dark px-6 py-3 text-sm font-medium hover:bg-anouma-plum">Zurück zur Beitrittsseite</a></div>;
}
if (phase === "kicked") return <div className="flex min-h-screen items-center justify-center bg-neutral-950 px-6 text-center text-white"><p>Du wurdest vom Meeting entfernt.</p></div>;
if (phase === "ended") return <div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-neutral-950 px-6 text-center text-white"><p>{errorMessage || "Die Verbindung zum Meeting wurde beendet."}</p><a href={`/termine/${slug}`} className="rounded-full bg-anouma-mauve-dark px-6 py-3 text-sm font-medium hover:bg-anouma-plum">Zurück zum Termin</a></div>;
if (phase === "kicked") {
return (
<div className="flex min-h-[70vh] flex-col items-center justify-center gap-4 bg-neutral-900 px-6 text-center text-white">
<p className="text-lg">Du wurdest vom Meeting entfernt.</p>
<a
href={`/termine/${slug}/beitreten`}
className="rounded-full bg-anouma-mauve-dark px-6 py-3 text-sm font-medium hover:bg-anouma-plum"
>
Erneut beitreten
</a>
</div>
);
}
if (phase === "ended") {
return (
<div className="flex min-h-[70vh] flex-col items-center justify-center gap-4 bg-neutral-900 px-6 text-center text-white">
<p className="text-lg">Die Verbindung zum Meeting wurde beendet.</p>
<a
href={`/termine/${slug}`}
className="rounded-full bg-anouma-mauve-dark px-6 py-3 text-sm font-medium hover:bg-anouma-plum"
>
Zurück zum Termin
</a>
</div>
);
}
const isHost = session.role === "host";
const remoteList = [...remotes.values()];
const local = participants[0];
const remoteParticipants = participants.slice(1);
const screenOwner = participants.find((participant) => participant.screenTrack);
const activeSpeaker = participants.find((participant) => participant.identity === activeSpeakerId) ?? remoteParticipants[0] ?? local;
const tile = (participant: ParticipantView, options: { compact?: boolean; videoTrack?: Track | null; name?: string } = {}) => (
<SfuVideoTile
key={`${participant.identity}-${options.name ?? "camera"}`}
videoTrack={options.videoTrack ?? participant.cameraTrack}
audioTrack={participant.microphoneTrack}
name={options.name ?? participant.name}
isLocal={participant.identity === local?.identity}
isHost={participant.role === "host"}
muted={!participant.microphoneEnabled}
videoOff={!participant.cameraEnabled && !options.videoTrack}
speaking={participant.speaking}
compact={options.compact}
/>
);
return (
<div className="flex min-h-[80vh] flex-col bg-neutral-900 text-white">
<div className="flex items-center justify-between border-b border-white/10 px-5 py-3">
<span className="text-sm font-medium">{session.eventTitle}</span>
<button
type="button"
onClick={() => setShowParticipants((v) => !v)}
className="rounded-full bg-white/10 px-4 py-1.5 text-xs font-medium hover:bg-white/20"
>
Teilnehmer ({remoteList.length + 1})
</button>
</div>
{errorMessage && (
<p className="bg-amber-900/50 px-5 py-2 text-center text-xs text-amber-100">{errorMessage}</p>
)}
<div className="flex flex-1">
<div className="grid flex-1 auto-rows-fr grid-cols-1 gap-3 p-4 sm:grid-cols-2 lg:grid-cols-3">
<VideoTile stream={localStream} name={session.name} isLocal isHost={isHost} muted={!micOn} videoOff={!camOn} />
{remoteList.map((r) => (
<VideoTile key={r.participantId} stream={r.stream} name={r.name} isHost={r.role === "host"} />
))}
<main className="flex min-h-screen flex-col bg-neutral-950 text-white">
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-white/10 px-5 py-4">
<div><p className="text-sm font-medium">{session.eventTitle}</p><p className="text-xs text-white/45">LiveKit-SFU · {participants.length} Teilnehmer</p></div>
<div className="flex items-center gap-2">
{(["grid", "speaker", "screen"] as LayoutMode[]).map((mode) => <button key={mode} type="button" onClick={() => setLayout(mode)} aria-pressed={layout === mode} className={`rounded-full px-3 py-2 text-xs ${layout === mode ? "bg-white text-neutral-900" : "bg-white/10 text-white/75 hover:bg-white/15"}`}>{mode === "grid" ? "Galerie" : mode === "speaker" ? "Sprecher" : "Bildschirm"}</button>)}
<button type="button" onClick={() => setShowParticipants((value) => !value)} className="rounded-full bg-white/10 px-3 py-2 text-xs hover:bg-white/15">Teilnehmer ({participants.length})</button>
</div>
{showParticipants && (
<aside className="w-64 shrink-0 border-l border-white/10 p-4">
<h2 className="mb-3 text-xs font-medium uppercase tracking-wide text-white/60">Teilnehmer</h2>
<ul className="space-y-2">
<li className="flex items-center justify-between text-sm">
<span>● {session.name} (du)</span>
</li>
{remoteList.map((r) => (
<li key={r.participantId} className="flex items-center justify-between text-sm">
<span>● {r.name}</span>
{isHost && (
<button
type="button"
onClick={() => kickParticipant(r.participantId)}
className="text-xs text-white/50 hover:text-red-300"
>
entfernen
</button>
)}
</li>
))}
</ul>
</aside>
)}
</header>
{errorMessage && <p className="bg-amber-900/50 px-5 py-2 text-center text-xs text-amber-100">{errorMessage}</p>}
<div className="flex flex-1 flex-col lg:flex-row">
<section className="flex-1 p-4">
{layout === "grid" && <div className="grid auto-rows-fr grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">{participants.map((participant) => tile(participant, { videoTrack: participant.screenTrack ?? participant.cameraTrack, name: participant.screenTrack ? `${participant.name} · Bildschirm` : participant.name }))}</div>}
{layout === "speaker" && activeSpeaker && <div className="space-y-3"><div className="mx-auto max-w-5xl">{tile(activeSpeaker, { videoTrack: activeSpeaker.screenTrack ?? activeSpeaker.cameraTrack })}</div><div className="grid grid-cols-2 gap-3 sm:grid-cols-4">{participants.filter((participant) => participant.identity !== activeSpeaker.identity).map((participant) => tile(participant, { compact: true }))}</div></div>}
{layout === "screen" && <div className="space-y-3">{screenOwner ? <div className="mx-auto max-w-6xl">{tile(screenOwner, { videoTrack: screenOwner.screenTrack, name: `${screenOwner.name} · Bildschirm` })}</div> : <div className="flex aspect-video items-center justify-center rounded-3xl border border-dashed border-white/15 text-sm text-white/45">Noch niemand teilt den Bildschirm.</div>}<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">{participants.map((participant) => tile(participant, { compact: true }))}</div></div>}
</section>
{showParticipants && <aside className="w-full border-t border-white/10 p-4 lg:w-72 lg:border-l lg:border-t-0"><h2 className="mb-3 text-xs font-medium uppercase tracking-wide text-white/55">Teilnehmer</h2><ul className="space-y-3">{participants.map((participant) => <li key={participant.identity} className="flex items-center justify-between gap-2 text-sm"><span className="truncate">● {participant.name}{participant.identity === local?.identity && " (du)"}</span>{session.role === "host" && participant.identity !== local?.identity && <button type="button" onClick={() => void kickParticipant(participant.identity)} className="shrink-0 text-xs text-white/50 hover:text-red-300">entfernen</button>}</li>)}</ul></aside>}
</div>
<div className="flex items-center justify-center gap-3 border-t border-white/10 px-5 py-4">
<ControlButton active={micOn} onClick={toggleMic} label={micOn ? "Mikrofon aus" : "Mikrofon an"} icon="mic" />
<ControlButton active={camOn} onClick={toggleCam} label={camOn ? "Kamera aus" : "Kamera an"} icon="cam" />
{isHost && (
<ControlButton
active={sharingScreen}
onClick={toggleScreenShare}
label={sharingScreen ? "Bildschirmfreigabe beenden" : "Bildschirm teilen"}
icon="screen"
/>
)}
<button
type="button"
onClick={leaveMeeting}
className="rounded-full bg-red-700 px-6 py-3 text-sm font-medium text-white hover:bg-red-800"
>
Meeting verlassen
</button>
</div>
</div>
<footer className="flex flex-wrap items-center justify-center gap-3 border-t border-white/10 px-5 py-4">
<ControlButton active={micOn} onClick={() => void toggleMic()} label={micOn ? "Mikrofon aus" : "Mikrofon an"} icon="mic" />
<ControlButton active={camOn} onClick={() => void toggleCamera()} label={camOn ? "Kamera aus" : "Kamera an"} icon="cam" />
{session.role === "host" && <ControlButton active={sharingScreen} onClick={() => void toggleScreenShare()} label={sharingScreen ? "Bildschirmfreigabe beenden" : "Bildschirm teilen"} icon="screen" />}
<button type="button" onClick={leaveMeeting} className="rounded-full bg-red-700 px-6 py-3 text-sm font-medium text-white hover:bg-red-800">Meeting verlassen</button>
</footer>
</main>
);
}
function ControlButton({
active,
onClick,
label,
icon,
}: {
active: boolean;
onClick: () => void;
label: string;
icon: "mic" | "cam" | "screen";
}) {
function ControlButton({ active, onClick, label, icon }: { active: boolean; onClick: () => void; label: string; icon: "mic" | "cam" | "screen" }) {
return (
<button
type="button"
onClick={onClick}
title={label}
aria-pressed={active}
className={`flex h-12 w-12 items-center justify-center rounded-full transition-colors ${
active ? "bg-white/15 hover:bg-white/25" : "bg-red-700 hover:bg-red-800"
}`}
>
<button type="button" onClick={onClick} title={label} aria-pressed={active} className={`flex h-12 w-12 items-center justify-center rounded-full transition-colors ${active ? "bg-white/15 hover:bg-white/25" : "bg-red-700 hover:bg-red-800"}`}>
<span className="sr-only">{label}</span>
<Icon name={icon} off={!active} />
<svg aria-hidden="true" viewBox="0 0 24 24" className="h-5 w-5 fill-none stroke-current" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
{icon === "mic" && <><path d="M12 3a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3Z" /><path d="M5 11a7 7 0 0 0 14 0M12 18v3M8 21h8" /></>}
{icon === "cam" && <><path d="M4 7h11a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2Z" /><path d="m17 10 5-3v10l-5-3" /></>}
{icon === "screen" && <><rect x="3" y="4" width="18" height="12" rx="1.5" /><path d="M8 20h8M12 16v4" /></>}
</svg>
</button>
);
}
function Icon({ name, off }: { name: "mic" | "cam" | "screen"; off: boolean }) {
if (name === "mic") {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
{off && <path d="M2 2l20 20" stroke="white" strokeWidth="1.8" strokeLinecap="round" />}
<path
d="M12 15a3 3 0 0 0 3-3V6a3 3 0 0 0-6 0v6a3 3 0 0 0 3 3Zm5-3a5 5 0 0 1-10 0M12 18v3"
stroke="white"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
if (name === "cam") {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
{off && <path d="M2 2l20 20" stroke="white" strokeWidth="1.8" strokeLinecap="round" />}
<rect x="2" y="6" width="14" height="12" rx="2" stroke="white" strokeWidth="1.8" />
<path d="M16 10l6-3v10l-6-3" stroke="white" strokeWidth="1.8" strokeLinejoin="round" />
</svg>
);
}
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<rect x="2" y="4" width="20" height="13" rx="2" stroke="white" strokeWidth="1.8" />
<path d="M8 21h8M12 17v4" stroke="white" strokeWidth="1.8" strokeLinecap="round" />
</svg>
);
}
+53 -31
View File
@@ -2,26 +2,46 @@
import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
const fieldClass =
"w-full rounded-2xl border border-anouma-taupe/30 bg-white px-5 py-3.5 text-base text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none";
import { fieldClass, TextField, FormError } from "@/components/FormField";
export const MEETING_SESSION_KEY = "anouma-meeting-session";
const MEETING_CLIENT_ID_KEY = "anouma-meeting-client-id";
// A stable per-browser identity so a host's "kick" survives the removed
// participant simply reconnecting (which always gets a fresh participantId
// — see lib/meeting/token.ts). Deliberately in localStorage, not
// sessionStorage: it must outlive the "kicked" screen clearing the session
// and the "Erneut beitreten" link going back through this form again.
function getOrCreateClientId(): string {
try {
const existing = localStorage.getItem(MEETING_CLIENT_ID_KEY);
if (existing) return existing;
const created = crypto.randomUUID();
localStorage.setItem(MEETING_CLIENT_ID_KEY, created);
return created;
} catch {
// Storage unavailable (private browsing, etc.) — fall back to a
// one-off id; kicks just won't stick across reconnects in that case.
return crypto.randomUUID();
}
}
export type StoredMeetingSession = {
token: string;
participantToken?: string;
serverUrl?: string;
participantId: string;
role: "host" | "participant";
name: string;
eventTitle: string;
eventSlug: string;
iceServers: RTCIceServer[];
};
export function JoinForm({ slug }: { slug: string }) {
const router = useRouter();
const [name, setName] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
@@ -33,7 +53,7 @@ export function JoinForm({ slug }: { slug: string }) {
const res = await fetch(`/api/meetings/${slug}/join`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, password }),
body: JSON.stringify({ name, password, clientId: getOrCreateClientId() }),
credentials: "include",
});
const data = await res.json();
@@ -45,12 +65,13 @@ export function JoinForm({ slug }: { slug: string }) {
const session: StoredMeetingSession = {
token: data.token,
participantToken: data.participantToken,
serverUrl: data.serverUrl,
participantId: data.participantId,
role: data.role,
name,
eventTitle: data.eventTitle,
eventSlug: slug,
iceServers: data.iceServers,
};
sessionStorage.setItem(MEETING_SESSION_KEY, JSON.stringify(session));
router.push(`/termine/${slug}/call`);
@@ -62,41 +83,42 @@ export function JoinForm({ slug }: { slug: string }) {
return (
<form onSubmit={handleSubmit} className="space-y-5">
<TextField
label="Dein Name"
id="name"
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name eingeben"
autoComplete="name"
/>
<div>
<label htmlFor="name" className="mb-2 block text-sm font-medium text-anouma-plum">
Dein Name
</label>
<input
id="name"
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name eingeben"
className={fieldClass}
autoComplete="name"
/>
</div>
<div>
<label htmlFor="password" className="mb-2 block text-sm font-medium text-anouma-plum">
Meeting-Passwort
</label>
<div className="mb-2 flex items-center justify-between">
<label htmlFor="password" className="block text-sm font-medium text-anouma-plum">
Meeting-Passwort
</label>
<button
type="button"
className="text-xs font-medium text-anouma-mauve-dark underline-offset-2 hover:underline"
onClick={() => setShowPassword((visible) => !visible)}
aria-pressed={showPassword}
>
{showPassword ? "Verbergen" : "Anzeigen"}
</button>
</div>
<input
id="password"
type="text"
className={fieldClass}
type={showPassword ? "text" : "password"}
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Passwort eingeben"
className={fieldClass}
autoComplete="off"
/>
</div>
{error && (
<p role="alert" className="text-sm text-red-700">
{error}
</p>
)}
{error && <FormError>{error}</FormError>}
<button
type="submit"
disabled={loading}
+131
View File
@@ -0,0 +1,131 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { createLocalTracks, Track, type LocalTrack } from "livekit-client";
export function PreJoinScreen({ onContinue }: { onContinue: (tracks: LocalTrack[]) => void }) {
const [tracks, setTracks] = useState<LocalTrack[]>([]);
const [error, setError] = useState<string | null>(null);
const [micOn, setMicOn] = useState(true);
const [cameraOn, setCameraOn] = useState(true);
const [speakerTested, setSpeakerTested] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
const handedOffRef = useRef(false);
const videoTrack = tracks.find((track) => track.kind === Track.Kind.Video);
const audioTrack = tracks.find((track) => track.kind === Track.Kind.Audio);
useEffect(() => {
const videoElement = videoRef.current;
if (!videoElement || !videoTrack) return;
videoTrack.attach(videoElement);
return () => {
videoTrack.detach(videoElement);
};
}, [videoTrack]);
useEffect(() => {
return () => {
if (!handedOffRef.current) tracks.forEach((track) => track.stop());
};
}, [tracks]);
async function requestDevices() {
tracks.forEach((track) => track.stop());
setError(null);
try {
const nextTracks = await createLocalTracks({ audio: true, video: true });
setTracks(nextTracks);
setMicOn(true);
setCameraOn(true);
} catch {
setError("Kamera oder Mikrofon konnten nicht freigegeben werden. Du kannst trotzdem ohne sie beitreten.");
}
}
async function toggleMic() {
if (!audioTrack) return;
if (micOn) await audioTrack.mute();
else await audioTrack.unmute();
setMicOn(!micOn);
}
async function toggleCamera() {
if (!videoTrack) return;
if (cameraOn) await videoTrack.mute();
else await videoTrack.unmute();
setCameraOn(!cameraOn);
}
function testSpeaker() {
const AudioContextClass = window.AudioContext || (window as Window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AudioContextClass) return;
const context = new AudioContextClass();
const oscillator = context.createOscillator();
const gain = context.createGain();
oscillator.frequency.value = 660;
gain.gain.value = 0.08;
oscillator.connect(gain).connect(context.destination);
oscillator.start();
oscillator.stop(context.currentTime + 0.25);
oscillator.addEventListener("ended", () => void context.close());
setSpeakerTested(true);
}
function continueToCall() {
handedOffRef.current = true;
onContinue(tracks);
}
return (
<main className="flex min-h-screen items-center justify-center bg-neutral-950 px-5 py-10 text-white">
<section className="w-full max-w-3xl rounded-[2rem] border border-white/10 bg-neutral-900 p-6 shadow-2xl sm:p-8">
<p className="text-xs font-medium uppercase tracking-[0.18em] text-white/50">Vorbereitung</p>
<h1 className="mt-2 font-serif text-3xl">Prüfe deine Geräte</h1>
<p className="mt-2 max-w-xl text-sm leading-relaxed text-white/65">
Erlaube Kamera und Mikrofon, teste kurz den Ton und tritt dann dem geschützten Meeting bei.
</p>
<div className="mt-7 grid gap-5 lg:grid-cols-[1.35fr_1fr]">
<div className="relative aspect-video overflow-hidden rounded-3xl bg-neutral-800">
{videoTrack && cameraOn ? (
<video ref={videoRef} autoPlay muted playsInline className="h-full w-full object-cover [transform:scaleX(-1)]" />
) : (
<div className="flex h-full items-center justify-center text-sm text-white/45">Kamera ausgeschaltet</div>
)}
{tracks.length > 0 && (
<span className="absolute bottom-3 left-3 rounded-full bg-black/50 px-3 py-1 text-xs text-white/80">
{micOn ? "Mikrofon an" : "Mikrofon aus"}
</span>
)}
</div>
<div className="flex flex-col justify-center gap-3">
<button
type="button"
onClick={requestDevices}
className="rounded-full bg-white px-5 py-3 text-sm font-medium text-neutral-900 hover:bg-white/85"
>
{tracks.length > 0 ? "Geräte erneut prüfen" : "Kamera & Mikrofon aktivieren"}
</button>
<div className="grid grid-cols-2 gap-3">
<button type="button" onClick={() => void toggleMic()} disabled={!audioTrack} className="rounded-2xl bg-white/10 px-4 py-3 text-xs hover:bg-white/15 disabled:opacity-40">
{micOn ? "Mikrofon aus" : "Mikrofon an"}
</button>
<button type="button" onClick={() => void toggleCamera()} disabled={!videoTrack} className="rounded-2xl bg-white/10 px-4 py-3 text-xs hover:bg-white/15 disabled:opacity-40">
{cameraOn ? "Kamera aus" : "Kamera an"}
</button>
</div>
<button type="button" onClick={testSpeaker} className="rounded-2xl border border-white/15 px-4 py-3 text-left text-xs text-white/80 hover:bg-white/10">
{speakerTested ? "✓ Lautsprecher getestet" : "Lautsprecher testen"}
</button>
{error && <p role="alert" className="text-xs leading-relaxed text-amber-200">{error}</p>}
<button type="button" onClick={continueToCall} className="mt-2 rounded-full bg-anouma-mauve-dark px-5 py-3.5 text-sm font-medium hover:bg-anouma-plum">
Meeting beitreten
</button>
</div>
</div>
</section>
</main>
);
}
+21 -37
View File
@@ -1,9 +1,7 @@
"use client";
import { useState, type FormEvent } from "react";
const fieldClass =
"w-full rounded-2xl border border-anouma-taupe/30 bg-white px-5 py-3 text-base text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none";
import { TextField, FormError } from "@/components/FormField";
export function RegisterForm({ slug }: { slug: string }) {
const [name, setName] = useState("");
@@ -37,46 +35,32 @@ export function RegisterForm({ slug }: { slug: string }) {
if (status === "done") {
return (
<p className="rounded-2xl bg-anouma-cream-light p-5 text-sm leading-relaxed text-anouma-plum" role="status">
Danke für deine Anmeldung — wir freuen uns auf dich!
Danke für deine Anmeldung – wir freuen uns auf dich!
</p>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="reg-name" className="mb-1.5 block text-sm font-medium text-anouma-plum">
Name
</label>
<input
id="reg-name"
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
className={fieldClass}
autoComplete="name"
/>
</div>
<div>
<label htmlFor="reg-email" className="mb-1.5 block text-sm font-medium text-anouma-plum">
E-Mail
</label>
<input
id="reg-email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className={fieldClass}
autoComplete="email"
/>
</div>
{error && (
<p role="alert" className="text-sm text-red-700">
{error}
</p>
)}
<TextField
label="Name"
id="reg-name"
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete="name"
/>
<TextField
label="E-Mail"
id="reg-email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
/>
{error && <FormError>{error}</FormError>}
<button
type="submit"
disabled={status === "loading"}
+69
View File
@@ -0,0 +1,69 @@
"use client";
import { useEffect, useRef } from "react";
import type { Track } from "livekit-client";
export function SfuVideoTile({
videoTrack,
audioTrack,
name,
isLocal = false,
isHost = false,
muted = false,
videoOff = false,
speaking = false,
compact = false,
}: {
videoTrack?: Track | null;
audioTrack?: Track | null;
name: string;
isLocal?: boolean;
isHost?: boolean;
muted?: boolean;
videoOff?: boolean;
speaking?: boolean;
compact?: boolean;
}) {
const videoRef = useRef<HTMLVideoElement>(null);
const audioRef = useRef<HTMLAudioElement>(null);
useEffect(() => {
const videoElement = videoRef.current;
if (!videoElement || !videoTrack || videoOff) return;
videoTrack.attach(videoElement);
return () => {
videoTrack.detach(videoElement);
};
}, [videoTrack, videoOff]);
useEffect(() => {
const audioElement = audioRef.current;
if (!audioElement || !audioTrack || isLocal) return;
audioTrack.attach(audioElement);
return () => {
audioTrack.detach(audioElement);
};
}, [audioTrack, isLocal]);
return (
<div className={`relative aspect-video overflow-hidden rounded-2xl bg-neutral-800 ${speaking ? "ring-2 ring-anouma-rose" : ""}`}>
{videoTrack && !videoOff ? (
<video ref={videoRef} autoPlay playsInline muted={isLocal} className="h-full w-full object-cover" />
) : (
<div className="flex h-full w-full items-center justify-center bg-neutral-800">
<div className={`${compact ? "h-10 w-10 text-lg" : "h-16 w-16 text-2xl"} flex items-center justify-center rounded-full bg-neutral-700 font-serif text-white`}>
{name.charAt(0).toUpperCase()}
</div>
</div>
)}
{!isLocal && <audio ref={audioRef} autoPlay />}
<div className="absolute inset-x-0 bottom-0 flex items-center justify-between gap-2 bg-gradient-to-t from-black/75 to-transparent px-3 py-2">
<span className="truncate text-sm font-medium text-white">{name}{isLocal && " (du)"}</span>
<div className="flex items-center gap-2 text-white/80">
{muted && <span aria-label="Mikrofon stumm">⌁</span>}
{isHost && <span className="rounded-full bg-anouma-mauve-dark/90 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-white">Host</span>}
</div>
</div>
</div>
);
}
+23
View File
@@ -53,6 +53,29 @@ services:
# HEALTHCHECK instruction (GET /api/health, which itself checks Postgres
# connectivity) — nothing to duplicate here.
# Self-hosted LiveKit SFU. Host networking is intentional: WebRTC media
# needs the UDP port range below to be reachable directly from the internet.
livekit:
image: livekit/livekit-server:latest
restart: unless-stopped
network_mode: host
env_file:
- .env
environment:
LIVEKIT_CONFIG: |
port: 7880
log_level: info
rtc:
tcp_port: 7881
port_range_start: 50000
port_range_end: 60000
use_external_ip: true
keys:
${LIVEKIT_API_KEY}: ${LIVEKIT_API_SECRET}
turn:
enabled: true
udp_port: 3478
# Optional external network to a reverse proxy (e.g. Nginx Proxy Manager)
# is attached via docker-compose.override.yml, generated by setup.sh only
# when such a network is actually detected — see DEPLOYMENT.md. Nothing
+45
View File
@@ -0,0 +1,45 @@
import type { GlobalConfig } from "payload";
import { canViewAnalytics, isAdmin } from "@/access";
/**
* Retention configuration for the analytics system, enforced by the nightly
* cleanup cron (lib/analytics/cleanup.ts) and shown in the "Analytics &
* Datenschutz" panel of the dashboard (components/admin/AdminAnalyticsView.tsx).
*/
export const AnalyticsSettings: GlobalConfig = {
slug: "analytics-settings",
label: "Analytics-Einstellungen",
admin: {
description: "Aufbewahrungsdauer für Analytics-Rohdaten und aggregierte Tagesstatistiken.",
group: "Datenschutz",
},
access: {
read: canViewAnalytics,
update: isAdmin,
},
fields: [
{
name: "rawRetentionDays",
type: "number",
label: "Rohdaten-Aufbewahrung (Tage)",
required: true,
defaultValue: 90,
min: 7,
admin: {
description:
"Wie lange einzelne Analytics-Events gespeichert bleiben. Begrenzt auch den wählbaren Zeitraum im Analytics-Dashboard.",
},
},
{
name: "aggregateRetentionMonths",
type: "number",
label: "Aggregat-Aufbewahrung (Monate)",
required: true,
defaultValue: 12,
min: 1,
admin: {
description: "Wie lange die täglichen Aggregat-Statistiken (ohne Einzel-Events) gespeichert bleiben.",
},
},
],
};
+102
View File
@@ -0,0 +1,102 @@
import type { GlobalConfig } from "payload";
import { isAdmin } from "@/access";
/**
* Consent-category configuration shown in the public cookie banner
* (components/Analytics.tsx). Read access is intentionally public — the
* unauthenticated frontend needs the labels/descriptions and the
* enabled/disabled flags before a visitor has logged in anywhere.
*
* "necessary" deliberately has no `enabled` field at all, anywhere in this
* schema — there is no toggle to accidentally switch off, so it can never
* be disabled through the admin UI (see requirement: technically required
* functionality must not be disable-able by an admin).
*/
export const CookieSettings: GlobalConfig = {
slug: "cookie-settings",
label: "Cookie-Einstellungen",
admin: {
description: "Kategorien und Texte für den Cookie-Consent-Banner der Website.",
group: "Datenschutz",
},
access: {
read: () => true,
update: isAdmin,
},
fields: [
{
name: "consentVersion",
type: "text",
label: "Consent-Version",
required: true,
defaultValue: "1.0",
admin: {
description:
"Bei Änderung müssen alle Besucher:innen erneut zustimmen — bereits gespeicherte Einwilligungen mit einer älteren Version werden ignoriert und der Banner erscheint erneut.",
},
},
{
type: "group",
name: "necessary",
label: "Notwendig",
admin: { description: "Immer aktiv — technisch erforderlich, kann nicht deaktiviert werden." },
fields: [
{ name: "label", type: "text", label: "Bezeichnung", required: true, defaultValue: "Notwendig" },
{
name: "description",
type: "textarea",
label: "Beschreibung",
required: true,
defaultValue: "Für den Betrieb der Website technisch erforderlich (z. B. Login, Sitzung). Kann nicht deaktiviert werden.",
},
],
},
{
type: "group",
name: "preferences",
label: "Präferenzen",
fields: [
{ name: "label", type: "text", label: "Bezeichnung", required: true, defaultValue: "Präferenzen" },
{
name: "description",
type: "textarea",
label: "Beschreibung",
required: true,
defaultValue: "Speichert Einstellungen wie z. B. bevorzugte Anzeigeoptionen.",
},
{
name: "enabled",
type: "checkbox",
label: "Kategorie aktiv",
defaultValue: true,
admin: { description: "Wenn deaktiviert, wird diese Kategorie im Banner nicht mehr angeboten." },
},
],
},
{
type: "group",
name: "statistics",
label: "Statistik",
fields: [
{ name: "label", type: "text", label: "Bezeichnung", required: true, defaultValue: "Statistik" },
{
name: "description",
type: "textarea",
label: "Beschreibung",
required: true,
defaultValue: "Anonymisierte, datensparsame Reichweitenmessung (Seitenaufrufe, Ereignisse) ohne personenbezogene Profile.",
},
{
name: "enabled",
type: "checkbox",
label: "Kategorie aktiv",
defaultValue: true,
admin: {
description:
"Globaler Schalter für das gesamte Analytics-System. Wenn deaktiviert, wird diese Kategorie im Banner nicht mehr angeboten und Analytics-Daten werden serverseitig abgelehnt — auch bei einer noch gespeicherten alten Zustimmung.",
},
},
],
},
],
};
+1 -1
View File
@@ -18,7 +18,7 @@ export const MeetingSettings: GlobalConfig = {
type: "number",
label: "Teilnehmer dürfen beitreten (Minuten vor Beginn)",
required: true,
defaultValue: 30,
defaultValue: 10,
min: 0,
},
{
+94
View File
@@ -0,0 +1,94 @@
import type { AnalyticsEvent } from "@/payload-types";
export type AggregatedStats = {
sessions: number;
pageViews: number;
uniqueVisitors: number;
avgPagesPerSession: number;
eventCounts: Record<string, number>;
deviceCounts: Record<string, number>;
browserCounts: Record<string, number>;
topPaths: Record<string, number>;
entryPaths: Record<string, number>;
exitPaths: Record<string, number>;
};
function bump(map: Record<string, number>, key: string | null | undefined) {
if (!key) return;
map[key] = (map[key] ?? 0) + 1;
}
/** Sorted (descending) [key, count] pairs, capped at `limit` — for dashboard tables. */
export function topEntries(map: Record<string, number>, limit = 10): [string, number][] {
return Object.entries(map)
.sort(([, a], [, b]) => b - a)
.slice(0, limit);
}
/**
* Pure aggregation over a set of raw AnalyticsEvents rows (any date range —
* one day for the cleanup rollup, or a whole dashboard-selected range for
* live queries). Kept side-effect free and DB-agnostic so both callers
* (lib/analytics/cleanup.ts and lib/analytics/dashboardQueries.ts) share
* one implementation.
*/
export function aggregateEvents(events: AnalyticsEvent[]): AggregatedStats {
const eventCounts: Record<string, number> = {};
const deviceCounts: Record<string, number> = {};
const browserCounts: Record<string, number> = {};
const topPaths: Record<string, number> = {};
const entryPaths: Record<string, number> = {};
const exitPaths: Record<string, number> = {};
const visitorHashes = new Set<string>();
const sessionIds = new Set<string>();
let pageViews = 0;
// Per-session first/last page_view, in chronological order, for entry/exit pages.
const sessionPageViews = new Map<string, { first: { at: string; path: string }; last: { at: string; path: string } }>();
const sorted = [...events].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
for (const ev of sorted) {
visitorHashes.add(ev.visitorHash);
sessionIds.add(ev.sessionId);
bump(eventCounts, ev.eventName);
bump(deviceCounts, ev.device);
bump(browserCounts, ev.browser);
if (ev.eventName === "page_view") {
pageViews += 1;
bump(topPaths, ev.path);
const existing = sessionPageViews.get(ev.sessionId);
if (!existing) {
sessionPageViews.set(ev.sessionId, {
first: { at: ev.createdAt, path: ev.path },
last: { at: ev.createdAt, path: ev.path },
});
} else {
existing.last = { at: ev.createdAt, path: ev.path };
}
}
}
for (const { first, last } of sessionPageViews.values()) {
bump(entryPaths, first.path);
bump(exitPaths, last.path);
}
const sessions = sessionIds.size;
return {
sessions,
pageViews,
uniqueVisitors: visitorHashes.size,
avgPagesPerSession: sessions > 0 ? Math.round((pageViews / sessions) * 10) / 10 : 0,
eventCounts,
deviceCounts,
browserCounts,
topPaths,
entryPaths,
exitPaths,
};
}
+109
View File
@@ -0,0 +1,109 @@
import type { Payload } from "payload";
import { aggregateEvents } from "@/lib/analytics/aggregate";
function dateKey(date: Date): string {
return date.toISOString().slice(0, 10);
}
function startOfUTCDay(date: Date): Date {
return new Date(`${dateKey(date)}T00:00:00.000Z`);
}
function addDays(date: Date, days: number): Date {
return new Date(date.getTime() + days * 24 * 60 * 60 * 1000);
}
/**
* Rolls up every not-yet-aggregated, fully-completed calendar day (UTC —
* matching the UTC-day salt rotation in lib/analytics/salt.ts) from raw
* analytics-events into analytics-daily-stats, THEN purges raw events older
* than the configured retention, THEN purges old daily-stats rows. Order
* matters: a day must be aggregated before its raw rows can ever be
* deleted, or that history is gone for good.
*
* Only aggregates days that don't already have a daily-stats row (each day
* is aggregated exactly once, ever) — safe to call this daily via
* app/(payload)/api/cron/analytics-cleanup/route.ts, and safe to re-run.
*/
export async function runAnalyticsCleanup(payload: Payload) {
const settings = await payload.findGlobal({ slug: "analytics-settings" });
const rawRetentionDays = settings.rawRetentionDays ?? 90;
const aggregateRetentionMonths = settings.aggregateRetentionMonths ?? 12;
const today = startOfUTCDay(new Date());
const { docs: earliestDocs } = await payload.find({
collection: "analytics-events",
sort: "createdAt",
limit: 1,
depth: 0,
});
const earliest = earliestDocs[0];
let daysAggregated = 0;
if (earliest) {
let cursor = startOfUTCDay(new Date(earliest.createdAt));
while (cursor < today) {
const key = dateKey(cursor);
const { totalDocs: alreadyAggregated } = await payload.count({
collection: "analytics-daily-stats",
where: { date: { equals: key } },
});
if (alreadyAggregated === 0) {
const dayEnd = addDays(cursor, 1);
const { docs: dayEvents } = await payload.find({
collection: "analytics-events",
where: {
and: [
{ createdAt: { greater_than_equal: cursor.toISOString() } },
{ createdAt: { less_than: dayEnd.toISOString() } },
],
},
limit: 0, // no limit — pull the whole day, small-site scale
depth: 0,
});
if (dayEvents.length > 0) {
const stats = aggregateEvents(dayEvents);
await payload.create({
collection: "analytics-daily-stats",
data: { date: key, ...stats },
});
daysAggregated += 1;
}
}
cursor = addDays(cursor, 1);
}
}
const rawCutoff = addDays(today, -rawRetentionDays).toISOString();
const { docs: staleEvents } = await payload.find({
collection: "analytics-events",
where: { createdAt: { less_than: rawCutoff } },
limit: 0,
depth: 0,
});
for (const doc of staleEvents) {
await payload.delete({ collection: "analytics-events", id: doc.id });
}
const aggregateCutoff = new Date(today);
aggregateCutoff.setUTCMonth(aggregateCutoff.getUTCMonth() - aggregateRetentionMonths);
const { docs: staleDailyStats } = await payload.find({
collection: "analytics-daily-stats",
where: { date: { less_than: dateKey(aggregateCutoff) } },
limit: 0,
depth: 0,
});
for (const doc of staleDailyStats) {
await payload.delete({ collection: "analytics-daily-stats", id: doc.id });
}
return {
daysAggregated,
rawEventsDeleted: staleEvents.length,
dailyStatsDeleted: staleDailyStats.length,
};
}
+92
View File
@@ -0,0 +1,92 @@
"use client";
/**
* Public developer API + consent storage for the analytics system. Importing
* this module has zero side effects — no network call, nothing initialized
* — until `track()` is actually called, and even then it's a no-op unless
* consent was actually granted. Callers never check consent themselves:
*
* track("booking_started");
*
* The CMS-configured part of consent (whether the "statistics" category is
* enabled at all, and the current consent version) is pushed in once by
* components/Analytics.tsx via setAnalyticsConfig() — see that file. Until
* that happens, `cmsConfig` is null and track() fails safe (no-op), so a
* component whose effects happen to run before Analytics.tsx's can never
* accidentally send data before the live config is known.
*/
const CONSENT_COOKIE = "anouma-consent";
export type ConsentState = {
necessary: true;
preferences: boolean;
statistics: boolean;
version: string;
decidedAt: string;
};
type CmsConfig = { statisticsEnabled: boolean; consentVersion: string };
let cmsConfig: CmsConfig | null = null;
export function setAnalyticsConfig(config: CmsConfig) {
cmsConfig = config;
}
export function getConsent(): ConsentState | null {
if (typeof document === "undefined") return null;
const match = document.cookie.match(/(?:^|;\s*)anouma-consent=([^;]*)/);
if (!match) return null;
try {
const parsed = JSON.parse(decodeURIComponent(match[1]));
if (parsed && typeof parsed === "object" && typeof parsed.version === "string") return parsed as ConsentState;
return null;
} catch {
return null;
}
}
export function setConsent(choice: { preferences: boolean; statistics: boolean }, version: string): ConsentState {
const state: ConsentState = {
necessary: true,
preferences: choice.preferences,
statistics: choice.statistics,
version,
decidedAt: new Date().toISOString(),
};
const maxAgeSeconds = 60 * 60 * 24 * 180; // 180 days
const secure = typeof location !== "undefined" && location.protocol === "https:" ? "; Secure" : "";
document.cookie = `${CONSENT_COOKIE}=${encodeURIComponent(JSON.stringify(state))}; Max-Age=${maxAgeSeconds}; Path=/; SameSite=Lax${secure}`;
return state;
}
/** True once the visitor has made a decision that still matches the current CMS consent version. */
export function hasCurrentConsentDecision(): boolean {
if (!cmsConfig) return false;
const consent = getConsent();
return Boolean(consent && consent.version === cmsConfig.consentVersion);
}
function statisticsAllowed(): boolean {
if (!cmsConfig || !cmsConfig.statisticsEnabled) return false;
const consent = getConsent();
return Boolean(consent && consent.version === cmsConfig.consentVersion && consent.statistics === true);
}
export function track(eventName: string, extra?: { path?: string }): void {
if (typeof window === "undefined") return;
if (!statisticsAllowed()) return;
const path = extra?.path ?? window.location.pathname;
const body = JSON.stringify({ eventName, path, referrer: document.referrer || undefined });
// navigator.sendBeacon can't reliably set Content-Type: application/json
// across browsers, so the server route also accepts a plain-text body.
if (typeof navigator !== "undefined" && navigator.sendBeacon) {
navigator.sendBeacon("/api/analytics/track", new Blob([body], { type: "text/plain" }));
return;
}
fetch("/api/analytics/track", { method: "POST", body, keepalive: true, headers: { "Content-Type": "text/plain" } }).catch(() => {});
}
+69
View File
@@ -0,0 +1,69 @@
import type { Payload } from "payload";
import type { AnalyticsEvent } from "@/payload-types";
import { aggregateEvents, type AggregatedStats } from "@/lib/analytics/aggregate";
export type DashboardRange = { from: Date; to: Date };
export type DailyPoint = { date: string; pageViews: number; sessions: number };
export type DashboardStats = AggregatedStats & {
series: DailyPoint[];
};
function dateKey(iso: string): string {
return iso.slice(0, 10);
}
async function fetchEvents(payload: Payload, range: DashboardRange): Promise<AnalyticsEvent[]> {
const { docs } = await payload.find({
collection: "analytics-events",
where: {
and: [
{ createdAt: { greater_than_equal: range.from.toISOString() } },
{ createdAt: { less_than_equal: range.to.toISOString() } },
],
},
limit: 0,
depth: 0,
sort: "createdAt",
});
return docs;
}
function buildSeries(events: AnalyticsEvent[]): DailyPoint[] {
const byDay = new Map<string, { pageViews: number; sessions: Set<string> }>();
for (const ev of events) {
const key = dateKey(ev.createdAt);
const bucket = byDay.get(key) ?? { pageViews: 0, sessions: new Set<string>() };
if (ev.eventName === "page_view") bucket.pageViews += 1;
bucket.sessions.add(ev.sessionId);
byDay.set(key, bucket);
}
return [...byDay.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, bucket]) => ({ date, pageViews: bucket.pageViews, sessions: bucket.sessions.size }));
}
export async function getDashboardStats(payload: Payload, range: DashboardRange): Promise<DashboardStats> {
const events = await fetchEvents(payload, range);
return { ...aggregateEvents(events), series: buildSeries(events) };
}
export type PercentDelta = number | null; // null = no comparison basis (previous value was 0)
export function percentDelta(current: number, previous: number): PercentDelta {
if (previous === 0) return current === 0 ? 0 : null;
return Math.round(((current - previous) / previous) * 1000) / 10;
}
export async function getDashboardComparison(
payload: Payload,
range: DashboardRange,
compareRange: DashboardRange,
): Promise<{ current: DashboardStats; previous: DashboardStats }> {
const [current, previous] = await Promise.all([
getDashboardStats(payload, range),
getDashboardStats(payload, compareRange),
]);
return { current, previous };
}
+26
View File
@@ -0,0 +1,26 @@
/**
* Central, extensible registry of known analytics event names. Adding a new
* event elsewhere in the app never requires a migration — the database
* column (collections/AnalyticsEvents.ts#eventName) is free-text — this
* list exists purely so `track()` calls get autocomplete/type-checking and
* the dashboard can show a nice German label instead of a raw event name.
*
* To add a new event: add one entry here, then call `track("your_event")`
* anywhere in the app (see lib/analytics/client.ts).
*/
export const ANALYTICS_EVENTS = {
page_view: "Seitenaufruf",
booking_started: "Buchung gestartet",
booking_completed: "Buchung abgeschlossen",
contact_clicked: "Kontakt angeklickt",
navigation_clicked: "Navigation geklickt",
} as const;
export type KnownAnalyticsEvent = keyof typeof ANALYTICS_EVENTS;
/** Any string works (see the collection comment above) — known ones just get a label. */
export type AnalyticsEventName = KnownAnalyticsEvent | (string & {});
export function analyticsEventLabel(eventName: string): string {
return (ANALYTICS_EVENTS as Record<string, string>)[eventName] ?? eventName;
}
+71
View File
@@ -0,0 +1,71 @@
import type { DashboardRange } from "@/lib/analytics/dashboardQueries";
export type RangeKey = "today" | "7d" | "30d" | "90d" | "custom";
export const RANGE_OPTIONS: { key: RangeKey; label: string; days: number | "today" }[] = [
{ key: "today", label: "Heute", days: "today" },
{ key: "7d", label: "7 Tage", days: 7 },
{ key: "30d", label: "30 Tage", days: 30 },
{ key: "90d", label: "90 Tage", days: 90 },
];
const DAY_MS = 24 * 60 * 60 * 1000;
function startOfUTCDay(date: Date): Date {
return new Date(`${date.toISOString().slice(0, 10)}T00:00:00.000Z`);
}
function endOfUTCDay(date: Date): Date {
return new Date(`${date.toISOString().slice(0, 10)}T23:59:59.999Z`);
}
function parseDateParam(value: string | null): Date | null {
if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
const date = new Date(`${value}T00:00:00.000Z`);
return Number.isNaN(date.getTime()) ? null : date;
}
/**
* Resolves the dashboard's period filter (?range=&from=&to=) into an actual
* date range, clamped to `maxDaysBack` (the configured raw-event retention —
* see globals/AnalyticsSettings.ts) so the UI never claims to show data that
* has already been purged. Also computes the matching "previous period" for
* comparison (same length, immediately preceding).
*/
export function resolveRange(
searchParams: URLSearchParams,
maxDaysBack: number,
): { key: RangeKey; range: DashboardRange; compareRange: DashboardRange } {
const now = new Date();
const earliestAllowed = startOfUTCDay(new Date(now.getTime() - maxDaysBack * DAY_MS));
const rawKey = searchParams.get("range");
const key: RangeKey = (["today", "7d", "30d", "90d", "custom"] as const).includes(rawKey as RangeKey)
? (rawKey as RangeKey)
: "7d";
if (key === "custom") {
const fromParam = parseDateParam(searchParams.get("from"));
const toParam = parseDateParam(searchParams.get("to"));
let from = fromParam ? startOfUTCDay(fromParam) : startOfUTCDay(new Date(now.getTime() - 7 * DAY_MS));
let to = toParam ? endOfUTCDay(toParam) : now;
if (from < earliestAllowed) from = earliestAllowed;
if (to > now) to = now;
if (from > to) from = to;
const lengthMs = to.getTime() - from.getTime();
const compareRange: DashboardRange = { from: new Date(from.getTime() - lengthMs), to: new Date(from.getTime() - 1) };
return { key, range: { from, to }, compareRange };
}
if (key === "today") {
const from = startOfUTCDay(now);
const yesterday = new Date(from.getTime() - DAY_MS);
return { key, range: { from, to: now }, compareRange: { from: yesterday, to: new Date(from.getTime() - 1) } };
}
const days = RANGE_OPTIONS.find((o) => o.key === key)?.days;
const windowDays = typeof days === "number" ? Math.min(days, maxDaysBack) : 7;
const from = new Date(Math.max(now.getTime() - windowDays * DAY_MS, earliestAllowed.getTime()));
const compareRange: DashboardRange = { from: new Date(from.getTime() - windowDays * DAY_MS), to: new Date(from.getTime() - 1) };
return { key, range: { from, to: now }, compareRange };
}
+33
View File
@@ -0,0 +1,33 @@
import { createHmac } from "node:crypto";
/**
* Derives a daily-rotating, non-reversible pseudonymous visitor identifier
* for session grouping — the same approach used by privacy-friendly
* analytics tools (Plausible/Fathom-style): hash(ip + userAgent + host)
* salted with a value that changes every calendar day. Raw IP/UA are never
* persisted anywhere, only this hash — and since the salt rotates daily,
* the same visitor hashes differently tomorrow, so there is no cross-day
* linkage and no long-term individual profile.
*
* ANALYTICS_SALT_SECRET is a dedicated secret (not PAYLOAD_SECRET) — this
* codebase keeps one secret per purpose on purpose (see
* MEETING_SESSION_SECRET's comment in .env.example).
*/
function secret(): string {
const value = process.env.ANALYTICS_SALT_SECRET;
if (!value) throw new Error("ANALYTICS_SALT_SECRET is not set");
return value;
}
function dateKey(date: Date): string {
return date.toISOString().slice(0, 10);
}
function dailySalt(date: Date): string {
return createHmac("sha256", secret()).update(dateKey(date)).digest("hex");
}
export function visitorHash(args: { ip: string; userAgent: string; host: string; date?: Date }): string {
const salt = dailySalt(args.date ?? new Date());
return createHmac("sha256", salt).update(`${args.ip}|${args.userAgent}|${args.host}`).digest("hex");
}
+29
View File
@@ -0,0 +1,29 @@
import type { Payload } from "payload";
import { randomUUID } from "node:crypto";
const SESSION_IDLE_WINDOW_MS = 30 * 60 * 1000;
/**
* Groups events into sessions without any client-side identifier: looks up
* the most recent event from the same `visitorHash` within the last 30
* minutes and reuses its sessionId, or mints a new one. One extra indexed
* read per tracked request — acceptable at this site's traffic volume, and
* simpler/safer than trying to hold a lock across an unauthenticated,
* high-frequency public endpoint (unlike lib/booking/lock.ts's advisory
* lock, an occasional extra spurious session here has no correctness
* consequences worth the added complexity).
*/
export async function resolveSessionId(payload: Payload, visitorHash: string): Promise<string> {
const since = new Date(Date.now() - SESSION_IDLE_WINDOW_MS).toISOString();
const { docs } = await payload.find({
collection: "analytics-events",
where: {
and: [{ visitorHash: { equals: visitorHash } }, { createdAt: { greater_than_equal: since } }],
},
sort: "-createdAt",
limit: 1,
depth: 0,
});
return docs[0]?.sessionId ?? randomUUID();
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Coarse device/browser classification from the User-Agent header. No
* library, no raw UA string ever stored — just one bucket per request,
* computed on the fly and discarded after classification. Not meant to be
* precise (e.g. WebView vs. real browser); good enough for the "device
* type" / "browser" breakdown in the Analytics dashboard.
*/
export type DeviceType = "mobile" | "desktop" | "tablet";
export type BrowserFamily = "chrome" | "firefox" | "safari" | "edge" | "other";
export function classifyDevice(userAgent: string): DeviceType {
const ua = userAgent.toLowerCase();
if (/ipad|tablet(?!.*mobile)|(android(?!.*mobile))/.test(ua)) return "tablet";
if (/mobi|iphone|ipod|android/.test(ua)) return "mobile";
return "desktop";
}
export function classifyBrowser(userAgent: string): BrowserFamily {
const ua = userAgent.toLowerCase();
// Order matters: Edge/Chrome UAs also contain "safari"; Chrome UAs also
// contain "chrome" but so do Edge/Opera — check the most specific tokens first.
if (/edg\//.test(ua)) return "edge";
if (/chrome\/|crios\//.test(ua)) return "chrome";
if (/firefox\/|fxios\//.test(ua)) return "firefox";
if (/safari\//.test(ua)) return "safari";
return "other";
}
+13 -1
View File
@@ -32,7 +32,19 @@ export function checkRateLimit(key: string, opts: { max: number; windowMs: numbe
}
export function getClientIp(request: Request): string {
// Deployment is always behind exactly one trusted reverse proxy (see
// DEPLOYMENT.md — Nginx Proxy Manager; the app port isn't published
// directly to the host). nginx's default `$proxy_add_x_forwarded_for`
// *appends* the real client IP to any X-Forwarded-For it already
// received rather than replacing it, so a directly-connecting client
// could prepend arbitrary spoofed entries of their own. Only the
// right-most (last) entry — the one the trusted proxy itself appended —
// is trustworthy; taking the first entry would let a client bypass this
// rate limit entirely by sending a fake X-Forwarded-For header.
const forwarded = request.headers.get("x-forwarded-for");
if (forwarded) return forwarded.split(",")[0]!.trim();
if (forwarded) {
const parts = forwarded.split(",");
return parts[parts.length - 1]!.trim();
}
return "unknown";
}
+6
View File
@@ -11,6 +11,7 @@ export type AdminCalendarItem = {
endTime: string;
status: string; // booking status, or "public" for a standalone Event
appointmentType?: "onsite" | "online";
callEventSlug?: string;
href: string;
};
@@ -61,6 +62,10 @@ export async function getAdminCalendarItems(
endTime: b.endTime,
status: b.status,
appointmentType: b.appointmentType,
callEventSlug:
b.appointmentType === "online" && typeof b.linkedEvent === "object" && b.linkedEvent?.slug
? b.linkedEvent.slug
: undefined,
href: `/admin/collections/booking-requests/${b.id}`,
};
});
@@ -74,6 +79,7 @@ export async function getAdminCalendarItems(
endTime: e.endTime ?? e.date,
status: "public",
appointmentType: e.isOnline ? "online" : "onsite",
callEventSlug: e.isOnline && e.slug ? e.slug : undefined,
href: `/admin/collections/events/${e.id}`,
}));
+23
View File
@@ -0,0 +1,23 @@
import type { BookingRequest, Event, MeetingSetting } from "@/payload-types";
import { canJoinMeeting, combineDateAndTime, getMeetingStatus, type MeetingStatus } from "@/lib/meeting/status";
export function getBookingMeetingStatus(booking: BookingRequest, settings: MeetingSetting): MeetingStatus | null {
const event = typeof booking.linkedEvent === "object" ? (booking.linkedEvent as Event) : null;
if (booking.status !== "confirmed" || booking.appointmentType !== "online" || !event?.isOnline) return null;
const start = combineDateAndTime(booking.date, booking.startTime);
const end = booking.endTime
? combineDateAndTime(booking.date, booking.endTime)
: new Date(start.getTime() + 60 * 60_000);
return getMeetingStatus({
start,
end,
joinWindowMinutes: settings.participantJoinWindowMinutes,
closeAfterMinutes: settings.meetingCloseAfterMinutes,
});
}
export function canJoinBookingMeeting(booking: BookingRequest, settings: MeetingSetting): boolean {
const status = getBookingMeetingStatus(booking, settings);
return status !== null && canJoinMeeting(status);
}
+37 -9
View File
@@ -1,5 +1,6 @@
import type { Payload } from "payload";
import { WEEKDAYS } from "@/collections/Availability";
import { berlinParts, berlinWallTimeToUTC, type CalendarDate } from "@/lib/calendar/timezone";
export type TimeSlot = { start: Date; end: Date };
@@ -13,18 +14,41 @@ const WEEKDAY_BY_INDEX = [
"saturday",
] as const;
// Availability/AvailabilityOverrides times are entered by the admin in their
// browser (Europe/Berlin) and stored as UTC instants. All wall-clock reads
// and writes here go through Intl's Berlin timezone data (not local Date
// accessors, and not manual UTC+1/+2 arithmetic), so the server process's
// own timezone (this app runs in UTC in production) never leaks into the
// computed availability — see lib/calendar/timezone.ts for the same pattern.
function dateKey(date: Date): string {
return date.toISOString().slice(0, 10);
const { year, month, day } = berlinParts(date);
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
function calendarDate(date: Date): CalendarDate {
const { year, month, day } = berlinParts(date);
return { year, month, day };
}
function addCalendarDays({ year, month, day }: CalendarDate, delta: number): CalendarDate {
// Pure calendar-rollover arithmetic (month/year boundaries) — no timezone
// conversion happens here, so using the UTC constructor is safe and simple.
const utc = new Date(Date.UTC(year, month - 1, day + delta));
return { year: utc.getUTCFullYear(), month: utc.getUTCMonth() + 1, day: utc.getUTCDate() };
}
function weekdayOf({ year, month, day }: CalendarDate): (typeof WEEKDAY_BY_INDEX)[number] {
return WEEKDAY_BY_INDEX[new Date(Date.UTC(year, month - 1, day)).getUTCDay()];
}
function timeOfDayMinutes(date: Date): number {
return date.getHours() * 60 + date.getMinutes();
const { hour, minute } = berlinParts(date);
return hour * 60 + minute;
}
function atMinutesOfDay(day: Date, minutes: number): Date {
const d = new Date(day);
d.setHours(0, Math.round(minutes), 0, 0);
return d;
function atMinutesOfDay(day: CalendarDate, minutes: number): Date {
const rounded = Math.round(minutes);
return berlinWallTimeToUTC(day, Math.floor(rounded / 60), rounded % 60);
}
function overlaps(aStart: Date, aEnd: Date, bStart: Date, bEnd: Date): boolean {
@@ -67,8 +91,12 @@ export async function getAvailableSlots(
const overridesByDate = new Map(overrides.map((o) => [dateKey(new Date(o.date)), o]));
const now = new Date();
for (let day = new Date(from); day <= to; day = new Date(day.getTime() + 24 * 60 * 60 * 1000)) {
const key = dateKey(day);
for (
let day = calendarDate(from);
atMinutesOfDay(day, 0) <= to;
day = addCalendarDays(day, 1)
) {
const key = `${day.year}-${String(day.month).padStart(2, "0")}-${String(day.day).padStart(2, "0")}`;
const override = overridesByDate.get(key);
let windows: { startMinutes: number; endMinutes: number }[] = [];
@@ -83,7 +111,7 @@ export async function getAvailableSlots(
},
];
} else {
const weekdayName = WEEKDAY_BY_INDEX[day.getDay()];
const weekdayName = weekdayOf(day);
windows = weekly
.filter((w) => w.weekday === weekdayName)
.map((w) => ({
+5
View File
@@ -15,6 +15,11 @@ function escapeICSText(value: string): string {
.replace(/\\/g, "\\\\")
.replace(/;/g, "\\;")
.replace(/,/g, "\\,")
// \r must be stripped (not just \n escaped): many ICS parsers split
// lines on a bare CR too, so leaving it unescaped lets untrusted text
// (e.g. a customer's booking message ending up in DESCRIPTION) break
// out of the property value and inject extra iCalendar lines/properties.
.replace(/\r\n?/g, "\\n")
.replace(/\n/g, "\\n");
}
+54
View File
@@ -1,5 +1,59 @@
const BERLIN_TZ = "Europe/Berlin";
export type CalendarDate = { year: number; month: number; day: number };
export function berlinDateKey(date: Date): string {
const { year, month, day } = berlinParts(date);
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
export function formatBerlinDate(date: Date, options: Intl.DateTimeFormatOptions = {}): string {
return new Intl.DateTimeFormat("de-DE", { ...options, timeZone: BERLIN_TZ }).format(date);
}
export function formatBerlinTime(date: Date, options: Intl.DateTimeFormatOptions = {}): string {
return new Intl.DateTimeFormat("de-DE", { ...options, timeZone: BERLIN_TZ }).format(date);
}
export function berlinParts(date: Date): CalendarDate & { hour: number; minute: number } {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: BERLIN_TZ,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).formatToParts(date);
const get = (type: string) => Number(parts.find((part) => part.type === type)?.value ?? "0");
return { year: get("year"), month: get("month"), day: get("day"), hour: get("hour"), minute: get("minute") };
}
export function berlinOffsetMinutes(date: Date): number {
const tzName = new Intl.DateTimeFormat("en-US", { timeZone: BERLIN_TZ, timeZoneName: "shortOffset" })
.formatToParts(date)
.find((part) => part.type === "timeZoneName")?.value ?? "GMT+1";
const match = tzName.match(/GMT([+-]\d+)(?::(\d+))?/);
const hours = match ? Number(match[1]) : 1;
const minutes = match?.[2] ? Number(match[2]) : 0;
return hours * 60 + (hours < 0 ? -minutes : minutes);
}
/** Converts a Berlin wall-clock date/time into the UTC instant it represents. */
export function berlinWallTimeToUTC(date: CalendarDate, hour: number, minute: number): Date {
const guess = Date.UTC(date.year, date.month - 1, date.day, hour, minute);
return new Date(guess - berlinOffsetMinutes(new Date(guess)) * 60_000);
}
export function berlinDayBounds(date: Date): { key: string; start: Date; end: Date } {
const current = berlinParts(date);
const nextCalendarDay = new Date(Date.UTC(current.year, current.month - 1, current.day + 1));
const next = berlinParts(nextCalendarDay);
const start = berlinWallTimeToUTC(current, 0, 0);
const nextStart = berlinWallTimeToUTC(next, 0, 0);
return { key: berlinDateKey(date), start, end: new Date(nextStart.getTime() - 1) };
}
/**
* Converts a JS Date (a UTC instant) into its Europe/Berlin wall-clock
* representation for iCalendar's local `TZID` date-time form

Some files were not shown because too many files have changed in this diff Show More