feat: redesign CMS and unify German admin UI
This commit is contained in:
+8
-8
@@ -1,6 +1,6 @@
|
|||||||
# This file documents every variable the app/deployment actually reads.
|
# This file documents every variable the app/deployment actually reads.
|
||||||
# In production, setup.sh generates the real file at /opt/anouma/.env for
|
# 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
|
# this to .env at the repo root and fill in DATABASE_URI/PAYLOAD_SECRET
|
||||||
# (see README.md → "Lokale Entwicklung").
|
# (see README.md → "Lokale Entwicklung").
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
# dev server against the Dockerized Postgres, use "127.0.0.1" instead.
|
# dev server against the Dockerized Postgres, use "127.0.0.1" instead.
|
||||||
DATABASE_URI=postgresql://postgres:postgres@postgres:5432/anouma
|
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).
|
# sync with DATABASE_URI above (setup.sh does this for you).
|
||||||
POSTGRES_USER=postgres
|
POSTGRES_USER=postgres
|
||||||
POSTGRES_PASSWORD=replace-with-a-long-random-secret
|
POSTGRES_PASSWORD=replace-with-a-long-random-secret
|
||||||
@@ -40,11 +40,11 @@ CRON_SECRET=replace-with-a-long-random-secret
|
|||||||
NEXT_PUBLIC_SERVER_URL=http://localhost:3000
|
NEXT_PUBLIC_SERVER_URL=http://localhost:3000
|
||||||
|
|
||||||
# Port the app container publishes on the host (behind a reverse proxy this
|
# 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
|
APP_PORT=3000
|
||||||
|
|
||||||
# ── Email (SMTP) ────────────────────────────────────────────────────────
|
# ── Email (SMTP) ────────────────────────────────────────────────────────
|
||||||
# The existing ANOUMA mail system — no external newsletter service.
|
# The existing ANOUMA mail system - no external newsletter service.
|
||||||
SMTP_HOST=
|
SMTP_HOST=
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
SMTP_USER=
|
SMTP_USER=
|
||||||
@@ -79,7 +79,7 @@ DOCKER_NETWORK=anouma-network
|
|||||||
|
|
||||||
# Name of an existing external Docker network to join (e.g. Nginx Proxy
|
# 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
|
# 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=
|
NPM_NETWORK=
|
||||||
|
|
||||||
# ── Backups (used by deploy.sh) ─────────────────────────────────────────
|
# ── Backups (used by deploy.sh) ─────────────────────────────────────────
|
||||||
@@ -87,7 +87,7 @@ NPM_NETWORK=
|
|||||||
BACKUP_RETENTION_DAYS=14
|
BACKUP_RETENTION_DAYS=14
|
||||||
|
|
||||||
# ── Container registry & deployment (used by deploy.sh) ────────────────
|
# ── Container registry & deployment (used by deploy.sh) ────────────────
|
||||||
# GitHub Container Registry. deploy.sh builds nothing — it only ever pulls
|
# GitHub Container Registry. deploy.sh builds nothing - it only ever pulls
|
||||||
# the image that GitHub Actions built and pushed.
|
# the image that GitHub Actions built and pushed.
|
||||||
REGISTRY=ghcr.io
|
REGISTRY=ghcr.io
|
||||||
REGISTRY_REPO=whitehai11/anouma
|
REGISTRY_REPO=whitehai11/anouma
|
||||||
@@ -96,10 +96,10 @@ REGISTRY_REPO=whitehai11/anouma
|
|||||||
# account password.
|
# account password.
|
||||||
REGISTRY_USERNAME=
|
REGISTRY_USERNAME=
|
||||||
REGISTRY_PASSWORD=
|
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.
|
# by hand. Empty until the first release has been deployed.
|
||||||
ANOUMA_IMAGE=
|
ANOUMA_IMAGE=
|
||||||
# Informational only (shown in the setup summary) — actual automatic
|
# Informational only (shown in the setup summary) - actual automatic
|
||||||
# deployment is controlled by whether release.yml + its GitHub secrets are
|
# deployment is controlled by whether release.yml + its GitHub secrets are
|
||||||
# configured, not by this flag.
|
# configured, not by this flag.
|
||||||
AUTO_DEPLOY=true
|
AUTO_DEPLOY=true
|
||||||
|
|||||||
+1
-1
@@ -49,7 +49,7 @@ next-env.d.ts
|
|||||||
*.deb
|
*.deb
|
||||||
*.AppImage
|
*.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
|
# 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)
|
# repo checkout, but these are ignored here too in case of local testing)
|
||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
# This is NOT the Next.js you know
|
# 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 -->
|
<!-- END:nextjs-agent-rules -->
|
||||||
|
|||||||
+26
-26
@@ -1,6 +1,6 @@
|
|||||||
# ANOUMA — Deployment
|
# ANOUMA - Deployment
|
||||||
|
|
||||||
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.
|
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
|
```text
|
||||||
Developer → git push main → GitHub Actions (CI)
|
Developer → git push main → GitHub Actions (CI)
|
||||||
@@ -9,7 +9,7 @@ Developer → git push main → GitHub Actions (CI)
|
|||||||
→ SSH → deploy.sh → docker compose pull → migrate → up -d → Healthcheck → LIVE
|
→ SSH → deploy.sh → docker compose pull → migrate → up -d → Healthcheck → LIVE
|
||||||
```
|
```
|
||||||
|
|
||||||
Building and testing happen exactly once, on `main` — tagging a release never
|
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
|
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
|
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
|
hasn't finished yet) fails the release with a clear error instead of quietly
|
||||||
@@ -27,18 +27,18 @@ chmod +x setup.sh
|
|||||||
`setup.sh` needs root (directly or via `sudo`) because it creates a system user and writes to `/opt`. It:
|
`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.
|
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.
|
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.
|
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.
|
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.
|
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`.
|
7. Copies `docker-compose.yml` and `deploy.sh` into `/opt/anouma`.
|
||||||
8. Starts Postgres (so it's ready for the first deploy).
|
8. Starts Postgres (so it's ready for the first deploy).
|
||||||
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.
|
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.
|
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 GitHub secrets
|
### After setup: add the GitHub secrets
|
||||||
|
|
||||||
@@ -50,19 +50,19 @@ Copy the deployment values `setup.sh` printed into **GitHub → whitehai11/anoum
|
|||||||
| `DEPLOY_PORT` | `22` (or your SSH port) |
|
| `DEPLOY_PORT` | `22` (or your SSH port) |
|
||||||
| `DEPLOY_USER` | `anouma-deploy` |
|
| `DEPLOY_USER` | `anouma-deploy` |
|
||||||
| `DEPLOY_SSH_KEY` | the private key setup.sh printed |
|
| `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) |
|
| `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 |
|
| `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
|
### 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
|
```bash
|
||||||
git tag v1.0.0
|
git tag v1.0.0
|
||||||
git push github v1.0.0 # GitHub Actions
|
git push github v1.0.0 # GitHub Actions
|
||||||
```
|
```
|
||||||
|
|
||||||
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`.
|
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`.
|
||||||
|
|
||||||
## GitHub Actions
|
## GitHub Actions
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ Release-Workflows ausführen.
|
|||||||
|
|
||||||
## `deploy.sh` (server-side, `/opt/anouma/deploy.sh`)
|
## `deploy.sh` (server-side, `/opt/anouma/deploy.sh`)
|
||||||
|
|
||||||
Not a manual tool — it's invoked exclusively by GitHub 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
|
```text
|
||||||
Lock (refuse a second concurrent deploy)
|
Lock (refuse a second concurrent deploy)
|
||||||
@@ -105,13 +105,13 @@ Lock (refuse a second concurrent deploy)
|
|||||||
→ success, or: automatic rollback to the previous version
|
→ 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 — 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.
|
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.
|
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
|
## 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:
|
Restore manually if needed:
|
||||||
|
|
||||||
@@ -137,7 +137,7 @@ In Nginx Proxy Manager, add a Proxy Host:
|
|||||||
| Scheme | `http` |
|
| Scheme | `http` |
|
||||||
| Forward Host | `app` (the Compose service name) |
|
| Forward Host | `app` (the Compose service name) |
|
||||||
| Forward Port | `3000` |
|
| Forward Port | `3000` |
|
||||||
| **Websockets Support** | **enabled** — required for the LiveKit signal connection |
|
| **Websockets Support** | **enabled** - required for the LiveKit signal connection |
|
||||||
|
|
||||||
Für LiveKit legst du zusätzlich einen zweiten Proxy Host an:
|
Für LiveKit legst du zusätzlich einen zweiten Proxy Host an:
|
||||||
|
|
||||||
@@ -163,7 +163,7 @@ Client C ── WebRTC ──┘ │
|
|||||||
```
|
```
|
||||||
|
|
||||||
`docker-compose.yml` startet LiveKit mit Host-Networking, dem UDP-Medienbereich
|
`docker-compose.yml` startet LiveKit mit Host-Networking, dem UDP-Medienbereich
|
||||||
`50000–60000`, dem TCP-Fallback `7881` und eingebettetem TURN/UDP auf `3478`.
|
`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
|
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
|
und die serverseitige Token-Route gegeben. Für den Browser ist ausschließlich
|
||||||
`LIVEKIT_URL` als `wss://`-Endpoint sichtbar.
|
`LIVEKIT_URL` als `wss://`-Endpoint sichtbar.
|
||||||
@@ -196,30 +196,30 @@ See `.env.example` for the full, commented list. `setup.sh` writes the real file
|
|||||||
| `DOCKER_NETWORK`, `NPM_NETWORK` | Docker network names |
|
| `DOCKER_NETWORK`, `NPM_NETWORK` | Docker network names |
|
||||||
| `BACKUP_RETENTION_DAYS` | Deploy-backup retention |
|
| `BACKUP_RETENTION_DAYS` | Deploy-backup retention |
|
||||||
| `REGISTRY`, `REGISTRY_REPO`, `REGISTRY_USERNAME`, `REGISTRY_PASSWORD` | Where `deploy.sh` pulls the image from |
|
| `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
|
## Persistent data
|
||||||
|
|
||||||
Deployments never delete data:
|
Deployments never delete data:
|
||||||
|
|
||||||
- **Database** — the `pgdata` named volume, independent of the `postgres` container's lifecycle.
|
- **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.
|
- **Uploaded media** - the `/opt/anouma/media` bind mount, independent of the `app` container's lifecycle.
|
||||||
- **Backups** — `/opt/anouma/backups`.
|
- **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).
|
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
|
## 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`.
|
- **postgres**: `pg_isready`.
|
||||||
|
|
||||||
`docker compose ps` (from `/opt/anouma`) shows the app, Postgres and LiveKit services.
|
`docker compose ps` (from `/opt/anouma`) shows the app, Postgres and LiveKit services.
|
||||||
|
|
||||||
## Security
|
## 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.
|
- 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`.
|
- `.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).
|
- 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. The GHCR token is needed only on the production server for pulling the private image; GitHub Actions uses its automatic `GITHUB_TOKEN`.
|
- `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`.
|
||||||
@@ -228,10 +228,10 @@ Neither `setup.sh` nor `deploy.sh` ever runs `docker system prune`, `docker volu
|
|||||||
|
|
||||||
| Symptom | Check |
|
| 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` |
|
| `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 "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 |
|
| 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` |
|
| 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 | Check LiveKit logs, the UDP 3478/50000–60000 firewall rules and the public `LIVEKIT_URL` |
|
| 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 |
|
| 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 |
|
| 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 |
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
# Multi-stage build for the ANOUMA app (Next.js + Payload CMS + LiveKit SFU
|
# 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
|
# 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.
|
# 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
|
# 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
|
FROM node:22-alpine AS base
|
||||||
|
|
||||||
@@ -32,12 +32,12 @@ RUN addgroup --system --gid 1001 nodejs \
|
|||||||
|
|
||||||
# Copied wholesale (not a hand-picked list of directories) so newly added
|
# Copied wholesale (not a hand-picked list of directories) so newly added
|
||||||
# source folders (collections, scripts, lib/*, components/*, ...) are never
|
# 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
|
# belong in the image (node_modules is re-added explicitly below, .next is
|
||||||
# the build output we do want).
|
# the build output we do want).
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app /app
|
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).
|
# Docker volumes mounted over these paths (see docker-compose.yml).
|
||||||
RUN mkdir -p media backups && chown -R nextjs:nodejs media backups
|
RUN mkdir -p media backups && chown -R nextjs:nodejs media backups
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
# Anouma
|
# 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 selbst gehostetem [LiveKit](https://livekit.io) SFU-Video-Call, 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
|
## Projektstruktur
|
||||||
|
|
||||||
- `app/(frontend)/` — die öffentliche Website (bestehendes ANOUMA-Design, eigener Root-Layout)
|
- `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/(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/(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)
|
- `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
|
- `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/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/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/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
|
- `lib/texte.ts` - die ursprünglichen Anouma-Texte, nur noch als Seed-Quelle verwendet
|
||||||
- `scripts/seed.ts` — überträgt die vorhandenen Inhalte ins CMS
|
- `scripts/seed.ts` - überträgt die vorhandenen Inhalte ins CMS
|
||||||
- `server.ts` — eigener Node-Server (statt `next start`), passend zur bestehenden Payload-Konfiguration
|
- `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`).
|
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`).
|
||||||
|
|
||||||
@@ -47,11 +47,11 @@ 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
|
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)
|
## Setup (Produktion / Deployment)
|
||||||
|
|
||||||
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:
|
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
|
```bash
|
||||||
git clone https://github.com/whitehai11/anouma.git
|
git clone https://github.com/whitehai11/anouma.git
|
||||||
@@ -68,7 +68,7 @@ Jedes weitere Deployment läuft danach ausschließlich über einen Git-Tag:
|
|||||||
git tag v1.2.0 && git push origin v1.2.0
|
git tag v1.2.0 && git push origin v1.2.0
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
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, GitHub Secrets, CI/Release-Workflows, Reverse-Proxy- und LiveKit-Einrichtung, Environment-Variablen, 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)**.
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ Nur Postgres per Docker, die App läuft direkt auf dem Host:
|
|||||||
docker compose up -d postgres
|
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
|
### Entwicklung
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ npm run dev
|
|||||||
```
|
```
|
||||||
|
|
||||||
- Website: http://localhost:3000
|
- 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
|
### Vorhandene Inhalte ins CMS übertragen
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ npm run dev
|
|||||||
npm run seed
|
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
|
### Produktions-Build ohne Docker
|
||||||
|
|
||||||
@@ -125,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 generate:importmap`| Admin-Importmap neu erzeugen (nach neuen Feldtypen) |
|
||||||
| `npm run migrate:create` | Neue Datenbank-Migration aus Config-Änderungen bauen |
|
| `npm run migrate:create` | Neue Datenbank-Migration aus Config-Änderungen bauen |
|
||||||
| `npm run lint` | ESLint |
|
| `npm run lint` | ESLint |
|
||||||
| `npm test` | Test-Suite (aktuell ein Platzhalter — noch keine Tests vorhanden) |
|
| `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 run seo:check` | Optionale SEO-Prüfung (doppelte Slugs, fehlende Alt-Texte) - nicht produktionskritisch |
|
||||||
|
|
||||||
## Design
|
## Design
|
||||||
|
|
||||||
|
|||||||
+7
-7
@@ -1,4 +1,4 @@
|
|||||||
# Security-Audit — 17.09.2026
|
# 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.
|
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.
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ Quellcodeprüfung von Payload-Zugriffsregeln, Kunden-/Admin-Authentifizierung, B
|
|||||||
|
|
||||||
`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.
|
`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
|
## 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`.
|
**Stellen:** `lib/booking/queries.ts:12`, `app/(frontend)/konto/termine/page.tsx:27`, `app/(frontend)/konto/page.tsx:46`, `collections/BookingRequests.ts:364`.
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ Die Kontoseiten übergeben das gesamte Ergebnis als `booking` an die Client Comp
|
|||||||
|
|
||||||
**Regressionstest:** Eine Buchung mit einer eindeutigen internen Testnotiz anlegen; die Notiz darf weder in HTML noch in einer RSC-Antwort des Kundenkontos vorkommen.
|
**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
|
## 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`.
|
**Stellen:** `app/(payload)/api/booking/[id]/cancel/route.ts:29`, `app/(payload)/api/meetings/[slug]/join/route.ts:49`, `collections/BookingRequests.ts:229`.
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ Die Stornierung setzt nur den Buchungsstatus. Der verknüpfte Online-Event und s
|
|||||||
|
|
||||||
**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.
|
**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
|
## 3. P2 - Token-Austausch umgeht das Meeting-Ende
|
||||||
|
|
||||||
**Stelle:** `app/(payload)/api/meetings/[slug]/connect/route.ts:27`.
|
**Stelle:** `app/(payload)/api/meetings/[slug]/connect/route.ts:27`.
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ Die Stornierung setzt nur den Buchungsstatus. Der verknüpfte Online-Event und s
|
|||||||
|
|
||||||
**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.
|
**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
|
## 4. P2 - Krankheits- und Urlaubsgründe sind öffentlich abrufbar
|
||||||
|
|
||||||
**Stelle:** `collections/AvailabilityOverrides.ts:17`.
|
**Stelle:** `collections/AvailabilityOverrides.ts:17`.
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ Die Collection erlaubt anonymes Lesen aller Dokumente. Das Feld `reason` enthäl
|
|||||||
|
|
||||||
**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.
|
**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
|
## 5. P2 - Registrierung und E-Mail-Wechsel umgehen die Versandlimits
|
||||||
|
|
||||||
**Stellen:** `collections/Customers.ts:84`, `collections/Customers.ts:46`.
|
**Stellen:** `collections/Customers.ts:84`, `collections/Customers.ts:46`.
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ Die öffentliche Kundenerstellung löst unmittelbar eine Bestätigungs-E-Mail au
|
|||||||
|
|
||||||
**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.
|
**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
|
## 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`.
|
**Stellen:** `app/(payload)/api/booking/request/route.ts:75`, `collections/BookingRequests.ts:163`.
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
import type { Access, FieldAccess } from "payload";
|
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
|
* and only with the "admin" role, so adding further roles to Users later
|
||||||
* only means extending this one check. Site visitors authenticate against
|
* only means extending this one check. Site visitors authenticate against
|
||||||
* the separate "customers" collection (see collections/Customers.ts) and
|
* the separate "customers" collection (see collections/Customers.ts) and
|
||||||
@@ -15,7 +15,7 @@ export const isAdminFieldLevel: FieldAccess = ({ req: { user } }) =>
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Admins always see Analytics; any other "users" account additionally needs
|
* Admins always see Analytics; any other "users" account additionally needs
|
||||||
* its own `canViewAnalytics` flag (see collections/Users.ts) — this is the
|
* its own `canViewAnalytics` flag (see collections/Users.ts) - this is the
|
||||||
* granular permission the single "admin" role doesn't otherwise provide.
|
* granular permission the single "admin" role doesn't otherwise provide.
|
||||||
* Used both as a Payload Access/FieldAccess function and called directly
|
* Used both as a Payload Access/FieldAccess function and called directly
|
||||||
* (with a minimal `{ req: { user } }` shape) from the custom admin nav-link
|
* (with a minimal `{ req: { user } }` shape) from the custom admin nav-link
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { ReactNode } from "react";
|
|||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
import "../(frontend)/globals.css";
|
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.
|
// dark, without the marketing navbar/footer around it.
|
||||||
|
|
||||||
const inter = Inter({ variable: "--font-inter", subsets: ["latin"] });
|
const inter = Inter({ variable: "--font-inter", subsets: ["latin"] });
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export async function generateMetadata({ params }: Args): Promise<Metadata> {
|
|||||||
|
|
||||||
const resolved = resolveSeo({
|
const resolved = resolveSeo({
|
||||||
seo: post.seo,
|
seo: post.seo,
|
||||||
fallbackTitle: `${post.title} – ${siteConfig.name}`,
|
fallbackTitle: `${post.title} - ${siteConfig.name}`,
|
||||||
fallbackDescription: () => post.teaser,
|
fallbackDescription: () => post.teaser,
|
||||||
fallbackImage: post.coverImage,
|
fallbackImage: post.coverImage,
|
||||||
defaultOgImage: seoSettings.defaultOgImage,
|
defaultOgImage: seoSettings.defaultOgImage,
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export async function generateMetadata({ params }: Args): Promise<Metadata> {
|
|||||||
|
|
||||||
const resolved = resolveSeo({
|
const resolved = resolveSeo({
|
||||||
seo: offer.seo,
|
seo: offer.seo,
|
||||||
fallbackTitle: `${offer.title} – ${siteConfig.name}`,
|
fallbackTitle: `${offer.title} - ${siteConfig.name}`,
|
||||||
fallbackDescription: () => offer.shortDescription,
|
fallbackDescription: () => offer.shortDescription,
|
||||||
fallbackImage: offer.image,
|
fallbackImage: offer.image,
|
||||||
defaultOgImage: seoSettings.defaultOgImage,
|
defaultOgImage: seoSettings.defaultOgImage,
|
||||||
@@ -133,7 +133,7 @@ export default async function OfferDetailPage({ params }: Args) {
|
|||||||
{offer.groupSchedule && (
|
{offer.groupSchedule && (
|
||||||
<div className="mb-8 rounded-2xl bg-white p-5 text-sm text-anouma-plum">
|
<div className="mb-8 rounded-2xl bg-white p-5 text-sm text-anouma-plum">
|
||||||
<p className="font-medium">Fester Termin</p>
|
<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>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{offer.groupCapacity && <p className="mb-2 text-sm text-anouma-plum/75">Plätze: {offer.groupCapacity}</p>}
|
{offer.groupCapacity && <p className="mb-2 text-sm text-anouma-plum/75">Plätze: {offer.groupCapacity}</p>}
|
||||||
|
|||||||
@@ -19,13 +19,13 @@ import { siteConfig } from "@/lib/site";
|
|||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const FALLBACK_DESCRIPTION =
|
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> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const [intro, seoSettings] = await Promise.all([getAngeboteIntroGlobal(), getSEOSettingsGlobal()]);
|
const [intro, seoSettings] = await Promise.all([getAngeboteIntroGlobal(), getSEOSettingsGlobal()]);
|
||||||
const resolved = resolveSeo({
|
const resolved = resolveSeo({
|
||||||
seo: intro.seo,
|
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,
|
fallbackDescription: () => intro.lead || seoSettings.defaultDescription || FALLBACK_DESCRIPTION,
|
||||||
defaultOgImage: seoSettings.defaultOgImage,
|
defaultOgImage: seoSettings.defaultOgImage,
|
||||||
});
|
});
|
||||||
@@ -170,7 +170,7 @@ export default async function AngebotePage() {
|
|||||||
|
|
||||||
<CTA
|
<CTA
|
||||||
title="Unsicher, welcher Raum passt?"
|
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> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
return buildMetadata({
|
return buildMetadata({
|
||||||
title: `Singkreise – ${siteConfig.name}`,
|
title: `Singkreise - ${siteConfig.name}`,
|
||||||
description: "Gemeinsam singen. Verbinden. Heilen. Für Herz und Seele.",
|
description: "Gemeinsam singen. Verbinden. Heilen. Für Herz und Seele.",
|
||||||
path: "/angebote/singkreise",
|
path: "/angebote/singkreise",
|
||||||
});
|
});
|
||||||
@@ -30,7 +30,7 @@ export default async function SingkreisePage() {
|
|||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
eyebrow="Singkreise"
|
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."
|
lead="Gemeinsam singen. Verbinden. Heilen. Für Herz und Seele."
|
||||||
crumbs={[{ title: "Angebote", href: "/angebote" }, { title: "Singkreise" }]}
|
crumbs={[{ title: "Angebote", href: "/angebote" }, { title: "Singkreise" }]}
|
||||||
/>
|
/>
|
||||||
@@ -53,7 +53,7 @@ export default async function SingkreisePage() {
|
|||||||
|
|
||||||
<CTA
|
<CTA
|
||||||
title="Deine Stimme erklingen lassen"
|
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."
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const TOKEN_SHAPE = /^[0-9a-f]{64}$/i;
|
|||||||
type Args = { params: Promise<{ tokenFile: string }> };
|
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
|
* Apple Calendar, Google Calendar, Outlook, Thunderbird etc. The token
|
||||||
* itself is the only credential (there's no login here), so it's treated
|
* 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
|
* like a secret: looked up by hash only, never logged, and the response is
|
||||||
|
|||||||
@@ -33,9 +33,9 @@ export default async function DatenschutzPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Verantwortliche Stelle</h2>
|
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Verantwortliche Stelle</h2>
|
||||||
<p className="mt-4 leading-relaxed text-anouma-plum">
|
<p className="mt-4 leading-relaxed text-anouma-plum">
|
||||||
[Platzhalter – Vor- und Nachname]
|
[Platzhalter - Vor- und Nachname]
|
||||||
<br />
|
<br />
|
||||||
[Platzhalter – Anschrift]
|
[Platzhalter - Anschrift]
|
||||||
<br />
|
<br />
|
||||||
E-Mail: {contact.email}
|
E-Mail: {contact.email}
|
||||||
</p>
|
</p>
|
||||||
@@ -48,7 +48,7 @@ export default async function DatenschutzPage() {
|
|||||||
<p className="mt-4 leading-relaxed text-anouma-plum">
|
<p className="mt-4 leading-relaxed text-anouma-plum">
|
||||||
Beim Besuch dieser Website werden aus technischen Gründen automatisch Informationen
|
Beim Besuch dieser Website werden aus technischen Gründen automatisch Informationen
|
||||||
erfasst, die dein Browser übermittelt (z. B. IP-Adresse, Datum und Uhrzeit des
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Antropos";
|
font-family: "Antropos";
|
||||||
src: url("/fonts/antropos.ttf") format("truetype");
|
src: url("/fonts/baar-antropos.ttf") format("truetype");
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 100 900;
|
font-weight: 100 900;
|
||||||
@@ -80,6 +80,10 @@ body {
|
|||||||
font-family: "Antropos", ui-sans-serif, system-ui, sans-serif;
|
font-family: "Antropos", ui-sans-serif, system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.font-antropos {
|
||||||
|
font-family: "Antropos", ui-serif, Georgia, serif;
|
||||||
|
}
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
background: var(--color-anouma-rose-soft);
|
background: var(--color-anouma-rose-soft);
|
||||||
color: var(--color-anouma-plum);
|
color: var(--color-anouma-plum);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { JsonLd, webPageJsonLd } from "@/lib/seo/jsonld";
|
|||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
return buildMetadata({ title: "Impressionen", description: DESCRIPTION, path: "/impressionen" });
|
return buildMetadata({ title: "Impressionen", description: DESCRIPTION, path: "/impressionen" });
|
||||||
@@ -36,7 +36,7 @@ export default async function ImpressionenPage() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
eyebrow="Impressionen"
|
eyebrow="Impressionen"
|
||||||
title="Einblicke in gemeinsame Räume"
|
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" }]}
|
crumbs={[{ title: "Impressionen" }]}
|
||||||
/>
|
/>
|
||||||
<Section tone="plain">
|
<Section tone="plain">
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
return {
|
return {
|
||||||
title: "Impressum",
|
title: "Impressum",
|
||||||
description: "Impressum von Anouma gemäß § 5 TMG.",
|
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.
|
// out of search results without cutting off the internal links on it.
|
||||||
robots: { index: false, follow: true },
|
robots: { index: false, follow: true },
|
||||||
alternates: { canonical: `${siteUrl}/impressum` },
|
alternates: { canonical: `${siteUrl}/impressum` },
|
||||||
@@ -35,11 +35,11 @@ export default async function ImpressumPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Angaben gemäß § 5 TMG</h2>
|
<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">
|
<p className="mt-4 leading-relaxed text-anouma-plum">
|
||||||
[Platzhalter – Vor- und Nachname]
|
[Platzhalter - Vor- und Nachname]
|
||||||
<br />
|
<br />
|
||||||
[Platzhalter – Straße und Hausnummer]
|
[Platzhalter - Straße und Hausnummer]
|
||||||
<br />
|
<br />
|
||||||
[Platzhalter – PLZ und Ort]
|
[Platzhalter - PLZ und Ort]
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ export default async function ImpressumPage() {
|
|||||||
Umsatzsteuer-Identifikationsnummer
|
Umsatzsteuer-Identifikationsnummer
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-4 leading-relaxed text-anouma-plum">
|
<p className="mt-4 leading-relaxed text-anouma-plum">
|
||||||
[Platzhalter – sofern vorhanden, gemäß § 27 a Umsatzsteuergesetz]
|
[Platzhalter - sofern vorhanden, gemäß § 27 a Umsatzsteuergesetz]
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ export default async function ImpressumPage() {
|
|||||||
Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV
|
Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-4 leading-relaxed text-anouma-plum">
|
<p className="mt-4 leading-relaxed text-anouma-plum">
|
||||||
[Platzhalter – Vor- und Nachname, Anschrift wie oben]
|
[Platzhalter - Vor- und Nachname, Anschrift wie oben]
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
const [contact, seoSettings] = await Promise.all([getContactGlobal(), getSEOSettingsGlobal()]);
|
const [contact, seoSettings] = await Promise.all([getContactGlobal(), getSEOSettingsGlobal()]);
|
||||||
const resolved = resolveSeo({
|
const resolved = resolveSeo({
|
||||||
seo: contact.seo,
|
seo: contact.seo,
|
||||||
fallbackTitle: `Kontakt – ${siteConfig.name}`,
|
fallbackTitle: `Kontakt - ${siteConfig.name}`,
|
||||||
fallbackDescription: () => contact.lead || seoSettings.defaultDescription || FALLBACK_DESCRIPTION,
|
fallbackDescription: () => contact.lead || seoSettings.defaultDescription || FALLBACK_DESCRIPTION,
|
||||||
defaultOgImage: seoSettings.defaultOgImage,
|
defaultOgImage: seoSettings.defaultOgImage,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { ResendVerificationButton } from "@/components/auth/ResendVerificationBu
|
|||||||
import { getCurrentCustomer } from "@/lib/auth/customer";
|
import { getCurrentCustomer } from "@/lib/auth/customer";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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 } };
|
export const metadata: Metadata = { robots: { index: false, follow: false } };
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default async function KontoProfilPage() {
|
|||||||
<div className="mt-6 rounded-3xl bg-white p-6">
|
<div className="mt-6 rounded-3xl bg-white p-6">
|
||||||
<ProfileForm id={customer.id} name={customer.name} phone={customer.phone ?? ""} />
|
<ProfileForm id={customer.id} name={customer.name} phone={customer.phone ?? ""} />
|
||||||
<p className="mt-6 text-xs text-anouma-plum/60">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export const metadata: Metadata = {
|
|||||||
metadataBase: new URL(siteConfig.domain),
|
metadataBase: new URL(siteConfig.domain),
|
||||||
title: {
|
title: {
|
||||||
default: siteConfig.title,
|
default: siteConfig.title,
|
||||||
template: `%s – ${siteConfig.name}`,
|
template: `%s - ${siteConfig.name}`,
|
||||||
},
|
},
|
||||||
description: siteConfig.description,
|
description: siteConfig.description,
|
||||||
openGraph: {
|
openGraph: {
|
||||||
@@ -47,7 +47,7 @@ export const metadata: Metadata = {
|
|||||||
title: siteConfig.title,
|
title: siteConfig.title,
|
||||||
description: siteConfig.description,
|
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 } } : {}),
|
...(process.env.GOOGLE_SITE_VERIFICATION ? { verification: { google: process.env.GOOGLE_SITE_VERIFICATION } } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -27,11 +27,11 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
const [home, seoSettings] = await Promise.all([getHomeGlobal(), getSEOSettingsGlobal()]);
|
const [home, seoSettings] = await Promise.all([getHomeGlobal(), getSEOSettingsGlobal()]);
|
||||||
const resolved = resolveSeo({
|
const resolved = resolveSeo({
|
||||||
seo: home.seo,
|
seo: home.seo,
|
||||||
fallbackTitle: `${siteConfig.name} – Begleitung für dich und deine Familie`,
|
fallbackTitle: `${siteConfig.name} - Begleitung für dich und deine Familie`,
|
||||||
fallbackDescription: () =>
|
fallbackDescription: () =>
|
||||||
home.heroSupporting ||
|
home.heroSupporting ||
|
||||||
seoSettings.defaultDescription ||
|
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,
|
fallbackImage: home.heroImage,
|
||||||
defaultOgImage: seoSettings.defaultOgImage,
|
defaultOgImage: seoSettings.defaultOgImage,
|
||||||
});
|
});
|
||||||
@@ -42,8 +42,8 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
ogImageUrl: resolved.ogImageUrl,
|
ogImageUrl: resolved.ogImageUrl,
|
||||||
keywords: resolved.keywords,
|
keywords: resolved.keywords,
|
||||||
});
|
});
|
||||||
// Homepage bypasses the "%s — Anouma" title template — the brand is
|
// Homepage bypasses the "%s - Anouma" title template - the brand is
|
||||||
// already part of the fallback/CMS title itself, avoiding "Anouma — Anouma".
|
// already part of the fallback/CMS title itself, avoiding "Anouma - Anouma".
|
||||||
return { ...meta, title: { absolute: resolved.title } };
|
return { ...meta, title: { absolute: resolved.title } };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,10 +93,10 @@ export default async function Home() {
|
|||||||
<SectionHeading
|
<SectionHeading
|
||||||
eyebrow="Angebote"
|
eyebrow="Angebote"
|
||||||
title="Räume, die zu deinem Weg passen"
|
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 space-y-6">
|
<div className="mt-14 space-y-6">
|
||||||
{/* Prozessbegleitung ist das Kernangebot – bekommt deshalb den größeren, ersten Platz statt eines gleichwertigen 4er-Rasters. */}
|
{/* Prozessbegleitung ist das Kernangebot - bekommt deshalb den größeren, ersten Platz statt eines gleichwertigen 4er-Rasters. */}
|
||||||
{featuredGroup && (
|
{featuredGroup && (
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<AngebotCard
|
<AngebotCard
|
||||||
@@ -263,7 +263,7 @@ export default async function Home() {
|
|||||||
title={home.ctaTitle || "Kennenlernen & Termine vereinbaren"}
|
title={home.ctaTitle || "Kennenlernen & Termine vereinbaren"}
|
||||||
lead={
|
lead={
|
||||||
home.ctaLead ||
|
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."
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
const [booking, seoSettings] = await Promise.all([getBookingGlobal(), getSEOSettingsGlobal()]);
|
const [booking, seoSettings] = await Promise.all([getBookingGlobal(), getSEOSettingsGlobal()]);
|
||||||
const resolved = resolveSeo({
|
const resolved = resolveSeo({
|
||||||
seo: booking.seo,
|
seo: booking.seo,
|
||||||
fallbackTitle: `Termin buchen – ${siteConfig.name}`,
|
fallbackTitle: `Termin buchen - ${siteConfig.name}`,
|
||||||
fallbackDescription: () => booking.lead || seoSettings.defaultDescription || FALLBACK_DESCRIPTION,
|
fallbackDescription: () => booking.lead || seoSettings.defaultDescription || FALLBACK_DESCRIPTION,
|
||||||
defaultOgImage: seoSettings.defaultOgImage,
|
defaultOgImage: seoSettings.defaultOgImage,
|
||||||
});
|
});
|
||||||
@@ -115,7 +115,7 @@ export default async function TerminBuchenPage() {
|
|||||||
title="Allgemeine Anfrage"
|
title="Allgemeine Anfrage"
|
||||||
lead={
|
lead={
|
||||||
bookableOffers.length > 0
|
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
|
: undefined
|
||||||
}
|
}
|
||||||
align="left"
|
align="left"
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export default async function BeitretenPage({ params }: Args) {
|
|||||||
<OrganicBlob tone="rose" className="-right-24 -top-24 h-96 w-96" />
|
<OrganicBlob tone="rose" className="-right-24 -top-24 h-96 w-96" />
|
||||||
<OrganicBlob tone="cream" className="-left-32 bottom-0 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">
|
<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
|
ANOUMA
|
||||||
</Link>
|
</Link>
|
||||||
<p className="mt-8 text-sm font-medium uppercase tracking-[0.18em] text-anouma-plum/70">
|
<p className="mt-8 text-sm font-medium uppercase tracking-[0.18em] text-anouma-plum/70">
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export async function generateMetadata({ params }: Args): Promise<Metadata> {
|
|||||||
|
|
||||||
const resolved = resolveSeo({
|
const resolved = resolveSeo({
|
||||||
seo: event.seo,
|
seo: event.seo,
|
||||||
fallbackTitle: `${event.title} – ${siteConfig.name}`,
|
fallbackTitle: `${event.title} - ${siteConfig.name}`,
|
||||||
fallbackDescription: () => excerptFromRichText(event.description) || `${event.title} bei ${siteConfig.name}.`,
|
fallbackDescription: () => excerptFromRichText(event.description) || `${event.title} bei ${siteConfig.name}.`,
|
||||||
fallbackImage: event.image,
|
fallbackImage: event.image,
|
||||||
defaultOgImage: seoSettings.defaultOgImage,
|
defaultOgImage: seoSettings.defaultOgImage,
|
||||||
@@ -74,7 +74,7 @@ export default async function EventDetailPage({ params }: Args) {
|
|||||||
if (event.isOnline) {
|
if (event.isOnline) {
|
||||||
const payload = await getPayload({ config });
|
const payload = await getPayload({ config });
|
||||||
const { user } = await payload.auth({ headers: await headers() });
|
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.
|
// (collection "customers") must never be treated as host.
|
||||||
const isHost = user?.collection === "users" && user.role === "admin";
|
const isHost = user?.collection === "users" && user.role === "admin";
|
||||||
const settings = await payload.findGlobal({ slug: "meeting-settings" });
|
const settings = await payload.findGlobal({ slug: "meeting-settings" });
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export default async function TerminePage() {
|
|||||||
<div className="rounded-3xl bg-anouma-cream-light p-8 text-center sm:p-10">
|
<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="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">
|
<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.
|
Schau gerne bald wieder vorbei, oder melde dich direkt - ich freue mich von dir zu hören.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-5 flex justify-center">
|
<div className="mt-5 flex justify-center">
|
||||||
<ArrowLink href="/kontakt">Zur Kontaktseite</ArrowLink>
|
<ArrowLink href="/kontakt">Zur Kontaktseite</ArrowLink>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
const [about, seoSettings] = await Promise.all([getAboutGlobal(), getSEOSettingsGlobal()]);
|
const [about, seoSettings] = await Promise.all([getAboutGlobal(), getSEOSettingsGlobal()]);
|
||||||
const resolved = resolveSeo({
|
const resolved = resolveSeo({
|
||||||
seo: about.seo,
|
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.",
|
fallbackDescription: () => excerptFromRichText(about.body) || seoSettings.defaultDescription || "Mein Weg, meine Werte und was mich bewegt.",
|
||||||
fallbackImage: about.portrait,
|
fallbackImage: about.portrait,
|
||||||
defaultOgImage: seoSettings.defaultOgImage,
|
defaultOgImage: seoSettings.defaultOgImage,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0
|
|||||||
import { CalendarNavLink as CalendarNavLink_4d06fac03127b339306d5f5ecb073251 } from '../../../components/admin/CalendarNavLink'
|
import { CalendarNavLink as CalendarNavLink_4d06fac03127b339306d5f5ecb073251 } from '../../../components/admin/CalendarNavLink'
|
||||||
import { AnalyticsNavLink as AnalyticsNavLink_29fa5c324cecba6cafb11381cbfac962 } from '../../../components/admin/AnalyticsNavLink'
|
import { AnalyticsNavLink as AnalyticsNavLink_29fa5c324cecba6cafb11381cbfac962 } from '../../../components/admin/AnalyticsNavLink'
|
||||||
import { ConversationsNavLink as ConversationsNavLink_67a2594cf28806bc76971e67212a6181 } from '../../../components/admin/ConversationsNavLink'
|
import { ConversationsNavLink as ConversationsNavLink_67a2594cf28806bc76971e67212a6181 } from '../../../components/admin/ConversationsNavLink'
|
||||||
|
import { AdminWelcome as AdminWelcome_b3c24bec21c663ad093f131aaae3d0a6 } from '../../../components/admin/AdminWelcome'
|
||||||
import { AdminCalendarView as AdminCalendarView_a2760c3ae85f4fd51d79a465baaf023a } from '../../../components/admin/AdminCalendarView'
|
import { AdminCalendarView as AdminCalendarView_a2760c3ae85f4fd51d79a465baaf023a } from '../../../components/admin/AdminCalendarView'
|
||||||
import { AdminAnalyticsView as AdminAnalyticsView_55d38702e4d86d52435b5ac4df3f55d0 } from '../../../components/admin/AdminAnalyticsView'
|
import { AdminAnalyticsView as AdminAnalyticsView_55d38702e4d86d52435b5ac4df3f55d0 } from '../../../components/admin/AdminAnalyticsView'
|
||||||
import { AdminConversationsView as AdminConversationsView_180f5c400a066a0a8f248da44d961055 } from '../../../components/admin/AdminConversationsView'
|
import { AdminConversationsView as AdminConversationsView_180f5c400a066a0a8f248da44d961055 } from '../../../components/admin/AdminConversationsView'
|
||||||
@@ -57,6 +58,7 @@ export const importMap = {
|
|||||||
"/components/admin/CalendarNavLink#CalendarNavLink": CalendarNavLink_4d06fac03127b339306d5f5ecb073251,
|
"/components/admin/CalendarNavLink#CalendarNavLink": CalendarNavLink_4d06fac03127b339306d5f5ecb073251,
|
||||||
"/components/admin/AnalyticsNavLink#AnalyticsNavLink": AnalyticsNavLink_29fa5c324cecba6cafb11381cbfac962,
|
"/components/admin/AnalyticsNavLink#AnalyticsNavLink": AnalyticsNavLink_29fa5c324cecba6cafb11381cbfac962,
|
||||||
"/components/admin/ConversationsNavLink#ConversationsNavLink": ConversationsNavLink_67a2594cf28806bc76971e67212a6181,
|
"/components/admin/ConversationsNavLink#ConversationsNavLink": ConversationsNavLink_67a2594cf28806bc76971e67212a6181,
|
||||||
|
"/components/admin/AdminWelcome#AdminWelcome": AdminWelcome_b3c24bec21c663ad093f131aaae3d0a6,
|
||||||
"/components/admin/AdminCalendarView#AdminCalendarView": AdminCalendarView_a2760c3ae85f4fd51d79a465baaf023a,
|
"/components/admin/AdminCalendarView#AdminCalendarView": AdminCalendarView_a2760c3ae85f4fd51d79a465baaf023a,
|
||||||
"/components/admin/AdminAnalyticsView#AdminAnalyticsView": AdminAnalyticsView_55d38702e4d86d52435b5ac4df3f55d0,
|
"/components/admin/AdminAnalyticsView#AdminAnalyticsView": AdminAnalyticsView_55d38702e4d86d52435b5ac4df3f55d0,
|
||||||
"/components/admin/AdminConversationsView#AdminConversationsView": AdminConversationsView_180f5c400a066a0a8f248da44d961055,
|
"/components/admin/AdminConversationsView#AdminConversationsView": AdminConversationsView_180f5c400a066a0a8f248da44d961055,
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import { calendarFeedUrl, generateCalendarFeedToken, hashCalendarFeedToken } fro
|
|||||||
export const dynamic = "force-dynamic";
|
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
|
* 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
|
* 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).
|
* before (see collections/Users.ts).
|
||||||
*/
|
*/
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ function normalizeEventName(value: unknown): string | null {
|
|||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Hostname only (never path/query) — see collections/AnalyticsEvents.ts. */
|
/** Hostname only (never path/query) - see collections/AnalyticsEvents.ts. */
|
||||||
function referrerHostFrom(referrer: unknown): string | undefined {
|
function referrerHostFrom(referrer: unknown): string | undefined {
|
||||||
if (typeof referrer !== "string" || !referrer) return undefined;
|
if (typeof referrer !== "string" || !referrer) return undefined;
|
||||||
try {
|
try {
|
||||||
@@ -35,7 +35,7 @@ function referrerHostFrom(referrer: unknown): string | undefined {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Public tracking beacon, called by lib/analytics/client.ts only after the
|
* Public tracking beacon, called by lib/analytics/client.ts only after the
|
||||||
* visitor has granted statistics consent — but the client's own claim is
|
* visitor has granted statistics consent - but the client's own claim is
|
||||||
* never trusted alone: this route re-checks the CookieSettings global's
|
* never trusted alone: this route re-checks the CookieSettings global's
|
||||||
* `statistics.enabled` flag server-side before writing anything, so a
|
* `statistics.enabled` flag server-side before writing anything, so a
|
||||||
* global kill-switch (or a stale/tampered client) can never bypass it.
|
* global kill-switch (or a stale/tampered client) can never bypass it.
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import type { Customer } from "@/payload-types";
|
|||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
// Deliberately identical whether or not an account exists / is already
|
// 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.
|
// those things about an email address that isn't their own session.
|
||||||
const GENERIC_RESPONSE = {
|
const GENERIC_RESPONSE = {
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -60,7 +60,7 @@ export async function POST(request: Request) {
|
|||||||
body = await request.json();
|
body = await request.json();
|
||||||
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
|
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
|
||||||
} catch {
|
} 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() : "";
|
const email = typeof body?.email === "string" ? body.email.trim().toLowerCase() : "";
|
||||||
if (!email || email.length > 254) {
|
if (!email || email.length > 254) {
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export async function POST(request: Request, { params }: Args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The beforeChange hook on booking-requests re-checks for conflicts
|
// The beforeChange hook on booking-requests re-checks for conflicts
|
||||||
// (with an advisory lock) before allowing this to become "confirmed" —
|
// (with an advisory lock) before allowing this to become "confirmed" -
|
||||||
// the alternative slot may have been taken by someone else since it was
|
// the alternative slot may have been taken by someone else since it was
|
||||||
// proposed.
|
// proposed.
|
||||||
await payload.update({
|
await payload.update({
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export async function POST(request: Request, { params }: Args) {
|
|||||||
if (!user || user.collection !== "customers") {
|
if (!user || user.collection !== "customers") {
|
||||||
return NextResponse.json({ error: "Bitte melde dich an." }, { status: 401 });
|
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.
|
// only creating/confirming a booking is a gated "protected" action.
|
||||||
|
|
||||||
return await withBookingTransaction(payload, `booking:${id}`, async (req) => {
|
return await withBookingTransaction(payload, `booking:${id}`, async (req) => {
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export async function POST(request: Request) {
|
|||||||
const end = new Date(start.getTime() + offer.durationMinutes * 60_000);
|
const end = new Date(start.getTime() + offer.durationMinutes * 60_000);
|
||||||
|
|
||||||
// Re-validate the slot is actually free right now (defense against a
|
// 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).
|
// Anna confirms (see collections/BookingRequests.ts).
|
||||||
const dayBounds = berlinDayBounds(start);
|
const dayBounds = berlinDayBounds(start);
|
||||||
return await withBookingTransaction(payload, dayBounds.key, async (req) => {
|
return await withBookingTransaction(payload, dayBounds.key, async (req) => {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ function safeEqual(a: string, b: string): boolean {
|
|||||||
/**
|
/**
|
||||||
* Intended to be called by an external cron job once daily, e.g.:
|
* 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
|
* curl -H "Authorization: Bearer $CRON_SECRET" https://anouma.org/api/cron/analytics-cleanup
|
||||||
* (a second, separate cron entry from /api/cron/event-reminders — reuses
|
* (a second, separate cron entry from /api/cron/event-reminders - reuses
|
||||||
* the same CRON_SECRET, no new secret needed)
|
* the same CRON_SECRET, no new secret needed)
|
||||||
*/
|
*/
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const dynamic = "force-dynamic";
|
|||||||
/**
|
/**
|
||||||
* Used by the Docker healthcheck (see docker-compose.yml) and by
|
* Used by the Docker healthcheck (see docker-compose.yml) and by
|
||||||
* setup.sh/deploy.sh to confirm the app can actually reach Postgres before
|
* 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() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export async function POST(request: Request, { params }: Args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { user } = await payload.auth({ headers: request.headers });
|
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.
|
// (collection "customers") must never be treated as host.
|
||||||
const isHost = user?.collection === "users" && user.role === "admin";
|
const isHost = user?.collection === "users" && user.role === "admin";
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ export async function POST(request: Request, { params }: Args) {
|
|||||||
role: isHost ? "host" : "participant",
|
role: isHost ? "host" : "participant",
|
||||||
accessVersion: access.accessVersion,
|
accessVersion: access.accessVersion,
|
||||||
ttlMs: Math.min(6 * 3600000, access.expiresAt - Date.now()),
|
ttlMs: Math.min(6 * 3600000, access.expiresAt - Date.now()),
|
||||||
// Only meaningful for participants — a host authenticates via a real
|
// Only meaningful for participants - a host authenticates via a real
|
||||||
// session already, so there's no need to anchor a kick to it (and
|
// session already, so there's no need to anchor a kick to it (and
|
||||||
// hosts can't be kicked in the first place.
|
// hosts can't be kicked in the first place.
|
||||||
clientId: isHost ? undefined : clientId,
|
clientId: isHost ? undefined : clientId,
|
||||||
|
|||||||
+383
-3
@@ -1,3 +1,383 @@
|
|||||||
/* Optional custom overrides for the Payload admin UI. Left empty intentionally —
|
@font-face {
|
||||||
the admin panel uses Payload's own default styling, only the public site
|
font-family: "Anouma Admin";
|
||||||
uses the ANOUMA design system (see app/(frontend)/globals.css). */
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
|
color: var(--anouma-ink);
|
||||||
|
font-size: clamp(26px, 3vw, 38px);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
|
--bg-color: var(--anouma-mauve);
|
||||||
|
--hover-bg: var(--anouma-plum);
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select,
|
||||||
|
.rs__control {
|
||||||
|
border-color: #d7c9c1;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 { siteConfig } from "@/lib/site";
|
||||||
import "./(frontend)/globals.css";
|
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.
|
// 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").
|
// 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">
|
<body className="flex min-h-full flex-col bg-background text-foreground">
|
||||||
{/* This page is always statically prerendered, so it can't read live
|
{/* 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={[]} />
|
<Navbar offers={[]} />
|
||||||
<main className="flex-1">
|
<main className="flex-1">
|
||||||
<section className="relative overflow-hidden py-24 sm:py-32">
|
<section className="relative overflow-hidden py-24 sm:py-32">
|
||||||
@@ -67,7 +67,7 @@ export default function GlobalNotFound() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</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. */}
|
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">
|
<footer className="bg-anouma-plum py-8 text-center text-sm text-anouma-cream-light">
|
||||||
© {new Date().getFullYear()} {siteConfig.name}
|
© {new Date().getFullYear()} {siteConfig.name}
|
||||||
|
|||||||
+4
-4
@@ -2,7 +2,7 @@ import type { MetadataRoute } from "next";
|
|||||||
import { getSiteUrl, isSiteIndexable } from "@/lib/seo/config";
|
import { getSiteUrl, isSiteIndexable } from "@/lib/seo/config";
|
||||||
|
|
||||||
// Reads the CMS's SEO settings (site URL override, robots kill switch) on
|
// 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 const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function robots(): Promise<MetadataRoute.Robots> {
|
export default async function robots(): Promise<MetadataRoute.Robots> {
|
||||||
@@ -10,7 +10,7 @@ export default async function robots(): Promise<MetadataRoute.Robots> {
|
|||||||
|
|
||||||
if (!indexable) {
|
if (!indexable) {
|
||||||
// Global kill switch (SEO-Einstellungen → "Website für Suchmaschinen
|
// 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: "/" }] };
|
return { rules: [{ userAgent: "*", disallow: "/" }] };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ export default async function robots(): Promise<MetadataRoute.Robots> {
|
|||||||
"/admin",
|
"/admin",
|
||||||
"/admin/*",
|
"/admin/*",
|
||||||
"/api/*",
|
"/api/*",
|
||||||
// Customer accounts — never public.
|
// Customer accounts - never public.
|
||||||
"/konto",
|
"/konto",
|
||||||
"/konto/*",
|
"/konto/*",
|
||||||
"/login",
|
"/login",
|
||||||
@@ -32,7 +32,7 @@ export default async function robots(): Promise<MetadataRoute.Robots> {
|
|||||||
"/auth/*",
|
"/auth/*",
|
||||||
// Private calendar-subscription feed (secret-token URLs).
|
// Private calendar-subscription feed (secret-token URLs).
|
||||||
"/calendar/*",
|
"/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/*/beitreten",
|
||||||
"/termine/*/call",
|
"/termine/*/call",
|
||||||
],
|
],
|
||||||
|
|||||||
+3
-3
@@ -2,7 +2,7 @@ import type { MetadataRoute } from "next";
|
|||||||
import { getAllEvents, getOffers, getPosts } from "@/lib/payload/content";
|
import { getAllEvents, getOffers, getPosts } from "@/lib/payload/content";
|
||||||
import { getSiteUrl, isSiteIndexable } from "@/lib/seo/config";
|
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
|
// prerendered at build time (no DB is available during `next build`; see
|
||||||
// AGENTS.md / the same convention used by every other CMS-backed route).
|
// AGENTS.md / the same convention used by every other CMS-backed route).
|
||||||
export const dynamic = "force-dynamic";
|
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]
|
// Static, always-public routes that aren't backed by a dynamic [slug]
|
||||||
// collection. Deliberately excludes: /login, /registrieren, /konto*,
|
// collection. Deliberately excludes: /login, /registrieren, /konto*,
|
||||||
// /admin*, /calendar/*, /auth/*, /termine/*/beitreten, /termine/*/call,
|
// /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).
|
// mirrors this same exclusion list).
|
||||||
const staticRoutes: { path: string; changeFrequency: MetadataRoute.Sitemap[number]["changeFrequency"]; priority: number }[] = [
|
const staticRoutes: { path: string; changeFrequency: MetadataRoute.Sitemap[number]["changeFrequency"]; priority: number }[] = [
|
||||||
{ path: "", changeFrequency: "weekly", priority: 1 },
|
{ path: "", changeFrequency: "weekly", priority: 1 },
|
||||||
@@ -40,7 +40,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// getOffers/getAllEvents/getPosts already only return published,
|
// 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).
|
// lib/payload/content.ts's header comment).
|
||||||
const [offers, events, posts] = await Promise.all([getOffers(), getAllEvents(), getPosts()]);
|
const [offers, events, posts] = await Promise.all([getOffers(), getAllEvents(), getPosts()]);
|
||||||
|
|
||||||
|
|||||||
@@ -5,21 +5,22 @@ import { canViewAnalytics } from "@/access";
|
|||||||
* One row per calendar day, upserted by the cleanup cron
|
* One row per calendar day, upserted by the cleanup cron
|
||||||
* (lib/analytics/cleanup.ts) from raw analytics-events BEFORE those raw rows
|
* (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
|
* age out. This is what lets aggregated history survive well beyond the raw
|
||||||
* retention window (default 90 days raw vs. 12 months aggregated — see
|
* retention window (default 90 days raw vs. 12 months aggregated - see
|
||||||
* globals/AnalyticsSettings.ts) without keeping individual events around.
|
* globals/AnalyticsSettings.ts) without keeping individual events around.
|
||||||
*
|
*
|
||||||
* Not currently read by the main Analytics dashboard filters (those all
|
* Not currently read by the main Analytics dashboard filters (those all
|
||||||
* stay within raw retention by design — see the implementation plan); this
|
* stay within raw retention by design - see the implementation plan); this
|
||||||
* exists so long-term aggregated history isn't silently lost once raw
|
* exists so long-term aggregated history isn't silently lost once raw
|
||||||
* events are purged, and is available for a future longer-range view.
|
* events are purged, and is available for a future longer-range view.
|
||||||
*/
|
*/
|
||||||
export const AnalyticsDailyStats: CollectionConfig = {
|
export const AnalyticsDailyStats: CollectionConfig = {
|
||||||
slug: "analytics-daily-stats",
|
slug: "analytics-daily-stats",
|
||||||
labels: {
|
labels: {
|
||||||
singular: "Analytics-Tagesstatistik",
|
singular: "Tagesstatistik",
|
||||||
plural: "Analytics-Tagesstatistiken",
|
plural: "Tagesstatistiken",
|
||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
|
group: "Auswertung",
|
||||||
hidden: true,
|
hidden: true,
|
||||||
},
|
},
|
||||||
access: {
|
access: {
|
||||||
|
|||||||
@@ -3,25 +3,26 @@ import { canViewAnalytics } from "@/access";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* One row per tracked event (page_view, booking_started, ...). Deliberately
|
* One row per tracked event (page_view, booking_started, ...). Deliberately
|
||||||
* append-only and never writable via the public/admin API — the only writer
|
* 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
|
* is app/(payload)/api/analytics/track/route.ts, which uses the trusted
|
||||||
* Local API (overrideAccess: true by default) to insert rows despite
|
* Local API (overrideAccess: true by default) to insert rows despite
|
||||||
* `create` being locked to `() => false` here. The only deleter is the
|
* `create` being locked to `() => false` here. The only deleter is the
|
||||||
* cleanup cron (lib/analytics/cleanup.ts), same mechanism.
|
* cleanup cron (lib/analytics/cleanup.ts), same mechanism.
|
||||||
*
|
*
|
||||||
* No name/email/IP/UA is ever stored — see lib/analytics/salt.ts for how
|
* No name/email/IP/UA is ever stored - see lib/analytics/salt.ts for how
|
||||||
* `visitorHash` is derived without persisting anything identifying.
|
* `visitorHash` is derived without persisting anything identifying.
|
||||||
*/
|
*/
|
||||||
export const AnalyticsEvents: CollectionConfig = {
|
export const AnalyticsEvents: CollectionConfig = {
|
||||||
slug: "analytics-events",
|
slug: "analytics-events",
|
||||||
labels: {
|
labels: {
|
||||||
singular: "Analytics-Event",
|
singular: "Messereignis",
|
||||||
plural: "Analytics-Events",
|
plural: "Messereignisse",
|
||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
// High-volume, low-signal raw data — not meant for manual browsing.
|
group: "Auswertung",
|
||||||
// The Analytics dashboard (a custom admin view) reads it programmatically.
|
|
||||||
hidden: true,
|
hidden: true,
|
||||||
|
// High-volume, low-signal raw data - not meant for manual browsing.
|
||||||
|
// The Analytics dashboard (a custom admin view) reads it programmatically.
|
||||||
},
|
},
|
||||||
access: {
|
access: {
|
||||||
create: () => false,
|
create: () => false,
|
||||||
@@ -42,7 +43,7 @@ export const AnalyticsEvents: CollectionConfig = {
|
|||||||
name: "visitorHash",
|
name: "visitorHash",
|
||||||
type: "text",
|
type: "text",
|
||||||
required: true,
|
required: true,
|
||||||
// Daily-rotating pseudonymous hash (see lib/analytics/salt.ts) — never
|
// Daily-rotating pseudonymous hash (see lib/analytics/salt.ts) - never
|
||||||
// a raw IP/UA, never stable across days, never exposed to the client.
|
// a raw IP/UA, never stable across days, never exposed to the client.
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const AvailabilityOverrides: CollectionConfig = {
|
|||||||
useAsTitle: "date",
|
useAsTitle: "date",
|
||||||
defaultColumns: ["date", "type", "reason"],
|
defaultColumns: ["date", "type", "reason"],
|
||||||
group: "Buchungen",
|
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: {
|
access: {
|
||||||
read: isAdmin,
|
read: isAdmin,
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ const notifyByEmail: CollectionAfterChangeHook = async ({ doc, previousDoc, oper
|
|||||||
})) as Event;
|
})) as Event;
|
||||||
online = {
|
online = {
|
||||||
joinUrl: `${serverUrl}/termine/${event.slug}/beitreten`,
|
joinUrl: `${serverUrl}/termine/${event.slug}/beitreten`,
|
||||||
password: event.meetingPassword || "—",
|
password: event.meetingPassword || "-",
|
||||||
};
|
};
|
||||||
} else if (doc.appointmentType === "onsite") {
|
} else if (doc.appointmentType === "onsite") {
|
||||||
const settings = await req.payload.findGlobal({ slug: "booking-settings", req });
|
const settings = await req.payload.findGlobal({ slug: "booking-settings", req });
|
||||||
@@ -198,7 +198,7 @@ const notifyByEmail: CollectionAfterChangeHook = async ({ doc, previousDoc, oper
|
|||||||
name: user.name,
|
name: user.name,
|
||||||
offerTitle: offer.title,
|
offerTitle: offer.title,
|
||||||
dateLabel: fmtDate(doc.date),
|
dateLabel: fmtDate(doc.date),
|
||||||
timeLabel: `${fmtTime(doc.startTime)} – ${fmtTime(doc.endTime)}`,
|
timeLabel: `${fmtTime(doc.startTime)} - ${fmtTime(doc.endTime)}`,
|
||||||
appointmentTypeLabel: appointmentTypeLabel(doc.appointmentType),
|
appointmentTypeLabel: appointmentTypeLabel(doc.appointmentType),
|
||||||
online,
|
online,
|
||||||
onsite,
|
onsite,
|
||||||
@@ -260,12 +260,12 @@ export const BookingRequests: CollectionConfig = {
|
|||||||
useAsTitle: "id",
|
useAsTitle: "id",
|
||||||
defaultColumns: ["offer", "user", "date", "startTime", "status"],
|
defaultColumns: ["offer", "user", "date", "startTime", "status"],
|
||||||
group: "Buchungen",
|
group: "Buchungen",
|
||||||
description: "Terminanfragen von Nutzern — 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: {
|
access: {
|
||||||
// Customer-initiated writes always go through the vetted /api/booking/*
|
// Customer-initiated writes always go through the vetted /api/booking/*
|
||||||
// routes (Local API, elevated access, after the route verifies
|
// 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
|
// customer from ever reading/writing another customer's booking, or
|
||||||
// setting their own status to "confirmed" directly.
|
// setting their own status to "confirmed" directly.
|
||||||
create: isAdmin,
|
create: isAdmin,
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ export const ConversationMessages: CollectionConfig = {
|
|||||||
useAsTitle: "body",
|
useAsTitle: "body",
|
||||||
defaultColumns: ["conversation", "senderType", "senderName", "createdAt"],
|
defaultColumns: ["conversation", "senderType", "senderName", "createdAt"],
|
||||||
group: "Kommunikation",
|
group: "Kommunikation",
|
||||||
description: "Einzelne Nachrichten innerhalb einer Anfrage. Antworten aus dem CMS werden automatisch per E-Mail verschickt.",
|
description: "Einzelne Nachrichten innerhalb einer Anfrage. Antworten aus der Verwaltung werden automatisch per E-Mail verschickt.",
|
||||||
|
hidden: true,
|
||||||
},
|
},
|
||||||
access: {
|
access: {
|
||||||
read: isAdmin,
|
read: isAdmin,
|
||||||
@@ -79,7 +80,7 @@ export const ConversationMessages: CollectionConfig = {
|
|||||||
{ label: "Anfragende Person", value: "visitor" },
|
{ label: "Anfragende Person", value: "visitor" },
|
||||||
{ label: "Anouma", value: "admin" },
|
{ label: "Anouma", value: "admin" },
|
||||||
],
|
],
|
||||||
admin: { description: "Für Antworten im CMS „Anouma“ auswählen. Eingehende E-Mails werden automatisch als Anfragende Person gespeichert." },
|
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: "senderName", type: "text", label: "Absendername" },
|
||||||
{ name: "senderEmail", type: "email", label: "Absender-E-Mail" },
|
{ name: "senderEmail", type: "email", label: "Absender-E-Mail" },
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { sendEmail } from "@/lib/email/sendBookingEmails";
|
|||||||
|
|
||||||
// Routes through the shared isAdmin check (role === "admin", not just
|
// Routes through the shared isAdmin check (role === "admin", not just
|
||||||
// collection === "users") so this stays correct if a non-admin role is ever
|
// 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.
|
// added to Users - see the comment on isAdmin in access/index.ts.
|
||||||
const isSelfOrAdmin: typeof isAdmin = (args) => {
|
const isSelfOrAdmin: typeof isAdmin = (args) => {
|
||||||
const user = args.req.user;
|
const user = args.req.user;
|
||||||
if (!user) return false;
|
if (!user) return false;
|
||||||
@@ -21,12 +21,12 @@ const isSelfOrAdmin: typeof isAdmin = (args) => {
|
|||||||
// customer changes their email address: issues a random, single-use,
|
// customer changes their email address: issues a random, single-use,
|
||||||
// time-limited email-verification token and resets emailVerified to false.
|
// time-limited email-verification token and resets emailVerified to false.
|
||||||
// Only the token's hash is ever persisted (see emailVerificationTokenHash
|
// Only the token's hash is ever persisted (see emailVerificationTokenHash
|
||||||
// below) — the plaintext exists only in req.context for the afterChange
|
// 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.
|
// 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
|
// The email-change case matters because `update` access on this collection
|
||||||
// is isSelfOrAdmin and the auto-added `email` field carries no field-level
|
// is isSelfOrAdmin and the auto-added `email` field carries no field-level
|
||||||
// access of its own — without this, a customer could verify a throwaway
|
// 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
|
// address once and then repoint `email` at an address they don't own while
|
||||||
// keeping emailVerified: true (which gates booking-confirmation emails).
|
// keeping emailVerified: true (which gates booking-confirmation emails).
|
||||||
const issueVerificationToken: CollectionBeforeChangeHook = ({ data, originalDoc, operation, req }) => {
|
const issueVerificationToken: CollectionBeforeChangeHook = ({ data, originalDoc, operation, req }) => {
|
||||||
@@ -110,7 +110,7 @@ export const Customers: CollectionConfig = {
|
|||||||
label: "Telefonnummer",
|
label: "Telefonnummer",
|
||||||
required: false,
|
required: false,
|
||||||
admin: {
|
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.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -118,7 +118,7 @@ export const Customers: CollectionConfig = {
|
|||||||
type: "checkbox",
|
type: "checkbox",
|
||||||
label: "E-Mail bestätigt",
|
label: "E-Mail bestätigt",
|
||||||
defaultValue: false,
|
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
|
// customer could simply include emailVerified: true in their own
|
||||||
// registration or profile request and skip verification entirely.
|
// registration or profile request and skip verification entirely.
|
||||||
access: { create: isAdminFieldLevel, update: isAdminFieldLevel },
|
access: { create: isAdminFieldLevel, update: isAdminFieldLevel },
|
||||||
@@ -130,7 +130,7 @@ export const Customers: CollectionConfig = {
|
|||||||
{
|
{
|
||||||
name: "emailVerificationTokenHash",
|
name: "emailVerificationTokenHash",
|
||||||
type: "text",
|
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.
|
// (running through the trusted Local API) ever touch this field.
|
||||||
access: { create: () => false, read: () => false, update: () => false },
|
access: { create: () => false, read: () => false, update: () => false },
|
||||||
admin: { hidden: true },
|
admin: { hidden: true },
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const EventRegistrations: CollectionConfig = {
|
|||||||
useAsTitle: "name",
|
useAsTitle: "name",
|
||||||
defaultColumns: ["name", "email", "event", "status", "registeredAt"],
|
defaultColumns: ["name", "email", "event", "status", "registeredAt"],
|
||||||
group: "Inhalte",
|
group: "Inhalte",
|
||||||
description: "Anmeldungen zu Terminen — wird beim öffentlichen Anmeldeformular befüllt.",
|
description: "Anmeldungen zu Terminen - wird beim öffentlichen Anmeldeformular befüllt.",
|
||||||
},
|
},
|
||||||
access: {
|
access: {
|
||||||
// Public registrations must go through /api/meetings/[slug]/register,
|
// Public registrations must go through /api/meetings/[slug]/register,
|
||||||
@@ -65,14 +65,14 @@ export const EventRegistrations: CollectionConfig = {
|
|||||||
{
|
{
|
||||||
name: "reminder60Sent",
|
name: "reminder60Sent",
|
||||||
type: "checkbox",
|
type: "checkbox",
|
||||||
label: "Reminder 60 Min. gesendet",
|
label: "Erinnerung 60 Min. gesendet",
|
||||||
defaultValue: false,
|
defaultValue: false,
|
||||||
admin: { width: "50%" },
|
admin: { width: "50%" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "reminder30Sent",
|
name: "reminder30Sent",
|
||||||
type: "checkbox",
|
type: "checkbox",
|
||||||
label: "Reminder 30 Min. gesendet",
|
label: "Erinnerung 30 Min. gesendet",
|
||||||
defaultValue: false,
|
defaultValue: false,
|
||||||
admin: { width: "50%" },
|
admin: { width: "50%" },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const EVENT_CATEGORIES = [
|
|||||||
|
|
||||||
// Auto-generates the meeting password for new online events, and
|
// Auto-generates the meeting password for new online events, and
|
||||||
// regenerates it whenever the admin ticks "Neues Passwort generieren".
|
// regenerates it whenever the admin ticks "Neues Passwort generieren".
|
||||||
// Never derived from the event id/slug — always a fresh random value.
|
// Never derived from the event id/slug - always a fresh random value.
|
||||||
const setMeetingPassword: CollectionBeforeChangeHook = ({ data, originalDoc }) => {
|
const setMeetingPassword: CollectionBeforeChangeHook = ({ data, originalDoc }) => {
|
||||||
if (!(data.isOnline ?? originalDoc?.isOnline)) return data;
|
if (!(data.isOnline ?? originalDoc?.isOnline)) return data;
|
||||||
if (!(data.meetingPassword ?? originalDoc?.meetingPassword) || data.regeneratePassword || originalDoc?.isOnline === false) {
|
if (!(data.meetingPassword ?? originalDoc?.meetingPassword) || data.regeneratePassword || originalDoc?.isOnline === false) {
|
||||||
@@ -38,7 +38,7 @@ export const Events: CollectionConfig = {
|
|||||||
useAsTitle: "title",
|
useAsTitle: "title",
|
||||||
defaultColumns: ["title", "date", "category", "_status"],
|
defaultColumns: ["title", "date", "category", "_status"],
|
||||||
group: "Inhalte",
|
group: "Inhalte",
|
||||||
description: "Termine und Veranstaltungen — erscheinen automatisch unter /termine.",
|
description: "Termine und Veranstaltungen - erscheinen automatisch unter /termine.",
|
||||||
},
|
},
|
||||||
versions: {
|
versions: {
|
||||||
drafts: {
|
drafts: {
|
||||||
@@ -149,7 +149,7 @@ export const Events: CollectionConfig = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "collapsible",
|
type: "collapsible",
|
||||||
label: "Online-Termin (Video-Call)",
|
label: "Online-Termin (Videoanruf)",
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
name: "isOnline",
|
name: "isOnline",
|
||||||
@@ -166,7 +166,7 @@ export const Events: CollectionConfig = {
|
|||||||
label: "Meeting-Passwort",
|
label: "Meeting-Passwort",
|
||||||
// Events are publicly readable (publishedOrAdmin) via the REST/
|
// Events are publicly readable (publishedOrAdmin) via the REST/
|
||||||
// GraphQL API, so without this the plaintext password would be
|
// GraphQL API, so without this the plaintext password would be
|
||||||
// returned to any visitor fetching the event — defeating the
|
// returned to any visitor fetching the event - defeating the
|
||||||
// password check in app/(payload)/api/meetings/[slug]/join. Only
|
// password check in app/(payload)/api/meetings/[slug]/join. Only
|
||||||
// exposed to admins there; the join route reads it via the Local
|
// exposed to admins there; the join route reads it via the Local
|
||||||
// API, which bypasses field access by default.
|
// API, which bypasses field access by default.
|
||||||
@@ -220,14 +220,14 @@ export const Events: CollectionConfig = {
|
|||||||
{
|
{
|
||||||
name: "hostReminder60Sent",
|
name: "hostReminder60Sent",
|
||||||
type: "checkbox",
|
type: "checkbox",
|
||||||
label: "Host-Reminder 60 Min. gesendet",
|
label: "Erinnerung an Anna 60 Min. gesendet",
|
||||||
defaultValue: false,
|
defaultValue: false,
|
||||||
admin: { readOnly: true, width: "50%" },
|
admin: { readOnly: true, width: "50%" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "hostReminder30Sent",
|
name: "hostReminder30Sent",
|
||||||
type: "checkbox",
|
type: "checkbox",
|
||||||
label: "Host-Reminder 30 Min. gesendet",
|
label: "Erinnerung an Anna 30 Min. gesendet",
|
||||||
defaultValue: false,
|
defaultValue: false,
|
||||||
admin: { readOnly: true, width: "50%" },
|
admin: { readOnly: true, width: "50%" },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ export const Media: CollectionConfig = {
|
|||||||
plural: "Bilder",
|
plural: "Bilder",
|
||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
|
group: "Inhalte",
|
||||||
useAsTitle: "alt",
|
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 ist für Barrierefreiheit und SEO Pflicht.",
|
||||||
},
|
},
|
||||||
access: {
|
access: {
|
||||||
// Images are public so the website can display them without a login.
|
// Images are public so the website can display them without a login.
|
||||||
@@ -26,7 +27,7 @@ export const Media: CollectionConfig = {
|
|||||||
staticDir: path.resolve(dirname, "..", "media"),
|
staticDir: path.resolve(dirname, "..", "media"),
|
||||||
// SVG deliberately excluded: it can carry inline <script>/event-handler
|
// SVG deliberately excluded: it can carry inline <script>/event-handler
|
||||||
// payloads that execute if the raw file is ever opened directly (not via
|
// payloads that execute if the raw file is ever opened directly (not via
|
||||||
// <img>) — not currently needed by any part of the site.
|
// <img>) - not currently needed by any part of the site.
|
||||||
mimeTypes: ["image/jpeg", "image/png", "image/webp"],
|
mimeTypes: ["image/jpeg", "image/png", "image/webp"],
|
||||||
imageSizes: [
|
imageSizes: [
|
||||||
{ name: "thumbnail", width: 400, position: "centre" },
|
{ name: "thumbnail", width: 400, position: "centre" },
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { seoFields } from "@/fields/seo";
|
|||||||
|
|
||||||
// Groups the 7 individual offers for the mega menu and the /angebote
|
// Groups the 7 individual offers for the mega menu and the /angebote
|
||||||
// overview page (Kindergruppen bundles Erdenkinder + Mädchenkreis, Singkreise
|
// 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 = [
|
export const OFFER_CATEGORIES = [
|
||||||
{ label: "Prozessbegleitung", value: "prozessbegleitung" },
|
{ label: "Prozessbegleitung", value: "prozessbegleitung" },
|
||||||
{ label: "Doula-Begleitung", value: "doula-begleitung" },
|
{ label: "Doula-Begleitung", value: "doula-begleitung" },
|
||||||
@@ -23,7 +23,7 @@ export const Offers: CollectionConfig = {
|
|||||||
useAsTitle: "title",
|
useAsTitle: "title",
|
||||||
defaultColumns: ["title", "category", "order", "_status"],
|
defaultColumns: ["title", "category", "order", "_status"],
|
||||||
group: "Inhalte",
|
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",
|
defaultSort: "order",
|
||||||
versions: {
|
versions: {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { isAdmin } from "@/access";
|
|||||||
|
|
||||||
function normalizePath(value: string): string {
|
function normalizePath(value: string): string {
|
||||||
// Lowercased to match the slugs it points at/from (fields/slug.ts always
|
// Lowercased to match the slugs it points at/from (fields/slug.ts always
|
||||||
// lowercases via slugify) — without this, "/Angebote/Doula" and
|
// lowercases via slugify) - without this, "/Angebote/Doula" and
|
||||||
// "/angebote/doula" count as different values to the `unique` constraint
|
// "/angebote/doula" count as different values to the `unique` constraint
|
||||||
// on fromPath, letting near-duplicate redirects coexist.
|
// on fromPath, letting near-duplicate redirects coexist.
|
||||||
const trimmed = value.trim().toLowerCase();
|
const trimmed = value.trim().toLowerCase();
|
||||||
@@ -14,7 +14,7 @@ function normalizePath(value: string): string {
|
|||||||
/**
|
/**
|
||||||
* Simple slug-change redirect table (see lib/seo/redirects.ts). Deliberately
|
* Simple slug-change redirect table (see lib/seo/redirects.ts). Deliberately
|
||||||
* scoped to the three dynamic [slug] detail routes (Angebote/Termine/
|
* 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
|
* looking this up only there avoids adding a database lookup to every
|
||||||
* single request the way a catch-all middleware would.
|
* single request the way a catch-all middleware would.
|
||||||
*/
|
*/
|
||||||
@@ -66,8 +66,8 @@ export const Redirects: CollectionConfig = {
|
|||||||
required: true,
|
required: true,
|
||||||
defaultValue: "permanent",
|
defaultValue: "permanent",
|
||||||
options: [
|
options: [
|
||||||
{ label: "301 – Dauerhaft", value: "permanent" },
|
{ label: "301 - Dauerhaft", value: "permanent" },
|
||||||
{ label: "302 – Vorübergehend", value: "temporary" },
|
{ label: "302 - Vorübergehend", value: "temporary" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,12 +10,13 @@ export const Users: CollectionConfig = {
|
|||||||
admin: {
|
admin: {
|
||||||
useAsTitle: "name",
|
useAsTitle: "name",
|
||||||
defaultColumns: ["name", "email", "role"],
|
defaultColumns: ["name", "email", "role"],
|
||||||
|
group: "Verwaltung",
|
||||||
description: "Zugänge für den geschützten Admin-Bereich.",
|
description: "Zugänge für den geschützten Admin-Bereich.",
|
||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
// Payload shows a "create first user" screen automatically when this
|
// Payload shows a "create first user" screen automatically when this
|
||||||
// collection is empty, bypassing normal access control just for that
|
// 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,
|
maxLoginAttempts: 5,
|
||||||
lockTime: 10 * 60 * 1000,
|
lockTime: 10 * 60 * 1000,
|
||||||
},
|
},
|
||||||
@@ -48,24 +49,24 @@ export const Users: CollectionConfig = {
|
|||||||
{
|
{
|
||||||
name: "canViewAnalytics",
|
name: "canViewAnalytics",
|
||||||
type: "checkbox",
|
type: "checkbox",
|
||||||
label: "Analytics sichtbar",
|
label: "Auswertung sichtbar",
|
||||||
defaultValue: true,
|
defaultValue: true,
|
||||||
// Admins always have access regardless of this flag (see
|
// Admins always have access regardless of this flag (see
|
||||||
// access/index.ts's canViewAnalytics) — this only matters once a
|
// access/index.ts's canViewAnalytics) - this only matters once a
|
||||||
// non-admin role exists, letting an admin grant/revoke Analytics
|
// non-admin role exists, letting an admin grant/revoke Analytics
|
||||||
// visibility per account without changing anyone's role.
|
// visibility per account without changing anyone's role.
|
||||||
access: {
|
access: {
|
||||||
update: isAdminFieldLevel,
|
update: isAdminFieldLevel,
|
||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
description: "Steuert, ob dieser Zugang das Analytics-Dashboard im Admin-Bereich sehen kann.",
|
description: "Steuert, ob dieser Zugang die Auswertung im Admin-Bereich sehen kann.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "calendarFeedTokenHash",
|
name: "calendarFeedTokenHash",
|
||||||
type: "text",
|
type: "text",
|
||||||
// Only ever set by /api/admin/calendar-feed/regenerate (see
|
// 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
|
// any API, so the plaintext token is never retrievable again after
|
||||||
// it's shown once at generation time.
|
// it's shown once at generation time.
|
||||||
access: { create: () => false, read: () => false, update: () => false },
|
access: { create: () => false, read: () => false, update: () => false },
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const buttonBase =
|
|||||||
* Mounted once in app/(frontend)/layout.tsx. Combines two independent jobs:
|
* Mounted once in app/(frontend)/layout.tsx. Combines two independent jobs:
|
||||||
* (1) the cookie-consent banner, and (2) automatic page_view tracking on
|
* (1) the cookie-consent banner, and (2) automatic page_view tracking on
|
||||||
* every client-side route change. Both only ever act through track()/
|
* every client-side route change. Both only ever act through track()/
|
||||||
* setAnalyticsConfig() from lib/analytics/client.ts — no network call is
|
* setAnalyticsConfig() from lib/analytics/client.ts - no network call is
|
||||||
* possible from this component before a visitor has actually consented.
|
* possible from this component before a visitor has actually consented.
|
||||||
*/
|
*/
|
||||||
export function Analytics({ consentVersion, preferencesEnabled, statisticsEnabled, necessary, preferences, statistics }: AnalyticsProps) {
|
export function Analytics({ consentVersion, preferencesEnabled, statisticsEnabled, necessary, preferences, statistics }: AnalyticsProps) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/** 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`. */
|
/** 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 }) {
|
export function ArrowIcon({ className = "" }: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ type ArrowLinkProps = {
|
|||||||
/**
|
/**
|
||||||
* The site's one secondary-action pattern: a text link with a small arrow
|
* 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
|
* 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
|
* 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.
|
* like an invitation to read on rather than another button to click.
|
||||||
*/
|
*/
|
||||||
export function ArrowLink({ href, children, tone = "mauve", size = "sm", className = "" }: ArrowLinkProps) {
|
export function ArrowLink({ href, children, tone = "mauve", size = "sm", className = "" }: ArrowLinkProps) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { ReactNode } from "react";
|
|||||||
type Variant = "primary" | "secondary" | "ghost" | "invert" | "invertOutline";
|
type Variant = "primary" | "secondary" | "ghost" | "invert" | "invertOutline";
|
||||||
|
|
||||||
// Text colors are chosen to meet WCAG AA (4.5:1) against every background
|
// 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> = {
|
const variants: Record<Variant, string> = {
|
||||||
primary: "bg-anouma-mauve-dark text-white hover:bg-anouma-plum",
|
primary: "bg-anouma-mauve-dark text-white hover:bg-anouma-plum",
|
||||||
secondary:
|
secondary:
|
||||||
|
|||||||
@@ -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="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 className="grid gap-12 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<div>
|
<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()}
|
{siteConfig.name.toUpperCase()}
|
||||||
</Link>
|
</Link>
|
||||||
<p className="mt-4 max-w-xs text-sm leading-relaxed text-anouma-cream-light/90">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { InputHTMLAttributes, ReactNode, TextareaHTMLAttributes } from "rea
|
|||||||
/**
|
/**
|
||||||
* The one field style used by every form on the site (previously each form
|
* 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
|
* declared its own near-identical `fieldClass` constant with small drifts in
|
||||||
* padding/size/background — see ContactForm, auth/*, meeting/* history).
|
* padding/size/background - see ContactForm, auth/*, meeting/* history).
|
||||||
* Functionality is untouched: both components just spread the native
|
* Functionality is untouched: both components just spread the native
|
||||||
* input/textarea props through, so controlled and uncontrolled forms,
|
* input/textarea props through, so controlled and uncontrolled forms,
|
||||||
* validation attributes, autoComplete, etc. all keep working exactly as
|
* validation attributes, autoComplete, etc. all keep working exactly as
|
||||||
@@ -23,7 +23,7 @@ function FieldLabel({ label, htmlFor, hidden = false }: { label: string; htmlFor
|
|||||||
type TextFieldProps = InputHTMLAttributes<HTMLInputElement> & {
|
type TextFieldProps = InputHTMLAttributes<HTMLInputElement> & {
|
||||||
label: string;
|
label: string;
|
||||||
id: 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). */
|
/** Keeps the label for screen readers only - for compact inline fields identified by a placeholder instead (e.g. ResendVerificationButton's email prompt). */
|
||||||
hideLabel?: boolean;
|
hideLabel?: boolean;
|
||||||
wrapperClassName?: string;
|
wrapperClassName?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
+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">
|
<div className="relative mx-auto aspect-[4/5] w-full max-w-md animate-fade-in [animation-delay:200ms] lg:max-w-none">
|
||||||
<ImagePlaceholder
|
<ImagePlaceholder
|
||||||
mood={mood}
|
mood={mood}
|
||||||
label="Anouma – warme, naturverbundene Begleitung"
|
label="Anouma - warme, naturverbundene Begleitung"
|
||||||
src={imageSrc}
|
src={imageSrc}
|
||||||
alt={imageAlt}
|
alt={imageAlt}
|
||||||
priority
|
priority
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { ImageMood } from "@/lib/angebote";
|
|||||||
/**
|
/**
|
||||||
* Renders a photo when `src` is provided, otherwise falls back to a warm,
|
* Renders a photo when `src` is provided, otherwise falls back to a warm,
|
||||||
* organic gradient placeholder in the given mood. Pages should always pass
|
* 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> = {
|
const moodGradients: Record<ImageMood, string> = {
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export function Navbar({ offers, customerName }: { offers: Offer[]; customerName
|
|||||||
<div className="mx-auto flex h-20 max-w-6xl items-center justify-between px-6 sm:px-8 lg:px-12">
|
<div className="mx-auto flex h-20 max-w-6xl items-center justify-between px-6 sm:px-8 lg:px-12">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
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()}
|
{siteConfig.name.toUpperCase()}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ type PageHeaderProps = {
|
|||||||
compact?: boolean;
|
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. */
|
/** 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({
|
export async function PageHeader({
|
||||||
eyebrow,
|
eyebrow,
|
||||||
title,
|
title,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { ReactNode } from "react";
|
|||||||
export function PlaceholderNote({ children }: { children: ReactNode }) {
|
export function PlaceholderNote({ children }: { children: ReactNode }) {
|
||||||
return (
|
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">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { analyticsEventLabel } from "@/lib/analytics/events";
|
|||||||
import { AnalyticsChart } from "./AnalyticsChart";
|
import { AnalyticsChart } from "./AnalyticsChart";
|
||||||
import styles from "./AdminAnalyticsView.module.css";
|
import styles from "./AdminAnalyticsView.module.css";
|
||||||
|
|
||||||
const DEVICE_LABELS: Record<string, string> = { mobile: "Mobile", desktop: "Desktop", tablet: "Tablet" };
|
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" };
|
const BROWSER_LABELS: Record<string, string> = { chrome: "Chrome", firefox: "Firefox", safari: "Safari", edge: "Edge", other: "Sonstige" };
|
||||||
|
|
||||||
function fmtInt(n: number): string {
|
function fmtInt(n: number): string {
|
||||||
@@ -73,7 +73,7 @@ export async function AdminAnalyticsView({ initPageResult }: AdminViewServerProp
|
|||||||
if (!canViewAnalytics({ req })) {
|
if (!canViewAnalytics({ req })) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.page}>
|
<div className={styles.page}>
|
||||||
<h1 className={styles.title}>Analytics</h1>
|
<h1 className={styles.title}>Auswertung</h1>
|
||||||
<p className={styles.subtitle}>Du hast keine Berechtigung, diesen Bereich zu sehen.</p>
|
<p className={styles.subtitle}>Du hast keine Berechtigung, diesen Bereich zu sehen.</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -102,8 +102,8 @@ export async function AdminAnalyticsView({ initPageResult }: AdminViewServerProp
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.page}>
|
<div className={styles.page}>
|
||||||
<h1 className={styles.title}>Analytics</h1>
|
<h1 className={styles.title}>Auswertung</h1>
|
||||||
<p className={styles.subtitle}>Datensparsame Reichweitenmessung — keine personenbezogenen Profile. Rohdaten werden {maxDaysBack} Tage aufbewahrt.</p>
|
<p className={styles.subtitle}>Datensparsame Reichweitenmessung ohne personenbezogene Profile. Rohdaten werden {maxDaysBack} Tage aufbewahrt.</p>
|
||||||
|
|
||||||
<div className={styles.filterBar}>
|
<div className={styles.filterBar}>
|
||||||
{RANGE_OPTIONS.map((option) => (
|
{RANGE_OPTIONS.map((option) => (
|
||||||
@@ -132,12 +132,12 @@ export async function AdminAnalyticsView({ initPageResult }: AdminViewServerProp
|
|||||||
|
|
||||||
{RANGE_OPTIONS.some((o) => typeof o.days === "number" && o.days > maxDaysBack) && (
|
{RANGE_OPTIONS.some((o) => typeof o.days === "number" && o.days > maxDaysBack) && (
|
||||||
<p style={{ color: "#888", fontSize: 12, marginTop: -16, marginBottom: 20 }}>
|
<p style={{ color: "#888", fontSize: 12, marginTop: -16, marginBottom: 20 }}>
|
||||||
Zeiträume sind auf die konfigurierte Rohdaten-Aufbewahrung von {maxDaysBack} Tagen begrenzt (siehe „Analytics-Einstellungen“).
|
Zeiträume sind auf die konfigurierte Rohdaten-Aufbewahrung von {maxDaysBack} Tagen begrenzt (siehe „Auswertungs-Einstellungen“).
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={styles.tiles}>
|
<div className={styles.tiles}>
|
||||||
<Tile label="Sessions" value={current.sessions} delta={delta("sessions")} />
|
<Tile label="Besuche" value={current.sessions} delta={delta("sessions")} />
|
||||||
<Tile label="Seitenaufrufe" value={current.pageViews} delta={delta("pageViews")} />
|
<Tile label="Seitenaufrufe" value={current.pageViews} delta={delta("pageViews")} />
|
||||||
<Tile label="Eindeutige Besucher" value={current.uniqueVisitors} delta={delta("uniqueVisitors")} />
|
<Tile label="Eindeutige Besucher" value={current.uniqueVisitors} delta={delta("uniqueVisitors")} />
|
||||||
<Tile label="Ø Seiten / Session" value={current.avgPagesPerSession} delta={delta("avgPagesPerSession")} />
|
<Tile label="Ø Seiten / Session" value={current.avgPagesPerSession} delta={delta("avgPagesPerSession")} />
|
||||||
@@ -146,7 +146,7 @@ export async function AdminAnalyticsView({ initPageResult }: AdminViewServerProp
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.chartCard}>
|
<div className={styles.chartCard}>
|
||||||
<h2 className={styles.sectionTitle}>Seitenaufrufe & Sessions</h2>
|
<h2 className={styles.sectionTitle}>Seitenaufrufe und Besuche</h2>
|
||||||
<AnalyticsChart series={current.series} />
|
<AnalyticsChart series={current.series} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -155,7 +155,7 @@ export async function AdminAnalyticsView({ initPageResult }: AdminViewServerProp
|
|||||||
<EntryTable title="Einstiegsseiten" entries={topEntries(current.entryPaths)} />
|
<EntryTable title="Einstiegsseiten" entries={topEntries(current.entryPaths)} />
|
||||||
<EntryTable title="Ausstiegsseiten" entries={topEntries(current.exitPaths)} />
|
<EntryTable title="Ausstiegsseiten" entries={topEntries(current.exitPaths)} />
|
||||||
<EntryTable
|
<EntryTable
|
||||||
title="Events"
|
title="Ereignisse"
|
||||||
entries={topEntries(current.eventCounts).map(([name, count]) => [analyticsEventLabel(name), count])}
|
entries={topEntries(current.eventCounts).map(([name, count]) => [analyticsEventLabel(name), count])}
|
||||||
/>
|
/>
|
||||||
<EntryTable
|
<EntryTable
|
||||||
@@ -169,25 +169,25 @@ export async function AdminAnalyticsView({ initPageResult }: AdminViewServerProp
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.privacyPanel}>
|
<div className={styles.privacyPanel}>
|
||||||
<h3>Analytics & Datenschutz</h3>
|
<h3>Auswertung und Datenschutz</h3>
|
||||||
<p>Erhobene Daten:</p>
|
<p>Erhobene Daten:</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Seitenaufrufe und Events (siehe „Events“-Tabelle)</li>
|
<li>Seitenaufrufe und Ereignisse (siehe Tabelle „Ereignisse“)</li>
|
||||||
<li>Gerätetyp (Mobile/Desktop/Tablet)</li>
|
<li>Gerätetyp (Handy/Computer/Tablet)</li>
|
||||||
<li>Browser-Familie</li>
|
<li>Browser-Familie</li>
|
||||||
<li>Referrer-Hostname (nur die Domain, keine vollständige URL)</li>
|
<li>Referrer-Hostname (nur die Domain, keine vollständige URL)</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>
|
<p>
|
||||||
Keine Namen, E-Mail-Adressen, Telefonnummern, vollständigen IP-Adressen oder Formularinhalte. Kein Fingerprinting, keine
|
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
|
dauerhaften individuellen Nutzerprofile - Besucher werden über einen täglich rotierenden, nicht zurückverfolgbaren Hash
|
||||||
gruppiert.
|
gruppiert.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<strong>Aufbewahrung:</strong> {settings.rawRetentionDays} Tage für Rohdaten, {settings.aggregateRetentionMonths} Monate für
|
<strong>Aufbewahrung:</strong> {settings.rawRetentionDays} Tage für Rohdaten, {settings.aggregateRetentionMonths} Monate für
|
||||||
aggregierte Tagesstatistiken (konfigurierbar unter „Analytics-Einstellungen“).
|
aggregierte Tagesstatistiken (konfigurierbar unter „Auswertungs-Einstellungen“).
|
||||||
</p>
|
</p>
|
||||||
<p style={{ fontStyle: "italic", color: "#777" }}>
|
<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
|
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.
|
Zweifel rechtlich prüfen lassen, wie auch beim Impressum/Datenschutztext der Website.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export async function AdminCalendarView({ initPageResult }: AdminViewServerProps
|
|||||||
|
|
||||||
// Local API (overrideAccess: true by default) so this can see
|
// Local API (overrideAccess: true by default) so this can see
|
||||||
// calendarFeedTokenHash despite its field-level access being locked to
|
// calendarFeedTokenHash despite its field-level access being locked to
|
||||||
// "nobody" for every real API path — see collections/Users.ts.
|
// "nobody" for every real API path - see collections/Users.ts.
|
||||||
const currentUser = req.user ? await req.payload.findByID({ collection: "users", id: req.user.id, req }) : null;
|
const currentUser = req.user ? await req.payload.findByID({ collection: "users", id: req.user.id, req }) : null;
|
||||||
const calendarFeedConfigured = Boolean((currentUser as { calendarFeedTokenHash?: string | null } | null)?.calendarFeedTokenHash);
|
const calendarFeedConfigured = Boolean((currentUser as { calendarFeedTokenHash?: string | null } | null)?.calendarFeedTokenHash);
|
||||||
const itemsByDay = new Map<string, AdminCalendarItem[]>();
|
const itemsByDay = new Map<string, AdminCalendarItem[]>();
|
||||||
@@ -181,7 +181,7 @@ export async function AdminCalendarView({ initPageResult }: AdminViewServerProps
|
|||||||
</span>
|
</span>
|
||||||
<strong>{entry.title}</strong>
|
<strong>{entry.title}</strong>
|
||||||
<span>
|
<span>
|
||||||
{fmtTime(entry.startTime)}–{fmtTime(entry.endTime)}
|
{fmtTime(entry.startTime)}-{fmtTime(entry.endTime)}
|
||||||
</span>
|
</span>
|
||||||
{entry.subtitle && <span>· {entry.subtitle}</span>}
|
{entry.subtitle && <span>· {entry.subtitle}</span>}
|
||||||
{entry.appointmentType && <span>· {entry.appointmentType === "online" ? "Online" : "Vor Ort"}</span>}
|
{entry.appointmentType && <span>· {entry.appointmentType === "online" ? "Online" : "Vor Ort"}</span>}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function AdminCallButton({ eventSlug }: { eventSlug: string }) {
|
|||||||
});
|
});
|
||||||
const data = await response.json().catch(() => ({}));
|
const data = await response.json().catch(() => ({}));
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
setError(data.error || "Der Video-Call konnte nicht gestartet werden.");
|
setError(data.error || "Der Videoanruf konnte nicht gestartet werden.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setUrl(data.testUrl);
|
setUrl(data.testUrl);
|
||||||
@@ -46,7 +46,7 @@ export function AdminCallButton({ eventSlug }: { eventSlug: string }) {
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="rounded-full bg-anouma-mauve-dark px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-anouma-plum disabled:cursor-wait disabled:opacity-60"
|
className="rounded-full bg-anouma-mauve-dark px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-anouma-plum disabled:cursor-wait disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{loading ? "Test-Link wird erzeugt …" : copied ? "Test-Link kopiert" : "Call testen"}
|
{loading ? "Test-Link wird erzeugt ..." : copied ? "Test-Link kopiert" : "Videoanruf testen"}
|
||||||
</button>
|
</button>
|
||||||
{url && !copied && (
|
{url && !copied && (
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function AdminTestCallButton() {
|
|||||||
});
|
});
|
||||||
const data = (await response.json().catch(() => ({}))) as { testUrl?: string; error?: string };
|
const data = (await response.json().catch(() => ({}))) as { testUrl?: string; error?: string };
|
||||||
if (!response.ok || !data.testUrl) {
|
if (!response.ok || !data.testUrl) {
|
||||||
setError(data.error || "Der Test-Call konnte nicht gestartet werden.");
|
setError(data.error || "Der Test-Videoanruf konnte nicht gestartet werden.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,25 +38,14 @@ export function AdminTestCallButton() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: "0 8px 8px" }}>
|
<div className="anouma-custom-nav-action">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void createTestCall()}
|
onClick={() => void createTestCall()}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
style={{
|
className="anouma-custom-nav-button"
|
||||||
width: "100%",
|
|
||||||
padding: "8px 12px",
|
|
||||||
borderRadius: 6,
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "#fff",
|
|
||||||
background: "#8b616d",
|
|
||||||
border: "1px solid #8b616d",
|
|
||||||
cursor: loading ? "wait" : "pointer",
|
|
||||||
opacity: loading ? 0.7 : 1,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{loading ? "Test-Link wird erzeugt …" : copied ? "✓ Test-Link kopiert" : "▶ Test-Videoanruf"}
|
{loading ? "Test-Link wird erzeugt ..." : copied ? "Test-Link kopiert" : "Videoanruf testen"}
|
||||||
</button>
|
</button>
|
||||||
{url && (
|
{url && (
|
||||||
<input
|
<input
|
||||||
@@ -65,10 +54,10 @@ export function AdminTestCallButton() {
|
|||||||
value={url}
|
value={url}
|
||||||
aria-label="Test-Videoanruf-Link"
|
aria-label="Test-Videoanruf-Link"
|
||||||
onFocus={(event) => event.currentTarget.select()}
|
onFocus={(event) => event.currentTarget.select()}
|
||||||
style={{ width: "100%", marginTop: 6, padding: "5px 7px", border: "1px solid #e2e2e2", borderRadius: 5, fontSize: 11 }}
|
className="anouma-custom-nav-input"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{error && <p style={{ margin: "6px 0 0", color: "#a33", fontSize: 11 }}>{error}</p>}
|
{error && <p className="anouma-custom-nav-error">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
const links = [
|
||||||
|
{ href: "/admin/anfragen", kicker: "Kommunikation", title: "Anfragen", text: "Kontakt aufnehmen, antworten und E-Mail-Verläufe bearbeiten." },
|
||||||
|
{ href: "/admin/kalender", kicker: "Planung", title: "Kalender", text: "Freie Zeiten, Buchungen und Veranstaltungen im Blick behalten." },
|
||||||
|
{ href: "/admin/collections/offers", kicker: "Website", title: "Angebote", text: "Angebote, Gruppen und Buchungsarten pflegen." },
|
||||||
|
{ href: "/admin/collections/events", kicker: "Termine", title: "Veranstaltungen", text: "Termine anlegen, bearbeiten und Anmeldungen verwalten." },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AdminWelcome() {
|
||||||
|
return (
|
||||||
|
<section className="anouma-admin-welcome" aria-labelledby="anouma-admin-welcome-title">
|
||||||
|
<div className="anouma-admin-welcome__intro">
|
||||||
|
<p className="anouma-admin-eyebrow">Anouma Verwaltung</p>
|
||||||
|
<h1 id="anouma-admin-welcome-title">Was möchtest du heute erledigen?</h1>
|
||||||
|
<p>Hier findest du die wichtigsten Bereiche für Website, Anfragen und Termine an einem Ort.</p>
|
||||||
|
</div>
|
||||||
|
<div className="anouma-admin-quicklinks">
|
||||||
|
{links.map((link, index) => (
|
||||||
|
<Link key={link.href} href={link.href} className="anouma-admin-quicklink">
|
||||||
|
<span className="anouma-admin-quicklink__number">0{index + 1}</span>
|
||||||
|
<span className="anouma-admin-quicklink__kicker">{link.kicker}</span>
|
||||||
|
<strong>{link.title}</strong>
|
||||||
|
<span>{link.text}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ export function AnalyticsChart({ series }: { series: DailyPoint[] }) {
|
|||||||
labelFormatter={(label) => `Tag: ${label}`}
|
labelFormatter={(label) => `Tag: ${label}`}
|
||||||
/>
|
/>
|
||||||
<Line type="monotone" dataKey="pageViews" name="Seitenaufrufe" stroke={ACCENT} strokeWidth={2} dot={false} />
|
<Line type="monotone" dataKey="pageViews" name="Seitenaufrufe" stroke={ACCENT} strokeWidth={2} dot={false} />
|
||||||
<Line type="monotone" dataKey="sessions" name="Sessions" stroke={ACCENT_MUTED} strokeWidth={2} dot={false} />
|
<Line type="monotone" dataKey="sessions" name="Besuche" stroke={ACCENT_MUTED} strokeWidth={2} dot={false} />
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,27 +3,16 @@ import type { ServerProps } from "payload";
|
|||||||
import { canViewAnalytics } from "@/access";
|
import { canViewAnalytics } from "@/access";
|
||||||
|
|
||||||
export function AnalyticsNavLink({ user }: ServerProps) {
|
export function AnalyticsNavLink({ user }: ServerProps) {
|
||||||
// Payload's custom views/nav links have no declarative `access` option —
|
// Payload's custom views/nav links have no declarative `access` option -
|
||||||
// this component must gate itself (see also AdminAnalyticsView.tsx, which
|
// this component must gate itself (see also AdminAnalyticsView.tsx, which
|
||||||
// re-checks the same thing for direct navigation to /admin/analytics).
|
// re-checks the same thing for direct navigation to /admin/analytics).
|
||||||
if (!canViewAnalytics({ req: { user } } as Parameters<typeof canViewAnalytics>[0])) return null;
|
if (!canViewAnalytics({ req: { user } } as Parameters<typeof canViewAnalytics>[0])) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: "0 8px 8px" }}>
|
<div className="anouma-custom-nav-item">
|
||||||
<Link
|
<Link href="/admin/analytics" className="anouma-custom-nav-link">
|
||||||
href="/admin/analytics"
|
<span className="anouma-custom-nav-link__icon">03</span>
|
||||||
style={{
|
<span>Auswertung</span>
|
||||||
display: "block",
|
|
||||||
padding: "8px 12px",
|
|
||||||
borderRadius: 6,
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "#8b616d",
|
|
||||||
textDecoration: "none",
|
|
||||||
border: "1px solid #e2e2e2",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
📊 Analytics
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export function CalendarFeedPanel({ initiallyConfigured }: { initiallyConfigured
|
|||||||
await navigator.clipboard.writeText(url);
|
await navigator.clipboard.writeText(url);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
} catch {
|
} catch {
|
||||||
// Clipboard API can be unavailable (e.g. insecure context) — the
|
// Clipboard API can be unavailable (e.g. insecure context) - the
|
||||||
// input is still selectable/copyable by hand in that case.
|
// input is still selectable/copyable by hand in that case.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,7 +46,7 @@ export function CalendarFeedPanel({ initiallyConfigured }: { initiallyConfigured
|
|||||||
<h2 className={styles.heading}>Kalender synchronisieren</h2>
|
<h2 className={styles.heading}>Kalender synchronisieren</h2>
|
||||||
<p className={styles.hint}>
|
<p className={styles.hint}>
|
||||||
Abonniere deinen persönlichen Kalender in Apple Kalender, Google Kalender, Outlook, Thunderbird oder einer
|
Abonniere deinen persönlichen Kalender in Apple Kalender, Google Kalender, Outlook, Thunderbird oder einer
|
||||||
anderen iCalendar-kompatiblen App. Der Link enthält ein geheimes Zugriffstoken — teile ihn nicht.
|
anderen iCalendar-kompatiblen App. Der Link enthält ein geheimes Zugriffstoken - teile ihn nicht.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{!url && (
|
{!url && (
|
||||||
@@ -65,7 +65,7 @@ export function CalendarFeedPanel({ initiallyConfigured }: { initiallyConfigured
|
|||||||
|
|
||||||
{url && (
|
{url && (
|
||||||
<div className={styles.result}>
|
<div className={styles.result}>
|
||||||
<p className={styles.resultWarning}>Wird nur jetzt einmal angezeigt — bitte gleich kopieren.</p>
|
<p className={styles.resultWarning}>Wird nur jetzt einmal angezeigt - bitte gleich kopieren.</p>
|
||||||
<div className={styles.urlRow}>
|
<div className={styles.urlRow}>
|
||||||
<input className={styles.urlInput} type="text" readOnly value={url} onFocus={(e) => e.currentTarget.select()} />
|
<input className={styles.urlInput} type="text" readOnly value={url} onFocus={(e) => e.currentTarget.select()} />
|
||||||
<button type="button" className={styles.secondaryButton} onClick={copy}>
|
<button type="button" className={styles.secondaryButton} onClick={copy}>
|
||||||
|
|||||||
@@ -4,21 +4,10 @@ import { AdminTestCallButton } from "./AdminTestCallButton";
|
|||||||
export function CalendarNavLink() {
|
export function CalendarNavLink() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div style={{ padding: "0 8px 8px" }}>
|
<div className="anouma-custom-nav-item">
|
||||||
<Link
|
<Link href="/admin/kalender" className="anouma-custom-nav-link">
|
||||||
href="/admin/kalender"
|
<span className="anouma-custom-nav-link__icon">02</span>
|
||||||
style={{
|
<span>Kalender</span>
|
||||||
display: "block",
|
|
||||||
padding: "8px 12px",
|
|
||||||
borderRadius: 6,
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "#8b616d",
|
|
||||||
textDecoration: "none",
|
|
||||||
border: "1px solid #e2e2e2",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
📅 Kalender
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<AdminTestCallButton />
|
<AdminTestCallButton />
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
export function ConversationsNavLink() {
|
export function ConversationsNavLink() {
|
||||||
return <div style={{ padding: "0 8px 8px" }}><Link href="/admin/anfragen" style={{ display: "block", padding: "8px 12px", borderRadius: 6, fontSize: 13, fontWeight: 600, color: "#8b616d", textDecoration: "none", border: "1px solid #e2e2e2" }}>💬 Anfragen</Link></div>;
|
return (
|
||||||
|
<div className="anouma-custom-nav-item">
|
||||||
|
<Link href="/admin/anfragen" className="anouma-custom-nav-link">
|
||||||
|
<span className="anouma-custom-nav-link__icon">01</span>
|
||||||
|
<span>Anfragen</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export function BookingCard({ booking, meetingStatus }: { booking: CustomerBooki
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="font-serif text-xl font-medium text-anouma-plum">{offer?.title ?? "Termin"}</h3>
|
<h3 className="font-serif text-xl font-medium text-anouma-plum">{offer?.title ?? "Termin"}</h3>
|
||||||
<p className="mt-1 text-sm text-anouma-plum/80">
|
<p className="mt-1 text-sm text-anouma-plum/80">
|
||||||
{formatBerlinDate(new Date(booking.date), { weekday: "long", day: "2-digit", month: "long", year: "numeric" })} · {formatBerlinTime(new Date(booking.startTime), { hour: "2-digit", minute: "2-digit" })} – {formatBerlinTime(new Date(booking.endTime), { hour: "2-digit", minute: "2-digit" })}
|
{formatBerlinDate(new Date(booking.date), { weekday: "long", day: "2-digit", month: "long", year: "numeric" })} · {formatBerlinTime(new Date(booking.startTime), { hour: "2-digit", minute: "2-digit" })} - {formatBerlinTime(new Date(booking.endTime), { hour: "2-digit", minute: "2-digit" })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<BookingStatusBadge status={displayStatus} />
|
<BookingStatusBadge status={displayStatus} />
|
||||||
@@ -57,7 +57,7 @@ export function BookingCard({ booking, meetingStatus }: { booking: CustomerBooki
|
|||||||
<div className="mt-4 rounded-2xl bg-anouma-cream-light p-4">
|
<div className="mt-4 rounded-2xl bg-anouma-cream-light p-4">
|
||||||
<p className="text-xs font-medium uppercase tracking-wide text-anouma-plum/70">Alternativer Termin</p>
|
<p className="text-xs font-medium uppercase tracking-wide text-anouma-plum/70">Alternativer Termin</p>
|
||||||
<p className="mt-1 text-base text-anouma-plum">
|
<p className="mt-1 text-base text-anouma-plum">
|
||||||
{formatBerlinDate(new Date(booking.proposedAlternative.date!), { weekday: "long", day: "2-digit", month: "long", year: "numeric" })} · {formatBerlinTime(new Date(booking.proposedAlternative.startTime!), { hour: "2-digit", minute: "2-digit" })} –{" "}
|
{formatBerlinDate(new Date(booking.proposedAlternative.date!), { weekday: "long", day: "2-digit", month: "long", year: "numeric" })} · {formatBerlinTime(new Date(booking.proposedAlternative.startTime!), { hour: "2-digit", minute: "2-digit" })} -{" "}
|
||||||
{formatBerlinTime(new Date(booking.proposedAlternative.endTime!), { hour: "2-digit", minute: "2-digit" })}
|
{formatBerlinTime(new Date(booking.proposedAlternative.endTime!), { hour: "2-digit", minute: "2-digit" })}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-3 flex flex-wrap gap-3">
|
<div className="mt-3 flex flex-wrap gap-3">
|
||||||
@@ -95,7 +95,7 @@ export function BookingCard({ booking, meetingStatus }: { booking: CustomerBooki
|
|||||||
<p className="text-sm text-anouma-plum/75">Dieser Video-Call ist bereits beendet.</p>
|
<p className="text-sm text-anouma-plum/75">Dieser Video-Call ist bereits beendet.</p>
|
||||||
)}
|
)}
|
||||||
{booking.appointmentType === "onsite" && (
|
{booking.appointmentType === "onsite" && (
|
||||||
<p className="text-sm text-anouma-plum/80">Vor Ort – Details siehe Bestätigungs-E-Mail.</p>
|
<p className="text-sm text-anouma-plum/80">Vor Ort - Details siehe Bestätigungs-E-Mail.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export function BookingWidget({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setResult({ ok: true });
|
setResult({ ok: true });
|
||||||
// Event only — never the offer/date/message content.
|
// Event only - never the offer/date/message content.
|
||||||
track("booking_started");
|
track("booking_started");
|
||||||
} catch {
|
} catch {
|
||||||
setResult({ ok: false, error: "Verbindung fehlgeschlagen." });
|
setResult({ ok: false, error: "Verbindung fehlgeschlagen." });
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const MEETING_CLIENT_ID_KEY = "anouma-meeting-client-id";
|
|||||||
|
|
||||||
// A stable per-browser identity so a host's "kick" survives the removed
|
// A stable per-browser identity so a host's "kick" survives the removed
|
||||||
// participant simply reconnecting (which always gets a fresh participantId
|
// participant simply reconnecting (which always gets a fresh participantId
|
||||||
// — see lib/meeting/token.ts). Deliberately in localStorage, not
|
// - see lib/meeting/token.ts). Deliberately in localStorage, not
|
||||||
// sessionStorage: it must outlive the "kicked" screen clearing the session
|
// sessionStorage: it must outlive the "kicked" screen clearing the session
|
||||||
// and the "Erneut beitreten" link going back through this form again.
|
// and the "Erneut beitreten" link going back through this form again.
|
||||||
function getOrCreateClientId(): string {
|
function getOrCreateClientId(): string {
|
||||||
@@ -20,7 +20,7 @@ function getOrCreateClientId(): string {
|
|||||||
localStorage.setItem(MEETING_CLIENT_ID_KEY, created);
|
localStorage.setItem(MEETING_CLIENT_ID_KEY, created);
|
||||||
return created;
|
return created;
|
||||||
} catch {
|
} catch {
|
||||||
// Storage unavailable (private browsing, etc.) — fall back to a
|
// Storage unavailable (private browsing, etc.) - fall back to a
|
||||||
// one-off id; kicks just won't stick across reconnects in that case.
|
// one-off id; kicks just won't stick across reconnects in that case.
|
||||||
return crypto.randomUUID();
|
return crypto.randomUUID();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export function RegisterForm({ slug }: { slug: string }) {
|
|||||||
if (status === "done") {
|
if (status === "done") {
|
||||||
return (
|
return (
|
||||||
<p className="rounded-2xl bg-anouma-cream-light p-5 text-sm leading-relaxed text-anouma-plum" role="status">
|
<p className="rounded-2xl bg-anouma-cream-light p-5 text-sm leading-relaxed text-anouma-plum" role="status">
|
||||||
Danke für deine Anmeldung – wir freuen uns auf dich!
|
Danke für deine Anmeldung - wir freuen uns auf dich!
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# ANOUMA — server-side deployment script.
|
# ANOUMA - server-side deployment script.
|
||||||
#
|
#
|
||||||
# This is NOT a manual update tool. It is invoked exclusively by GitHub
|
# This is NOT a manual update tool. It is invoked exclusively by GitHub
|
||||||
# Actions over SSH as the restricted `anouma-deploy` user (see setup.sh,
|
# Actions over SSH as the restricted `anouma-deploy` user (see setup.sh,
|
||||||
# which installs a forced-command SSH key that can only ever run this
|
# which installs a forced-command SSH key that can only ever run this
|
||||||
# script). The production server never builds an image — it only ever
|
# script). The production server never builds an image - it only ever
|
||||||
# pulls an already-built, already-tested tag from the registry.
|
# pulls an already-built, already-tested tag from the registry.
|
||||||
#
|
#
|
||||||
# deploy.sh deploy vX.Y.Z
|
# deploy.sh deploy vX.Y.Z
|
||||||
@@ -26,10 +26,10 @@ fail() { log_error "$1"; exit 1; }
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# When invoked through the restricted SSH key (see setup.sh), OpenSSH's
|
# When invoked through the restricted SSH key (see setup.sh), OpenSSH's
|
||||||
# forced "command=" ignores whatever the SSH client asked for and runs this
|
# forced "command=" ignores whatever the SSH client asked for and runs this
|
||||||
# script with NO arguments — the client's actual request arrives in
|
# script with NO arguments - the client's actual request arrives in
|
||||||
# $SSH_ORIGINAL_COMMAND instead. Re-split it into "$@" so the rest of this
|
# $SSH_ORIGINAL_COMMAND instead. Re-split it into "$@" so the rest of this
|
||||||
# script doesn't need to care which path it came from. This is intentionally
|
# script doesn't need to care which path it came from. This is intentionally
|
||||||
# unquoted (word-splitting, not `eval`) — no shell metacharacter in
|
# unquoted (word-splitting, not `eval`) - no shell metacharacter in
|
||||||
# SSH_ORIGINAL_COMMAND is ever interpreted as code, it just becomes literal
|
# SSH_ORIGINAL_COMMAND is ever interpreted as code, it just becomes literal
|
||||||
# argument text, which the strict version regex below then validates.
|
# argument text, which the strict version regex below then validates.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -41,13 +41,13 @@ fi
|
|||||||
ACTION="${1:-}"
|
ACTION="${1:-}"
|
||||||
VERSION="${2:-}"
|
VERSION="${2:-}"
|
||||||
|
|
||||||
[ -f .env ] || fail ".env not found — run setup.sh on this server first."
|
[ -f .env ] || fail ".env not found - run setup.sh on this server first."
|
||||||
|
|
||||||
COMPOSE="docker compose"
|
COMPOSE="docker compose"
|
||||||
docker compose version >/dev/null 2>&1 || COMPOSE="docker-compose"
|
docker compose version >/dev/null 2>&1 || COMPOSE="docker-compose"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Deployment lock — never run two deployments concurrently.
|
# Deployment lock - never run two deployments concurrently.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
LOCK_FILE="$(pwd)/.deploy.lock"
|
LOCK_FILE="$(pwd)/.deploy.lock"
|
||||||
if [ -f "$LOCK_FILE" ] && kill -0 "$(cat "$LOCK_FILE")" 2>/dev/null; then
|
if [ -f "$LOCK_FILE" ] && kill -0 "$(cat "$LOCK_FILE")" 2>/dev/null; then
|
||||||
@@ -58,10 +58,10 @@ echo $$ > "$LOCK_FILE"
|
|||||||
trap 'rm -f "$LOCK_FILE"' EXIT
|
trap 'rm -f "$LOCK_FILE"' EXIT
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# .env helpers — same idempotent read/write approach as setup.sh.
|
# .env helpers - same idempotent read/write approach as setup.sh.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
env_get() { grep -E "^$1=" .env 2>/dev/null | tail -n1 | cut -d'=' -f2- || true; }
|
env_get() { grep -E "^$1=" .env 2>/dev/null | tail -n1 | cut -d'=' -f2- || true; }
|
||||||
# Quoted the same way as setup.sh's env_set — this file is `source`d as a
|
# Quoted the same way as setup.sh's env_set - this file is `source`d as a
|
||||||
# bash script below, so an unquoted value with shell metacharacters would
|
# bash script below, so an unquoted value with shell metacharacters would
|
||||||
# break parsing.
|
# break parsing.
|
||||||
env_set() {
|
env_set() {
|
||||||
@@ -98,7 +98,7 @@ backup_database() {
|
|||||||
$COMPOSE exec -T postgres pg_dump -U "${POSTGRES_USER:-postgres}" "${POSTGRES_DB:-anouma}" | gzip > "$file"
|
$COMPOSE exec -T postgres pg_dump -U "${POSTGRES_USER:-postgres}" "${POSTGRES_DB:-anouma}" | gzip > "$file"
|
||||||
log_success "Backup created ($(du -h "$file" | cut -f1))."
|
log_success "Backup created ($(du -h "$file" | cut -f1))."
|
||||||
else
|
else
|
||||||
log_warn "Postgres is not running yet — skipping backup (first-ever deploy?)."
|
log_warn "Postgres is not running yet - skipping backup (first-ever deploy?)."
|
||||||
fi
|
fi
|
||||||
local retention="${BACKUP_RETENTION_DAYS:-14}"
|
local retention="${BACKUP_RETENTION_DAYS:-14}"
|
||||||
find backups -name 'database-*.sql.gz' -mtime "+${retention}" -not -name "$(basename "$file")" -delete 2>/dev/null || true
|
find backups -name 'database-*.sql.gz' -mtime "+${retention}" -not -name "$(basename "$file")" -delete 2>/dev/null || true
|
||||||
@@ -108,7 +108,7 @@ registry_login() {
|
|||||||
if [ -n "${REGISTRY_USERNAME:-}" ] && [ -n "${REGISTRY_PASSWORD:-}" ]; then
|
if [ -n "${REGISTRY_USERNAME:-}" ] && [ -n "${REGISTRY_PASSWORD:-}" ]; then
|
||||||
echo "$REGISTRY_PASSWORD" | docker login "${REGISTRY:-ghcr.io}" -u "$REGISTRY_USERNAME" --password-stdin >/dev/null 2>&1 \
|
echo "$REGISTRY_PASSWORD" | docker login "${REGISTRY:-ghcr.io}" -u "$REGISTRY_USERNAME" --password-stdin >/dev/null 2>&1 \
|
||||||
&& log_success "Registry login OK." \
|
&& log_success "Registry login OK." \
|
||||||
|| fail "Registry login failed — check REGISTRY_USERNAME/REGISTRY_PASSWORD in .env."
|
|| fail "Registry login failed - check REGISTRY_USERNAME/REGISTRY_PASSWORD in .env."
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +122,7 @@ set_image_version() {
|
|||||||
|
|
||||||
# One full deploy attempt for a given version tag: pull → migrate → start →
|
# One full deploy attempt for a given version tag: pull → migrate → start →
|
||||||
# healthcheck. Returns non-zero on any failure without partially cleaning up
|
# healthcheck. Returns non-zero on any failure without partially cleaning up
|
||||||
# — the caller decides whether to retry with the previous version.
|
# - the caller decides whether to retry with the previous version.
|
||||||
deploy_version() {
|
deploy_version() {
|
||||||
local version="$1"
|
local version="$1"
|
||||||
set_image_version "$version"
|
set_image_version "$version"
|
||||||
@@ -159,18 +159,18 @@ do_deploy() {
|
|||||||
|
|
||||||
log_error "${version} FAILED post-deploy healthcheck."
|
log_error "${version} FAILED post-deploy healthcheck."
|
||||||
if [ -z "$previous_version" ]; then
|
if [ -z "$previous_version" ]; then
|
||||||
fail "No previous version on record — nothing to roll back to. Check: $COMPOSE logs app"
|
fail "No previous version on record - nothing to roll back to. Check: $COMPOSE logs app"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log_warn "Rolling back to ${previous_version} …"
|
log_warn "Rolling back to ${previous_version} …"
|
||||||
if deploy_version "$previous_version"; then
|
if deploy_version "$previous_version"; then
|
||||||
log_warn "Rollback to ${previous_version} succeeded — production is back on the previous version."
|
log_warn "Rollback to ${previous_version} succeeded - production is back on the previous version."
|
||||||
log_warn "Database migrations from the failed deploy were NOT reverted (see DEPLOYMENT.md) — restore the backup above manually if ${version} shipped an incompatible migration."
|
log_warn "Database migrations from the failed deploy were NOT reverted (see DEPLOYMENT.md) - restore the backup above manually if ${version} shipped an incompatible migration."
|
||||||
echo "$previous_version" > .current-version
|
echo "$previous_version" > .current-version
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
fail "Rollback to ${previous_version} ALSO failed — manual intervention required: $COMPOSE logs app"
|
fail "Rollback to ${previous_version} ALSO failed - manual intervention required: $COMPOSE logs app"
|
||||||
}
|
}
|
||||||
|
|
||||||
case "$ACTION" in
|
case "$ACTION" in
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# Nginx Proxy Manager) that has its own Docker network.
|
# Nginx Proxy Manager) that has its own Docker network.
|
||||||
#
|
#
|
||||||
# setup.sh generates docker-compose.override.yml automatically when it
|
# setup.sh generates docker-compose.override.yml automatically when it
|
||||||
# detects such a network — this file is just the committed reference/manual
|
# detects such a network - this file is just the committed reference/manual
|
||||||
# fallback. Docker Compose picks up docker-compose.override.yml automatically
|
# fallback. Docker Compose picks up docker-compose.override.yml automatically
|
||||||
# alongside docker-compose.yml, no extra -f flags needed.
|
# alongside docker-compose.yml, no extra -f flags needed.
|
||||||
#
|
#
|
||||||
@@ -21,5 +21,5 @@ services:
|
|||||||
- anouma-network
|
- anouma-network
|
||||||
- npm-network
|
- npm-network
|
||||||
# No need to publish the port to the host when a reverse proxy reaches
|
# No need to publish the port to the host when a reverse proxy reaches
|
||||||
# the container directly over the shared network — remove/comment the
|
# the container directly over the shared network - remove/comment the
|
||||||
# "ports" mapping in docker-compose.yml if so.
|
# "ports" mapping in docker-compose.yml if so.
|
||||||
|
|||||||
+5
-5
@@ -17,7 +17,7 @@ services:
|
|||||||
POSTGRES_DB: ${POSTGRES_DB:-anouma}
|
POSTGRES_DB: ${POSTGRES_DB:-anouma}
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
# Not published to the host by default — only reachable from other
|
# Not published to the host by default - only reachable from other
|
||||||
# containers on anouma-network. Uncomment to reach it from the host too
|
# containers on anouma-network. Uncomment to reach it from the host too
|
||||||
# (e.g. with a GUI DB client) during development:
|
# (e.g. with a GUI DB client) during development:
|
||||||
# ports:
|
# ports:
|
||||||
@@ -29,7 +29,7 @@ services:
|
|||||||
retries: 20
|
retries: 20
|
||||||
|
|
||||||
app:
|
app:
|
||||||
# Production never builds from source — the image is built once by
|
# Production never builds from source - the image is built once by
|
||||||
# GitHub Actions on release and pushed to GHCR; this host only
|
# GitHub Actions on release and pushed to GHCR; this host only
|
||||||
# ever pulls a specific, already-tested tag (see deploy.sh, which is the
|
# ever pulls a specific, already-tested tag (see deploy.sh, which is the
|
||||||
# only thing that ever changes ANOUMA_IMAGE in .env).
|
# only thing that ever changes ANOUMA_IMAGE in .env).
|
||||||
@@ -40,7 +40,7 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
# DATABASE_URI in .env must point at the "postgres" service name, e.g.
|
# DATABASE_URI in .env must point at the "postgres" service name, e.g.
|
||||||
# postgresql://postgres:<password>@postgres:5432/anouma — setup.sh sets
|
# postgresql://postgres:<password>@postgres:5432/anouma - setup.sh sets
|
||||||
# this up for you automatically.
|
# this up for you automatically.
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:${APP_PORT:-3000}:3000"
|
- "127.0.0.1:${APP_PORT:-3000}:3000"
|
||||||
@@ -51,7 +51,7 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
# Container-level healthcheck is inherited from the image's own
|
# Container-level healthcheck is inherited from the image's own
|
||||||
# HEALTHCHECK instruction (GET /api/health, which itself checks Postgres
|
# HEALTHCHECK instruction (GET /api/health, which itself checks Postgres
|
||||||
# connectivity) — nothing to duplicate here.
|
# connectivity) - nothing to duplicate here.
|
||||||
|
|
||||||
# Self-hosted LiveKit SFU. Host networking is intentional: WebRTC media
|
# Self-hosted LiveKit SFU. Host networking is intentional: WebRTC media
|
||||||
# needs the UDP port range below to be reachable directly from the internet.
|
# needs the UDP port range below to be reachable directly from the internet.
|
||||||
@@ -78,5 +78,5 @@ services:
|
|||||||
|
|
||||||
# Optional external network to a reverse proxy (e.g. Nginx Proxy Manager)
|
# Optional external network to a reverse proxy (e.g. Nginx Proxy Manager)
|
||||||
# is attached via docker-compose.override.yml, generated by setup.sh only
|
# is attached via docker-compose.override.yml, generated by setup.sh only
|
||||||
# when such a network is actually detected — see DEPLOYMENT.md. Nothing
|
# when such a network is actually detected - see DEPLOYMENT.md. Nothing
|
||||||
# here assumes NPM (or any reverse proxy) exists.
|
# here assumes NPM (or any reverse proxy) exists.
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
import type { Field } from "payload";
|
import type { Field } from "payload";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reusable, always-optional SEO field group — added to every public-facing
|
* Reusable, always-optional SEO field group - added to every public-facing
|
||||||
* collection/global. Never required: every consumer falls back to a
|
* collection/global. Never required: every consumer falls back to a
|
||||||
* sensible, automatically derived value when a field is left empty (see
|
* sensible, automatically derived value when a field is left empty (see
|
||||||
* lib/seo/resolve.ts), per the "SEO fields are optional with fallbacks"
|
* lib/seo/resolve.ts), per the "SEO fields are optional with fallbacks"
|
||||||
@@ -12,7 +12,7 @@ export function seoFields(): Field {
|
|||||||
type: "collapsible",
|
type: "collapsible",
|
||||||
label: "SEO",
|
label: "SEO",
|
||||||
admin: {
|
admin: {
|
||||||
description: "Optional — wird automatisch aus dem Inhalt abgeleitet, wenn hier nichts eingetragen ist.",
|
description: "Optional - wird automatisch aus dem Inhalt abgeleitet, wenn hier nichts eingetragen ist.",
|
||||||
},
|
},
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ export function slugField(sourceField = "title"): Field {
|
|||||||
return {
|
return {
|
||||||
name: "slug",
|
name: "slug",
|
||||||
type: "text",
|
type: "text",
|
||||||
label: "Slug (URL)",
|
label: "URL-Kürzel",
|
||||||
unique: true,
|
unique: true,
|
||||||
index: true,
|
index: true,
|
||||||
admin: {
|
admin: {
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import { canViewAnalytics, isAdmin } from "@/access";
|
|||||||
*/
|
*/
|
||||||
export const AnalyticsSettings: GlobalConfig = {
|
export const AnalyticsSettings: GlobalConfig = {
|
||||||
slug: "analytics-settings",
|
slug: "analytics-settings",
|
||||||
label: "Analytics-Einstellungen",
|
label: "Auswertungs-Einstellungen",
|
||||||
admin: {
|
admin: {
|
||||||
description: "Aufbewahrungsdauer für Analytics-Rohdaten und aggregierte Tagesstatistiken.",
|
description: "Aufbewahrungsdauer für Rohdaten und zusammengefasste Tagesstatistiken.",
|
||||||
group: "Datenschutz",
|
group: "Datenschutz",
|
||||||
},
|
},
|
||||||
access: {
|
access: {
|
||||||
@@ -27,7 +27,7 @@ export const AnalyticsSettings: GlobalConfig = {
|
|||||||
min: 7,
|
min: 7,
|
||||||
admin: {
|
admin: {
|
||||||
description:
|
description:
|
||||||
"Wie lange einzelne Analytics-Events gespeichert bleiben. Begrenzt auch den wählbaren Zeitraum im Analytics-Dashboard.",
|
"Wie lange einzelne Messwerte gespeichert bleiben. Begrenzt auch den wählbaren Zeitraum in der Auswertung.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import { isAdmin } from "@/access";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Consent-category configuration shown in the public cookie banner
|
* Consent-category configuration shown in the public cookie banner
|
||||||
* (components/Analytics.tsx). Read access is intentionally public — the
|
* (components/Analytics.tsx). Read access is intentionally public - the
|
||||||
* unauthenticated frontend needs the labels/descriptions and the
|
* unauthenticated frontend needs the labels/descriptions and the
|
||||||
* enabled/disabled flags before a visitor has logged in anywhere.
|
* enabled/disabled flags before a visitor has logged in anywhere.
|
||||||
*
|
*
|
||||||
* "necessary" deliberately has no `enabled` field at all, anywhere in this
|
* "necessary" deliberately has no `enabled` field at all, anywhere in this
|
||||||
* schema — there is no toggle to accidentally switch off, so it can never
|
* schema - there is no toggle to accidentally switch off, so it can never
|
||||||
* be disabled through the admin UI (see requirement: technically required
|
* be disabled through the admin UI (see requirement: technically required
|
||||||
* functionality must not be disable-able by an admin).
|
* functionality must not be disable-able by an admin).
|
||||||
*/
|
*/
|
||||||
@@ -32,14 +32,14 @@ export const CookieSettings: GlobalConfig = {
|
|||||||
defaultValue: "1.0",
|
defaultValue: "1.0",
|
||||||
admin: {
|
admin: {
|
||||||
description:
|
description:
|
||||||
"Bei Änderung müssen alle Besucher erneut zustimmen — bereits gespeicherte Einwilligungen mit einer älteren Version werden ignoriert und der Banner erscheint erneut.",
|
"Bei Änderung müssen alle Besucher erneut zustimmen - bereits gespeicherte Einwilligungen mit einer älteren Version werden ignoriert und der Banner erscheint erneut.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "group",
|
type: "group",
|
||||||
name: "necessary",
|
name: "necessary",
|
||||||
label: "Notwendig",
|
label: "Notwendig",
|
||||||
admin: { description: "Immer aktiv — technisch erforderlich, kann nicht deaktiviert werden." },
|
admin: { description: "Immer aktiv - technisch erforderlich, kann nicht deaktiviert werden." },
|
||||||
fields: [
|
fields: [
|
||||||
{ name: "label", type: "text", label: "Bezeichnung", required: true, defaultValue: "Notwendig" },
|
{ name: "label", type: "text", label: "Bezeichnung", required: true, defaultValue: "Notwendig" },
|
||||||
{
|
{
|
||||||
@@ -93,7 +93,7 @@ export const CookieSettings: GlobalConfig = {
|
|||||||
defaultValue: true,
|
defaultValue: true,
|
||||||
admin: {
|
admin: {
|
||||||
description:
|
description:
|
||||||
"Globaler Schalter für das gesamte Analytics-System. Wenn deaktiviert, wird diese Kategorie im Banner nicht mehr angeboten und Analytics-Daten werden serverseitig abgelehnt — auch bei einer noch gespeicherten alten Zustimmung.",
|
"Globaler Schalter für die gesamte Reichweitenmessung. Wenn deaktiviert, wird diese Kategorie im Banner nicht mehr angeboten und Messdaten werden serverseitig abgelehnt - auch bei einer noch gespeicherten alten Zustimmung.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const WEEKDAYS = [
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Central SEO settings, edited once by Anna, applied automatically across
|
* Central SEO settings, edited once by Anna, applied automatically across
|
||||||
* the whole website (see lib/seo/*). Everything here is optional — nothing
|
* the whole website (see lib/seo/*). Everything here is optional - nothing
|
||||||
* is invented if a field is left blank (see lib/seo/jsonld.ts, which only
|
* is invented if a field is left blank (see lib/seo/jsonld.ts, which only
|
||||||
* emits LocalBusiness structured data once real address data exists).
|
* emits LocalBusiness structured data once real address data exists).
|
||||||
*/
|
*/
|
||||||
@@ -21,7 +21,7 @@ export const SEOSettings: GlobalConfig = {
|
|||||||
slug: "seo-settings",
|
slug: "seo-settings",
|
||||||
label: "SEO-Einstellungen",
|
label: "SEO-Einstellungen",
|
||||||
admin: {
|
admin: {
|
||||||
description: "Globale SEO-Vorgaben für die gesamte Website — greift automatisch überall dort, wo eine Seite keine eigenen SEO-Angaben hat.",
|
description: "Globale SEO-Vorgaben für die gesamte Website - greift automatisch überall dort, wo eine Seite keine eigenen SEO-Angaben hat.",
|
||||||
group: "SEO",
|
group: "SEO",
|
||||||
},
|
},
|
||||||
access: {
|
access: {
|
||||||
@@ -40,7 +40,7 @@ export const SEOSettings: GlobalConfig = {
|
|||||||
type: "text",
|
type: "text",
|
||||||
label: "Website-URL",
|
label: "Website-URL",
|
||||||
admin: {
|
admin: {
|
||||||
description: "Nur bei Bedarf setzen (z. B. https://anouma.org) — überschreibt die Server-Konfiguration. Leer lassen, um die technische Standardeinstellung zu verwenden.",
|
description: "Nur bei Bedarf setzen (z. B. https://anouma.org) - überschreibt die Server-Konfiguration. Leer lassen, um die technische Standardeinstellung zu verwenden.",
|
||||||
},
|
},
|
||||||
validate: (value: string | null | undefined) => {
|
validate: (value: string | null | undefined) => {
|
||||||
if (!value) return true;
|
if (!value) return true;
|
||||||
@@ -58,8 +58,8 @@ export const SEOSettings: GlobalConfig = {
|
|||||||
name: "defaultOgImage",
|
name: "defaultOgImage",
|
||||||
type: "upload",
|
type: "upload",
|
||||||
relationTo: "media",
|
relationTo: "media",
|
||||||
label: "Standard Social-Media-Bild",
|
label: "Standardbild für soziale Netzwerke",
|
||||||
admin: { description: "Wird verwendet, wenn eine Seite kein eigenes Open-Graph-Bild hat." },
|
admin: { description: "Wird verwendet, wenn eine Seite kein eigenes Bild für soziale Netzwerke hat." },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "robotsIndexable",
|
name: "robotsIndexable",
|
||||||
@@ -67,14 +67,14 @@ export const SEOSettings: GlobalConfig = {
|
|||||||
label: "Website für Suchmaschinen sichtbar",
|
label: "Website für Suchmaschinen sichtbar",
|
||||||
defaultValue: true,
|
defaultValue: true,
|
||||||
admin: {
|
admin: {
|
||||||
description: "Deaktivieren, um die gesamte öffentliche Website vorübergehend für Suchmaschinen zu sperren (z. B. während des Aufbaus). Betrifft nicht Admin/Konto — die sind ohnehin immer gesperrt.",
|
description: "Deaktivieren, um die gesamte öffentliche Website vorübergehend für Suchmaschinen zu sperren (z. B. während des Aufbaus). Betrifft nicht Admin/Konto - die sind ohnehin immer gesperrt.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Lokale Informationen",
|
label: "Lokale Informationen",
|
||||||
admin: { description: "Werden nur verwendet, wenn tatsächlich ausgefüllt — es werden keine Angaben erfunden." },
|
admin: { description: "Werden nur verwendet, wenn tatsächlich ausgefüllt - es werden keine Angaben erfunden." },
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
name: "businessName",
|
name: "businessName",
|
||||||
@@ -99,7 +99,7 @@ export const SEOSettings: GlobalConfig = {
|
|||||||
name: "openingHours",
|
name: "openingHours",
|
||||||
type: "array",
|
type: "array",
|
||||||
label: "Öffnungs-/Erreichbarkeitszeiten",
|
label: "Öffnungs-/Erreichbarkeitszeiten",
|
||||||
admin: { description: "Optional — nur für tatsächlich feste Zeiten (z. B. Sprechzeiten)." },
|
admin: { description: "Optional - nur für tatsächlich feste Zeiten (z. B. Sprechzeiten)." },
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
type: "row",
|
type: "row",
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ function bump(map: Record<string, number>, key: string | null | undefined) {
|
|||||||
map[key] = (map[key] ?? 0) + 1;
|
map[key] = (map[key] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sorted (descending) [key, count] pairs, capped at `limit` — for dashboard tables. */
|
/** Sorted (descending) [key, count] pairs, capped at `limit` - for dashboard tables. */
|
||||||
export function topEntries(map: Record<string, number>, limit = 10): [string, number][] {
|
export function topEntries(map: Record<string, number>, limit = 10): [string, number][] {
|
||||||
return Object.entries(map)
|
return Object.entries(map)
|
||||||
.sort(([, a], [, b]) => b - a)
|
.sort(([, a], [, b]) => b - a)
|
||||||
@@ -26,7 +26,7 @@ export function topEntries(map: Record<string, number>, limit = 10): [string, nu
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure aggregation over a set of raw AnalyticsEvents rows (any date range —
|
* Pure aggregation over a set of raw AnalyticsEvents rows (any date range -
|
||||||
* one day for the cleanup rollup, or a whole dashboard-selected range for
|
* one day for the cleanup rollup, or a whole dashboard-selected range for
|
||||||
* live queries). Kept side-effect free and DB-agnostic so both callers
|
* live queries). Kept side-effect free and DB-agnostic so both callers
|
||||||
* (lib/analytics/cleanup.ts and lib/analytics/dashboardQueries.ts) share
|
* (lib/analytics/cleanup.ts and lib/analytics/dashboardQueries.ts) share
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ function addDays(date: Date, days: number): Date {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rolls up every not-yet-aggregated, fully-completed calendar day (UTC —
|
* Rolls up every not-yet-aggregated, fully-completed calendar day (UTC -
|
||||||
* matching the UTC-day salt rotation in lib/analytics/salt.ts) from raw
|
* matching the UTC-day salt rotation in lib/analytics/salt.ts) from raw
|
||||||
* analytics-events into analytics-daily-stats, THEN purges raw events older
|
* analytics-events into analytics-daily-stats, THEN purges raw events older
|
||||||
* than the configured retention, THEN purges old daily-stats rows. Order
|
* than the configured retention, THEN purges old daily-stats rows. Order
|
||||||
@@ -22,7 +22,7 @@ function addDays(date: Date, days: number): Date {
|
|||||||
* deleted, or that history is gone for good.
|
* deleted, or that history is gone for good.
|
||||||
*
|
*
|
||||||
* Only aggregates days that don't already have a daily-stats row (each day
|
* Only aggregates days that don't already have a daily-stats row (each day
|
||||||
* is aggregated exactly once, ever) — safe to call this daily via
|
* is aggregated exactly once, ever) - safe to call this daily via
|
||||||
* app/(payload)/api/cron/analytics-cleanup/route.ts, and safe to re-run.
|
* app/(payload)/api/cron/analytics-cleanup/route.ts, and safe to re-run.
|
||||||
*/
|
*/
|
||||||
export async function runAnalyticsCleanup(payload: Payload) {
|
export async function runAnalyticsCleanup(payload: Payload) {
|
||||||
@@ -60,7 +60,7 @@ export async function runAnalyticsCleanup(payload: Payload) {
|
|||||||
{ createdAt: { less_than: dayEnd.toISOString() } },
|
{ createdAt: { less_than: dayEnd.toISOString() } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
limit: 0, // no limit — pull the whole day, small-site scale
|
limit: 0, // no limit - pull the whole day, small-site scale
|
||||||
depth: 0,
|
depth: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,15 +2,15 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Public developer API + consent storage for the analytics system. Importing
|
* Public developer API + consent storage for the analytics system. Importing
|
||||||
* this module has zero side effects — no network call, nothing initialized
|
* this module has zero side effects - no network call, nothing initialized
|
||||||
* — until `track()` is actually called, and even then it's a no-op unless
|
* - until `track()` is actually called, and even then it's a no-op unless
|
||||||
* consent was actually granted. Callers never check consent themselves:
|
* consent was actually granted. Callers never check consent themselves:
|
||||||
*
|
*
|
||||||
* track("booking_started");
|
* track("booking_started");
|
||||||
*
|
*
|
||||||
* The CMS-configured part of consent (whether the "statistics" category is
|
* The CMS-configured part of consent (whether the "statistics" category is
|
||||||
* enabled at all, and the current consent version) is pushed in once by
|
* enabled at all, and the current consent version) is pushed in once by
|
||||||
* components/Analytics.tsx via setAnalyticsConfig() — see that file. Until
|
* components/Analytics.tsx via setAnalyticsConfig() - see that file. Until
|
||||||
* that happens, `cmsConfig` is null and track() fails safe (no-op), so a
|
* that happens, `cmsConfig` is null and track() fails safe (no-op), so a
|
||||||
* component whose effects happen to run before Analytics.tsx's can never
|
* component whose effects happen to run before Analytics.tsx's can never
|
||||||
* accidentally send data before the live config is known.
|
* accidentally send data before the live config is known.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Central, extensible registry of known analytics event names. Adding a new
|
* Central, extensible registry of known analytics event names. Adding a new
|
||||||
* event elsewhere in the app never requires a migration — the database
|
* event elsewhere in the app never requires a migration - the database
|
||||||
* column (collections/AnalyticsEvents.ts#eventName) is free-text — this
|
* column (collections/AnalyticsEvents.ts#eventName) is free-text - this
|
||||||
* list exists purely so `track()` calls get autocomplete/type-checking and
|
* list exists purely so `track()` calls get autocomplete/type-checking and
|
||||||
* the dashboard can show a nice German label instead of a raw event name.
|
* the dashboard can show a nice German label instead of a raw event name.
|
||||||
*
|
*
|
||||||
@@ -18,7 +18,7 @@ export const ANALYTICS_EVENTS = {
|
|||||||
|
|
||||||
export type KnownAnalyticsEvent = keyof typeof ANALYTICS_EVENTS;
|
export type KnownAnalyticsEvent = keyof typeof ANALYTICS_EVENTS;
|
||||||
|
|
||||||
/** Any string works (see the collection comment above) — known ones just get a label. */
|
/** Any string works (see the collection comment above) - known ones just get a label. */
|
||||||
export type AnalyticsEventName = KnownAnalyticsEvent | (string & {});
|
export type AnalyticsEventName = KnownAnalyticsEvent | (string & {});
|
||||||
|
|
||||||
export function analyticsEventLabel(eventName: string): string {
|
export function analyticsEventLabel(eventName: string): string {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ function parseDateParam(value: string | null): Date | null {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves the dashboard's period filter (?range=&from=&to=) into an actual
|
* Resolves the dashboard's period filter (?range=&from=&to=) into an actual
|
||||||
* date range, clamped to `maxDaysBack` (the configured raw-event retention —
|
* date range, clamped to `maxDaysBack` (the configured raw-event retention -
|
||||||
* see globals/AnalyticsSettings.ts) so the UI never claims to show data that
|
* see globals/AnalyticsSettings.ts) so the UI never claims to show data that
|
||||||
* has already been purged. Also computes the matching "previous period" for
|
* has already been purged. Also computes the matching "previous period" for
|
||||||
* comparison (same length, immediately preceding).
|
* comparison (same length, immediately preceding).
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ import { createHmac } from "node:crypto";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Derives a daily-rotating, non-reversible pseudonymous visitor identifier
|
* Derives a daily-rotating, non-reversible pseudonymous visitor identifier
|
||||||
* for session grouping — the same approach used by privacy-friendly
|
* for session grouping - the same approach used by privacy-friendly
|
||||||
* analytics tools (Plausible/Fathom-style): hash(ip + userAgent + host)
|
* analytics tools (Plausible/Fathom-style): hash(ip + userAgent + host)
|
||||||
* salted with a value that changes every calendar day. Raw IP/UA are never
|
* salted with a value that changes every calendar day. Raw IP/UA are never
|
||||||
* persisted anywhere, only this hash — and since the salt rotates daily,
|
* persisted anywhere, only this hash - and since the salt rotates daily,
|
||||||
* the same visitor hashes differently tomorrow, so there is no cross-day
|
* the same visitor hashes differently tomorrow, so there is no cross-day
|
||||||
* linkage and no long-term individual profile.
|
* linkage and no long-term individual profile.
|
||||||
*
|
*
|
||||||
* ANALYTICS_SALT_SECRET is a dedicated secret (not PAYLOAD_SECRET) — this
|
* ANALYTICS_SALT_SECRET is a dedicated secret (not PAYLOAD_SECRET) - this
|
||||||
* codebase keeps one secret per purpose on purpose (see
|
* codebase keeps one secret per purpose on purpose (see
|
||||||
* MEETING_SESSION_SECRET's comment in .env.example).
|
* MEETING_SESSION_SECRET's comment in .env.example).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const SESSION_IDLE_WINDOW_MS = 30 * 60 * 1000;
|
|||||||
* Groups events into sessions without any client-side identifier: looks up
|
* Groups events into sessions without any client-side identifier: looks up
|
||||||
* the most recent event from the same `visitorHash` within the last 30
|
* the most recent event from the same `visitorHash` within the last 30
|
||||||
* minutes and reuses its sessionId, or mints a new one. One extra indexed
|
* minutes and reuses its sessionId, or mints a new one. One extra indexed
|
||||||
* read per tracked request — acceptable at this site's traffic volume, and
|
* read per tracked request - acceptable at this site's traffic volume, and
|
||||||
* simpler/safer than trying to hold a lock across an unauthenticated,
|
* simpler/safer than trying to hold a lock across an unauthenticated,
|
||||||
* high-frequency public endpoint (unlike lib/booking/lock.ts's advisory
|
* high-frequency public endpoint (unlike lib/booking/lock.ts's advisory
|
||||||
* lock, an occasional extra spurious session here has no correctness
|
* lock, an occasional extra spurious session here has no correctness
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Coarse device/browser classification from the User-Agent header. No
|
* Coarse device/browser classification from the User-Agent header. No
|
||||||
* library, no raw UA string ever stored — just one bucket per request,
|
* library, no raw UA string ever stored - just one bucket per request,
|
||||||
* computed on the fly and discarded after classification. Not meant to be
|
* computed on the fly and discarded after classification. Not meant to be
|
||||||
* precise (e.g. WebView vs. real browser); good enough for the "device
|
* precise (e.g. WebView vs. real browser); good enough for the "device
|
||||||
* type" / "browser" breakdown in the Analytics dashboard.
|
* type" / "browser" breakdown in the Analytics dashboard.
|
||||||
@@ -19,7 +19,7 @@ export function classifyDevice(userAgent: string): DeviceType {
|
|||||||
export function classifyBrowser(userAgent: string): BrowserFamily {
|
export function classifyBrowser(userAgent: string): BrowserFamily {
|
||||||
const ua = userAgent.toLowerCase();
|
const ua = userAgent.toLowerCase();
|
||||||
// Order matters: Edge/Chrome UAs also contain "safari"; Chrome UAs also
|
// Order matters: Edge/Chrome UAs also contain "safari"; Chrome UAs also
|
||||||
// contain "chrome" but so do Edge/Opera — check the most specific tokens first.
|
// contain "chrome" but so do Edge/Opera - check the most specific tokens first.
|
||||||
if (/edg\//.test(ua)) return "edge";
|
if (/edg\//.test(ua)) return "edge";
|
||||||
if (/chrome\/|crios\//.test(ua)) return "chrome";
|
if (/chrome\/|crios\//.test(ua)) return "chrome";
|
||||||
if (/firefox\/|fxios\//.test(ua)) return "firefox";
|
if (/firefox\/|fxios\//.test(ua)) return "firefox";
|
||||||
|
|||||||
+2
-2
@@ -12,7 +12,7 @@ export type ImageMood =
|
|||||||
| "peach";
|
| "peach";
|
||||||
|
|
||||||
// Purely a presentation fallback for offers/events without an uploaded photo
|
// Purely a presentation fallback for offers/events without an uploaded photo
|
||||||
// yet — content itself always comes from the CMS.
|
// yet - content itself always comes from the CMS.
|
||||||
const moodBySlug: Record<string, ImageMood> = {
|
const moodBySlug: Record<string, ImageMood> = {
|
||||||
prozessbegleitung: "plum",
|
prozessbegleitung: "plum",
|
||||||
"doula-begleitung": "dustyrose",
|
"doula-begleitung": "dustyrose",
|
||||||
@@ -50,7 +50,7 @@ const categoryHref: Record<string, string> = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Groups the flat `offers` collection into the four nav/overview sections.
|
* Groups the flat `offers` collection into the four nav/overview sections.
|
||||||
* Private offers (e.g. "Einzelbegleitung") are deliberately excluded — they
|
* Private offers (e.g. "Einzelbegleitung") are deliberately excluded - they
|
||||||
* stay reachable only via their own direct booking link.
|
* stay reachable only via their own direct booking link.
|
||||||
*/
|
*/
|
||||||
export function groupOffersByCategory(offers: Offer[]): OfferCategoryGroup[] {
|
export function groupOffersByCategory(offers: Offer[]): OfferCategoryGroup[] {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Minimal in-memory fixed-window rate limiter for a single Node process
|
* Minimal in-memory fixed-window rate limiter for a single Node process
|
||||||
* (this app always runs as one process — see server.ts). Good enough to
|
* (this app always runs as one process - see server.ts). Good enough to
|
||||||
* blunt abuse of the email-verification resend endpoint without adding an
|
* blunt abuse of the email-verification resend endpoint without adding an
|
||||||
* external store; resets on deploy/restart, which is an acceptable
|
* external store; resets on deploy/restart, which is an acceptable
|
||||||
* trade-off for this use case.
|
* trade-off for this use case.
|
||||||
@@ -34,12 +34,12 @@ export function checkRateLimit(key: string, opts: { max: number; windowMs: numbe
|
|||||||
|
|
||||||
export function getClientIp(request: { headers: Headers }): string {
|
export function getClientIp(request: { headers: Headers }): string {
|
||||||
// Deployment is always behind exactly one trusted reverse proxy (see
|
// Deployment is always behind exactly one trusted reverse proxy (see
|
||||||
// DEPLOYMENT.md — Nginx Proxy Manager; the app port isn't published
|
// DEPLOYMENT.md - Nginx Proxy Manager; the app port isn't published
|
||||||
// directly to the host). nginx's default `$proxy_add_x_forwarded_for`
|
// directly to the host). nginx's default `$proxy_add_x_forwarded_for`
|
||||||
// *appends* the real client IP to any X-Forwarded-For it already
|
// *appends* the real client IP to any X-Forwarded-For it already
|
||||||
// received rather than replacing it, so a directly-connecting client
|
// received rather than replacing it, so a directly-connecting client
|
||||||
// could prepend arbitrary spoofed entries of their own. Only the
|
// could prepend arbitrary spoofed entries of their own. Only the
|
||||||
// right-most (last) entry — the one the trusted proxy itself appended —
|
// right-most (last) entry - the one the trusted proxy itself appended -
|
||||||
// is trustworthy; taking the first entry would let a client bypass this
|
// is trustworthy; taking the first entry would let a client bypass this
|
||||||
// rate limit entirely by sending a fake X-Forwarded-For header.
|
// rate limit entirely by sending a fake X-Forwarded-For header.
|
||||||
const forwarded = request.headers.get("x-forwarded-for");
|
const forwarded = request.headers.get("x-forwarded-for");
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import { createHash, randomBytes } from "node:crypto";
|
|||||||
/** How long a freshly issued email-verification link stays valid. */
|
/** How long a freshly issued email-verification link stays valid. */
|
||||||
export const EMAIL_VERIFICATION_TTL_MS = 24 * 60 * 60 * 1000;
|
export const EMAIL_VERIFICATION_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
/** 256 bits of randomness, hex-encoded — not guessable, never derived from user data. */
|
/** 256 bits of randomness, hex-encoded - not guessable, never derived from user data. */
|
||||||
export function generateVerificationToken(): string {
|
export function generateVerificationToken(): string {
|
||||||
return randomBytes(32).toString("hex");
|
return randomBytes(32).toString("hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Only this hash is ever persisted (see collections/Customers.ts) — the
|
* Only this hash is ever persisted (see collections/Customers.ts) - the
|
||||||
* plaintext token exists only in the URL sent by email and briefly in
|
* plaintext token exists only in the URL sent by email and briefly in
|
||||||
* memory while that email is being sent.
|
* memory while that email is being sent.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { customerBookingView, type CustomerBooking } from "./customerView";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* These are called only after the caller has already verified who the
|
* These are called only after the caller has already verified who the
|
||||||
* current customer is (see lib/auth/customer.ts) — the `where: { user }`
|
* current customer is (see lib/auth/customer.ts) - the `where: { user }`
|
||||||
* filter below is what actually scopes results to that one customer.
|
* filter below is what actually scopes results to that one customer.
|
||||||
*/
|
*/
|
||||||
export const getCustomerBookings = cache(async (customerId: number): Promise<CustomerBooking[]> => {
|
export const getCustomerBookings = cache(async (customerId: number): Promise<CustomerBooking[]> => {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const WEEKDAY_BY_INDEX = [
|
|||||||
// and writes here go through Intl's Berlin timezone data (not local Date
|
// and writes here go through Intl's Berlin timezone data (not local Date
|
||||||
// accessors, and not manual UTC+1/+2 arithmetic), so the server process's
|
// accessors, and not manual UTC+1/+2 arithmetic), so the server process's
|
||||||
// own timezone (this app runs in UTC in production) never leaks into the
|
// own timezone (this app runs in UTC in production) never leaks into the
|
||||||
// computed availability — see lib/calendar/timezone.ts for the same pattern.
|
// computed availability - see lib/calendar/timezone.ts for the same pattern.
|
||||||
function dateKey(date: Date): string {
|
function dateKey(date: Date): string {
|
||||||
const { year, month, day } = berlinParts(date);
|
const { year, month, day } = berlinParts(date);
|
||||||
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||||
@@ -31,7 +31,7 @@ function calendarDate(date: Date): CalendarDate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addCalendarDays({ year, month, day }: CalendarDate, delta: number): CalendarDate {
|
function addCalendarDays({ year, month, day }: CalendarDate, delta: number): CalendarDate {
|
||||||
// Pure calendar-rollover arithmetic (month/year boundaries) — no timezone
|
// Pure calendar-rollover arithmetic (month/year boundaries) - no timezone
|
||||||
// conversion happens here, so using the UTC constructor is safe and simple.
|
// conversion happens here, so using the UTC constructor is safe and simple.
|
||||||
const utc = new Date(Date.UTC(year, month - 1, day + delta));
|
const utc = new Date(Date.UTC(year, month - 1, day + delta));
|
||||||
return { year: utc.getUTCFullYear(), month: utc.getUTCMonth() + 1, day: utc.getUTCDate() };
|
return { year: utc.getUTCFullYear(), month: utc.getUTCMonth() + 1, day: utc.getUTCDate() };
|
||||||
@@ -58,7 +58,7 @@ function overlaps(aStart: Date, aEnd: Date, bStart: Date, bEnd: Date): boolean {
|
|||||||
/**
|
/**
|
||||||
* Computes bookable slots for one offer across a date range, from the
|
* Computes bookable slots for one offer across a date range, from the
|
||||||
* configured weekly Availability (plus per-date AvailabilityOverrides),
|
* configured weekly Availability (plus per-date AvailabilityOverrides),
|
||||||
* minus anything already pending/confirmed on Anna's schedule — she can
|
* minus anything already pending/confirmed on Anna's schedule - she can
|
||||||
* only do one thing at a time, regardless of which offer it's for.
|
* only do one thing at a time, regardless of which offer it's for.
|
||||||
*/
|
*/
|
||||||
export async function getAvailableSlots(
|
export async function getAvailableSlots(
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user