Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7edeafe7c3 | ||
|
|
ad353190f3 | ||
|
|
82a89cd3d9 | ||
|
|
46a7cc23bd | ||
|
|
661b12bb7b | ||
|
|
d7576cb925 | ||
|
|
b29862b4b7 | ||
|
|
caa24905ec | ||
|
|
1970be29e7 | ||
|
|
79cd7f7475 | ||
|
|
7ec0c51ff5 | ||
|
|
a2b3e99174 | ||
|
|
97dd32a2b4 | ||
|
|
66710fc65c | ||
|
|
cb30ecade9 | ||
|
|
4edc5c2422 | ||
|
|
d99c69f46d | ||
|
|
ba2147c717 | ||
|
|
69f2653563 | ||
|
|
ab83a32d7b | ||
|
|
e6fa2e39b2 | ||
|
|
8f5a074716 | ||
|
|
906ea764ac | ||
|
|
e26da639ce | ||
|
|
7c2b0ec2b5 | ||
|
|
16bcdd2383 | ||
|
|
caf586f1ad | ||
|
|
b29b017341 | ||
|
|
f03e0e17de | ||
|
|
8c88b48580 | ||
|
|
32e37d277f | ||
|
|
d0864e2911 | ||
|
|
f1cd07b3ad | ||
|
|
526a908c80 | ||
|
|
65b6eeac51 | ||
|
|
82c4261a81 |
+47
-22
@@ -1,6 +1,6 @@
|
||||
# This file documents every variable the app/deployment actually reads.
|
||||
# In production, setup.sh generates the real file at /opt/anouma/.env for
|
||||
# you — you shouldn't need to hand-edit this. For local development, copy
|
||||
# you - you shouldn't need to hand-edit this. For local development, copy
|
||||
# this to .env at the repo root and fill in DATABASE_URI/PAYLOAD_SECRET
|
||||
# (see README.md → "Lokale Entwicklung").
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
# dev server against the Dockerized Postgres, use "127.0.0.1" instead.
|
||||
DATABASE_URI=postgresql://postgres:postgres@postgres:5432/anouma
|
||||
|
||||
# Only used by the "postgres" service in docker-compose.yml — keep these in
|
||||
# Only used by the "postgres" service in docker-compose.yml - keep these in
|
||||
# sync with DATABASE_URI above (setup.sh does this for you).
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=replace-with-a-long-random-secret
|
||||
@@ -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
|
||||
@@ -34,25 +39,45 @@ CRON_SECRET=replace-with-a-long-random-secret
|
||||
# links inside emails. Set this to the real domain in production.
|
||||
NEXT_PUBLIC_SERVER_URL=http://localhost:3000
|
||||
|
||||
# Cloudflare Turnstile. The site key is public and is passed to the client
|
||||
# from the server; the secret is used only by the server-side Siteverify call.
|
||||
TURNSTILE_SITE_KEY=0x4AAAAAAE7AqgHiQF9DffQO
|
||||
TURNSTILE_SECRET_KEY=
|
||||
# Local development value. Production must contain only the public hostname:
|
||||
# TURNSTILE_HOSTNAMES=anouma.org
|
||||
TURNSTILE_HOSTNAMES=localhost,127.0.0.1
|
||||
|
||||
# Port the app container publishes on the host (behind a reverse proxy this
|
||||
# usually doesn't need to be reachable directly — see DEPLOYMENT.md).
|
||||
# usually doesn't need to be reachable directly - see DEPLOYMENT.md).
|
||||
APP_PORT=3000
|
||||
|
||||
# ── Email (SMTP) ────────────────────────────────────────────────────────
|
||||
# The existing ANOUMA mail system — no external newsletter service.
|
||||
# The existing ANOUMA mail system - no external newsletter service.
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM="ANOUMA <support@anouma.org>"
|
||||
# Mailbox address used by the SMTP relay. The app displays ANOUMA as the name.
|
||||
SMTP_FROM=support@anouma.org
|
||||
# Keep true for publicly trusted SMTP certificates. Set false only when the
|
||||
# configured SMTP proxy intentionally uses a private/self-signed certificate.
|
||||
SMTP_TLS_REJECT_UNAUTHORIZED=true
|
||||
|
||||
# ── 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=
|
||||
# Address used as Reply-To for CMS replies. Configure this mailbox with an
|
||||
# inbound provider/webhook that POSTs replies to /api/email/inbound.
|
||||
INBOUND_REPLY_TO=reply-to@anouma.org
|
||||
# Shared secret sent as x-inbound-secret (or Authorization: Bearer ...) by the
|
||||
# inbound email provider. Generate with: openssl rand -hex 32
|
||||
INBOUND_EMAIL_SECRET=
|
||||
|
||||
# ── 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 →
|
||||
@@ -66,7 +91,7 @@ DOCKER_NETWORK=anouma-network
|
||||
|
||||
# Name of an existing external Docker network to join (e.g. Nginx Proxy
|
||||
# Manager's network) so a reverse proxy can reach the app container
|
||||
# directly. Leave empty if you don't use one — setup.sh auto-detects this.
|
||||
# directly. Leave empty if you don't use one - setup.sh auto-detects this.
|
||||
NPM_NETWORK=
|
||||
|
||||
# ── Backups (used by deploy.sh) ─────────────────────────────────────────
|
||||
@@ -74,19 +99,19 @@ NPM_NETWORK=
|
||||
BACKUP_RETENTION_DAYS=14
|
||||
|
||||
# ── Container registry & deployment (used by deploy.sh) ────────────────
|
||||
# Gitea's built-in container registry. deploy.sh builds nothing — it only
|
||||
# ever pulls this image (see .gitea/workflows/release.yml, which is what
|
||||
# actually builds and pushes it).
|
||||
REGISTRY=git.maro.run
|
||||
REGISTRY_REPO=maro/anouma
|
||||
# GitHub Container Registry. deploy.sh builds nothing - it only ever pulls
|
||||
# the image that GitHub Actions built and pushed.
|
||||
REGISTRY=ghcr.io
|
||||
REGISTRY_REPO=whitehai11/anouma
|
||||
# Only needed if the registry/repository requires authentication to pull.
|
||||
# Use a Gitea access token as the password, not your account password.
|
||||
# Use a GitHub token with at least read:packages as the password, not your
|
||||
# account password.
|
||||
REGISTRY_USERNAME=
|
||||
REGISTRY_PASSWORD=
|
||||
# Set automatically by deploy.sh on every successful deploy — do not edit
|
||||
# Set automatically by deploy.sh on every successful deploy - do not edit
|
||||
# by hand. Empty until the first release has been deployed.
|
||||
ANOUMA_IMAGE=
|
||||
# Informational only (shown in the setup summary) — actual automatic
|
||||
# deployment is controlled by whether release.yml + its Gitea secrets are
|
||||
# Informational only (shown in the setup summary) - actual automatic
|
||||
# deployment is controlled by whether release.yml + its GitHub secrets are
|
||||
# configured, not by this flag.
|
||||
AUTO_DEPLOY=true
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: node:22-alpine
|
||||
# No live database is needed here — every Payload-backed route in this
|
||||
# app is force-dynamic, so `next build` never touches Postgres. These
|
||||
# are dummy values only so a real config error (a missing secret) is
|
||||
# never silently masked by CI happening to run without one.
|
||||
#
|
||||
# NODE_ENV is deliberately NOT set here: `npm ci` treats a production
|
||||
# NODE_ENV as `--omit=dev`, which would skip eslint/typescript/etc. and
|
||||
# break Lint/Typecheck/Test. It's set below, scoped to the Build step.
|
||||
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:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install build dependencies (sharp needs libc6-compat on alpine)
|
||||
run: apk add --no-cache libc6-compat
|
||||
|
||||
- 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
|
||||
@@ -1,125 +0,0 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
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.
|
||||
container:
|
||||
image: node:22-bookworm-slim
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Docker CLI and ssh client
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends docker.io openssh-client
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
- name: Determine image tags
|
||||
id: image
|
||||
# Gitea Actions' runner is protocol-compatible with GitHub Actions,
|
||||
# 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).
|
||||
run: |
|
||||
echo "registry=${REGISTRY:-git.maro.run}" >> "$GITHUB_OUTPUT"
|
||||
echo "repo=${REGISTRY_REPO:-maro/anouma}" >> "$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
|
||||
|
||||
- name: Build image
|
||||
run: |
|
||||
IMAGE="${{ steps.image.outputs.registry }}/${{ steps.image.outputs.repo }}"
|
||||
VERSION="${{ steps.image.outputs.version }}"
|
||||
docker build -t "$IMAGE:$VERSION" -t "$IMAGE:latest" .
|
||||
|
||||
- name: Push image
|
||||
run: |
|
||||
IMAGE="${{ steps.image.outputs.registry }}/${{ steps.image.outputs.repo }}"
|
||||
VERSION="${{ steps.image.outputs.version }}"
|
||||
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 }}
|
||||
# Optional: pin the exact host key instead of trusting it on
|
||||
# first connect (see DEPLOYMENT.md). Safe to leave unset.
|
||||
DEPLOY_HOST_KEY: ${{ secrets.DEPLOY_HOST_KEY }}
|
||||
VERSION: ${{ steps.image.outputs.version }}
|
||||
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
|
||||
|
||||
# The forced command in the server's authorized_keys (see
|
||||
# setup.sh) ignores this and always runs deploy.sh itself — the
|
||||
# string below only ever reaches it via $SSH_ORIGINAL_COMMAND.
|
||||
ssh -i "$key_file" -o UserKnownHostsFile="$known_hosts_file" -p "${DEPLOY_PORT:-22}" \
|
||||
"${DEPLOY_USER}@${DEPLOY_HOST}" "deploy ${VERSION}"
|
||||
@@ -0,0 +1,61 @@
|
||||
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@v5
|
||||
- name: Use Node.js 22
|
||||
uses: actions/setup-node@v5
|
||||
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
|
||||
|
||||
build-image:
|
||||
needs: test
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
packages: write
|
||||
env:
|
||||
IMAGE: ghcr.io/whitehai11/anouma
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
- name: Log in to registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build image
|
||||
run: docker build --tag "$IMAGE:$GITHUB_SHA" .
|
||||
- name: Push image
|
||||
run: docker push "$IMAGE:$GITHUB_SHA"
|
||||
@@ -0,0 +1,56 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*.*.*"]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
promote-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
IMAGE: ghcr.io/whitehai11/anouma
|
||||
steps:
|
||||
- name: Log in to registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- 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}"
|
||||
+1
-1
@@ -49,7 +49,7 @@ next-env.d.ts
|
||||
*.deb
|
||||
*.AppImage
|
||||
|
||||
# docker / deployment (machine-specific, generated by setup.sh — production
|
||||
# docker / deployment (machine-specific, generated by setup.sh - production
|
||||
# artifacts actually live in /opt/anouma on the server, never inside this
|
||||
# repo checkout, but these are ignored here too in case of local testing)
|
||||
docker-compose.override.yml
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
This version has breaking changes - APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
|
||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
This block is written and re-added by `next dev` - verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
|
||||
+107
-64
@@ -1,18 +1,24 @@
|
||||
# ANOUMA — Deployment
|
||||
# ANOUMA - Deployment
|
||||
|
||||
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.
|
||||
Production deployment is fully CI/CD-driven via GitHub Actions. There is no `update.sh` and the production server never builds anything - it only ever pulls a tested image from GitHub Container 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 → GitHub Actions (CI)
|
||||
→ Tests → Docker Build → GHCR 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
|
||||
git clone https://git.maro.run/maro/anouma.git
|
||||
git clone https://github.com/whitehai11/anouma.git
|
||||
cd anouma
|
||||
chmod +x setup.sh
|
||||
./setup.sh
|
||||
@@ -21,22 +27,22 @@ chmod +x setup.sh
|
||||
`setup.sh` needs root (directly or via `sudo`) because it creates a system user and writes to `/opt`. It:
|
||||
|
||||
1. Checks/installs Docker, Docker Compose, git, openssl, curl, ssh.
|
||||
2. Creates `/opt/anouma` (refuses to touch it if an installation already exists there — you choose "use existing" or abort; nothing is ever deleted).
|
||||
2. Creates `/opt/anouma` (refuses to touch it if an installation already exists there - you choose "use existing" or abort; nothing is ever deleted).
|
||||
3. Detects an existing Nginx Proxy Manager network and optionally joins it.
|
||||
4. Writes `/opt/anouma/.env` with every secret the app needs, freshly generated (`openssl rand -hex 32`) and **never regenerated** on a later run.
|
||||
5. Asks the few things that can't be generated (domain, optionally SMTP, optionally the registry credentials) — skipped automatically if already answered before.
|
||||
6. Creates a restricted `anouma-deploy` system user, generates a dedicated SSH keypair for it, and installs the public key with a **forced command** so that key can only ever run `deploy.sh` — never an interactive shell.
|
||||
5. Asks the few things that can't be generated (domain, optionally SMTP, optionally the registry credentials) - skipped automatically if already answered before.
|
||||
6. Creates a restricted `anouma-deploy` system user, generates a dedicated SSH keypair for it, and installs the public key with a **forced command** so that key can only ever run `deploy.sh` - never an interactive shell.
|
||||
7. Copies `docker-compose.yml` and `deploy.sh` into `/opt/anouma`.
|
||||
8. Starts Postgres (so it's ready for the first deploy).
|
||||
9. Prints the exact values to paste into **Gitea → maro/anouma → Settings → Secrets → Actions**, including the deploy private key — shown only once, the first time the key is generated.
|
||||
9. Prints the exact values to paste into **GitHub → whitehai11/anouma → Settings → Secrets and variables → Actions**, including the deploy private key - shown only once, the first time the key is generated.
|
||||
|
||||
Run `./setup.sh --non-interactive` for a scripted install; any value that has no safe default and wasn't exported as an environment variable beforehand aborts with a clear message naming the missing variable, instead of silently guessing.
|
||||
|
||||
Re-running `./setup.sh` later (e.g. after pulling script updates) is always safe: existing secrets, the SSH key, the database and all volumes are left exactly as they are — only `docker-compose.yml` and `deploy.sh` themselves get refreshed, since those are generated artifacts, not data.
|
||||
Re-running `./setup.sh` later (e.g. after pulling script updates) is always safe: existing secrets, the SSH key, the database and all volumes are left exactly as they are - only `docker-compose.yml` and `deploy.sh` themselves get refreshed, since those are generated artifacts, not data.
|
||||
|
||||
### After setup: add the Gitea secrets
|
||||
### After setup: add the GitHub secrets
|
||||
|
||||
Copy the block `setup.sh` printed into **Gitea → maro/anouma → Settings → Secrets → Actions**:
|
||||
Copy the deployment values `setup.sh` printed into **GitHub → whitehai11/anouma → Settings → Secrets and variables → Actions**:
|
||||
|
||||
| Secret | Value |
|
||||
| --- | --- |
|
||||
@@ -44,39 +50,48 @@ Copy the block `setup.sh` printed into **Gitea → maro/anouma → Settings →
|
||||
| `DEPLOY_PORT` | `22` (or your SSH port) |
|
||||
| `DEPLOY_USER` | `anouma-deploy` |
|
||||
| `DEPLOY_SSH_KEY` | the private key setup.sh printed |
|
||||
| `DEPLOY_HOST_KEY` | optional — pin the server's SSH host public key instead of trusting it on first connect (`ssh-keyscan -p <port> <host>` on a machine you already trust) |
|
||||
| `REGISTRY_USERNAME` / `REGISTRY_PASSWORD` | registry push credentials (a Gitea access token as the password, not your account password) |
|
||||
| `DEPLOY_HOST_KEY` | optional - pin the server's SSH host public key instead of trusting it on first connect (`ssh-keyscan -p <port> <host>` on a machine you already trust) |
|
||||
| `REGISTRY_USERNAME` / `REGISTRY_PASSWORD` | **not needed in GitHub Actions**; these belong in `/opt/anouma/.env` on the server for pulling the private GHCR image |
|
||||
|
||||
### First deploy
|
||||
|
||||
Nothing runs on the server yet at this point — no release has been built. Push the first tag:
|
||||
Nothing runs on the server yet at this point - no release has been built. Push the first tag:
|
||||
|
||||
```bash
|
||||
git tag v1.0.0
|
||||
git push origin v1.0.0
|
||||
git push github v1.0.0 # GitHub Actions
|
||||
```
|
||||
|
||||
Gitea Actions takes it from there (see below). Once it's live, seed the original content once: `ssh anouma-deploy@<host>` won't work (the key is restricted — see Security), so run it directly on the server instead: `cd /opt/anouma && docker compose run --rm app npm run seed`.
|
||||
The GitHub workflow takes it from there. Once it's live, seed the original content once: `ssh anouma-deploy@<host>` won't work (the key is restricted - see Security), so run it directly on the server instead: `cd /opt/anouma && docker compose run --rm app npm run seed`.
|
||||
|
||||
## Gitea Actions
|
||||
## GitHub Actions
|
||||
|
||||
### CI (`.gitea/workflows/ci.yml`)
|
||||
The GitHub repository is `whitehai11/anouma`; the workflows live in
|
||||
`.github/workflows/ci.yml` and `.github/workflows/release.yml`. In GitHub go to
|
||||
**Settings → Secrets and variables → Actions → Repository secrets** and create:
|
||||
|
||||
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.)
|
||||
| Secret | Required | Value |
|
||||
| --- | --- | --- |
|
||||
| `DEPLOY_HOST` | yes | Public IP or hostname of the production server |
|
||||
| `DEPLOY_PORT` | yes | SSH port, normally `22` |
|
||||
| `DEPLOY_USER` | yes | `anouma-deploy` |
|
||||
| `DEPLOY_SSH_KEY` | yes | Private key generated by `setup.sh` for the restricted deploy user |
|
||||
| `DEPLOY_HOST_KEY` | no | Complete SSH host-key line from a trusted `ssh-keyscan`; safer than first-connect trust |
|
||||
|
||||
### Release (`.gitea/workflows/release.yml`)
|
||||
GitHub Actions authentifiziert sich mit dem automatisch bereitgestellten
|
||||
`GITHUB_TOKEN`; dafür musst du keine Registry-Secrets anlegen. Registry und
|
||||
Image sind im Workflow fest auf `ghcr.io/whitehai11/anouma` gesetzt. Runtime-Secrets
|
||||
wie `PAYLOAD_SECRET`, `MEETING_SESSION_SECRET`, `LIVEKIT_API_SECRET` und
|
||||
`DATABASE_URI` gehören ausschließlich in `/opt/anouma/.env` auf dem Server und
|
||||
nicht in GitHub Actions.
|
||||
|
||||
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`.
|
||||
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.
|
||||
Wichtig: Für Releases nur GitHub Actions verwenden. Gitea kann als reiner
|
||||
Repository-Spiegel bestehen bleiben, darf aber keine eigenen Build- oder
|
||||
Release-Workflows ausführen.
|
||||
|
||||
## `deploy.sh` (server-side, `/opt/anouma/deploy.sh`)
|
||||
|
||||
Not a manual tool — it's invoked exclusively by Gitea Actions over SSH. What it does for `deploy.sh deploy vX.Y.Z`:
|
||||
Not a manual tool - it's invoked exclusively by GitHub Actions over SSH. What it does for `deploy.sh deploy vX.Y.Z`:
|
||||
|
||||
```text
|
||||
Lock (refuse a second concurrent deploy)
|
||||
@@ -90,13 +105,13 @@ Lock (refuse a second concurrent deploy)
|
||||
→ success, or: automatic rollback to the previous version
|
||||
```
|
||||
|
||||
If the post-deploy healthcheck fails, `deploy.sh` automatically redeploys the **previous** version (recorded from `ANOUMA_IMAGE` before the attempt) and reports the failed deploy as exit code 1 — Gitea Actions shows the release as failed even though production recovered. Database migrations from the failed release are **not** reverted (write migrations to be forward-compatible); restore the pre-deploy backup manually if a migration truly needs undoing.
|
||||
If the post-deploy healthcheck fails, `deploy.sh` automatically redeploys the **previous** version (recorded from `ANOUMA_IMAGE` before the attempt) and reports the failed deploy as exit code 1 - GitHub Actions shows the release as failed even though production recovered. Database migrations from the failed release are **not** reverted (write migrations to be forward-compatible); restore the pre-deploy backup manually if a migration truly needs undoing.
|
||||
|
||||
A lock file (`/opt/anouma/.deploy.lock`) prevents two deploys from running at once; a stale lock from a killed process is detected and ignored automatically.
|
||||
|
||||
## Backups
|
||||
|
||||
Every deploy backs up the database first, to `/opt/anouma/backups/database-YYYY-MM-DD-HHMM.sql.gz`, before anything else changes. Backups older than `BACKUP_RETENTION_DAYS` (default 14) are cleaned up automatically — the backup just created is never deleted, even if retention is set very low.
|
||||
Every deploy backs up the database first, to `/opt/anouma/backups/database-YYYY-MM-DD-HHMM.sql.gz`, before anything else changes. Backups older than `BACKUP_RETENTION_DAYS` (default 14) are cleaned up automatically - the backup just created is never deleted, even if retention is set very low.
|
||||
|
||||
Restore manually if needed:
|
||||
|
||||
@@ -122,26 +137,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,48 +188,53 @@ 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 |
|
||||
| `TURNSTILE_SITE_KEY` | Public Cloudflare Turnstile site key |
|
||||
| `TURNSTILE_SECRET_KEY` | Server-only Cloudflare Turnstile secret; keep it in `/opt/anouma/.env` and never in GitHub Actions or the repository |
|
||||
| `TURNSTILE_HOSTNAMES` | Hostnames accepted by Siteverify; production is `anouma.org` only (local development may use `localhost,127.0.0.1`) |
|
||||
| `SMTP_*` | The existing ANOUMA mail system. `SMTP_TLS_REJECT_UNAUTHORIZED` stays `true` unless the configured SMTP proxy intentionally uses a private/self-signed certificate; only then set it to `false`. |
|
||||
| `INBOUND_REPLY_TO`, `INBOUND_EMAIL_SECRET` | Mailbox and shared secret for the inbound email webhook (`/api/email/inbound`) |
|
||||
| `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 |
|
||||
| `ANOUMA_IMAGE` | Set automatically by `deploy.sh` on every deploy — don't edit by hand |
|
||||
| `ANOUMA_IMAGE` | Set automatically by `deploy.sh` on every deploy - don't edit by hand |
|
||||
|
||||
## Persistent data
|
||||
|
||||
Deployments never delete data:
|
||||
|
||||
- **Database** — the `pgdata` named volume, independent of the `postgres` container's lifecycle.
|
||||
- **Uploaded media** — the `/opt/anouma/media` bind mount, independent of the `app` container's lifecycle.
|
||||
- **Backups** — `/opt/anouma/backups`.
|
||||
- **Database** - the `pgdata` named volume, independent of the `postgres` container's lifecycle.
|
||||
- **Uploaded media** - the `/opt/anouma/media` bind mount, independent of the `app` container's lifecycle.
|
||||
- **Backups** - `/opt/anouma/backups`.
|
||||
|
||||
Neither `setup.sh` nor `deploy.sh` ever runs `docker system prune`, `docker volume prune`, `docker network prune`, or `docker compose down -v`. Neither touches a container or network it doesn't own (an existing NPM install is never modified beyond optionally joining its network).
|
||||
|
||||
## Healthchecks
|
||||
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
- `anouma-deploy` has **no sudo rights at all** and **no password** — the only way in is its dedicated SSH key.
|
||||
- `anouma-deploy` has **no sudo rights at all** and **no password** - the only way in is its dedicated SSH key.
|
||||
- That SSH key is restricted with `restrict,command="/opt/anouma/deploy.sh"` in `authorized_keys`: whatever command an SSH client requests is ignored and `deploy.sh` runs instead (the client's actual request only reaches it via `$SSH_ORIGINAL_COMMAND`, which `deploy.sh` validates against a strict `deploy vX.Y.Z` pattern before doing anything). No arbitrary shell access is possible through this key, even if it leaks.
|
||||
- `anouma-deploy` **is** a member of the `docker` group, because running `docker compose` requires it — and Docker-group membership is well known to be effectively root-equivalent (a container can bind-mount the host filesystem). This is a deliberate, documented trade-off: the blast radius is contained by the two points above (no interactive access, and the only reachable code path is the fixed `deploy.sh` logic), not eliminated. For a stricter setup, consider rootless Docker or a dedicated deployment tool with finer-grained Docker API ACLs — out of scope here.
|
||||
- `anouma-deploy` **is** a member of the `docker` group, because running `docker compose` requires it - and Docker-group membership is well known to be effectively root-equivalent (a container can bind-mount the host filesystem). This is a deliberate, documented trade-off: the blast radius is contained by the two points above (no interactive access, and the only reachable code path is the fixed `deploy.sh` logic), not eliminated. For a stricter setup, consider rootless Docker or a dedicated deployment tool with finer-grained Docker API ACLs - out of scope here.
|
||||
- `.env` and the deploy SSH private key are `chmod 600`.
|
||||
- Secrets are never committed, never logged, and never printed more than once (`setup.sh` shows the private key only the first time it's generated; on a later run it prints "Existing deployment key found." instead).
|
||||
- `REGISTRY_PASSWORD`/`SMTP_PASSWORD`/etc. are masked (`********`) in `setup.sh`'s configuration summary — only the one-time Gitea-secrets block shows real values, since that block exists specifically for you to copy them into Gitea.
|
||||
- `REGISTRY_PASSWORD`/`SMTP_PASSWORD`/etc. are masked (`********`) in `setup.sh`'s configuration summary. The GHCR token is needed only on the production server for pulling the private image; GitHub Actions uses its automatic `GITHUB_TOKEN`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| 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 "Deploy to production" | Check the Gitea secrets are all set correctly, and that the server's SSH port is reachable from the runner |
|
||||
| `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 GitHub repository 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 |
|
||||
|
||||
+11
-7
@@ -1,14 +1,14 @@
|
||||
# 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.
|
||||
# .env.example) - this image builds without a database connection.
|
||||
|
||||
FROM node:22-alpine AS base
|
||||
RUN apk add --no-cache libc6-compat su-exec
|
||||
|
||||
# ---- deps: install once, cached as its own layer -------------------------
|
||||
FROM base AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
@@ -32,16 +32,20 @@ RUN addgroup --system --gid 1001 nodejs \
|
||||
|
||||
# Copied wholesale (not a hand-picked list of directories) so newly added
|
||||
# source folders (collections, scripts, lib/*, components/*, ...) are never
|
||||
# silently missing at runtime — .dockerignore already excludes what doesn't
|
||||
# silently missing at runtime - .dockerignore already excludes what doesn't
|
||||
# belong in the image (node_modules is re-added explicitly below, .next is
|
||||
# the build output we do want).
|
||||
COPY --from=builder --chown=nextjs:nodejs /app /app
|
||||
|
||||
# Writable at runtime, independent of the image — actual data lives in the
|
||||
# Writable at runtime, independent of the image - actual data lives in the
|
||||
# Docker volumes mounted over these paths (see docker-compose.yml).
|
||||
RUN mkdir -p media backups && chown -R nextjs:nodejs media backups
|
||||
|
||||
USER nextjs
|
||||
COPY --from=builder --chown=root:root /app/docker/anouma-entrypoint.sh /usr/local/bin/anouma-entrypoint.sh
|
||||
RUN chmod 755 /usr/local/bin/anouma-entrypoint.sh
|
||||
|
||||
USER root
|
||||
ENTRYPOINT ["/usr/local/bin/anouma-entrypoint.sh"]
|
||||
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
|
||||
@@ -1,31 +1,42 @@
|
||||
# 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
|
||||
|
||||
- `app/(frontend)/` — die öffentliche Website (bestehendes ANOUMA-Design, eigener Root-Layout)
|
||||
- `app/(call)/` — die Video-Call-Oberfläche (`/termine/[slug]/call`), eigenes minimalistisches dunkles Layout ohne Navbar/Footer
|
||||
- `app/(payload)/` — der Admin-Bereich unter `/admin`, Payloads REST/GraphQL-API sowie die Meeting-/Cron-API-Routen (`api/meetings/...`, `api/cron/...`)
|
||||
- `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/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
|
||||
- `app/(frontend)/` - die öffentliche Website (bestehendes ANOUMA-Design, eigener Root-Layout)
|
||||
- `app/(call)/` - die Video-Call-Oberfläche (`/termine/[slug]/call`), eigenes minimalistisches dunkles Layout ohne Navbar/Footer
|
||||
- `app/(payload)/` - der Admin-Bereich unter `/admin`, Payloads REST/GraphQL-API sowie die Meeting-/Cron-API-Routen (`api/meetings/...`, `api/cron/...`)
|
||||
- `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, 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`), 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`).
|
||||
|
||||
## Online-Termine & Video-Call
|
||||
|
||||
## Kontaktanfragen und CMS-Nachrichten
|
||||
|
||||
Kontakt- und Terminanfragen werden unter `/admin/anfragen` als Konversationen
|
||||
gespeichert. Antworten aus dem CMS werden per SMTP verschickt. Setze dafür
|
||||
`INBOUND_REPLY_TO` auf die Mailbox, auf die Besucher antworten sollen. Ein
|
||||
Inbound-Mail-Provider muss eingehende Antworten anschließend als JSON an
|
||||
`POST /api/email/inbound` weiterleiten und dabei `x-inbound-secret` mit dem
|
||||
Wert aus `INBOUND_EMAIL_SECRET` senden. Erwartet werden mindestens `from`,
|
||||
`subject`, `text` und eine eindeutige `messageId`; der Thread-Schlüssel im
|
||||
Betreff (`[ANOUMA-…]`) ordnet die Antwort der richtigen Anfrage zu.
|
||||
|
||||
- Ein Termin wird per Häkchen „Online-Termin“ zu einem Video-Call-Termin. Payload generiert dabei automatisch ein zufälliges Meeting-Passwort (nie aus der Meeting-ID abgeleitet); die Admin kann jederzeit ein neues erzeugen lassen.
|
||||
- Ö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
|
||||
@@ -36,20 +47,20 @@ Erinnerungs-Mails (60/30 Minuten vorher, pro Termin im CMS einzeln an-/abschaltb
|
||||
curl -H "Authorization: Bearer $CRON_SECRET" https://anouma.org/api/cron/event-reminders
|
||||
```
|
||||
|
||||
Der Versand ist idempotent (`reminder60Sent`/`reminder30Sent` je Anmeldung, `hostReminder60Sent`/`hostReminder30Sent` je Termin) — ein häufiger laufender Cron verschickt also nie doppelt.
|
||||
Der Versand ist idempotent (`reminder60Sent`/`reminder30Sent` je Anmeldung, `hostReminder60Sent`/`hostReminder30Sent` je Termin) - ein häufiger laufender Cron verschickt also nie doppelt.
|
||||
|
||||
## Setup (Produktion / Deployment)
|
||||
|
||||
Produktion läuft komplett über Gitea Actions CI/CD — der Server baut nichts selbst, er zieht ein fertiges, getestetes Image aus der Registry. Einmalig auf dem Server:
|
||||
Produktion läuft komplett über GitHub Actions CI/CD - der Server baut nichts selbst, er zieht ein fertiges, getestetes Image aus GitHub Container Registry. Einmalig auf dem Server:
|
||||
|
||||
```bash
|
||||
git clone https://git.maro.run/maro/anouma.git
|
||||
git clone https://github.com/whitehai11/anouma.git
|
||||
cd anouma
|
||||
chmod +x setup.sh
|
||||
./setup.sh
|
||||
```
|
||||
|
||||
`setup.sh` installiert bei Bedarf Docker, legt `/opt/anouma` an, generiert alle Secrets automatisch (nie erneut, wenn schon vorhanden), richtet einen eingeschränkten Deployment-User samt SSH-Key für Gitea Actions ein und zeigt am Ende genau die Werte an, die als Gitea Secrets hinterlegt werden müssen. `./setup.sh --non-interactive` läuft ohne Rückfragen.
|
||||
`setup.sh` installiert bei Bedarf Docker, legt `/opt/anouma` an, generiert alle Secrets automatisch (nie erneut, wenn schon vorhanden), richtet einen eingeschränkten Deployment-User samt SSH-Key für GitHub Actions ein und zeigt am Ende genau die Werte an, die als GitHub Repository Secrets hinterlegt werden müssen. Das Docker-Image landet unter `ghcr.io/whitehai11/anouma`. `./setup.sh --non-interactive` läuft ohne Rückfragen.
|
||||
|
||||
Jedes weitere Deployment läuft danach ausschließlich über einen Git-Tag:
|
||||
|
||||
@@ -57,9 +68,9 @@ Jedes weitere Deployment läuft danach ausschließlich über einen Git-Tag:
|
||||
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.
|
||||
GitHub Actions testet, baut das Image, pusht es nach GHCR 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, GitHub 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)
|
||||
|
||||
@@ -77,7 +88,7 @@ Nur Postgres per Docker, die App läuft direkt auf dem Host:
|
||||
docker compose up -d postgres
|
||||
```
|
||||
|
||||
(oder eine gehostete Postgres-Instanz, z. B. Neon/Supabase — einfach `DATABASE_URI` in `.env` entsprechend setzen).
|
||||
(oder eine gehostete Postgres-Instanz, z. B. Neon/Supabase - einfach `DATABASE_URI` in `.env` entsprechend setzen).
|
||||
|
||||
### Entwicklung
|
||||
|
||||
@@ -86,7 +97,7 @@ npm run dev
|
||||
```
|
||||
|
||||
- Website: http://localhost:3000
|
||||
- Admin: http://localhost:3000/admin — beim ersten Aufruf legst du dort direkt deinen eigenen Admin-Zugang (E-Mail + Passwort) an. Es gibt kein Standardpasswort im Code.
|
||||
- Admin: http://localhost:3000/admin - beim ersten Aufruf legst du dort direkt deinen eigenen Admin-Zugang (E-Mail + Passwort) an. Es gibt kein Standardpasswort im Code.
|
||||
|
||||
### Vorhandene Inhalte ins CMS übertragen
|
||||
|
||||
@@ -94,7 +105,7 @@ npm run dev
|
||||
npm run seed
|
||||
```
|
||||
|
||||
Überträgt die 7 Angebote sowie die Texte für Startseite, Über mich, Angebote-Einleitung, Aktuelles-Einleitung, Kontakt und Termin buchen aus `lib/texte.ts` ins CMS. Kann gefahrlos mehrfach ausgeführt werden. Kontaktdaten (E-Mail/Telefon/Region) werden zunächst als Platzhalter gesetzt — bitte im Admin unter „Kontakt“ durch die echten Angaben ersetzen.
|
||||
Überträgt die 7 Angebote sowie die Texte für Startseite, Über mich, Angebote-Einleitung, Aktuelles-Einleitung, Kontakt und Termin buchen aus `lib/texte.ts` ins CMS. Kann gefahrlos mehrfach ausgeführt werden. Kontaktdaten (E-Mail/Telefon/Region) werden zunächst als Platzhalter gesetzt - bitte im Admin unter „Kontakt“ durch die echten Angaben ersetzen.
|
||||
|
||||
### Produktions-Build ohne Docker
|
||||
|
||||
@@ -114,8 +125,8 @@ Für einen vollständig dockerisierten Produktionsbetrieb (App + Datenbank, Heal
|
||||
| `npm run generate:importmap`| Admin-Importmap neu erzeugen (nach neuen Feldtypen) |
|
||||
| `npm run migrate:create` | Neue Datenbank-Migration aus Config-Änderungen bauen |
|
||||
| `npm run lint` | ESLint |
|
||||
| `npm test` | Test-Suite (aktuell ein Platzhalter — noch keine Tests vorhanden) |
|
||||
| `npm run seo:check` | Optionale SEO-Prüfung (doppelte Slugs, fehlende Alt-Texte) — nicht produktionskritisch |
|
||||
| `npm test` | Test-Suite (aktuell ein Platzhalter - noch keine Tests vorhanden) |
|
||||
| `npm run seo:check` | Optionale SEO-Prüfung (doppelte Slugs, fehlende Alt-Texte) - nicht produktionskritisch |
|
||||
|
||||
## Design
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# Security-Audit - 17.09.2026
|
||||
|
||||
Geprüfter Commit: `cb30eca`. Ergebnis: **ein Befund hoher Priorität (P1), fünf Befunde mittlerer Priorität (P2)** sowie Hinweise zu Abhängigkeiten. Keine Behebung vorgenommen.
|
||||
|
||||
## Umfang und Nachweisgrenzen
|
||||
|
||||
Quellcodeprüfung von Payload-Zugriffsregeln, Kunden-/Admin-Authentifizierung, Buchungen, Meetings, Analytics, Kalender-Feed, E-Mail-Versand und Deployment-Konfiguration. Die installierten Next.js-Guides zu Datensicherheit und Route Handlers sowie Payloads tatsächliche Local-API-Implementierung wurden herangezogen.
|
||||
|
||||
`npm test`: 5/5 Tests bestanden. Zusätzlich wurden die unveränderten Route Handler für Stornierung, Meeting-Beitritt, Token-Austausch und Buchungsanfragen isoliert ausgeführt, mit simulierter Datenbank und simuliertem LiveKit. Keine Produktivzugriffe, echten E-Mails oder Datenänderungen. Diese Prüfungen belegen die Handler-Logik, nicht das Verhalten eines realen Reverse-Proxys oder Video-Servers. Produktionskonfiguration, Git-Historie und laufende Infrastruktur waren nicht Gegenstand eines Penetrationstests.
|
||||
|
||||
## 1. P1 - Interne Buchungsnotizen gelangen in den Kundenbrowser
|
||||
|
||||
**Stellen:** `lib/booking/queries.ts:12`, `app/(frontend)/konto/termine/page.tsx:27`, `app/(frontend)/konto/page.tsx:46`, `collections/BookingRequests.ts:364`.
|
||||
|
||||
`getCustomerBookings()` liest vollständige Buchungsdokumente mit `depth: 2` über die Payload Local API. Ohne explizites `overrideAccess: false` umgeht diese sowohl Collection- als auch Feldzugriffsregeln. Das wurde in `node_modules/payload/dist/collections/operations/local/find.js` und `node_modules/payload/dist/fields/hooks/afterRead/promise.js` verifiziert.
|
||||
|
||||
Die Kontoseiten übergeben das gesamte Ergebnis als `booking` an die Client Component `BookingCard`. Dadurch wird auch `internalNote` an den Browser serialisiert, obwohl es durch `isAdminFieldLevel` geschützt und als „Nur für Anna sichtbar“ bezeichnet ist. Dass die Komponente die Notiz nicht sichtbar rendert, schützt sie nicht.
|
||||
|
||||
**Auswirkung:** Ein angemeldeter Kunde kann interne Notizen zu seinen Buchungen in der Seiten-/RSC-Antwort lesen. Kein Zugriff auf fremde Buchungen wurde hier festgestellt.
|
||||
|
||||
**Nachweis:** Statisch vollständiger Datenfluss von privilegierter Abfrage bis zur Client-Grenze; kein Live-Abruf realer Kundendaten.
|
||||
|
||||
**Behebung:** Kundenansichten nur explizit ausgewählte Felder als separates DTO übergeben. Wenn möglich zusätzlich mit `overrideAccess: false` und authentifiziertem Benutzer lesen. Privilegiert benötigte Meeting-Daten gesondert und minimal auflösen. Auch verschachtelte Beziehungen nicht vollständig serialisieren.
|
||||
|
||||
**Regressionstest:** Eine Buchung mit einer eindeutigen internen Testnotiz anlegen; die Notiz darf weder in HTML noch in einer RSC-Antwort des Kundenkontos vorkommen.
|
||||
|
||||
## 2. P2 - Stornierung widerruft den privaten Meeting-Zugang nicht
|
||||
|
||||
**Stellen:** `app/(payload)/api/booking/[id]/cancel/route.ts:29`, `app/(payload)/api/meetings/[slug]/join/route.ts:49`, `collections/BookingRequests.ts:229`.
|
||||
|
||||
Die Stornierung setzt nur den Buchungsstatus. Der verknüpfte Online-Event und sein Passwort bleiben gültig. Der Join-Endpunkt prüft Online-Status, Passwort und Zeitfenster, aber nicht den Status der zugehörigen privaten Buchung. Der E-Mail-Hook bei Stornierung sperrt den Event ebenfalls nicht.
|
||||
|
||||
**Auswirkung:** Wer das zuvor erhaltene Passwort kennt, kann innerhalb des ursprünglichen Zeitfensters auch nach der Stornierung neue Meeting-Zugangsdaten erhalten.
|
||||
|
||||
**Nachweis:** Isolierter Aufruf des Cancel-Handlers änderte die Buchung auf `cancelled`; anschließender anonymer Join mit dem alten Passwort lieferte HTTP 200 und Zugangsdaten.
|
||||
|
||||
**Behebung:** Für private Events bei jedem Join und Connect eine weiterhin bestätigte, passend verknüpfte Buchung verlangen. Bei Stornierung bestehende Sitzungen widerrufen; gegebenenfalls aktive Teilnehmer über LiveKit entfernen.
|
||||
|
||||
## 3. P2 - Token-Austausch umgeht das Meeting-Ende
|
||||
|
||||
**Stelle:** `app/(payload)/api/meetings/[slug]/connect/route.ts:27`.
|
||||
|
||||
`/connect` prüft das App-Token und die Kick-Liste, anschließend nur noch `isOnline`. Anders als `/join` prüft der Handler weder das aktuelle Meeting-Zeitfenster noch einen Widerruf durch Passwortwechsel. App-Tokens sind standardmäßig sechs Stunden gültig; neu ausgestellte LiveKit-Tokens gelten jeweils zwei Stunden.
|
||||
|
||||
**Auswirkung:** Ein zuvor zugelassener Teilnehmer erhält nach Ende des erlaubten Beitrittsfensters weiterhin neue Video-Zugangsdaten, solange sein App-Token gültig ist. Ein Passwortwechsel allein invalidiert bereits ausgestellte App-Tokens ebenfalls nicht.
|
||||
|
||||
**Nachweis:** Mit gültigem App-Token und einem simulierten, bereits am Vortag beendeten Event lieferte der unveränderte Connect-Handler HTTP 200 mit einem neuen Video-Token.
|
||||
|
||||
**Behebung:** Dieselbe serverseitige Zugangsprüfung für Join und Connect verwenden. Token-Laufzeiten am erlaubten Meeting-Ende begrenzen und einen Sitzungs-/Berechtigungsstand für Widerrufe prüfen. Die beabsichtigte Admin-Testzimmer-Ausnahme separat behandeln.
|
||||
|
||||
## 4. P2 - Krankheits- und Urlaubsgründe sind öffentlich abrufbar
|
||||
|
||||
**Stelle:** `collections/AvailabilityOverrides.ts:17`.
|
||||
|
||||
Die Collection erlaubt anonymes Lesen aller Dokumente. Das Feld `reason` enthält unter anderem `krankheit` und `urlaub` und besitzt keinen eigenen Leseschutz. Die generische Payload-API unter `/api/availability-overrides` macht damit Datum und Abwesenheitsgrund öffentlich; gleiches gilt grundsätzlich für GraphQL.
|
||||
|
||||
**Auswirkung:** Persönliche Abwesenheitsgründe lassen sich ohne Anmeldung abfragen, soweit entsprechende Einträge gespeichert sind. Zur Buchung reicht die Information, welche Slots verfügbar sind.
|
||||
|
||||
**Nachweis:** Statisch anhand von Collection-Zugriff, Felddefinition und generischem REST-Router.
|
||||
|
||||
**Behebung:** Collection-Lesen auf Admins beschränken oder mindestens den Grund schützen. Der bestehende serverseitige Slot-Endpunkt kann weiterhin ausschließlich verfügbare Zeiten herausgeben.
|
||||
|
||||
## 5. P2 - Registrierung und E-Mail-Wechsel umgehen die Versandlimits
|
||||
|
||||
**Stellen:** `collections/Customers.ts:84`, `collections/Customers.ts:46`.
|
||||
|
||||
Die öffentliche Kundenerstellung löst unmittelbar eine Bestätigungs-E-Mail aus. Änderungen der eigenen E-Mail-Adresse lösen denselben Versand erneut aus. Für diese Operationen gibt es keine entsprechenden Limits in der Collection. Die Limits des separaten `/api/auth/resend-verification`-Handlers greifen hier nicht; `maxLoginAttempts` schützt nur Login-Versuche.
|
||||
|
||||
**Auswirkung:** Ohne bestätigte Adresse lassen sich neue Konten für verschiedene Empfänger erzeugen beziehungsweise eigene E-Mail-Adressen wiederholt ändern und damit Bestätigungsmails auslösen. Das ermöglicht Versandmissbrauch, Datenbankwachstum und Belastung des SMTP-Kontos. Ein externes Proxy-Limit könnte die Häufigkeit mindern, ist im geprüften Repository aber nicht als Schutz für diese Vorgänge implementiert.
|
||||
|
||||
**Nachweis:** Statisch anhand der öffentlichen Collection-Operationen und Versand-Hooks. Kein Versandtest durchgeführt.
|
||||
|
||||
**Behebung:** Erstellung und E-Mail-Änderung vor dem Versand zentral pro IP, Konto und Zieladresse begrenzen. Regeln auf Collection-/Hook-Ebene oder einem exklusiven, geschützten Einstiegspunkt durchsetzen, sodass REST und GraphQL sie nicht umgehen können.
|
||||
|
||||
## 6. P2 - Ein bestätigtes Konto kann beliebig viele identische Buchungsanfragen erzeugen
|
||||
|
||||
**Stellen:** `app/(payload)/api/booking/request/route.ts:75`, `collections/BookingRequests.ts:163`.
|
||||
|
||||
Der Handler prüft Anmeldung, E-Mail-Bestätigung und Slot-Verfügbarkeit, aber weder eine Rate-Limit-Quote noch eine schon bestehende identische offene Anfrage. Offene Anfragen belegen den Slot nicht. Jeder erfolgreiche Create löst Bestätigungs- und Admin-Benachrichtigungen aus.
|
||||
|
||||
**Auswirkung:** Ein einziges verifiziertes Konto kann den Admin-Eingang, die Buchungsliste und den Mailversand mit Duplikaten fluten.
|
||||
|
||||
**Nachweis:** 25 unmittelbar aufeinanderfolgende identische Anfragen wurden vom unveränderten Handler mit HTTP 200 angenommen und an die simulierte Create-Operation weitergegeben. Keine echten E-Mails verschickt.
|
||||
|
||||
**Behebung:** Pro Kunde begrenzen und identische offene Anfragen atomar deduplizieren. Wiederholte identische Requests sollten keinen weiteren Datensatz und keinen erneuten Mailversand erzeugen.
|
||||
|
||||
## Abhängigkeiten und weitere Prüfergebnisse
|
||||
|
||||
`npm audit --json` meldete im Lockfile **7 betroffene Pakete: 6 moderate, 1 low, 0 high, 0 critical**. Die Zahl enthält transitive Abhängigkeitsketten und bedeutet nicht sieben unabhängig ausnutzbare Anwendungslücken.
|
||||
|
||||
- **esbuild 0.18.20** ist über `@payloadcms/db-postgres → drizzle-kit → @esbuild-kit/esm-loader → @esbuild-kit/core-utils` installiert. Das [Advisory GHSA-67mh-4wv8-2f99](https://github.com/advisories/GHSA-67mh-4wv8-2f99) betrifft esbuilds Entwicklungs-Webserver. Im geprüften App-Startpfad wurde dieser Server nicht gefunden; daraus wird keine bestätigte Produktionslücke abgeleitet. npm meldet für diese Kette keinen verfügbaren automatischen Fix.
|
||||
- **DOMPurify/Monaco:** Das Lockfile-Audit meldete mehrere DOMPurify-Advisories, darunter [GHSA-55q2-fjhq-7xh7](https://github.com/advisories/GHSA-55q2-fjhq-7xh7), mit verfügbarem Fix. Dieses Advisory setzt spezielle `IN_PLACE`-/Hook-Nutzung voraus. `npm ls dompurify monaco-editor --all` zeigte beide Pakete im aktuellen Installationsbaum nicht an. Produktionsinstallation aus dem Lockfile und tatsächliche Nutzung müssen vor einer Aussage zur Ausnutzbarkeit abgeglichen werden; ein XSS-Exploit wurde nicht nachgewiesen.
|
||||
- Ein begrenzter Musterscan der aktuell versionierten Dateien fand keine privaten Schlüsselblöcke oder GitHub-/AWS-Tokenmuster. Das ist kein vollständiger Secret- oder Historien-Scan.
|
||||
- Positiv: Adminrechte verlangen Auth-Collection und Rolle; Buchungsaktionen prüfen den Eigentümer; Verifizierungs-/Kalender-Tokens werden gehasht gespeichert; öffentliche Event-Felder geben das Meeting-Passwort nicht über die reguläre Payload-API frei; Cron-Aufrufe prüfen ein Secret.
|
||||
|
||||
## Reihenfolge der Behebung
|
||||
|
||||
Zuerst die Weitergabe interner Notizen stoppen. Danach private Meeting-Zugänge und Token-Austausch an den aktuellen Berechtigungsstatus binden, Abwesenheitsgründe schützen und die beiden Versand-/Buchungsmissbrauchspfade begrenzen. Abhängigkeitsupdates separat prüfen und mit dem tatsächlich ausgelieferten Installationsbaum abgleichen.
|
||||
+12
-1
@@ -1,7 +1,7 @@
|
||||
import type { Access, FieldAccess } from "payload";
|
||||
|
||||
/**
|
||||
* Only the "users" auth collection (Anna/admins) may perform the action —
|
||||
* Only the "users" auth collection (Anna/admins) may perform the action -
|
||||
* and only with the "admin" role, so adding further roles to Users later
|
||||
* only means extending this one check. Site visitors authenticate against
|
||||
* the separate "customers" collection (see collections/Customers.ts) and
|
||||
@@ -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".
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ReactNode } from "react";
|
||||
import { Inter } from "next/font/google";
|
||||
import "../(frontend)/globals.css";
|
||||
|
||||
// Its own root layout — the video-call UI is intentionally full-bleed and
|
||||
// Its own root layout - the video-call UI is intentionally full-bleed and
|
||||
// dark, without the marketing navbar/footer around it.
|
||||
|
||||
const inter = Inter({ variable: "--font-inter", subsets: ["latin"] });
|
||||
|
||||
@@ -38,7 +38,7 @@ export async function generateMetadata({ params }: Args): Promise<Metadata> {
|
||||
|
||||
const resolved = resolveSeo({
|
||||
seo: post.seo,
|
||||
fallbackTitle: `${post.title} – ${siteConfig.name}`,
|
||||
fallbackTitle: `${post.title} - ${siteConfig.name}`,
|
||||
fallbackDescription: () => post.teaser,
|
||||
fallbackImage: post.coverImage,
|
||||
defaultOgImage: seoSettings.defaultOgImage,
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ImagePlaceholder } from "@/components/ImagePlaceholder";
|
||||
import { RichText } from "@/components/RichText";
|
||||
import { CTA } from "@/components/CTA";
|
||||
import { BookingWidget } from "@/components/booking/BookingWidget";
|
||||
import { GroupRegistrationForm } from "@/components/GroupRegistrationForm";
|
||||
import { getOfferBySlug } from "@/lib/payload/content";
|
||||
import { moodForOffer } from "@/lib/angebote";
|
||||
import { mediaAlt, mediaUrl } from "@/lib/payload/media";
|
||||
@@ -21,8 +22,10 @@ import { resolveRedirect } from "@/lib/seo/redirects";
|
||||
import { siteConfig } from "@/lib/site";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY ?? "";
|
||||
|
||||
type Args = { params: Promise<{ slug: string }> };
|
||||
const WEEKDAY_LABELS: Record<string, string> = { monday: "Montag", tuesday: "Dienstag", wednesday: "Mittwoch", thursday: "Donnerstag", friday: "Freitag", saturday: "Samstag", sunday: "Sonntag" };
|
||||
|
||||
async function resolveOfferOrRedirect(slug: string) {
|
||||
const offer = await getOfferBySlug(slug);
|
||||
@@ -43,7 +46,7 @@ export async function generateMetadata({ params }: Args): Promise<Metadata> {
|
||||
|
||||
const resolved = resolveSeo({
|
||||
seo: offer.seo,
|
||||
fallbackTitle: `${offer.title} – ${siteConfig.name}`,
|
||||
fallbackTitle: `${offer.title} - ${siteConfig.name}`,
|
||||
fallbackDescription: () => offer.shortDescription,
|
||||
fallbackImage: offer.image,
|
||||
defaultOgImage: seoSettings.defaultOgImage,
|
||||
@@ -123,7 +126,23 @@ export default async function OfferDetailPage({ params }: Args) {
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{offer.bookable ? (
|
||||
{offer.bookingMode === "group" ? (
|
||||
<Section tone="cream">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<h2 className="mb-3 text-center font-serif text-3xl font-medium text-anouma-plum">Für die Gruppe anmelden</h2>
|
||||
{offer.groupInfo && <p className="mx-auto mb-6 max-w-xl text-center text-base leading-relaxed text-anouma-plum">{offer.groupInfo}</p>}
|
||||
{offer.groupSchedule && (
|
||||
<div className="mb-8 rounded-2xl bg-white p-5 text-sm text-anouma-plum">
|
||||
<p className="font-medium">Fester Termin</p>
|
||||
<p className="mt-1">{offer.groupSchedule.recurrence === "monthly" ? "Monatlich" : "Wöchentlich"}{offer.groupSchedule.weekday ? ` · ${WEEKDAY_LABELS[offer.groupSchedule.weekday] ?? offer.groupSchedule.weekday}` : ""}{offer.groupSchedule.startTime ? ` · ${offer.groupSchedule.startTime}` : ""}{offer.groupSchedule.endTime ? `-${offer.groupSchedule.endTime}` : ""}</p>
|
||||
</div>
|
||||
)}
|
||||
{offer.groupCapacity && <p className="mb-2 text-sm text-anouma-plum/75">Plätze: {offer.groupCapacity}</p>}
|
||||
{offer.registrationDeadline && <p className="mb-6 text-sm text-anouma-plum/75">Anmeldung möglich bis {new Date(offer.registrationDeadline).toLocaleDateString("de-DE")}</p>}
|
||||
<GroupRegistrationForm offerSlug={offer.slug!} siteKey={turnstileSiteKey} />
|
||||
</div>
|
||||
</Section>
|
||||
) : offer.bookable ? (
|
||||
<Section tone="cream">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<h2 className="mb-6 text-center font-serif text-3xl font-medium text-anouma-plum">Termin auswählen</h2>
|
||||
|
||||
@@ -19,13 +19,13 @@ 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()]);
|
||||
const resolved = resolveSeo({
|
||||
seo: intro.seo,
|
||||
fallbackTitle: `Begleitung für dich und deine Familie – ${siteConfig.name}`,
|
||||
fallbackTitle: `Begleitung für dich und deine Familie - ${siteConfig.name}`,
|
||||
fallbackDescription: () => intro.lead || seoSettings.defaultDescription || FALLBACK_DESCRIPTION,
|
||||
defaultOgImage: seoSettings.defaultOgImage,
|
||||
});
|
||||
@@ -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."
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return buildMetadata({
|
||||
title: `Singkreise – ${siteConfig.name}`,
|
||||
title: `Singkreise - ${siteConfig.name}`,
|
||||
description: "Gemeinsam singen. Verbinden. Heilen. Für Herz und Seele.",
|
||||
path: "/angebote/singkreise",
|
||||
});
|
||||
@@ -30,7 +30,7 @@ export default async function SingkreisePage() {
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow="Singkreise"
|
||||
title="Singkreise – miteinander singen, klingen und sein"
|
||||
title="Singkreise - miteinander singen, klingen und sein"
|
||||
lead="Gemeinsam singen. Verbinden. Heilen. Für Herz und Seele."
|
||||
crumbs={[{ title: "Angebote", href: "/angebote" }, { title: "Singkreise" }]}
|
||||
/>
|
||||
@@ -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">
|
||||
|
||||
@@ -11,7 +11,7 @@ const TOKEN_SHAPE = /^[0-9a-f]{64}$/i;
|
||||
type Args = { params: Promise<{ tokenFile: string }> };
|
||||
|
||||
/**
|
||||
* GET /calendar/<token>.ics — a private iCalendar feed, subscribable from
|
||||
* GET /calendar/<token>.ics - a private iCalendar feed, subscribable from
|
||||
* Apple Calendar, Google Calendar, Outlook, Thunderbird etc. The token
|
||||
* itself is the only credential (there's no login here), so it's treated
|
||||
* like a secret: looked up by hash only, never logged, and the response is
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@font-face {
|
||||
font-family: "Antropos";
|
||||
src: url("/fonts/baar-antropos.ttf") format("truetype");
|
||||
font-display: swap;
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
/* Anouma color palette */
|
||||
--color-anouma-mauve-dark: #8b616d;
|
||||
@@ -27,8 +35,8 @@
|
||||
--color-background: #fbf6f1;
|
||||
--color-foreground: #453238;
|
||||
|
||||
--font-serif: var(--font-cormorant), "Cormorant Garamond", ui-serif, Georgia, serif;
|
||||
--font-sans: var(--font-inter), "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-serif: "Antropos", ui-serif, Georgia, serif;
|
||||
--font-sans: "Antropos", ui-sans-serif, system-ui, sans-serif;
|
||||
|
||||
--animate-fade-up: fade-up 0.8s ease-out both;
|
||||
--animate-fade-in: fade-in 1s ease-out both;
|
||||
@@ -69,7 +77,11 @@ html {
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-sans);
|
||||
font-family: "Antropos", ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.font-antropos {
|
||||
font-family: "Antropos", ui-serif, Georgia, serif;
|
||||
}
|
||||
|
||||
::selection {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -12,7 +12,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
return {
|
||||
title: "Impressum",
|
||||
description: "Impressum von Anouma gemäß § 5 TMG.",
|
||||
// noindex, but still crawlable — legal boilerplate deliberately kept
|
||||
// noindex, but still crawlable - legal boilerplate deliberately kept
|
||||
// out of search results without cutting off the internal links on it.
|
||||
robots: { index: false, follow: true },
|
||||
alternates: { canonical: `${siteUrl}/impressum` },
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -14,12 +14,13 @@ import { siteConfig } from "@/lib/site";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const FALLBACK_DESCRIPTION = "Ich freue mich, von dir zu hören.";
|
||||
const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY ?? "";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const [contact, seoSettings] = await Promise.all([getContactGlobal(), getSEOSettingsGlobal()]);
|
||||
const resolved = resolveSeo({
|
||||
seo: contact.seo,
|
||||
fallbackTitle: `Kontakt – ${siteConfig.name}`,
|
||||
fallbackTitle: `Kontakt - ${siteConfig.name}`,
|
||||
fallbackDescription: () => contact.lead || seoSettings.defaultDescription || FALLBACK_DESCRIPTION,
|
||||
defaultOgImage: seoSettings.defaultOgImage,
|
||||
});
|
||||
@@ -32,9 +33,11 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
});
|
||||
}
|
||||
|
||||
export default async function KontaktPage() {
|
||||
export default async function KontaktPage({ searchParams }: { searchParams: Promise<{ subject?: string }> }) {
|
||||
const contact = await getContactGlobal();
|
||||
const siteUrl = await getSiteUrl();
|
||||
const params = await searchParams;
|
||||
const isAppointment = params.subject === "Terminanfrage";
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -50,12 +53,15 @@ 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">
|
||||
<div className="grid gap-14 lg:grid-cols-[1fr_0.8fr]">
|
||||
<Reveal>
|
||||
<ContactForm toEmail={contact.email} />
|
||||
<ContactForm siteKey={turnstileSiteKey} subjectPrefix={isAppointment ? "Terminanfrage" : "Allgemeine Anfrage"} type={isAppointment ? "appointment" : "contact"} submitLabel={isAppointment ? "Terminanfrage senden" : "Nachricht senden"} />
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.1} className="space-y-8">
|
||||
@@ -67,12 +73,9 @@ export default async function KontaktPage() {
|
||||
<span className="block text-xs font-medium uppercase tracking-[0.18em] text-anouma-plum/70">
|
||||
E-Mail
|
||||
</span>
|
||||
<a
|
||||
href={`mailto:${contact.email}`}
|
||||
className="text-lg text-anouma-plum hover:text-anouma-mauve-dark"
|
||||
>
|
||||
<span className="text-lg text-anouma-plum">
|
||||
{contact.email}
|
||||
</a>
|
||||
</span>
|
||||
</p>
|
||||
{contact.phone && (
|
||||
<p>
|
||||
@@ -85,7 +88,7 @@ export default async function KontaktPage() {
|
||||
{contact.region && (
|
||||
<p>
|
||||
<span className="block text-xs font-medium uppercase tracking-[0.18em] text-anouma-plum/70">
|
||||
Region
|
||||
Adresse
|
||||
</span>
|
||||
<span className="text-lg text-anouma-plum">{contact.region}</span>
|
||||
</p>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ResendVerificationButton } from "@/components/auth/ResendVerificationBu
|
||||
import { getCurrentCustomer } from "@/lib/auth/customer";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
// Applies to every /konto/* page at once — private account area, never indexable.
|
||||
// Applies to every /konto/* page at once - private account area, never indexable.
|
||||
export const metadata: Metadata = { robots: { index: false, follow: false } };
|
||||
|
||||
const navItems = [
|
||||
@@ -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,9 +13,10 @@ 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");
|
||||
const confirmed = bookings.filter((b) => b.status === "confirmed" && new Date(b.endTime) >= new Date());
|
||||
const past = bookings.filter((b) => new Date(b.endTime) < new Date() || b.status === "rejected" || b.status === "cancelled");
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
@@ -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: {
|
||||
@@ -46,18 +47,19 @@ export const metadata: Metadata = {
|
||||
title: siteConfig.title,
|
||||
description: siteConfig.description,
|
||||
},
|
||||
// Configurable via env only — never a hardcoded verification ID (see .env.example).
|
||||
// Configurable via env only - never a hardcoded verification ID (see .env.example).
|
||||
...(process.env.GOOGLE_SITE_VERIFICATION ? { verification: { google: process.env.GOOGLE_SITE_VERIFICATION } } : {}),
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { LoginForm } from "@/components/auth/LoginForm";
|
||||
import { getCurrentCustomer } from "@/lib/auth/customer";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY ?? "";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Anmelden",
|
||||
@@ -20,11 +21,11 @@ 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>
|
||||
<LoginForm />
|
||||
<LoginForm siteKey={turnstileSiteKey} />
|
||||
</Suspense>
|
||||
<p className="mt-6 text-center text-sm text-anouma-plum">
|
||||
Noch kein Konto?{" "}
|
||||
|
||||
+83
-62
@@ -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";
|
||||
@@ -25,11 +27,11 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
const [home, seoSettings] = await Promise.all([getHomeGlobal(), getSEOSettingsGlobal()]);
|
||||
const resolved = resolveSeo({
|
||||
seo: home.seo,
|
||||
fallbackTitle: `${siteConfig.name} – Begleitung für dich und deine Familie`,
|
||||
fallbackTitle: `${siteConfig.name} - Begleitung für dich und deine Familie`,
|
||||
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,
|
||||
});
|
||||
@@ -40,8 +42,8 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
ogImageUrl: resolved.ogImageUrl,
|
||||
keywords: resolved.keywords,
|
||||
});
|
||||
// Homepage bypasses the "%s — Anouma" title template — the brand is
|
||||
// already part of the fallback/CMS title itself, avoiding "Anouma — Anouma".
|
||||
// Homepage bypasses the "%s - Anouma" title template - the brand is
|
||||
// already part of the fallback/CMS title itself, avoiding "Anouma - Anouma".
|
||||
return { ...meta, title: { absolute: resolved.title } };
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { RegisterForm } from "@/components/auth/RegisterForm";
|
||||
import { getCurrentCustomer } from "@/lib/auth/customer";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY ?? "";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Konto erstellen",
|
||||
@@ -24,10 +25,11 @@ 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">
|
||||
<RegisterForm />
|
||||
<RegisterForm siteKey={turnstileSiteKey} />
|
||||
<p className="mt-6 text-center text-sm text-anouma-plum">
|
||||
Schon ein Konto?{" "}
|
||||
<Link href="/login" className="font-medium text-anouma-mauve-dark underline underline-offset-4">
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Section, SectionHeading } from "@/components/Section";
|
||||
import { ContactForm } from "@/components/ContactForm";
|
||||
import { AngebotCard } from "@/components/AngebotCard";
|
||||
import { Reveal } from "@/components/Reveal";
|
||||
import { getBookingGlobal, getContactGlobal, getSEOSettingsGlobal } from "@/lib/payload/globals";
|
||||
import { getBookingGlobal, getSEOSettingsGlobal } from "@/lib/payload/globals";
|
||||
import { getOffers } from "@/lib/payload/content";
|
||||
import { moodForOffer } from "@/lib/angebote";
|
||||
import { mediaUrl } from "@/lib/payload/media";
|
||||
@@ -17,12 +17,13 @@ import { siteConfig } from "@/lib/site";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const FALLBACK_DESCRIPTION = "Kennenlernen & Termine vereinbaren.";
|
||||
const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY ?? "";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const [booking, seoSettings] = await Promise.all([getBookingGlobal(), getSEOSettingsGlobal()]);
|
||||
const resolved = resolveSeo({
|
||||
seo: booking.seo,
|
||||
fallbackTitle: `Termin buchen – ${siteConfig.name}`,
|
||||
fallbackTitle: `Termin buchen - ${siteConfig.name}`,
|
||||
fallbackDescription: () => booking.lead || seoSettings.defaultDescription || FALLBACK_DESCRIPTION,
|
||||
defaultOgImage: seoSettings.defaultOgImage,
|
||||
});
|
||||
@@ -36,7 +37,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
}
|
||||
|
||||
export default async function TerminBuchenPage() {
|
||||
const [booking, contact, offers] = await Promise.all([getBookingGlobal(), getContactGlobal(), getOffers()]);
|
||||
const [booking, offers] = await Promise.all([getBookingGlobal(), getOffers()]);
|
||||
const bookableOffers = offers.filter((offer) => offer.bookable);
|
||||
const siteUrl = await getSiteUrl();
|
||||
|
||||
@@ -56,8 +57,25 @@ export default async function TerminBuchenPage() {
|
||||
crumbs={[{ title: "Termin buchen" }]}
|
||||
/>
|
||||
|
||||
<Section tone="cream">
|
||||
<div className="mx-auto grid max-w-4xl gap-5 md:grid-cols-2">
|
||||
<a href="#freie-termine" className="rounded-3xl bg-white p-7 transition-transform hover:-translate-y-1">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.18em] text-anouma-dustyrose">Direkt buchen</p>
|
||||
<h2 className="mt-3 font-serif text-2xl font-medium text-anouma-plum">Freien Termin auswählen</h2>
|
||||
<p className="mt-3 text-sm leading-relaxed text-anouma-plum/80">Wähle ein Angebot, einen freien Tag und eine passende Uhrzeit im Kalender.</p>
|
||||
<span className="mt-5 inline-flex rounded-full bg-anouma-mauve-dark px-5 py-2.5 text-sm font-medium text-white">Zum Kalender</span>
|
||||
</a>
|
||||
<a href="/kontakt?subject=Terminanfrage" className="rounded-3xl bg-anouma-plum p-7 transition-transform hover:-translate-y-1">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.18em] text-anouma-cream-light/75">Persönlich abstimmen</p>
|
||||
<h2 className="mt-3 font-serif text-2xl font-medium text-white">Anfrage stellen</h2>
|
||||
<p className="mt-3 text-sm leading-relaxed text-white/80">Wenn du keinen freien Kalendertermin brauchst oder etwas Besonderes besprechen möchtest.</p>
|
||||
<span className="mt-5 inline-flex rounded-full bg-white px-5 py-2.5 text-sm font-medium text-anouma-plum">Zur Kontaktseite</span>
|
||||
</a>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{bookableOffers.length > 0 && (
|
||||
<Section tone="plain">
|
||||
<Section tone="plain" id="freie-termine">
|
||||
<SectionHeading eyebrow="Angebot auswählen" title="Wofür möchtest du einen Termin?" />
|
||||
<div className="mt-14 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{bookableOffers.map((offer, i) => (
|
||||
@@ -98,21 +116,19 @@ 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"
|
||||
/>
|
||||
<div className="mt-10">
|
||||
<ContactForm
|
||||
toEmail={contact.email}
|
||||
siteKey={turnstileSiteKey}
|
||||
subjectPrefix="Terminanfrage über anouma.org"
|
||||
type="appointment"
|
||||
submitLabel="Terminanfrage senden"
|
||||
/>
|
||||
</div>
|
||||
{booking.formNote && (
|
||||
<p className="mt-8 text-sm leading-relaxed text-anouma-plum/80">{booking.formNote}</p>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
</>
|
||||
|
||||
@@ -5,8 +5,10 @@ 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";
|
||||
const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY ?? "";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Meeting beitreten",
|
||||
@@ -21,15 +23,15 @@ 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">
|
||||
<OrganicBlob tone="rose" className="-right-24 -top-24 h-96 w-96" />
|
||||
<OrganicBlob tone="cream" className="-left-32 bottom-0 h-96 w-96" />
|
||||
<div className="relative mx-auto w-full max-w-md px-6 text-center">
|
||||
<Link href="/" className="font-serif text-2xl font-semibold tracking-[0.12em] text-anouma-mauve-dark">
|
||||
<Link href="/" className="font-antropos text-2xl font-semibold tracking-[0.12em] text-anouma-mauve-dark">
|
||||
ANOUMA
|
||||
</Link>
|
||||
<p className="mt-8 text-sm font-medium uppercase tracking-[0.18em] text-anouma-plum/70">
|
||||
@@ -41,7 +43,7 @@ export default async function BeitretenPage({ params }: Args) {
|
||||
</p>
|
||||
|
||||
<div className="mt-10 rounded-[2rem] bg-white/70 p-8 text-left shadow-sm backdrop-blur">
|
||||
<JoinForm slug={slug} />
|
||||
<JoinForm slug={slug} siteKey={turnstileSiteKey} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -25,6 +25,7 @@ import { resolveRedirect } from "@/lib/seo/redirects";
|
||||
import { siteConfig } from "@/lib/site";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY ?? "";
|
||||
|
||||
type Args = { params: Promise<{ slug: string }> };
|
||||
|
||||
@@ -47,7 +48,7 @@ export async function generateMetadata({ params }: Args): Promise<Metadata> {
|
||||
|
||||
const resolved = resolveSeo({
|
||||
seo: event.seo,
|
||||
fallbackTitle: `${event.title} – ${siteConfig.name}`,
|
||||
fallbackTitle: `${event.title} - ${siteConfig.name}`,
|
||||
fallbackDescription: () => excerptFromRichText(event.description) || `${event.title} bei ${siteConfig.name}.`,
|
||||
fallbackImage: event.image,
|
||||
defaultOgImage: seoSettings.defaultOgImage,
|
||||
@@ -74,9 +75,9 @@ export default async function EventDetailPage({ params }: Args) {
|
||||
if (event.isOnline) {
|
||||
const payload = await getPayload({ config });
|
||||
const { user } = await payload.auth({ headers: await headers() });
|
||||
// Must check the collection, not just presence — a logged-in customer
|
||||
// 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 +127,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]">
|
||||
@@ -135,7 +138,7 @@ export default async function EventDetailPage({ params }: Args) {
|
||||
<div>
|
||||
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Anmeldung</h2>
|
||||
<div className="mt-4 max-w-sm">
|
||||
<RegisterForm slug={slug} />
|
||||
<RegisterForm slug={slug} siteKey={turnstileSiteKey} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -21,7 +21,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
const [about, seoSettings] = await Promise.all([getAboutGlobal(), getSEOSettingsGlobal()]);
|
||||
const resolved = resolveSeo({
|
||||
seo: about.seo,
|
||||
fallbackTitle: `Über mich – ${siteConfig.name}`,
|
||||
fallbackTitle: `Über mich - ${siteConfig.name}`,
|
||||
fallbackDescription: () => excerptFromRichText(about.body) || seoSettings.defaultDescription || "Mein Weg, meine Werte und was mich bewegt.",
|
||||
fallbackImage: about.portrait,
|
||||
defaultOgImage: seoSettings.defaultOgImage,
|
||||
|
||||
@@ -22,7 +22,13 @@ import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997e
|
||||
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { CalendarNavLink as CalendarNavLink_4d06fac03127b339306d5f5ecb073251 } from '../../../components/admin/CalendarNavLink'
|
||||
import { AnalyticsNavLink as AnalyticsNavLink_29fa5c324cecba6cafb11381cbfac962 } from '../../../components/admin/AnalyticsNavLink'
|
||||
import { ConversationsNavLink as ConversationsNavLink_67a2594cf28806bc76971e67212a6181 } from '../../../components/admin/ConversationsNavLink'
|
||||
import { UploadLimitGuard as UploadLimitGuard_1a82b0e63592dbfb4e9d3c5c946debf3 } from '../../../components/admin/UploadLimitGuard'
|
||||
import { AdminWelcome as AdminWelcome_b3c24bec21c663ad093f131aaae3d0a6 } from '../../../components/admin/AdminWelcome'
|
||||
import { AdminCalendarView as AdminCalendarView_a2760c3ae85f4fd51d79a465baaf023a } from '../../../components/admin/AdminCalendarView'
|
||||
import { AdminAnalyticsView as AdminAnalyticsView_55d38702e4d86d52435b5ac4df3f55d0 } from '../../../components/admin/AdminAnalyticsView'
|
||||
import { AdminConversationsView as AdminConversationsView_180f5c400a066a0a8f248da44d961055 } from '../../../components/admin/AdminConversationsView'
|
||||
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
|
||||
|
||||
/** @type import('payload').ImportMap */
|
||||
@@ -51,6 +57,12 @@ export const importMap = {
|
||||
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"/components/admin/CalendarNavLink#CalendarNavLink": CalendarNavLink_4d06fac03127b339306d5f5ecb073251,
|
||||
"/components/admin/AnalyticsNavLink#AnalyticsNavLink": AnalyticsNavLink_29fa5c324cecba6cafb11381cbfac962,
|
||||
"/components/admin/ConversationsNavLink#ConversationsNavLink": ConversationsNavLink_67a2594cf28806bc76971e67212a6181,
|
||||
"/components/admin/UploadLimitGuard#UploadLimitGuard": UploadLimitGuard_1a82b0e63592dbfb4e9d3c5c946debf3,
|
||||
"/components/admin/AdminWelcome#AdminWelcome": AdminWelcome_b3c24bec21c663ad093f131aaae3d0a6,
|
||||
"/components/admin/AdminCalendarView#AdminCalendarView": AdminCalendarView_a2760c3ae85f4fd51d79a465baaf023a,
|
||||
"/components/admin/AdminAnalyticsView#AdminAnalyticsView": AdminAnalyticsView_55d38702e4d86d52435b5ac4df3f55d0,
|
||||
"/components/admin/AdminConversationsView#AdminConversationsView": AdminConversationsView_180f5c400a066a0a8f248da44d961055,
|
||||
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
|
||||
}
|
||||
|
||||
@@ -6,10 +6,10 @@ import { calendarFeedUrl, generateCalendarFeedToken, hashCalendarFeedToken } fro
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* POST /api/admin/calendar-feed/regenerate — (re)generates Anna's personal
|
||||
* POST /api/admin/calendar-feed/regenerate - (re)generates Anna's personal
|
||||
* ICS feed link. Only the new token's hash is stored; the plaintext URL is
|
||||
* returned exactly once in this response and can never be retrieved again
|
||||
* — generating a new one immediately invalidates whichever link was issued
|
||||
* - generating a new one immediately invalidates whichever link was issued
|
||||
* before (see collections/Users.ts).
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getPayload } from "payload";
|
||||
import config from "@payload-config";
|
||||
import { createMeetingToken } from "@/lib/meeting/token";
|
||||
import { authorizeMeeting } from "@/lib/meeting/access";
|
||||
|
||||
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();
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
|
||||
} 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 access = await authorizeMeeting(payload, event, "host");
|
||||
if (!access) return NextResponse.json({ error: "Dieser Termin ist nicht offen oder wurde storniert." }, { status: 403 });
|
||||
const { token } = createMeetingToken({
|
||||
eventSlug,
|
||||
name: typeof user.name === "string" && user.name.trim() ? user.name.trim() : "Host",
|
||||
role: "host",
|
||||
accessVersion: access.accessVersion,
|
||||
ttlMs: Math.min(6 * 3600000, access.expiresAt - Date.now()),
|
||||
});
|
||||
|
||||
// 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,34 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
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";
|
||||
|
||||
/** Creates a short-lived, database-free test room for an authenticated admin. */
|
||||
export async function POST(request: Request) {
|
||||
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 eventSlug = `test-${randomUUID()}`;
|
||||
const { token } = createMeetingToken({
|
||||
eventSlug,
|
||||
name: typeof user.name === "string" && user.name.trim() ? user.name.trim() : "Host",
|
||||
role: "host",
|
||||
testRoom: true,
|
||||
ttlMs: 60 * 60 * 1000,
|
||||
});
|
||||
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 test meeting start failed", error);
|
||||
return NextResponse.json({ error: "Der Test-Call konnte nicht gestartet werden." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
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);
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
|
||||
} 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 { checkRateLimit, getClientIp } from "@/lib/auth/rateLimit";
|
||||
import { allowVerificationEmail } from "@/lib/auth/verificationRateLimit";
|
||||
import { EMAIL_VERIFICATION_TTL_MS, generateVerificationToken, hashVerificationToken, verificationExpiryISO } from "@/lib/auth/verification";
|
||||
import { verificationEmail } from "@/lib/email/authTemplates";
|
||||
import { sendEmail } from "@/lib/email/sendBookingEmails";
|
||||
@@ -10,7 +11,7 @@ import type { Customer } from "@/payload-types";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Deliberately identical whether or not an account exists / is already
|
||||
// verified — this endpoint must never let a caller find out either of
|
||||
// verified - this endpoint must never let a caller find out either of
|
||||
// those things about an email address that isn't their own session.
|
||||
const GENERIC_RESPONSE = {
|
||||
ok: true,
|
||||
@@ -57,11 +58,12 @@ export async function POST(request: Request) {
|
||||
let body: { email?: unknown } = {};
|
||||
try {
|
||||
body = await request.json();
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
|
||||
} catch {
|
||||
// no body — handled below as a missing email
|
||||
// no body - handled below as a missing email
|
||||
}
|
||||
const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : "";
|
||||
if (!email) {
|
||||
const email = typeof body?.email === "string" ? body.email.trim().toLowerCase() : "";
|
||||
if (!email || email.length > 254) {
|
||||
return NextResponse.json({ error: "E-Mail-Adresse erforderlich." }, { status: 400 });
|
||||
}
|
||||
if (!checkRateLimit(`resend-verification:email:${email}`, { max: 3, windowMs: 60 * 60 * 1000 })) {
|
||||
@@ -77,8 +79,8 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
if (customer && !customer.emailVerified) {
|
||||
if (!checkRateLimit(`resend-verification:customer:${customer.id}`, { max: 3, windowMs: 60 * 60 * 1000 })) {
|
||||
return NextResponse.json({ error: "Zu viele Anfragen. Bitte versuche es später erneut." }, { status: 429 });
|
||||
if (!allowVerificationEmail(request, customer.email, customer.id)) {
|
||||
return NextResponse.json(GENERIC_RESPONSE);
|
||||
}
|
||||
await issueAndSend(payload, customer);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getPayload } from "payload";
|
||||
import config from "@payload-config";
|
||||
import { combineDateAndTime } from "@/lib/meeting/status";
|
||||
import { withBookingTransaction } from "@/lib/booking/transaction";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -19,31 +21,40 @@ export async function POST(request: Request, { params }: Args) {
|
||||
return NextResponse.json({ error: "Bitte bestätige zuerst deine E-Mail-Adresse.", code: "EMAIL_NOT_VERIFIED" }, { status: 403 });
|
||||
}
|
||||
|
||||
const booking = await payload.findByID({ collection: "booking-requests", id, depth: 0 }).catch(() => null);
|
||||
if (!booking || Number(booking.user) !== Number(user.id)) {
|
||||
return NextResponse.json({ error: "Buchung nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
const alternative = booking.proposedAlternative;
|
||||
if (booking.status !== "pending" || !alternative?.date || !alternative.startTime || !alternative.endTime) {
|
||||
return NextResponse.json({ error: "Für diese Buchung liegt kein alternativer Termin vor." }, { status: 409 });
|
||||
}
|
||||
return await withBookingTransaction(payload, `booking:${id}`, async (req) => {
|
||||
const { docs } = await payload.find({ collection: "booking-requests", where: { id: { equals: id } }, depth: 0, limit: 1, req });
|
||||
const booking = docs[0];
|
||||
if (!booking || Number(booking.user) !== Number(user.id)) {
|
||||
return NextResponse.json({ error: "Buchung nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
const alternative = booking.proposedAlternative;
|
||||
if (booking.status !== "pending" || !alternative?.date || !alternative.startTime || !alternative.endTime) {
|
||||
return NextResponse.json({ error: "Für diese Buchung liegt kein alternativer Termin vor." }, { status: 409 });
|
||||
}
|
||||
|
||||
// The beforeChange hook on booking-requests re-checks for conflicts
|
||||
// (with an advisory lock) before allowing this to become "confirmed" —
|
||||
// the alternative slot may have been taken by someone else since it was
|
||||
// proposed.
|
||||
await payload.update({
|
||||
collection: "booking-requests",
|
||||
id,
|
||||
data: {
|
||||
status: "confirmed",
|
||||
date: alternative.date,
|
||||
startTime: alternative.startTime,
|
||||
endTime: alternative.endTime,
|
||||
},
|
||||
const alternativeStart = combineDateAndTime(alternative.date, alternative.startTime);
|
||||
if (!Number.isFinite(alternativeStart.getTime()) || alternativeStart <= new Date()) {
|
||||
return NextResponse.json({ error: "Dieser Terminvorschlag liegt bereits in der Vergangenheit." }, { status: 409 });
|
||||
}
|
||||
|
||||
// The beforeChange hook on booking-requests re-checks for conflicts
|
||||
// (with an advisory lock) before allowing this to become "confirmed" -
|
||||
// the alternative slot may have been taken by someone else since it was
|
||||
// proposed.
|
||||
await payload.update({
|
||||
collection: "booking-requests",
|
||||
id,
|
||||
req,
|
||||
data: {
|
||||
status: "confirmed",
|
||||
date: alternative.date,
|
||||
startTime: alternative.startTime,
|
||||
endTime: alternative.endTime,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "status" in error && (error as { status: number }).status === 409) {
|
||||
return NextResponse.json({ error: error.message }, { status: 409 });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getPayload } from "payload";
|
||||
import config from "@payload-config";
|
||||
import { withBookingTransaction } from "@/lib/booking/transaction";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -15,19 +16,22 @@ export async function POST(request: Request, { params }: Args) {
|
||||
if (!user || user.collection !== "customers") {
|
||||
return NextResponse.json({ error: "Bitte melde dich an." }, { status: 401 });
|
||||
}
|
||||
// Cancelling is deliberately allowed regardless of verification status —
|
||||
// Cancelling is deliberately allowed regardless of verification status -
|
||||
// only creating/confirming a booking is a gated "protected" action.
|
||||
|
||||
const booking = await payload.findByID({ collection: "booking-requests", id, depth: 0 }).catch(() => null);
|
||||
if (!booking || Number(booking.user) !== Number(user.id)) {
|
||||
return NextResponse.json({ error: "Buchung nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
if (booking.status === "cancelled") {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
return await withBookingTransaction(payload, `booking:${id}`, async (req) => {
|
||||
const { docs } = await payload.find({ collection: "booking-requests", where: { id: { equals: id } }, depth: 0, limit: 1, req });
|
||||
const booking = docs[0];
|
||||
if (!booking || Number(booking.user) !== Number(user.id)) {
|
||||
return NextResponse.json({ error: "Buchung nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
if (booking.status === "cancelled") {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
await payload.update({ collection: "booking-requests", id, data: { status: "cancelled" } });
|
||||
return NextResponse.json({ ok: true });
|
||||
await payload.update({ collection: "booking-requests", id, req, data: { status: "cancelled" } });
|
||||
return NextResponse.json({ ok: true });
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("booking cancel failed", error);
|
||||
return NextResponse.json({ error: "Der Termin konnte nicht storniert werden." }, { status: 500 });
|
||||
|
||||
@@ -2,6 +2,9 @@ import { NextResponse } from "next/server";
|
||||
import { getPayload } from "payload";
|
||||
import config from "@payload-config";
|
||||
import { getAvailableSlots } from "@/lib/booking/slots";
|
||||
import { withBookingTransaction } from "@/lib/booking/transaction";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/auth/rateLimit";
|
||||
import { berlinDayBounds } from "@/lib/calendar/timezone";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -16,13 +19,15 @@ export async function POST(request: Request) {
|
||||
let body: Body;
|
||||
try {
|
||||
body = await request.json();
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
|
||||
}
|
||||
|
||||
const offerSlug = typeof body.offerSlug === "string" ? body.offerSlug : "";
|
||||
const startISO = typeof body.start === "string" ? body.start : "";
|
||||
const appointmentType = body.appointmentType === "online" ? "online" : "onsite";
|
||||
const appointmentType = body.appointmentType;
|
||||
if (appointmentType !== "online" && appointmentType !== "onsite") return NextResponse.json({ error: "Ungültige Terminart." }, { status: 400 });
|
||||
const message = typeof body.message === "string" ? body.message.trim().slice(0, 2000) : "";
|
||||
|
||||
if (!offerSlug || !startISO) {
|
||||
@@ -39,6 +44,11 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: "Bitte bestätige zuerst deine E-Mail-Adresse.", code: "EMAIL_NOT_VERIFIED" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (!checkRateLimit(`booking:ip:${getClientIp(request)}`, { max: 30, windowMs: 3600000 }) ||
|
||||
!checkRateLimit(`booking:customer:${user.id}`, { max: 10, windowMs: 3600000 })) {
|
||||
return NextResponse.json({ error: "Zu viele Terminanfragen. Bitte versuche es später erneut." }, { status: 429 });
|
||||
}
|
||||
|
||||
const { docs } = await payload.find({
|
||||
collection: "offers",
|
||||
where: { slug: { equals: offerSlug } },
|
||||
@@ -57,38 +67,50 @@ export async function POST(request: Request) {
|
||||
const end = new Date(start.getTime() + offer.durationMinutes * 60_000);
|
||||
|
||||
// Re-validate the slot is actually free right now (defense against a
|
||||
// stale slot list) — the hard, race-safe check still runs again when
|
||||
// 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 slotsByDay = await getAvailableSlots(payload, {
|
||||
durationMinutes: offer.durationMinutes,
|
||||
from: dayStart,
|
||||
to: dayEnd,
|
||||
});
|
||||
const key = dayStart.toISOString().slice(0, 10);
|
||||
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 });
|
||||
}
|
||||
const dayBounds = berlinDayBounds(start);
|
||||
return await withBookingTransaction(payload, dayBounds.key, async (req) => {
|
||||
const { docs: existing } = await payload.find({ collection: "booking-requests", req, depth: 0, limit: 1,
|
||||
where: { and: [
|
||||
{ user: { equals: user.id } }, { offer: { equals: offer.id } },
|
||||
{ startTime: { equals: start.toISOString() } }, { appointmentType: { equals: appointmentType } },
|
||||
{ status: { in: ["pending", "confirmed"] } },
|
||||
] },
|
||||
});
|
||||
if (existing[0]) return NextResponse.json({ ok: true });
|
||||
const slotsByDay = await getAvailableSlots(payload, {
|
||||
durationMinutes: offer.durationMinutes!,
|
||||
req,
|
||||
from: dayBounds.start,
|
||||
to: dayBounds.end,
|
||||
});
|
||||
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 });
|
||||
}
|
||||
|
||||
await payload.create({
|
||||
collection: "booking-requests",
|
||||
data: {
|
||||
user: user.id,
|
||||
offer: offer.id,
|
||||
date: start.toISOString(),
|
||||
startTime: start.toISOString(),
|
||||
endTime: end.toISOString(),
|
||||
appointmentType,
|
||||
status: "pending",
|
||||
userMessage: message || undefined,
|
||||
},
|
||||
});
|
||||
await payload.create({
|
||||
collection: "booking-requests",
|
||||
req,
|
||||
data: {
|
||||
user: user.id,
|
||||
offer: offer.id,
|
||||
date: start.toISOString(),
|
||||
startTime: start.toISOString(),
|
||||
endTime: end.toISOString(),
|
||||
appointmentType,
|
||||
// Calendar bookings use already published availability, so a slot
|
||||
// selected here is booked immediately. The separate contact form
|
||||
// remains the path for requests that need personal coordination.
|
||||
status: "confirmed",
|
||||
userMessage: message || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
return NextResponse.json({ ok: true });
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("booking request failed", error);
|
||||
return NextResponse.json({ error: "Die Anfrage konnte nicht gesendet werden. Bitte versuche es später erneut." }, { status: 500 });
|
||||
|
||||
@@ -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, parseBerlinDate } from "@/lib/calendar/timezone";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -29,10 +30,19 @@ 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();
|
||||
from.setHours(0, 0, 0, 0);
|
||||
const to = toParam ? new Date(toParam) : new Date(from.getTime() + 21 * 24 * 60 * 60 * 1000);
|
||||
to.setHours(23, 59, 59, 999);
|
||||
const from = fromParam ? parseBerlinDate(fromParam) : berlinDayBounds(new Date()).start;
|
||||
if (!from) {
|
||||
return NextResponse.json({ error: "Ungültiges Startdatum." }, { status: 400 });
|
||||
}
|
||||
const toDate = toParam ? parseBerlinDate(toParam) : new Date(from.getTime() + 21 * 24 * 60 * 60 * 1000);
|
||||
if (!toDate) {
|
||||
return NextResponse.json({ error: "Ungültiges Enddatum." }, { status: 400 });
|
||||
}
|
||||
const to = berlinDayBounds(toDate).end;
|
||||
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getPayload } from "payload";
|
||||
import config from "@payload-config";
|
||||
import { getClientIp, checkRateLimit } from "@/lib/auth/rateLimit";
|
||||
import { newConversationAdminEmail } from "@/lib/email/conversationTemplates";
|
||||
import { sendEmail } from "@/lib/email/sendBookingEmails";
|
||||
import { verifyTurnstileToken } from "@/lib/turnstile/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Body = {
|
||||
name?: unknown;
|
||||
email?: unknown;
|
||||
message?: unknown;
|
||||
subject?: unknown;
|
||||
type?: unknown;
|
||||
token?: unknown;
|
||||
};
|
||||
|
||||
function clean(value: unknown, max: number): string {
|
||||
return typeof value === "string" ? value.trim().slice(0, max) : "";
|
||||
}
|
||||
|
||||
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 name = clean(body.name, 160);
|
||||
const email = clean(body.email, 254).toLowerCase();
|
||||
const message = clean(body.message, 5000);
|
||||
const subject = clean(body.subject, 180) || "Allgemeine Anfrage";
|
||||
const type = body.type === "appointment" ? "appointment" : "contact";
|
||||
|
||||
if (!name || !email || !message || !/^\S+@\S+\.\S+$/.test(email)) {
|
||||
return NextResponse.json({ error: "Bitte fülle alle Pflichtfelder korrekt aus." }, { status: 400 });
|
||||
}
|
||||
if (!(await verifyTurnstileToken(body.token, "contact", request))) {
|
||||
return NextResponse.json({ error: "Sicherheitsprüfung fehlgeschlagen. Bitte versuche es erneut." }, { status: 403 });
|
||||
}
|
||||
if (!checkRateLimit(`contact:ip:${getClientIp(request)}`, { max: 8, windowMs: 60 * 60 * 1000 })) {
|
||||
return NextResponse.json({ error: "Zu viele Anfragen. Bitte versuche es später erneut." }, { status: 429 });
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await getPayload({ config });
|
||||
const { user } = await payload.auth({ headers: request.headers });
|
||||
const customerId = user?.collection === "customers" ? user.id : undefined;
|
||||
const conversation = await payload.create({
|
||||
collection: "conversations",
|
||||
overrideAccess: true,
|
||||
data: {
|
||||
name,
|
||||
email,
|
||||
subject,
|
||||
type,
|
||||
status: "open",
|
||||
threadKey: randomUUID(),
|
||||
...(customerId ? { customer: customerId } : {}),
|
||||
lastMessageAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
await payload.create({
|
||||
collection: "conversation-messages",
|
||||
overrideAccess: true,
|
||||
data: {
|
||||
conversation: conversation.id,
|
||||
senderType: "visitor",
|
||||
senderName: name,
|
||||
senderEmail: email,
|
||||
body: message,
|
||||
},
|
||||
});
|
||||
|
||||
const contact = await payload.findGlobal({ slug: "contact", overrideAccess: true });
|
||||
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || new URL(request.url).origin;
|
||||
const adminUrl = `${serverUrl}/admin/collections/conversations/${conversation.id}`;
|
||||
if (contact.email) {
|
||||
await sendEmail(contact.email, newConversationAdminEmail({ name, email, subject, body: message, adminUrl, threadKey: conversation.threadKey }), {
|
||||
replyTo: process.env.INBOUND_REPLY_TO || "reply-to@anouma.org",
|
||||
});
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error("contact inquiry failed", error);
|
||||
return NextResponse.json({ error: "Die Nachricht konnte nicht gesendet werden. Bitte versuche es später erneut." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -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,38 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { REST_POST } from "@payloadcms/next/routes";
|
||||
import config from "@payload-config";
|
||||
import { verifyTurnstileToken } from "@/lib/turnstile/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const payloadPost = REST_POST(config);
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.clone().json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Sicherheitsprüfung fehlgeschlagen. Bitte versuche es erneut." }, { status: 403 });
|
||||
}
|
||||
|
||||
const token = isRecord(body) ? body.token : undefined;
|
||||
if (!(await verifyTurnstileToken(token, "login", request))) {
|
||||
return NextResponse.json({ error: "Sicherheitsprüfung fehlgeschlagen. Bitte versuche es erneut." }, { status: 403 });
|
||||
}
|
||||
|
||||
const sanitizedBody = isRecord(body) ? { ...body } : body;
|
||||
if (isRecord(sanitizedBody)) delete sanitizedBody.token;
|
||||
const headers = new Headers(request.headers);
|
||||
headers.delete("content-length");
|
||||
headers.set("content-type", "application/json");
|
||||
const downstreamRequest = new Request(request.url, {
|
||||
method: request.method,
|
||||
headers,
|
||||
body: JSON.stringify(sanitizedBody),
|
||||
});
|
||||
return payloadPost(downstreamRequest, { params: Promise.resolve({ slug: ["customers", "login"] }) });
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { REST_POST } from "@payloadcms/next/routes";
|
||||
import config from "@payload-config";
|
||||
import { verifyTurnstileToken } from "@/lib/turnstile/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const payloadPost = REST_POST(config);
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.clone().json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Sicherheitsprüfung fehlgeschlagen. Bitte versuche es erneut." }, { status: 403 });
|
||||
}
|
||||
|
||||
const token = isRecord(body) ? body.token : undefined;
|
||||
if (!(await verifyTurnstileToken(token, "registration", request))) {
|
||||
return NextResponse.json({ error: "Sicherheitsprüfung fehlgeschlagen. Bitte versuche es erneut." }, { status: 403 });
|
||||
}
|
||||
|
||||
const sanitizedBody = isRecord(body) ? { ...body } : body;
|
||||
if (isRecord(sanitizedBody)) delete sanitizedBody.token;
|
||||
const headers = new Headers(request.headers);
|
||||
headers.delete("content-length");
|
||||
headers.set("content-type", "application/json");
|
||||
const downstreamRequest = new Request(request.url, {
|
||||
method: request.method,
|
||||
headers,
|
||||
body: JSON.stringify(sanitizedBody),
|
||||
});
|
||||
return payloadPost(downstreamRequest, { params: Promise.resolve({ slug: ["customers"] }) });
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getPayload } from "payload";
|
||||
import config from "@payload-config";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type InboundBody = {
|
||||
from?: unknown;
|
||||
sender?: unknown;
|
||||
email?: unknown;
|
||||
name?: unknown;
|
||||
subject?: unknown;
|
||||
text?: unknown;
|
||||
body?: unknown;
|
||||
html?: unknown;
|
||||
messageId?: unknown;
|
||||
inReplyTo?: unknown;
|
||||
};
|
||||
|
||||
function stringValue(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function extractEmail(value: unknown): string {
|
||||
if (value && typeof value === "object" && "email" in value) return extractEmail((value as { email?: unknown }).email);
|
||||
const raw = stringValue(value);
|
||||
const match = raw.match(/<([^>]+)>/) || raw.match(/([\w.+-]+@[\w.-]+\.[A-Za-z]{2,})/);
|
||||
return (match?.[1] || raw).trim().toLowerCase();
|
||||
}
|
||||
|
||||
function stripHtml(value: string): string {
|
||||
return value.replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function findThreadKey(subject: string): string | null {
|
||||
return subject.match(/\[ANOUMA-([^\]]+)\]/i)?.[1] || null;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const secret = process.env.INBOUND_EMAIL_SECRET;
|
||||
const supplied = request.headers.get("x-inbound-secret") || request.headers.get("authorization")?.replace(/^Bearer\s+/i, "");
|
||||
if (!secret || supplied !== secret) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
let body: InboundBody;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "JSON erwartet." }, { status: 400 });
|
||||
}
|
||||
|
||||
const fromRaw = body.from ?? body.sender ?? body.email;
|
||||
const email = extractEmail(fromRaw);
|
||||
const fromName = fromRaw && typeof fromRaw === "object" && "name" in fromRaw ? stringValue((fromRaw as { name?: unknown }).name) : "";
|
||||
const name = stringValue(body.name) || fromName || stringValue(fromRaw).replace(/<[^>]+>/, "").trim() || email;
|
||||
const subject = stringValue(body.subject) || "Antwort auf deine Anfrage";
|
||||
const messageId = stringValue(body.messageId);
|
||||
const text = stringValue(body.text) || stringValue(body.body) || stripHtml(stringValue(body.html));
|
||||
if (!email || !messageId || !text) return NextResponse.json({ error: "Absender, messageId und Inhalt sind erforderlich." }, { status: 400 });
|
||||
|
||||
try {
|
||||
const payload = await getPayload({ config });
|
||||
if (messageId) {
|
||||
const existing = await payload.find({ collection: "conversation-messages", where: { externalMessageId: { equals: messageId } }, limit: 1, overrideAccess: true });
|
||||
if (existing.docs[0]) return NextResponse.json({ ok: true, duplicate: true });
|
||||
}
|
||||
|
||||
const threadKey = findThreadKey(subject);
|
||||
let conversation = threadKey
|
||||
? (await payload.find({ collection: "conversations", where: { threadKey: { equals: threadKey } }, limit: 1, overrideAccess: true })).docs[0]
|
||||
: undefined;
|
||||
if (!conversation) {
|
||||
conversation = (await payload.find({ collection: "conversations", where: { email: { equals: email }, status: { not_equals: "closed" } }, sort: "-lastMessageAt", limit: 1, overrideAccess: true })).docs[0];
|
||||
}
|
||||
if (!conversation) return NextResponse.json({ error: "Keine passende Konversation gefunden." }, { status: 404 });
|
||||
|
||||
await payload.create({
|
||||
collection: "conversation-messages",
|
||||
overrideAccess: true,
|
||||
data: {
|
||||
conversation: conversation.id,
|
||||
senderType: "visitor",
|
||||
senderName: name,
|
||||
senderEmail: email,
|
||||
body: text.slice(0, 10000),
|
||||
externalMessageId: messageId,
|
||||
},
|
||||
});
|
||||
await payload.update({ collection: "conversations", id: conversation.id, overrideAccess: true, data: { status: "open", lastMessageAt: new Date().toISOString() } });
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error("inbound email failed", error);
|
||||
return NextResponse.json({ error: "E-Mail konnte nicht übernommen werden." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getPayload } from "payload";
|
||||
import config from "@payload-config";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/auth/rateLimit";
|
||||
import { groupRegistrationEmail } from "@/lib/email/conversationTemplates";
|
||||
import { sendEmail } from "@/lib/email/sendBookingEmails";
|
||||
import { verifyTurnstileToken } from "@/lib/turnstile/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const value = (v: unknown, max: number) => typeof v === "string" ? v.trim().slice(0, max) : "";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!checkRateLimit(`group-registration:${getClientIp(request)}`, { max: 5, windowMs: 60 * 60 * 1000 })) return NextResponse.json({ error: "Zu viele Anmeldungen. Bitte versuche es später erneut." }, { status: 429 });
|
||||
let body: Record<string, unknown>;
|
||||
try { body = await request.json(); } catch { return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 }); }
|
||||
const offerSlug = value(body.offerSlug, 160);
|
||||
const name = value(body.name, 160);
|
||||
const email = value(body.email, 254).toLowerCase();
|
||||
const phone = value(body.phone, 80);
|
||||
const message = value(body.message, 3000);
|
||||
if (!offerSlug || !name || !email || !/^\S+@\S+\.\S+$/.test(email)) return NextResponse.json({ error: "Bitte fülle Name und E-Mail korrekt aus." }, { status: 400 });
|
||||
if (!(await verifyTurnstileToken(body.token, "group-registration", request))) return NextResponse.json({ error: "Sicherheitsprüfung fehlgeschlagen. Bitte versuche es erneut." }, { status: 403 });
|
||||
|
||||
try {
|
||||
const payload = await getPayload({ config });
|
||||
const offerResult = await payload.find({ collection: "offers", where: { slug: { equals: offerSlug } }, limit: 1, overrideAccess: false });
|
||||
const offer = offerResult.docs[0];
|
||||
if (!offer || offer.bookingMode !== "group") return NextResponse.json({ error: "Dieses Gruppenangebot ist nicht verfügbar." }, { status: 404 });
|
||||
if (offer.registrationDeadline && new Date(offer.registrationDeadline) < new Date()) return NextResponse.json({ error: "Die Anmeldefrist ist bereits abgelaufen." }, { status: 409 });
|
||||
if (offer.groupCapacity) {
|
||||
const count = await payload.count({ collection: "group-registrations", where: { and: [{ offer: { equals: offer.id } }, { status: { in: ["pending", "accepted"] } }] }, overrideAccess: true });
|
||||
if (count.totalDocs >= offer.groupCapacity && offer.groupRegistrationMode === "capacity") return NextResponse.json({ error: "Für dieses Angebot sind aktuell keine Plätze mehr frei." }, { status: 409 });
|
||||
}
|
||||
const registration = await payload.create({ collection: "group-registrations", overrideAccess: true, data: { offer: offer.id, name, email, phone, message, status: "pending" } });
|
||||
const contact = await payload.findGlobal({ slug: "contact", overrideAccess: true });
|
||||
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || new URL(request.url).origin;
|
||||
await Promise.all([
|
||||
contact.email ? sendEmail(contact.email, groupRegistrationEmail({ name, offerTitle: offer.title, status: "admin", adminUrl: `${serverUrl}/admin/collections/group-registrations/${registration.id}` }), { replyTo: email }) : Promise.resolve(),
|
||||
sendEmail(email, groupRegistrationEmail({ name, offerTitle: offer.title, status: "received" })),
|
||||
]);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error("group registration failed", error);
|
||||
return NextResponse.json({ error: "Die Anmeldung konnte nicht gesendet werden." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ export const dynamic = "force-dynamic";
|
||||
/**
|
||||
* Used by the Docker healthcheck (see docker-compose.yml) and by
|
||||
* setup.sh/deploy.sh to confirm the app can actually reach Postgres before
|
||||
* being considered "up" — not just that the Node process is listening.
|
||||
* being considered "up" - not just that the Node process is listening.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
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";
|
||||
import { authorizeMeeting } from "@/lib/meeting/access";
|
||||
|
||||
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 {
|
||||
let expiresAt = meetingToken.exp;
|
||||
const isAdminTestRoom = meetingToken.role === "host" && meetingToken.testRoom === true && slug.startsWith("test-");
|
||||
if (!isAdminTestRoom) {
|
||||
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 access = await authorizeMeeting(payload, docs[0], meetingToken.role);
|
||||
if (!access || access.accessVersion !== meetingToken.accessVersion) {
|
||||
return NextResponse.json({ error: "Dieser Meeting-Zugang ist nicht mehr gültig. Bitte tritt erneut bei." }, { status: 403 });
|
||||
}
|
||||
expiresAt = Math.min(expiresAt, access.expiresAt);
|
||||
}
|
||||
|
||||
const participantToken = await createLiveKitToken({
|
||||
eventSlug: slug,
|
||||
participantId: meetingToken.participantId,
|
||||
name: meetingToken.name,
|
||||
role: meetingToken.role,
|
||||
clientId: meetingToken.clientId,
|
||||
accessVersion: meetingToken.accessVersion,
|
||||
expiresAt,
|
||||
});
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,11 @@ import { NextResponse } from "next/server";
|
||||
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 { authorizeMeeting } from "@/lib/meeting/access";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/auth/rateLimit";
|
||||
import { createLiveKitToken, getLiveKitClientUrl } from "@/lib/meeting/livekit";
|
||||
import { isMeetingParticipantKicked } from "@/lib/meeting/kicks";
|
||||
import { verifyTurnstileToken } from "@/lib/turnstile/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -19,19 +22,30 @@ type Args = { params: Promise<{ slug: string }> };
|
||||
|
||||
export async function POST(request: Request, { params }: Args) {
|
||||
const { slug } = await params;
|
||||
if (!checkRateLimit(`meeting-join:${getClientIp(request)}`, { max: 30, windowMs: 60000 })) {
|
||||
return NextResponse.json({ error: "Zu viele Beitrittsversuche." }, { status: 429 });
|
||||
}
|
||||
|
||||
let body: { name?: unknown; password?: unknown };
|
||||
let body: { name?: unknown; password?: unknown; clientId?: unknown; token?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
if (!(await verifyTurnstileToken(body.token, "meeting-join", request))) {
|
||||
return NextResponse.json({ error: "Sicherheitsprüfung fehlgeschlagen. Bitte versuche es erneut." }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await getPayload({ config });
|
||||
@@ -47,9 +61,9 @@ 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
|
||||
// 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) {
|
||||
@@ -60,31 +74,34 @@ export async function POST(request: Request, { params }: Args) {
|
||||
}
|
||||
}
|
||||
|
||||
const settings = await payload.findGlobal({ slug: "meeting-settings" });
|
||||
const start = combineDateAndTime(event.date, event.startTime);
|
||||
const end = event.endTime
|
||||
? combineDateAndTime(event.date, event.endTime)
|
||||
: new Date(start.getTime() + 60 * 60_000);
|
||||
|
||||
const status = getMeetingStatus({
|
||||
start,
|
||||
end,
|
||||
joinWindowMinutes: isHost ? settings.hostJoinWindowMinutes : settings.participantJoinWindowMinutes,
|
||||
closeAfterMinutes: settings.meetingCloseAfterMinutes,
|
||||
});
|
||||
|
||||
if (!canJoinMeeting(status)) {
|
||||
const message =
|
||||
status === "scheduled"
|
||||
? "Dieser Termin ist noch nicht offen. Bitte versuche es näher am Beginn erneut."
|
||||
: "Dieser Termin ist bereits beendet.";
|
||||
return NextResponse.json({ error: message }, { status: 403 });
|
||||
const access = await authorizeMeeting(payload, event, isHost ? "host" : "participant");
|
||||
if (!access) {
|
||||
return NextResponse.json({ error: "Dieser Termin ist nicht offen oder wurde storniert." }, { status: 403 });
|
||||
}
|
||||
|
||||
const { token, participantId } = createMeetingToken({
|
||||
eventSlug: slug,
|
||||
name,
|
||||
role: isHost ? "host" : "participant",
|
||||
accessVersion: access.accessVersion,
|
||||
ttlMs: Math.min(6 * 3600000, access.expiresAt - Date.now()),
|
||||
// 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,
|
||||
accessVersion: access.accessVersion,
|
||||
expiresAt: access.expiresAt,
|
||||
participantId,
|
||||
name,
|
||||
role: isHost ? "host" : "participant",
|
||||
clientId: isHost ? undefined : clientId,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -92,7 +109,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,51 @@
|
||||
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();
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
|
||||
} 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, host.accessVersion);
|
||||
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,26 +1,57 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getPayload } from "payload";
|
||||
import config from "@payload-config";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/auth/rateLimit";
|
||||
import { verifyTurnstileToken } from "@/lib/turnstile/server";
|
||||
|
||||
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;
|
||||
|
||||
let body: { name?: unknown; email?: unknown };
|
||||
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; token?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
if (!(await verifyTurnstileToken(body.token, "meeting-registration", request))) {
|
||||
return NextResponse.json({ error: "Sicherheitsprüfung fehlgeschlagen. Bitte versuche es erneut." }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await getPayload({ config });
|
||||
@@ -31,41 +62,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 });
|
||||
|
||||
+592
-3
@@ -1,3 +1,592 @@
|
||||
/* Optional custom overrides for the Payload admin UI. Left empty intentionally —
|
||||
the admin panel uses Payload's own default styling, only the public site
|
||||
uses the ANOUMA design system (see app/(frontend)/globals.css). */
|
||||
@font-face {
|
||||
font-family: "Anouma Admin";
|
||||
src: url("/fonts/baar-antropos.ttf") format("truetype");
|
||||
font-display: swap;
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
}
|
||||
|
||||
:root {
|
||||
--anouma-ink: #342c31;
|
||||
--anouma-plum: #66505f;
|
||||
--anouma-mauve: #8b616d;
|
||||
--anouma-rose: #d19ca1;
|
||||
--anouma-cream: #f7f2ed;
|
||||
--anouma-paper: #fffdfb;
|
||||
--anouma-line: #e4d9d2;
|
||||
--font-body: "Anouma Admin", Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: "SFMono-Regular", Consolas, monospace;
|
||||
}
|
||||
|
||||
/* Keep Payload's own color tokens aligned with the light admin surface. The
|
||||
previous theme could leave the content area pale while retaining white text
|
||||
from dark mode, which made headings and descriptions effectively invisible. */
|
||||
html,
|
||||
html[data-theme="light"],
|
||||
html[data-theme="dark"] {
|
||||
--theme-bg: var(--anouma-cream);
|
||||
--theme-input-bg: var(--anouma-paper);
|
||||
--theme-text: var(--anouma-ink);
|
||||
--theme-elevation-0: var(--anouma-paper);
|
||||
--theme-elevation-50: #fbf7f4;
|
||||
--theme-elevation-100: #f1e9e4;
|
||||
--theme-elevation-150: var(--anouma-line);
|
||||
--theme-elevation-200: #d7c9c1;
|
||||
--theme-elevation-300: #b9aaa8;
|
||||
--theme-elevation-400: #8b7b81;
|
||||
--theme-elevation-500: #776870;
|
||||
--theme-elevation-600: #66505f;
|
||||
--theme-elevation-700: #51404b;
|
||||
--theme-elevation-800: var(--anouma-ink);
|
||||
--theme-elevation-900: #211a1e;
|
||||
--theme-elevation-950: #171216;
|
||||
--theme-elevation-1000: #100c0f;
|
||||
--font-body: "Anouma Admin", Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: "SFMono-Regular", Consolas, monospace;
|
||||
background: var(--anouma-cream);
|
||||
color: var(--anouma-ink);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background: var(--anouma-cream);
|
||||
color: var(--anouma-ink);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
body,
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
body,
|
||||
#app,
|
||||
.template-default,
|
||||
.template-default__wrap,
|
||||
.collection-list,
|
||||
.collection-list__wrap,
|
||||
.collection-edit,
|
||||
.dashboard,
|
||||
.dashboard__wrap {
|
||||
color: var(--anouma-ink);
|
||||
}
|
||||
|
||||
/* The left navigation is intentionally dark and quiet. Content stays light. */
|
||||
.nav {
|
||||
background: var(--anouma-ink);
|
||||
border-right: 1px solid #4b3f46;
|
||||
color: #f8f2ed;
|
||||
}
|
||||
|
||||
.nav__header {
|
||||
border-bottom: 1px solid #4b3f46;
|
||||
padding: 24px 20px 20px;
|
||||
}
|
||||
|
||||
.nav__header a,
|
||||
.nav__header button {
|
||||
color: #fffaf6;
|
||||
}
|
||||
|
||||
.nav__links {
|
||||
padding: 18px 12px;
|
||||
}
|
||||
|
||||
.nav-group {
|
||||
margin: 0 0 18px;
|
||||
}
|
||||
|
||||
.nav-group__label,
|
||||
.nav-group__toggle {
|
||||
color: #bdaeb4;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.nav-group__label {
|
||||
padding: 0 10px 8px;
|
||||
}
|
||||
|
||||
.nav__link {
|
||||
border-radius: 4px;
|
||||
color: #f8f2ed;
|
||||
font-size: 14px;
|
||||
margin: 2px 0;
|
||||
min-height: 38px;
|
||||
padding: 9px 10px;
|
||||
}
|
||||
|
||||
.nav__link:hover,
|
||||
.nav__link:focus-visible,
|
||||
.nav__link.active {
|
||||
background: #4c3d47;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.nav__link.active {
|
||||
box-shadow: inset 3px 0 0 var(--anouma-rose);
|
||||
}
|
||||
|
||||
.nav__controls {
|
||||
border-top: 1px solid #4b3f46;
|
||||
padding: 14px 12px;
|
||||
}
|
||||
|
||||
.app-header,
|
||||
.step-nav {
|
||||
background: var(--anouma-paper);
|
||||
border-bottom: 1px solid var(--anouma-line);
|
||||
color: var(--anouma-plum);
|
||||
}
|
||||
|
||||
.app-header a,
|
||||
.app-header button,
|
||||
.step-nav a,
|
||||
.step-nav span,
|
||||
.step-nav label {
|
||||
color: var(--anouma-plum);
|
||||
}
|
||||
|
||||
.app-header svg,
|
||||
.step-nav svg {
|
||||
color: var(--anouma-plum);
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.template-default__wrap,
|
||||
.dashboard__wrap,
|
||||
.collection-list__wrap,
|
||||
.edit-view__wrap {
|
||||
background: var(--anouma-cream);
|
||||
}
|
||||
|
||||
.template-default__wrap {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.dashboard__header,
|
||||
.collection-list__header,
|
||||
.edit-view__header {
|
||||
border-bottom: 1px solid var(--anouma-line);
|
||||
margin-bottom: 26px;
|
||||
padding-bottom: 18px;
|
||||
}
|
||||
|
||||
.dashboard__header h1,
|
||||
.collection-list__header h1,
|
||||
.edit-view__header h1,
|
||||
.list-header__title,
|
||||
.list-header__title h1,
|
||||
.document-fields__label {
|
||||
color: var(--anouma-ink);
|
||||
font-size: clamp(26px, 3vw, 38px);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.list-header,
|
||||
.list-header__content,
|
||||
.collection-list__sub-header,
|
||||
.list-controls,
|
||||
.no-results,
|
||||
.no-results h3,
|
||||
.no-results p,
|
||||
.collection-list__table-wrap,
|
||||
.document-fields,
|
||||
.document-fields__main,
|
||||
.field-type,
|
||||
.field-type__wrap,
|
||||
.label-wrapper,
|
||||
.field-label,
|
||||
.field-label label,
|
||||
.field-description,
|
||||
.description {
|
||||
color: var(--anouma-ink);
|
||||
}
|
||||
|
||||
.list-header__title,
|
||||
.list-header__title a,
|
||||
.list-header__title button,
|
||||
.list-header__actions,
|
||||
.list-controls button,
|
||||
.list-controls a {
|
||||
color: var(--anouma-ink);
|
||||
}
|
||||
|
||||
.list-header__title-actions .btn--style-secondary,
|
||||
.list-header__actions .btn--style-secondary,
|
||||
.list-controls .btn--style-subtle {
|
||||
--color: var(--anouma-ink);
|
||||
--hover-color: var(--anouma-ink);
|
||||
--bg-color: var(--anouma-paper);
|
||||
--hover-bg: #f1e9e4;
|
||||
--btn-border: 1px solid var(--anouma-line);
|
||||
--hover-btn-border: 1px solid var(--anouma-mauve);
|
||||
}
|
||||
|
||||
.card,
|
||||
.dashboard__card,
|
||||
.table-wrap,
|
||||
.collection-list__table-wrap,
|
||||
.field-type,
|
||||
.document-fields__main {
|
||||
border-color: var(--anouma-line);
|
||||
}
|
||||
|
||||
.card,
|
||||
.dashboard__card,
|
||||
.table-wrap {
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 24px rgb(52 44 49 / 4%);
|
||||
}
|
||||
|
||||
.btn {
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn--style-primary {
|
||||
--color: #fff;
|
||||
--bg-color: var(--anouma-mauve);
|
||||
--hover-bg: var(--anouma-plum);
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select,
|
||||
.rs__control {
|
||||
background: var(--anouma-paper) !important;
|
||||
border-color: #d7c9c1;
|
||||
border-radius: 4px;
|
||||
color: var(--anouma-ink) !important;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus,
|
||||
select:focus,
|
||||
.rs__control--is-focused {
|
||||
border-color: var(--anouma-mauve);
|
||||
box-shadow: 0 0 0 2px rgb(139 97 109 / 12%);
|
||||
}
|
||||
|
||||
.field-label,
|
||||
.field-label label {
|
||||
color: var(--anouma-plum);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.description,
|
||||
.field-description {
|
||||
color: #7b6b72;
|
||||
}
|
||||
|
||||
.upload,
|
||||
.file-field,
|
||||
.upload-field-card,
|
||||
.upload__dropzoneContent,
|
||||
.dropzone,
|
||||
.bulk-upload--add-files,
|
||||
.bulk-upload--file-manager {
|
||||
color: var(--anouma-ink);
|
||||
}
|
||||
|
||||
.upload,
|
||||
.file-field,
|
||||
.bulk-upload--add-files,
|
||||
.bulk-upload--file-manager,
|
||||
.file-field__filename,
|
||||
.file-field__remote-file,
|
||||
.upload-field-card {
|
||||
background: var(--anouma-paper);
|
||||
border-color: var(--anouma-line);
|
||||
}
|
||||
|
||||
.upload__dragAndDropText,
|
||||
.upload__orText,
|
||||
.bulk-upload--add-files__dragAndDropText,
|
||||
.file-field__filename,
|
||||
.file-field__remote-file,
|
||||
.upload-field-card__filename,
|
||||
.upload-field-card__meta {
|
||||
color: var(--anouma-plum);
|
||||
}
|
||||
|
||||
.upload a,
|
||||
.upload button,
|
||||
.file-field button,
|
||||
.bulk-upload--add-files button,
|
||||
.bulk-upload--file-manager button {
|
||||
color: var(--anouma-ink);
|
||||
}
|
||||
|
||||
.table thead th,
|
||||
.table th {
|
||||
background: #f1e9e4;
|
||||
color: var(--anouma-plum);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.table tbody tr:hover {
|
||||
background: #fbf7f4;
|
||||
}
|
||||
|
||||
.collections {
|
||||
color: var(--anouma-ink);
|
||||
}
|
||||
|
||||
.collections__group {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.collections__label {
|
||||
color: var(--anouma-ink) !important;
|
||||
font-size: 20px;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.015em;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.collections__card-list {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.collections .card {
|
||||
background: var(--anouma-paper) !important;
|
||||
border: 1px solid var(--anouma-line);
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 8px 22px rgb(52 44 49 / 5%);
|
||||
color: var(--anouma-ink) !important;
|
||||
min-height: 64px;
|
||||
}
|
||||
|
||||
.collections .card:hover {
|
||||
border-color: var(--anouma-mauve);
|
||||
}
|
||||
|
||||
.collections .card__title {
|
||||
color: var(--anouma-ink) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.collections .card__actions .btn {
|
||||
color: var(--anouma-mauve);
|
||||
}
|
||||
|
||||
textarea::placeholder,
|
||||
input::placeholder {
|
||||
color: #8b7b81;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.anouma-admin-back {
|
||||
align-items: center;
|
||||
border: 1px solid var(--anouma-line);
|
||||
border-radius: 4px;
|
||||
color: var(--anouma-plum);
|
||||
display: inline-flex;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
padding: 8px 12px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.anouma-admin-back:hover,
|
||||
.anouma-admin-back:focus-visible {
|
||||
background: var(--anouma-paper);
|
||||
border-color: var(--anouma-mauve);
|
||||
color: var(--anouma-ink);
|
||||
}
|
||||
|
||||
.anouma-admin-welcome {
|
||||
background: var(--anouma-paper);
|
||||
border: 1px solid var(--anouma-line);
|
||||
border-left: 4px solid var(--anouma-mauve);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 12px 32px rgb(52 44 49 / 5%);
|
||||
margin: 0 0 28px;
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.anouma-admin-welcome__intro {
|
||||
max-width: 680px;
|
||||
}
|
||||
|
||||
.anouma-admin-eyebrow {
|
||||
color: var(--anouma-mauve);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
margin: 0 0 8px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.anouma-admin-welcome h1 {
|
||||
color: var(--anouma-ink);
|
||||
font-size: clamp(25px, 3vw, 36px);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.03em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.anouma-admin-welcome__intro > p:last-child {
|
||||
color: #776870;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
margin: 10px 0 0;
|
||||
}
|
||||
|
||||
.anouma-admin-quicklinks {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
margin-top: 26px;
|
||||
}
|
||||
|
||||
.anouma-admin-quicklink {
|
||||
background: #faf5f1;
|
||||
border: 1px solid var(--anouma-line);
|
||||
border-radius: 4px;
|
||||
color: var(--anouma-ink);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 154px;
|
||||
padding: 16px;
|
||||
text-decoration: none;
|
||||
transition: border-color 120ms ease, background 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.anouma-admin-quicklink:hover,
|
||||
.anouma-admin-quicklink:focus-visible {
|
||||
background: #fffaf7;
|
||||
border-color: var(--anouma-mauve);
|
||||
color: var(--anouma-ink);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.anouma-admin-quicklink__number {
|
||||
color: var(--anouma-rose);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.anouma-admin-quicklink__kicker {
|
||||
color: var(--anouma-mauve);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.anouma-admin-quicklink strong {
|
||||
font-size: 17px;
|
||||
font-weight: 650;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.anouma-admin-quicklink > span:last-child {
|
||||
color: #7b6b72;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.anouma-custom-nav-item,
|
||||
.anouma-custom-nav-action {
|
||||
margin: 0 0 6px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.anouma-custom-nav-link,
|
||||
.anouma-custom-nav-button {
|
||||
align-items: center;
|
||||
border: 1px solid #5a4a55;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
gap: 10px;
|
||||
min-height: 38px;
|
||||
padding: 8px 10px;
|
||||
text-decoration: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.anouma-custom-nav-link {
|
||||
color: #fffaf6;
|
||||
}
|
||||
|
||||
.anouma-custom-nav-link:hover,
|
||||
.anouma-custom-nav-link:focus-visible {
|
||||
background: #4c3d47;
|
||||
border-color: #7d6371;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.anouma-custom-nav-link__icon {
|
||||
color: var(--anouma-rose);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.anouma-custom-nav-button {
|
||||
background: var(--anouma-mauve);
|
||||
border-color: var(--anouma-mauve);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.anouma-custom-nav-button:hover,
|
||||
.anouma-custom-nav-button:focus-visible {
|
||||
background: var(--anouma-plum);
|
||||
border-color: var(--anouma-plum);
|
||||
}
|
||||
|
||||
.anouma-custom-nav-button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.anouma-custom-nav-input {
|
||||
background: #fffaf6;
|
||||
border: 1px solid #d7c9c1;
|
||||
border-radius: 3px;
|
||||
color: var(--anouma-ink);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
margin-top: 6px;
|
||||
padding: 6px 7px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.anouma-custom-nav-error {
|
||||
color: #f1b5b5;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.anouma-admin-quicklinks {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.anouma-admin-welcome {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.anouma-admin-quicklinks {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ImagePlaceholder } from "@/components/ImagePlaceholder";
|
||||
import { siteConfig } from "@/lib/site";
|
||||
import "./(frontend)/globals.css";
|
||||
|
||||
// Required because the app has two root layouts — (frontend) and (payload) —
|
||||
// Required because the app has two root layouts - (frontend) and (payload) -
|
||||
// so there is no single layout Next.js could compose a 404 page from.
|
||||
// See node_modules/next/dist/docs/.../not-found.md ("global-not-found.js").
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function GlobalNotFound() {
|
||||
>
|
||||
<body className="flex min-h-full flex-col bg-background text-foreground">
|
||||
{/* This page is always statically prerendered, so it can't read live
|
||||
CMS data — the mega menu is intentionally omitted here. */}
|
||||
CMS data - the mega menu is intentionally omitted here. */}
|
||||
<Navbar offers={[]} />
|
||||
<main className="flex-1">
|
||||
<section className="relative overflow-hidden py-24 sm:py-32">
|
||||
@@ -67,7 +67,7 @@ export default function GlobalNotFound() {
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
{/* Minimal static footer — the full Footer reads live CMS data,
|
||||
{/* Minimal static footer - the full Footer reads live CMS data,
|
||||
which isn't available on this always-static 404 page. */}
|
||||
<footer className="bg-anouma-plum py-8 text-center text-sm text-anouma-cream-light">
|
||||
© {new Date().getFullYear()} {siteConfig.name}
|
||||
|
||||
+4
-4
@@ -2,7 +2,7 @@ import type { MetadataRoute } from "next";
|
||||
import { getSiteUrl, isSiteIndexable } from "@/lib/seo/config";
|
||||
|
||||
// Reads the CMS's SEO settings (site URL override, robots kill switch) on
|
||||
// every request — must not be statically prerendered at build time.
|
||||
// every request - must not be statically prerendered at build time.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function robots(): Promise<MetadataRoute.Robots> {
|
||||
@@ -10,7 +10,7 @@ export default async function robots(): Promise<MetadataRoute.Robots> {
|
||||
|
||||
if (!indexable) {
|
||||
// Global kill switch (SEO-Einstellungen → "Website für Suchmaschinen
|
||||
// sichtbar") — block everything rather than list exceptions.
|
||||
// sichtbar") - block everything rather than list exceptions.
|
||||
return { rules: [{ userAgent: "*", disallow: "/" }] };
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export default async function robots(): Promise<MetadataRoute.Robots> {
|
||||
"/admin",
|
||||
"/admin/*",
|
||||
"/api/*",
|
||||
// Customer accounts — never public.
|
||||
// Customer accounts - never public.
|
||||
"/konto",
|
||||
"/konto/*",
|
||||
"/login",
|
||||
@@ -32,7 +32,7 @@ export default async function robots(): Promise<MetadataRoute.Robots> {
|
||||
"/auth/*",
|
||||
// Private calendar-subscription feed (secret-token URLs).
|
||||
"/calendar/*",
|
||||
// Meeting join/call flow — session-gated, never a page worth indexing.
|
||||
// Meeting join/call flow - session-gated, never a page worth indexing.
|
||||
"/termine/*/beitreten",
|
||||
"/termine/*/call",
|
||||
],
|
||||
|
||||
+3
-3
@@ -2,7 +2,7 @@ import type { MetadataRoute } from "next";
|
||||
import { getAllEvents, getOffers, getPosts } from "@/lib/payload/content";
|
||||
import { getSiteUrl, isSiteIndexable } from "@/lib/seo/config";
|
||||
|
||||
// Reads live CMS content on every request — must not be statically
|
||||
// Reads live CMS content on every request - must not be statically
|
||||
// prerendered at build time (no DB is available during `next build`; see
|
||||
// AGENTS.md / the same convention used by every other CMS-backed route).
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -10,7 +10,7 @@ export const dynamic = "force-dynamic";
|
||||
// Static, always-public routes that aren't backed by a dynamic [slug]
|
||||
// collection. Deliberately excludes: /login, /registrieren, /konto*,
|
||||
// /admin*, /calendar/*, /auth/*, /termine/*/beitreten, /termine/*/call,
|
||||
// /api/* — none of those are public content (see app/robots.ts, which
|
||||
// /api/* - none of those are public content (see app/robots.ts, which
|
||||
// mirrors this same exclusion list).
|
||||
const staticRoutes: { path: string; changeFrequency: MetadataRoute.Sitemap[number]["changeFrequency"]; priority: number }[] = [
|
||||
{ path: "", changeFrequency: "weekly", priority: 1 },
|
||||
@@ -40,7 +40,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
}));
|
||||
|
||||
// getOffers/getAllEvents/getPosts already only return published,
|
||||
// publicly-readable documents (overrideAccess: false — see
|
||||
// publicly-readable documents (overrideAccess: false - see
|
||||
// lib/payload/content.ts's header comment).
|
||||
const [offers, events, posts] = await Promise.all([getOffers(), getAllEvents(), getPosts()]);
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
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: "Tagesstatistik",
|
||||
plural: "Tagesstatistiken",
|
||||
},
|
||||
admin: {
|
||||
group: "Auswertung",
|
||||
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,93 @@
|
||||
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: "Messereignis",
|
||||
plural: "Messereignisse",
|
||||
},
|
||||
admin: {
|
||||
group: "Auswertung",
|
||||
hidden: true,
|
||||
// High-volume, low-signal raw data - not meant for manual browsing.
|
||||
// The Analytics dashboard (a custom admin view) reads it programmatically.
|
||||
},
|
||||
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" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -11,10 +11,10 @@ export const AvailabilityOverrides: CollectionConfig = {
|
||||
useAsTitle: "date",
|
||||
defaultColumns: ["date", "type", "reason"],
|
||||
group: "Buchungen",
|
||||
description: "Einzelne Abweichungen von den wiederkehrenden Verfügbarkeiten — Urlaub, Feiertage, Krankheit oder Sonderöffnungszeiten.",
|
||||
description: "Einzelne Abweichungen von den wiederkehrenden Verfügbarkeiten - Urlaub, Feiertage, Krankheit oder Sonderöffnungszeiten.",
|
||||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
read: isAdmin,
|
||||
create: isAdmin,
|
||||
update: isAdmin,
|
||||
delete: isAdmin,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { APIError } from "payload";
|
||||
import type { CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionConfig, Where } from "payload";
|
||||
import { isAdmin, isAdminFieldLevel } from "@/access";
|
||||
import { combineDateAndTime } from "@/lib/meeting/status";
|
||||
import { acquireBookingDayLock } from "@/lib/booking/lock";
|
||||
import {
|
||||
alternativeProposedEmail,
|
||||
@@ -12,6 +14,7 @@ import {
|
||||
} from "@/lib/email/bookingTemplates";
|
||||
import { sendEmail } from "@/lib/email/sendBookingEmails";
|
||||
import type { Customer, Event, Offer } from "@/payload-types";
|
||||
import { berlinDateKey, formatBerlinDate, formatBerlinTime } from "@/lib/calendar/timezone";
|
||||
|
||||
export const APPOINTMENT_TYPES = [
|
||||
{ label: "Vor Ort", value: "onsite" },
|
||||
@@ -26,15 +29,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) =>
|
||||
@@ -48,87 +51,73 @@ async function notifyAdmins(
|
||||
await Promise.all(admins.filter((admin) => admin.email).map((admin) => sendEmail(admin.email, template)));
|
||||
}
|
||||
|
||||
// Runs whenever a booking is about to become "confirmed": locks the day
|
||||
// (see lib/booking/lock.ts), re-checks for overlapping confirmed bookings
|
||||
// inside that same lock/transaction, and — for online appointments — creates
|
||||
// the linked video-call Event (reusing the existing meeting/password system)
|
||||
// before the booking itself is written.
|
||||
const handleConfirmation: CollectionBeforeChangeHook = async ({ data, originalDoc, req }) => {
|
||||
// Covers both paths: an existing request being confirmed, AND Anna
|
||||
// manually creating a booking that's already "confirmed" from the start
|
||||
// (e.g. a phone booking) — both must go through the same conflict check.
|
||||
const wasAlreadyConfirmed = originalDoc?.status === "confirmed";
|
||||
if (data.status !== "confirmed" || wasAlreadyConfirmed) {
|
||||
return data;
|
||||
}
|
||||
|
||||
// Normalize Payload time-only inputs before conflict checks and synchronize
|
||||
// the private meeting whenever a booking is moved, cancelled or reactivated.
|
||||
export const handleConfirmation: CollectionBeforeChangeHook = async ({ data, originalDoc, req }) => {
|
||||
const status = data.status ?? originalDoc?.status ?? "pending";
|
||||
const date = data.date ?? originalDoc?.date;
|
||||
const startTime = data.startTime ?? originalDoc?.startTime;
|
||||
const endTime = data.endTime ?? originalDoc?.endTime;
|
||||
const appointmentType = data.appointmentType ?? originalDoc?.appointmentType;
|
||||
if (!date || !startTime || !endTime) return data; // Field-level validation will reject this anyway.
|
||||
if (!date || !startTime || !endTime) return data;
|
||||
const start = combineDateAndTime(date, startTime);
|
||||
const end = combineDateAndTime(date, endTime);
|
||||
if (!Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime()) || end <= start) {
|
||||
throw new APIError("Das Ende muss nach dem Beginn am selben Tag liegen.", 400, undefined, true);
|
||||
}
|
||||
data.date = start.toISOString();
|
||||
data.startTime = start.toISOString();
|
||||
data.endTime = end.toISOString();
|
||||
|
||||
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 conflictClauses: Where[] = [
|
||||
{ status: { equals: "confirmed" } },
|
||||
{ date: { greater_than_equal: dayStart } },
|
||||
{ date: { less_than_equal: dayEnd } },
|
||||
];
|
||||
if (originalDoc?.id) conflictClauses.push({ id: { not_equals: originalDoc.id } });
|
||||
|
||||
const { docs: sameDayConfirmed } = await req.payload.find({
|
||||
collection: "booking-requests",
|
||||
where: { and: conflictClauses },
|
||||
req,
|
||||
limit: 200,
|
||||
});
|
||||
|
||||
const newStart = new Date(startTime).getTime();
|
||||
const newEnd = new Date(endTime).getTime();
|
||||
const conflict = sameDayConfirmed.some((doc) => {
|
||||
const s = new Date(doc.startTime).getTime();
|
||||
const e = new Date(doc.endTime).getTime();
|
||||
return newStart < e && s < newEnd;
|
||||
});
|
||||
|
||||
if (conflict) {
|
||||
throw new APIError(
|
||||
"Dieser Zeitraum ist bereits mit einem anderen bestätigten Termin belegt. Bitte wähle eine andere Zeit oder lehne den überschneidenden Termin zuerst ab.",
|
||||
409,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
const alternative = data.proposedAlternative;
|
||||
if (alternative && (alternative.date || alternative.startTime || alternative.endTime)) {
|
||||
if (!alternative.date || !alternative.startTime || !alternative.endTime) {
|
||||
throw new APIError("Bitte den alternativen Termin vollständig angeben.", 400, undefined, true);
|
||||
}
|
||||
const altStart = combineDateAndTime(alternative.date, alternative.startTime);
|
||||
const altEnd = combineDateAndTime(alternative.date, alternative.endTime);
|
||||
if (!Number.isFinite(altStart.getTime()) || !Number.isFinite(altEnd.getTime()) || altEnd <= altStart) {
|
||||
throw new APIError("Der alternative Zeitraum ist ungültig.", 400, undefined, true);
|
||||
}
|
||||
data.proposedAlternative = { date: altStart.toISOString(), startTime: altStart.toISOString(), endTime: altEnd.toISOString() };
|
||||
}
|
||||
|
||||
if (appointmentType === "online" && !data.linkedEvent && !originalDoc?.linkedEvent) {
|
||||
const offer = (await req.payload.findByID({ collection: "offers", id: data.offer ?? originalDoc?.offer, req })) as Offer;
|
||||
const event = (await req.payload.create({
|
||||
collection: "events",
|
||||
req,
|
||||
data: {
|
||||
title: offer.title,
|
||||
date,
|
||||
startTime,
|
||||
endTime,
|
||||
category: "sonstiges",
|
||||
isOnline: true, // Events' own beforeChange hook auto-generates the password.
|
||||
isPrivateBooking: true,
|
||||
// Only set on the update-to-confirmed path — on create-as-confirmed
|
||||
// this booking doesn't have an id yet (assigned after insert), so
|
||||
// the back-reference is simply left blank in that one case.
|
||||
...(originalDoc?.id ? { bookingRequest: originalDoc.id } : {}),
|
||||
},
|
||||
// Created as a draft so it never appears in public /termine listings
|
||||
// (see lib/payload/content.ts) — it's still reachable via its direct
|
||||
// join link, which is exactly what a private 1:1 booking needs.
|
||||
draft: true,
|
||||
})) as Event;
|
||||
if (status === "confirmed") {
|
||||
await acquireBookingDayLock(req, dateKey(data.date));
|
||||
const conflictClauses: Where[] = [
|
||||
{ status: { equals: "confirmed" } },
|
||||
{ startTime: { less_than: data.endTime } },
|
||||
{ endTime: { greater_than: data.startTime } },
|
||||
];
|
||||
if (originalDoc?.id) conflictClauses.push({ id: { not_equals: originalDoc.id } });
|
||||
const { totalDocs } = await req.payload.count({ collection: "booking-requests", where: { and: conflictClauses }, req });
|
||||
if (totalDocs) throw new APIError("Dieser Zeitraum ist bereits mit einem bestätigten Termin belegt.", 409, undefined, true);
|
||||
}
|
||||
|
||||
const linked = data.linkedEvent ?? originalDoc?.linkedEvent;
|
||||
const linkedId = typeof linked === "object" ? linked?.id : linked;
|
||||
if (linkedId) {
|
||||
const existing = await req.payload.findByID({ collection: "events", id: linkedId, depth: 0, req });
|
||||
if (existing.isPrivateBooking) {
|
||||
const isOnline = status === "confirmed" && appointmentType === "online";
|
||||
if (existing.date !== data.date || existing.startTime !== data.startTime || existing.endTime !== data.endTime || existing.isOnline !== isOnline) {
|
||||
await req.payload.update({ collection: "events", id: existing.id, req, data: {
|
||||
date: data.date, startTime: data.startTime, endTime: data.endTime, isOnline,
|
||||
} });
|
||||
}
|
||||
}
|
||||
} else if (status === "confirmed" && appointmentType === "online") {
|
||||
const relatedOffer = data.offer ?? originalDoc?.offer;
|
||||
const offer = await req.payload.findByID({ collection: "offers", id: typeof relatedOffer === "object" ? relatedOffer.id : relatedOffer, req });
|
||||
const event = await req.payload.create({ collection: "events", req, draft: true, data: {
|
||||
title: offer.title,
|
||||
slug: `buchung-${randomUUID()}`,
|
||||
date: data.date, startTime: data.startTime, endTime: data.endTime,
|
||||
category: "sonstiges", isOnline: true, isPrivateBooking: true,
|
||||
...(originalDoc?.id ? { bookingRequest: originalDoc.id } : {}),
|
||||
} });
|
||||
data.linkedEvent = event.id;
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -181,7 +170,8 @@ const notifyByEmail: CollectionAfterChangeHook = async ({ doc, previousDoc, oper
|
||||
|
||||
const statusChanged = operation === "create" || previousDoc?.status !== doc.status;
|
||||
|
||||
if (statusChanged && doc.status === "confirmed") {
|
||||
const scheduleChanged = previousDoc && ["date", "startTime", "endTime", "appointmentType"].some((field) => doc[field] !== previousDoc[field]);
|
||||
if ((statusChanged || scheduleChanged) && doc.status === "confirmed") {
|
||||
let online: { joinUrl: string; password: string } | undefined;
|
||||
let onsite: { address: string; mapsUrl: string } | undefined;
|
||||
|
||||
@@ -193,7 +183,7 @@ const notifyByEmail: CollectionAfterChangeHook = async ({ doc, previousDoc, oper
|
||||
})) as Event;
|
||||
online = {
|
||||
joinUrl: `${serverUrl}/termine/${event.slug}/beitreten`,
|
||||
password: event.meetingPassword || "—",
|
||||
password: event.meetingPassword || "-",
|
||||
};
|
||||
} else if (doc.appointmentType === "onsite") {
|
||||
const settings = await req.payload.findGlobal({ slug: "booking-settings", req });
|
||||
@@ -208,7 +198,7 @@ const notifyByEmail: CollectionAfterChangeHook = async ({ doc, previousDoc, oper
|
||||
name: user.name,
|
||||
offerTitle: offer.title,
|
||||
dateLabel: fmtDate(doc.date),
|
||||
timeLabel: `${fmtTime(doc.startTime)} – ${fmtTime(doc.endTime)}`,
|
||||
timeLabel: `${fmtTime(doc.startTime)} - ${fmtTime(doc.endTime)}`,
|
||||
appointmentTypeLabel: appointmentTypeLabel(doc.appointmentType),
|
||||
online,
|
||||
onsite,
|
||||
@@ -237,7 +227,7 @@ const notifyByEmail: CollectionAfterChangeHook = async ({ doc, previousDoc, oper
|
||||
);
|
||||
}
|
||||
|
||||
const proposedNew = doc.proposedAlternative?.date && doc.proposedAlternative.date !== previousDoc?.proposedAlternative?.date;
|
||||
const proposedNew = doc.proposedAlternative?.date && JSON.stringify(doc.proposedAlternative) !== JSON.stringify(previousDoc?.proposedAlternative);
|
||||
if (proposedNew && doc.status === "pending") {
|
||||
await sendEmail(
|
||||
user.email,
|
||||
@@ -270,17 +260,17 @@ export const BookingRequests: CollectionConfig = {
|
||||
useAsTitle: "id",
|
||||
defaultColumns: ["offer", "user", "date", "startTime", "status"],
|
||||
group: "Buchungen",
|
||||
description: "Terminanfragen von Nutzer:innen — bestätigen, ablehnen oder einen alternativen Termin vorschlagen, indem du die Felder unten änderst und speicherst.",
|
||||
description: "Terminanfragen von Nutzern - bestätigen, ablehnen oder einen alternativen Termin vorschlagen, indem du die Felder unten änderst und speicherst.",
|
||||
},
|
||||
access: {
|
||||
// Customer-initiated writes always go through the vetted /api/booking/*
|
||||
// routes (Local API, elevated access, after the route verifies
|
||||
// ownership) — never directly through REST/GraphQL. This keeps a
|
||||
// ownership) - never directly through REST/GraphQL. This keeps a
|
||||
// customer from ever reading/writing another customer's booking, or
|
||||
// setting their own status to "confirmed" directly.
|
||||
create: isAdmin,
|
||||
read: ({ req }) => {
|
||||
if (req.user?.collection === "users") return true;
|
||||
if (req.user?.collection === "users") return req.user.role === "admin";
|
||||
if (req.user?.collection === "customers") return { user: { equals: req.user.id } };
|
||||
return false;
|
||||
},
|
||||
@@ -288,15 +278,26 @@ export const BookingRequests: CollectionConfig = {
|
||||
delete: isAdmin,
|
||||
},
|
||||
hooks: {
|
||||
beforeOperation: [async ({ args, operation, req }) => {
|
||||
if (operation === "create" || operation === "update" || operation === "delete") {
|
||||
await acquireBookingDayLock(req, "booking-calendar");
|
||||
}
|
||||
return args;
|
||||
}],
|
||||
beforeChange: [handleConfirmation],
|
||||
afterChange: [notifyByEmail],
|
||||
afterDelete: [async ({ doc, req }) => {
|
||||
const id = typeof doc.linkedEvent === "object" ? doc.linkedEvent?.id : doc.linkedEvent;
|
||||
if (id) await req.payload.update({ collection: "events", id, req, data: { isOnline: false } });
|
||||
return doc;
|
||||
}],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "user",
|
||||
type: "relationship",
|
||||
relationTo: "customers",
|
||||
label: "Nutzer:in",
|
||||
label: "Nutzer",
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { CollectionAfterChangeHook, CollectionConfig } from "payload";
|
||||
import { isAdmin } from "@/access";
|
||||
import { conversationReplyEmail } from "@/lib/email/conversationTemplates";
|
||||
import { sendEmail } from "@/lib/email/sendBookingEmails";
|
||||
|
||||
const sendAdminReply: CollectionAfterChangeHook = async ({ doc, operation, req }) => {
|
||||
if (operation !== "create" || doc.senderType !== "admin") return doc;
|
||||
|
||||
try {
|
||||
const conversation = await req.payload.findByID({
|
||||
collection: "conversations",
|
||||
id: typeof doc.conversation === "object" ? doc.conversation.id : doc.conversation,
|
||||
depth: 0,
|
||||
req,
|
||||
});
|
||||
const adminUser = req.user?.collection === "users" ? req.user : null;
|
||||
await sendEmail(
|
||||
conversation.email,
|
||||
conversationReplyEmail({
|
||||
name: conversation.name,
|
||||
subject: conversation.subject,
|
||||
body: doc.body,
|
||||
senderName: doc.senderName || adminUser?.name || "Anouma",
|
||||
threadKey: conversation.threadKey,
|
||||
}),
|
||||
{ replyTo: process.env.INBOUND_REPLY_TO || "reply-to@anouma.org" },
|
||||
);
|
||||
await req.payload.update({
|
||||
collection: "conversations",
|
||||
id: conversation.id,
|
||||
req,
|
||||
data: { status: "waiting", lastMessageAt: new Date().toISOString() },
|
||||
});
|
||||
} catch (err) {
|
||||
req.payload.logger.error({ err, msg: "conversation admin reply email failed" });
|
||||
}
|
||||
|
||||
return doc;
|
||||
};
|
||||
|
||||
export const ConversationMessages: CollectionConfig = {
|
||||
slug: "conversation-messages",
|
||||
labels: {
|
||||
singular: "Nachricht",
|
||||
plural: "Nachrichten",
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: "body",
|
||||
defaultColumns: ["conversation", "senderType", "senderName", "createdAt"],
|
||||
group: "Kommunikation",
|
||||
description: "Einzelne Nachrichten innerhalb einer Anfrage. Antworten aus der Verwaltung werden automatisch per E-Mail verschickt.",
|
||||
hidden: true,
|
||||
},
|
||||
access: {
|
||||
read: isAdmin,
|
||||
create: isAdmin,
|
||||
update: isAdmin,
|
||||
delete: isAdmin,
|
||||
},
|
||||
timestamps: true,
|
||||
hooks: {
|
||||
afterChange: [sendAdminReply],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "conversation",
|
||||
type: "relationship",
|
||||
relationTo: "conversations",
|
||||
label: "Anfrage",
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: "senderType",
|
||||
type: "select",
|
||||
label: "Absenderart",
|
||||
required: true,
|
||||
defaultValue: "admin",
|
||||
options: [
|
||||
{ label: "Anfragende Person", value: "visitor" },
|
||||
{ label: "Anouma", value: "admin" },
|
||||
],
|
||||
admin: { description: "Für Antworten aus der Verwaltung „Anouma“ auswählen. Eingehende E-Mails werden automatisch als anfragende Person gespeichert." },
|
||||
},
|
||||
{ name: "senderName", type: "text", label: "Absendername" },
|
||||
{ name: "senderEmail", type: "email", label: "Absender-E-Mail" },
|
||||
{
|
||||
name: "body",
|
||||
type: "textarea",
|
||||
label: "Nachricht",
|
||||
required: true,
|
||||
minLength: 1,
|
||||
},
|
||||
{
|
||||
name: "externalMessageId",
|
||||
type: "text",
|
||||
label: "Externe Nachrichten-ID",
|
||||
unique: true,
|
||||
index: true,
|
||||
admin: { position: "sidebar", readOnly: true },
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { CollectionConfig } from "payload";
|
||||
import { isAdmin, isAdminFieldLevel } from "@/access";
|
||||
|
||||
export const Conversations: CollectionConfig = {
|
||||
slug: "conversations",
|
||||
labels: {
|
||||
singular: "Anfrage",
|
||||
plural: "Anfragen",
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: "subject",
|
||||
defaultColumns: ["subject", "name", "email", "status", "lastMessageAt"],
|
||||
group: "Kommunikation",
|
||||
description: "Kontakt- und Terminanfragen als durchsuchbare Konversationen.",
|
||||
},
|
||||
access: {
|
||||
read: isAdmin,
|
||||
create: () => false,
|
||||
update: isAdmin,
|
||||
delete: isAdmin,
|
||||
},
|
||||
timestamps: true,
|
||||
fields: [
|
||||
{
|
||||
name: "subject",
|
||||
type: "text",
|
||||
label: "Betreff",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
type: "row",
|
||||
fields: [
|
||||
{ name: "name", type: "text", label: "Name", required: true, admin: { width: "50%" } },
|
||||
{ name: "email", type: "email", label: "E-Mail", required: true, admin: { width: "50%" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "type",
|
||||
type: "select",
|
||||
label: "Art",
|
||||
required: true,
|
||||
defaultValue: "contact",
|
||||
options: [
|
||||
{ label: "Kontakt", value: "contact" },
|
||||
{ label: "Terminanfrage", value: "appointment" },
|
||||
{ label: "Gruppenangebot", value: "group" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
type: "select",
|
||||
label: "Status",
|
||||
required: true,
|
||||
defaultValue: "open",
|
||||
options: [
|
||||
{ label: "Offen", value: "open" },
|
||||
{ label: "Wartet auf Antwort", value: "waiting" },
|
||||
{ label: "Erledigt", value: "closed" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "customer",
|
||||
type: "relationship",
|
||||
relationTo: "customers",
|
||||
label: "Kundenkonto",
|
||||
admin: { position: "sidebar", readOnly: true },
|
||||
},
|
||||
{
|
||||
name: "threadKey",
|
||||
type: "text",
|
||||
label: "E-Mail-Thread-Schlüssel",
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true,
|
||||
defaultValue: () => randomUUID(),
|
||||
access: { read: isAdminFieldLevel, update: () => false },
|
||||
admin: { position: "sidebar", readOnly: true },
|
||||
},
|
||||
{
|
||||
name: "lastMessageAt",
|
||||
type: "date",
|
||||
label: "Letzte Nachricht",
|
||||
index: true,
|
||||
admin: { position: "sidebar", readOnly: true },
|
||||
},
|
||||
],
|
||||
};
|
||||
+41
-19
@@ -1,24 +1,46 @@
|
||||
import type { CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionConfig } from "payload";
|
||||
import { isAdminFieldLevel } from "@/access";
|
||||
import { APIError } from "payload";
|
||||
import { allowVerificationEmail } from "@/lib/auth/verificationRateLimit";
|
||||
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;
|
||||
|
||||
if (!allowVerificationEmail(req, String(data.email ?? originalDoc?.email ?? ""), originalDoc?.id)) {
|
||||
throw new APIError("Zu viele Bestätigungs-E-Mails. Bitte versuche es später erneut.", 429, undefined, true);
|
||||
}
|
||||
|
||||
const token = generateVerificationToken();
|
||||
data.emailVerified = false;
|
||||
data.emailVerificationTokenHash = hashVerificationToken(token);
|
||||
@@ -27,10 +49,10 @@ 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;
|
||||
delete req.context.pendingVerificationToken;
|
||||
try {
|
||||
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3000";
|
||||
await sendEmail(
|
||||
@@ -51,13 +73,13 @@ export const Customers: CollectionConfig = {
|
||||
slug: "customers",
|
||||
labels: {
|
||||
singular: "Kundin/Kunde",
|
||||
plural: "Kund:innen",
|
||||
plural: "Kunden",
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: "name",
|
||||
defaultColumns: ["name", "email", "phone"],
|
||||
group: "Buchungen",
|
||||
description: "Konten der Website-Besucher:innen (nicht die Admin-Zugänge unter „Benutzer:innen“).",
|
||||
description: "Konten der Website-Besucher (nicht die Admin-Zugänge unter „Benutzer“).",
|
||||
},
|
||||
auth: {
|
||||
maxLoginAttempts: 8,
|
||||
@@ -69,7 +91,7 @@ export const Customers: CollectionConfig = {
|
||||
create: () => true,
|
||||
read: isSelfOrAdmin,
|
||||
update: isSelfOrAdmin,
|
||||
delete: ({ req }) => req.user?.collection === "users",
|
||||
delete: isAdmin,
|
||||
},
|
||||
hooks: {
|
||||
beforeChange: [issueVerificationToken],
|
||||
@@ -88,7 +110,7 @@ export const Customers: CollectionConfig = {
|
||||
label: "Telefonnummer",
|
||||
required: false,
|
||||
admin: {
|
||||
description: "Optional — für eine spätere Nutzung vorbereitet, aktuell nicht verpflichtend.",
|
||||
description: "Optional - für eine spätere Nutzung vorbereitet, aktuell nicht verpflichtend.",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -96,7 +118,7 @@ export const Customers: CollectionConfig = {
|
||||
type: "checkbox",
|
||||
label: "E-Mail bestätigt",
|
||||
defaultValue: false,
|
||||
// "create" AND "update" must both be locked down — otherwise a
|
||||
// "create" AND "update" must both be locked down - otherwise a
|
||||
// customer could simply include emailVerified: true in their own
|
||||
// registration or profile request and skip verification entirely.
|
||||
access: { create: isAdminFieldLevel, update: isAdminFieldLevel },
|
||||
@@ -108,7 +130,7 @@ export const Customers: CollectionConfig = {
|
||||
{
|
||||
name: "emailVerificationTokenHash",
|
||||
type: "text",
|
||||
// Never exposed or settable via any API — only the two hooks above
|
||||
// Never exposed or settable via any API - only the two hooks above
|
||||
// (running through the trusted Local API) ever touch this field.
|
||||
access: { create: () => false, read: () => false, update: () => false },
|
||||
admin: { hidden: true },
|
||||
|
||||
@@ -11,11 +11,14 @@ export const EventRegistrations: CollectionConfig = {
|
||||
useAsTitle: "name",
|
||||
defaultColumns: ["name", "email", "event", "status", "registeredAt"],
|
||||
group: "Inhalte",
|
||||
description: "Anmeldungen zu Terminen — wird beim öffentlichen Anmeldeformular befüllt.",
|
||||
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,
|
||||
@@ -62,14 +65,14 @@ export const EventRegistrations: CollectionConfig = {
|
||||
{
|
||||
name: "reminder60Sent",
|
||||
type: "checkbox",
|
||||
label: "Reminder 60 Min. gesendet",
|
||||
label: "Erinnerung 60 Min. gesendet",
|
||||
defaultValue: false,
|
||||
admin: { width: "50%" },
|
||||
},
|
||||
{
|
||||
name: "reminder30Sent",
|
||||
type: "checkbox",
|
||||
label: "Reminder 30 Min. gesendet",
|
||||
label: "Erinnerung 30 Min. gesendet",
|
||||
defaultValue: false,
|
||||
admin: { width: "50%" },
|
||||
},
|
||||
|
||||
+18
-9
@@ -1,8 +1,9 @@
|
||||
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";
|
||||
import { revokeChangedMeeting } from "@/lib/meeting/revoke";
|
||||
|
||||
export const EVENT_CATEGORIES = [
|
||||
{ label: "Prozessbegleitung", value: "prozessbegleitung" },
|
||||
@@ -17,10 +18,10 @@ export const EVENT_CATEGORIES = [
|
||||
|
||||
// Auto-generates the meeting password for new online events, and
|
||||
// regenerates it whenever the admin ticks "Neues Passwort generieren".
|
||||
// Never derived from the event id/slug — always a fresh random value.
|
||||
const setMeetingPassword: CollectionBeforeChangeHook = ({ data }) => {
|
||||
if (!data?.isOnline) return data;
|
||||
if (!data.meetingPassword || data.regeneratePassword) {
|
||||
// Never derived from the event id/slug - always a fresh random value.
|
||||
const setMeetingPassword: CollectionBeforeChangeHook = ({ data, originalDoc }) => {
|
||||
if (!(data.isOnline ?? originalDoc?.isOnline)) return data;
|
||||
if (!(data.meetingPassword ?? originalDoc?.meetingPassword) || data.regeneratePassword || originalDoc?.isOnline === false) {
|
||||
data.meetingPassword = generateMeetingPassword();
|
||||
}
|
||||
data.regeneratePassword = false;
|
||||
@@ -37,7 +38,7 @@ export const Events: CollectionConfig = {
|
||||
useAsTitle: "title",
|
||||
defaultColumns: ["title", "date", "category", "_status"],
|
||||
group: "Inhalte",
|
||||
description: "Termine und Veranstaltungen — erscheinen automatisch unter /termine.",
|
||||
description: "Termine und Veranstaltungen - erscheinen automatisch unter /termine.",
|
||||
},
|
||||
versions: {
|
||||
drafts: {
|
||||
@@ -52,6 +53,7 @@ export const Events: CollectionConfig = {
|
||||
},
|
||||
hooks: {
|
||||
beforeChange: [setMeetingPassword],
|
||||
afterChange: [revokeChangedMeeting],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
@@ -147,7 +149,7 @@ export const Events: CollectionConfig = {
|
||||
},
|
||||
{
|
||||
type: "collapsible",
|
||||
label: "Online-Termin (Video-Call)",
|
||||
label: "Online-Termin (Videoanruf)",
|
||||
fields: [
|
||||
{
|
||||
name: "isOnline",
|
||||
@@ -162,6 +164,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),
|
||||
@@ -211,14 +220,14 @@ export const Events: CollectionConfig = {
|
||||
{
|
||||
name: "hostReminder60Sent",
|
||||
type: "checkbox",
|
||||
label: "Host-Reminder 60 Min. gesendet",
|
||||
label: "Erinnerung an Anna 60 Min. gesendet",
|
||||
defaultValue: false,
|
||||
admin: { readOnly: true, width: "50%" },
|
||||
},
|
||||
{
|
||||
name: "hostReminder30Sent",
|
||||
type: "checkbox",
|
||||
label: "Host-Reminder 30 Min. gesendet",
|
||||
label: "Erinnerung an Anna 30 Min. gesendet",
|
||||
defaultValue: false,
|
||||
admin: { readOnly: true, width: "50%" },
|
||||
},
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
import { isAdmin } from "@/access";
|
||||
|
||||
export const GroupRegistrations: CollectionConfig = {
|
||||
slug: "group-registrations",
|
||||
labels: {
|
||||
singular: "Gruppenanmeldung",
|
||||
plural: "Gruppenanmeldungen",
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: "name",
|
||||
defaultColumns: ["name", "email", "offer", "status", "registeredAt"],
|
||||
group: "Buchungen",
|
||||
description: "Anmeldungen und Bewerbungen für Gruppenangebote.",
|
||||
},
|
||||
access: {
|
||||
read: isAdmin,
|
||||
create: () => false,
|
||||
update: isAdmin,
|
||||
delete: isAdmin,
|
||||
},
|
||||
timestamps: true,
|
||||
fields: [
|
||||
{ name: "offer", type: "relationship", relationTo: "offers", label: "Angebot", required: true, index: true },
|
||||
{
|
||||
type: "row",
|
||||
fields: [
|
||||
{ name: "name", type: "text", label: "Name", required: true, admin: { width: "50%" } },
|
||||
{ name: "email", type: "email", label: "E-Mail", required: true, admin: { width: "50%" } },
|
||||
],
|
||||
},
|
||||
{ name: "phone", type: "text", label: "Telefon" },
|
||||
{ name: "message", type: "textarea", label: "Nachricht / Motivation" },
|
||||
{
|
||||
name: "status",
|
||||
type: "select",
|
||||
label: "Status",
|
||||
required: true,
|
||||
defaultValue: "pending",
|
||||
options: [
|
||||
{ label: "Zu prüfen", value: "pending" },
|
||||
{ label: "Angenommen", value: "accepted" },
|
||||
{ label: "Warteliste", value: "waitlist" },
|
||||
{ label: "Abgelehnt", value: "rejected" },
|
||||
{ label: "Storniert", value: "cancelled" },
|
||||
],
|
||||
},
|
||||
{ name: "registeredAt", type: "date", label: "Eingegangen am", defaultValue: () => new Date().toISOString(), admin: { position: "sidebar" } },
|
||||
],
|
||||
};
|
||||
@@ -12,8 +12,9 @@ export const Media: CollectionConfig = {
|
||||
plural: "Bilder",
|
||||
},
|
||||
admin: {
|
||||
group: "Inhalte",
|
||||
useAsTitle: "alt",
|
||||
description: "Bilder für die Website — Alt-Text ist für Barrierefreiheit und SEO Pflicht.",
|
||||
description: "Bilder für die Website. Alt-Text und Bildbeschreibung sind optional. Bis zu 100 Dateien pro Upload.",
|
||||
},
|
||||
access: {
|
||||
// Images are public so the website can display them without a login.
|
||||
@@ -24,7 +25,11 @@ export const Media: CollectionConfig = {
|
||||
},
|
||||
upload: {
|
||||
staticDir: path.resolve(dirname, "..", "media"),
|
||||
mimeTypes: ["image/jpeg", "image/png", "image/webp", "image/svg+xml"],
|
||||
bulkUpload: true,
|
||||
// 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" },
|
||||
@@ -38,9 +43,9 @@ export const Media: CollectionConfig = {
|
||||
name: "alt",
|
||||
type: "text",
|
||||
label: "Alt-Text",
|
||||
required: true,
|
||||
required: false,
|
||||
admin: {
|
||||
description: "Kurze Beschreibung des Bildes für Screenreader und Suchmaschinen.",
|
||||
description: "Optional. Kann später für Barrierefreiheit und SEO ergänzt werden.",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
+68
-2
@@ -5,7 +5,7 @@ import { seoFields } from "@/fields/seo";
|
||||
|
||||
// Groups the 7 individual offers for the mega menu and the /angebote
|
||||
// overview page (Kindergruppen bundles Erdenkinder + Mädchenkreis, Singkreise
|
||||
// bundles the three singing circles — matching the site's navigation).
|
||||
// bundles the three singing circles - matching the site's navigation).
|
||||
export const OFFER_CATEGORIES = [
|
||||
{ label: "Prozessbegleitung", value: "prozessbegleitung" },
|
||||
{ label: "Doula-Begleitung", value: "doula-begleitung" },
|
||||
@@ -23,7 +23,7 @@ export const Offers: CollectionConfig = {
|
||||
useAsTitle: "title",
|
||||
defaultColumns: ["title", "category", "order", "_status"],
|
||||
group: "Inhalte",
|
||||
description: "Die Angebote von Anouma — erscheinen im Menü und auf /angebote.",
|
||||
description: "Die Angebote von Anouma - erscheinen im Menü und auf /angebote.",
|
||||
},
|
||||
defaultSort: "order",
|
||||
versions: {
|
||||
@@ -106,6 +106,72 @@ export const Offers: CollectionConfig = {
|
||||
description: "Aktiviert die Terminbuchung mit Kalenderauswahl auf der Angebotsseite.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bookingMode",
|
||||
type: "select",
|
||||
label: "Buchungsart",
|
||||
required: true,
|
||||
defaultValue: "individual",
|
||||
options: [
|
||||
{ label: "Einzeltermin mit Kalender", value: "individual" },
|
||||
{ label: "Gruppenangebot / Anmeldung", value: "group" },
|
||||
],
|
||||
admin: {
|
||||
position: "sidebar",
|
||||
description: "Gruppenangebote werden über Anmeldung, feste Wiederholung oder Bewerbungsfrist organisiert.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "groupRegistrationMode",
|
||||
type: "select",
|
||||
label: "Gruppenmodus",
|
||||
defaultValue: "fixed",
|
||||
options: [
|
||||
{ label: "Fester Termin (wöchentlich/monatlich)", value: "fixed" },
|
||||
{ label: "Plätze mit Bewerbungsfrist", value: "capacity" },
|
||||
{ label: "Unverbindliche Anmeldung", value: "request" },
|
||||
],
|
||||
admin: { condition: (data) => data?.bookingMode === "group" },
|
||||
},
|
||||
{
|
||||
name: "groupSchedule",
|
||||
type: "group",
|
||||
label: "Wiederkehrender Termin",
|
||||
admin: { condition: (data) => data?.bookingMode === "group" && data?.groupRegistrationMode === "fixed" },
|
||||
fields: [
|
||||
{
|
||||
name: "recurrence",
|
||||
type: "select",
|
||||
label: "Rhythmus",
|
||||
defaultValue: "weekly",
|
||||
options: [
|
||||
{ label: "Jede Woche", value: "weekly" },
|
||||
{ label: "Jeden Monat", value: "monthly" },
|
||||
],
|
||||
},
|
||||
{ name: "weekday", type: "select", label: "Wochentag", options: [
|
||||
{ label: "Montag", value: "monday" }, { label: "Dienstag", value: "tuesday" }, { label: "Mittwoch", value: "wednesday" },
|
||||
{ label: "Donnerstag", value: "thursday" }, { label: "Freitag", value: "friday" }, { label: "Samstag", value: "saturday" }, { label: "Sonntag", value: "sunday" },
|
||||
] },
|
||||
{ name: "monthDay", type: "number", label: "Tag im Monat", min: 1, max: 31, admin: { condition: (data) => data?.recurrence === "monthly" } },
|
||||
{ name: "startTime", type: "text", label: "Beginn", admin: { description: "z. B. 18:00 Uhr" } },
|
||||
{ name: "endTime", type: "text", label: "Ende", admin: { description: "z. B. 20:00 Uhr" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "row",
|
||||
admin: { condition: (data) => data?.bookingMode === "group" },
|
||||
fields: [
|
||||
{ name: "groupCapacity", type: "number", label: "Plätze", min: 1, admin: { width: "50%" } },
|
||||
{ name: "registrationDeadline", type: "date", label: "Anmelde-/Bewerbungsfrist", admin: { width: "50%", date: { pickerAppearance: "dayOnly", displayFormat: "dd.MM.yyyy" } } },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "groupInfo",
|
||||
type: "textarea",
|
||||
label: "Hinweis zum Gruppenangebot",
|
||||
admin: { condition: (data) => data?.bookingMode === "group", description: "Wird auf der Angebotsseite oberhalb des Anmeldeformulars angezeigt." },
|
||||
},
|
||||
{
|
||||
type: "row",
|
||||
fields: [
|
||||
|
||||
@@ -68,7 +68,7 @@ export const Posts: CollectionConfig = {
|
||||
{
|
||||
name: "author",
|
||||
type: "text",
|
||||
label: "Autor:in",
|
||||
label: "Autor",
|
||||
defaultValue: "Anouma",
|
||||
admin: { position: "sidebar" },
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -10,7 +14,7 @@ function normalizePath(value: string): string {
|
||||
/**
|
||||
* Simple slug-change redirect table (see lib/seo/redirects.ts). Deliberately
|
||||
* scoped to the three dynamic [slug] detail routes (Angebote/Termine/
|
||||
* Aktuelles) — that's the only place slugs actually live and can change, so
|
||||
* Aktuelles) - that's the only place slugs actually live and can change, so
|
||||
* looking this up only there avoids adding a database lookup to every
|
||||
* single request the way a catch-all middleware would.
|
||||
*/
|
||||
@@ -62,8 +66,8 @@ export const Redirects: CollectionConfig = {
|
||||
required: true,
|
||||
defaultValue: "permanent",
|
||||
options: [
|
||||
{ label: "301 – Dauerhaft", value: "permanent" },
|
||||
{ label: "302 – Vorübergehend", value: "temporary" },
|
||||
{ label: "301 - Dauerhaft", value: "permanent" },
|
||||
{ label: "302 - Vorübergehend", value: "temporary" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
+22
-5
@@ -4,18 +4,19 @@ import { isAdmin, isAdminFieldLevel } from "@/access";
|
||||
export const Users: CollectionConfig = {
|
||||
slug: "users",
|
||||
labels: {
|
||||
singular: "Benutzer:in",
|
||||
plural: "Benutzer:innen",
|
||||
singular: "Benutzer",
|
||||
plural: "Benutzer",
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: "name",
|
||||
defaultColumns: ["name", "email", "role"],
|
||||
group: "Verwaltung",
|
||||
description: "Zugänge für den geschützten Admin-Bereich.",
|
||||
},
|
||||
auth: {
|
||||
// Payload shows a "create first user" screen automatically when this
|
||||
// collection is empty, bypassing normal access control just for that
|
||||
// one-time setup — no hardcoded default password is ever needed.
|
||||
// one-time setup - no hardcoded default password is ever needed.
|
||||
maxLoginAttempts: 5,
|
||||
lockTime: 10 * 60 * 1000,
|
||||
},
|
||||
@@ -40,16 +41,32 @@ export const Users: CollectionConfig = {
|
||||
defaultValue: "admin",
|
||||
// Only "admin" exists today; add further roles here later and extend
|
||||
// the checks in access/index.ts to match.
|
||||
options: [{ label: "Administrator:in", value: "admin" }],
|
||||
options: [{ label: "Administrator", value: "admin" }],
|
||||
access: {
|
||||
update: isAdminFieldLevel,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "canViewAnalytics",
|
||||
type: "checkbox",
|
||||
label: "Auswertung 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 die Auswertung im Admin-Bereich sehen kann.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "calendarFeedTokenHash",
|
||||
type: "text",
|
||||
// Only ever set by /api/admin/calendar-feed/regenerate (see
|
||||
// lib/calendar/feedToken.ts) — never exposed or directly settable via
|
||||
// lib/calendar/feedToken.ts) - never exposed or directly settable via
|
||||
// any API, so the plaintext token is never retrievable again after
|
||||
// it's shown once at generation time.
|
||||
access: { create: () => false, read: () => false, update: () => false },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import type { ReactNode } from "react";
|
||||
type Variant = "primary" | "secondary" | "ghost" | "invert" | "invertOutline";
|
||||
|
||||
// Text colors are chosen to meet WCAG AA (4.5:1) against every background
|
||||
// tone the button appears on — see components/Section.tsx for the tones.
|
||||
// tone the button appears on - see components/Section.tsx for the tones.
|
||||
const variants: Record<Variant, string> = {
|
||||
primary: "bg-anouma-mauve-dark text-white hover:bg-anouma-plum",
|
||||
secondary:
|
||||
|
||||
+1
-4
@@ -1,5 +1,4 @@
|
||||
import { Button } from "@/components/Button";
|
||||
import { OrganicBlob } from "@/components/OrganicBlob";
|
||||
import { Container } from "@/components/Section";
|
||||
|
||||
type CTAProps = {
|
||||
@@ -20,9 +19,7 @@ export function CTA({
|
||||
secondaryLabel = "Kontakt aufnehmen",
|
||||
}: CTAProps) {
|
||||
return (
|
||||
<section className="relative overflow-hidden bg-anouma-plum py-20 text-anouma-cream-light sm:py-24">
|
||||
<OrganicBlob tone="mauve" className="-left-20 -top-20 h-80 w-80 opacity-30" />
|
||||
<OrganicBlob tone="rose" className="-bottom-24 -right-16 h-72 w-72 opacity-20" />
|
||||
<section className="border-y border-anouma-cream-light/15 bg-anouma-mauve-dark py-16 text-anouma-cream-light sm:py-20">
|
||||
<Container className="relative text-center">
|
||||
<h2 className="mx-auto max-w-2xl text-balance font-serif text-4xl font-medium leading-tight sm:text-5xl">
|
||||
{title}
|
||||
|
||||
+46
-41
@@ -1,73 +1,78 @@
|
||||
"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";
|
||||
import { TurnstileWidget } from "@/components/TurnstileWidget";
|
||||
|
||||
type ContactFormProps = {
|
||||
toEmail: string;
|
||||
toEmail?: string;
|
||||
subjectPrefix?: string;
|
||||
type?: "contact" | "appointment";
|
||||
submitLabel?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens the visitor's email client with a pre-filled message. There is no
|
||||
* backend configured yet — swap this for a Server Action once a mail
|
||||
* provider is connected.
|
||||
*/
|
||||
export function ContactForm({
|
||||
toEmail,
|
||||
subjectPrefix = "Nachricht über anouma.org",
|
||||
siteKey,
|
||||
subjectPrefix = "Allgemeine Anfrage",
|
||||
type = "contact",
|
||||
submitLabel = "Nachricht senden",
|
||||
}: ContactFormProps) {
|
||||
const [sent, setSent] = useState(false);
|
||||
}: ContactFormProps & { siteKey: string }) {
|
||||
const [state, setState] = useState<{ sent: boolean; error?: string }>({ sent: false });
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [resetKey, setResetKey] = useState(0);
|
||||
|
||||
function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const data = new FormData(e.currentTarget);
|
||||
const name = String(data.get("name") ?? "");
|
||||
const email = String(data.get("email") ?? "");
|
||||
const message = String(data.get("message") ?? "");
|
||||
|
||||
const subject = encodeURIComponent(`${subjectPrefix} von ${name}`);
|
||||
const body = encodeURIComponent(`${message}\n\n— ${name} (${email})`);
|
||||
window.location.href = `mailto:${toEmail}?subject=${subject}&body=${body}`;
|
||||
setSent(true);
|
||||
setSubmitting(true);
|
||||
setState({ sent: false });
|
||||
try {
|
||||
const response = await fetch("/api/contact/inquiry", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, email, message, subject: `${subjectPrefix} von ${name}`, type, token }),
|
||||
});
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
setState({ sent: false, error: result.error || "Die Nachricht konnte nicht gesendet werden." });
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
track("contact_clicked");
|
||||
setState({ sent: true });
|
||||
e.currentTarget.reset();
|
||||
} catch {
|
||||
setState({ sent: false, error: "Verbindung fehlgeschlagen. Bitte versuche es erneut." });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
setResetKey((key) => key + 1);
|
||||
}
|
||||
}
|
||||
|
||||
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} />
|
||||
<TurnstileWidget siteKey={siteKey} action="contact" resetKey={resetKey} onTokenChange={setToken} />
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !token}
|
||||
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"
|
||||
>
|
||||
{submitLabel}
|
||||
{submitting ? "Wird gesendet …" : submitLabel}
|
||||
</button>
|
||||
{sent && (
|
||||
{state.sent && (
|
||||
<p className="text-sm text-anouma-olive" role="status">
|
||||
Dein E-Mail-Programm sollte sich jetzt geöffnet haben. Falls nicht, schreib gerne direkt an{" "}
|
||||
{toEmail}.
|
||||
Danke, deine Nachricht ist angekommen. Anna antwortet dir per E-Mail; du kannst direkt auf ihre Antwort reagieren.
|
||||
</p>
|
||||
)}
|
||||
{state.error && <p className="text-sm text-red-700" role="alert">{state.error}</p>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -12,11 +12,11 @@ export async function Footer() {
|
||||
<div className="mx-auto max-w-6xl px-6 py-16 sm:px-8 lg:px-12">
|
||||
<div className="grid gap-12 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<Link href="/" className="font-serif text-2xl font-semibold tracking-[0.08em]">
|
||||
<Link href="/" className="font-antropos text-2xl font-semibold tracking-[0.08em]">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { TextField, TextAreaField } from "@/components/FormField";
|
||||
import { TurnstileWidget } from "@/components/TurnstileWidget";
|
||||
|
||||
export function GroupRegistrationForm({ offerSlug, siteKey }: { offerSlug: string; siteKey: string }) {
|
||||
const [state, setState] = useState<{ sent: boolean; error?: string }>({ sent: false });
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [resetKey, setResetKey] = useState(0);
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setBusy(true);
|
||||
setState({ sent: false });
|
||||
const data = new FormData(event.currentTarget);
|
||||
try {
|
||||
const response = await fetch("/api/group-registration", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
offerSlug,
|
||||
name: data.get("name"),
|
||||
email: data.get("email"),
|
||||
phone: data.get("phone"),
|
||||
message: data.get("message"),
|
||||
token,
|
||||
}),
|
||||
});
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (!response.ok) setState({ sent: false, error: result.error || "Die Anmeldung konnte nicht gesendet werden." });
|
||||
else { setState({ sent: true }); event.currentTarget.reset(); }
|
||||
} catch { setState({ sent: false, error: "Verbindung fehlgeschlagen. Bitte versuche es erneut." }); }
|
||||
finally { setBusy(false); setResetKey((key) => key + 1); }
|
||||
}
|
||||
|
||||
if (state.sent) return <p className="rounded-2xl bg-anouma-cream-light p-5 text-sm leading-relaxed text-anouma-olive" role="status">Danke, deine Anmeldung ist angekommen. Anna meldet sich, sobald sie die Plätze geprüft hat.</p>;
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="space-y-5">
|
||||
<TextField label="Name" id="group-name" name="name" type="text" required autoComplete="name" />
|
||||
<TextField label="E-Mail" id="group-email" name="email" type="email" required autoComplete="email" />
|
||||
<TextField label="Telefon (optional)" id="group-phone" name="phone" type="tel" autoComplete="tel" />
|
||||
<TextAreaField label="Nachricht / Motivation (optional)" id="group-message" name="message" rows={4} />
|
||||
<TurnstileWidget siteKey={siteKey} action="group-registration" resetKey={resetKey} onTokenChange={setToken} />
|
||||
{state.error && <p className="text-sm text-red-700" role="alert">{state.error}</p>}
|
||||
<button type="submit" disabled={busy || !token} className="rounded-full bg-anouma-mauve-dark px-7 py-3.5 text-sm font-medium text-white transition-colors hover:bg-anouma-plum disabled:opacity-60">{busy ? "Wird gesendet …" : "Anmeldung senden"}</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
+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
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ImageMood } from "@/lib/angebote";
|
||||
/**
|
||||
* Renders a photo when `src` is provided, otherwise falls back to a warm,
|
||||
* organic gradient placeholder in the given mood. Pages should always pass
|
||||
* `src` once real photography exists — no other prop changes are needed.
|
||||
* `src` once real photography exists - no other prop changes are needed.
|
||||
*/
|
||||
|
||||
const moodGradients: Record<ImageMood, string> = {
|
||||
|
||||
+58
-49
@@ -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,11 +65,11 @@ 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="/"
|
||||
className="font-serif text-2xl font-semibold tracking-[0.08em] text-anouma-mauve-dark"
|
||||
className="font-antropos text-2xl font-semibold tracking-[0.08em] text-anouma-mauve-dark"
|
||||
>
|
||||
{siteConfig.name.toUpperCase()}
|
||||
</Link>
|
||||
@@ -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) {
|
||||
/** 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,
|
||||
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,87 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { loadTurnstile, type TurnstileWidgetId } from "@/lib/turnstile/client";
|
||||
|
||||
type TurnstileWidgetProps = {
|
||||
siteKey: string;
|
||||
action: string;
|
||||
resetKey: number;
|
||||
onTokenChange: (token: string | null) => void;
|
||||
};
|
||||
|
||||
export function TurnstileWidget({ siteKey, action, resetKey, onTokenChange }: TurnstileWidgetProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const widgetIdRef = useRef<TurnstileWidgetId | null>(null);
|
||||
const onTokenChangeRef = useRef(onTokenChange);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [verificationStatus, setVerificationStatus] = useState<"idle" | "ready" | "expired">("idle");
|
||||
|
||||
useEffect(() => {
|
||||
onTokenChangeRef.current = onTokenChange;
|
||||
}, [onTokenChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (resetKey === 0) return;
|
||||
if (widgetIdRef.current !== null && window.turnstile) {
|
||||
window.turnstile.reset(widgetIdRef.current);
|
||||
}
|
||||
onTokenChangeRef.current(null);
|
||||
}, [resetKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!siteKey || !containerRef.current || widgetIdRef.current !== null) return;
|
||||
|
||||
let cancelled = false;
|
||||
loadTurnstile()
|
||||
.then((turnstile) => {
|
||||
if (cancelled || !containerRef.current || widgetIdRef.current !== null) return;
|
||||
|
||||
try {
|
||||
widgetIdRef.current = turnstile.render(containerRef.current, {
|
||||
sitekey: siteKey,
|
||||
action,
|
||||
callback: (token) => {
|
||||
setVerificationStatus("ready");
|
||||
onTokenChangeRef.current(token);
|
||||
},
|
||||
"expired-callback": () => {
|
||||
setVerificationStatus("expired");
|
||||
onTokenChangeRef.current(null);
|
||||
},
|
||||
"error-callback": () => {
|
||||
onTokenChangeRef.current(null);
|
||||
setLoadError(true);
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
setLoadError(true);
|
||||
onTokenChangeRef.current(null);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setLoadError(true);
|
||||
onTokenChangeRef.current(null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [action, siteKey]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2" aria-live="polite">
|
||||
<div ref={containerRef} data-turnstile-container="true" />
|
||||
{!siteKey || loadError ? (
|
||||
<p className="text-sm text-red-700" role="alert">
|
||||
Die Sicherheitsprüfung ist momentan nicht verfügbar. Bitte lade die Seite neu und versuche es erneut.
|
||||
</p>
|
||||
) : verificationStatus === "expired" ? (
|
||||
<p className="text-sm text-red-700" role="alert">
|
||||
Die Sicherheitsprüfung ist abgelaufen. Bitte bestätige sie erneut.
|
||||
</p>
|
||||
) : null}
|
||||
</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,199 @@
|
||||
import type { AdminViewServerProps } from "payload";
|
||||
import Link from "next/link";
|
||||
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: "Handy", desktop: "Computer", 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}>
|
||||
<Link href="/admin" className="anouma-admin-back">Zur Übersicht</Link>
|
||||
<h1 className={styles.title}>Auswertung</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}>
|
||||
<Link href="/admin" className="anouma-admin-back">Zur Übersicht</Link>
|
||||
<h1 className={styles.title}>Auswertung</h1>
|
||||
<p className={styles.subtitle}>Datensparsame Reichweitenmessung ohne personenbezogene 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 „Auswertungs-Einstellungen“).
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className={styles.tiles}>
|
||||
<Tile label="Besuche" 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 und Besuche</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="Ereignisse"
|
||||
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>Auswertung und Datenschutz</h3>
|
||||
<p>Erhobene Daten:</p>
|
||||
<ul>
|
||||
<li>Seitenaufrufe und Ereignisse (siehe Tabelle „Ereignisse“)</li>
|
||||
<li>Gerätetyp (Handy/Computer/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 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 „Auswertungs-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;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user