Compare commits
6
Commits
v1.0.1
...
32e37d277f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32e37d277f | ||
|
|
d0864e2911 | ||
|
|
f1cd07b3ad | ||
|
|
526a908c80 | ||
|
|
65b6eeac51 | ||
|
|
82c4261a81 |
+13
-7
@@ -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 →
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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."
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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."
|
||||
}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
};
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 & 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 & 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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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(() => {});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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}`,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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
@@ -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) => ({
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user