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.
|
||||
# In production, setup.sh generates the real file at /opt/anouma/.env for
|
||||
# you — you shouldn't need to hand-edit this. For local development, copy
|
||||
# you - you shouldn't need to hand-edit this. For local development, copy
|
||||
# this to .env at the repo root and fill in DATABASE_URI/PAYLOAD_SECRET
|
||||
# (see README.md → "Lokale Entwicklung").
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
# dev server against the Dockerized Postgres, use "127.0.0.1" instead.
|
||||
DATABASE_URI=postgresql://postgres:postgres@postgres:5432/anouma
|
||||
|
||||
# Only used by the "postgres" service in docker-compose.yml — keep these in
|
||||
# Only used by the "postgres" service in docker-compose.yml - keep these in
|
||||
# sync with DATABASE_URI above (setup.sh does this for you).
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=replace-with-a-long-random-secret
|
||||
@@ -40,11 +40,11 @@ CRON_SECRET=replace-with-a-long-random-secret
|
||||
NEXT_PUBLIC_SERVER_URL=http://localhost:3000
|
||||
|
||||
# Port the app container publishes on the host (behind a reverse proxy this
|
||||
# usually doesn't need to be reachable directly — see DEPLOYMENT.md).
|
||||
# usually doesn't need to be reachable directly - see DEPLOYMENT.md).
|
||||
APP_PORT=3000
|
||||
|
||||
# ── Email (SMTP) ────────────────────────────────────────────────────────
|
||||
# The existing ANOUMA mail system — no external newsletter service.
|
||||
# The existing ANOUMA mail system - no external newsletter service.
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
@@ -79,7 +79,7 @@ DOCKER_NETWORK=anouma-network
|
||||
|
||||
# Name of an existing external Docker network to join (e.g. Nginx Proxy
|
||||
# Manager's network) so a reverse proxy can reach the app container
|
||||
# directly. Leave empty if you don't use one — setup.sh auto-detects this.
|
||||
# directly. Leave empty if you don't use one - setup.sh auto-detects this.
|
||||
NPM_NETWORK=
|
||||
|
||||
# ── Backups (used by deploy.sh) ─────────────────────────────────────────
|
||||
@@ -87,7 +87,7 @@ NPM_NETWORK=
|
||||
BACKUP_RETENTION_DAYS=14
|
||||
|
||||
# ── 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.
|
||||
REGISTRY=ghcr.io
|
||||
REGISTRY_REPO=whitehai11/anouma
|
||||
@@ -96,10 +96,10 @@ REGISTRY_REPO=whitehai11/anouma
|
||||
# account password.
|
||||
REGISTRY_USERNAME=
|
||||
REGISTRY_PASSWORD=
|
||||
# Set automatically by deploy.sh on every successful deploy — do not edit
|
||||
# Set automatically by deploy.sh on every successful deploy - do not edit
|
||||
# by hand. Empty until the first release has been deployed.
|
||||
ANOUMA_IMAGE=
|
||||
# Informational only (shown in the setup summary) — actual automatic
|
||||
# Informational only (shown in the setup summary) - actual automatic
|
||||
# deployment is controlled by whether release.yml + its GitHub secrets are
|
||||
# configured, not by this flag.
|
||||
AUTO_DEPLOY=true
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@ next-env.d.ts
|
||||
*.deb
|
||||
*.AppImage
|
||||
|
||||
# docker / deployment (machine-specific, generated by setup.sh — production
|
||||
# docker / deployment (machine-specific, generated by setup.sh - production
|
||||
# artifacts actually live in /opt/anouma on the server, never inside this
|
||||
# repo checkout, but these are ignored here too in case of local testing)
|
||||
docker-compose.override.yml
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
This version has breaking changes - APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
|
||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
This block is written and re-added by `next dev` - verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
|
||||
+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
|
||||
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
|
||||
```
|
||||
|
||||
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
|
||||
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
|
||||
@@ -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:
|
||||
|
||||
1. Checks/installs Docker, Docker Compose, git, openssl, curl, ssh.
|
||||
2. Creates `/opt/anouma` (refuses to touch it if an installation already exists there — you choose "use existing" or abort; nothing is ever deleted).
|
||||
2. Creates `/opt/anouma` (refuses to touch it if an installation already exists there - you choose "use existing" or abort; nothing is ever deleted).
|
||||
3. Detects an existing Nginx Proxy Manager network and optionally joins it.
|
||||
4. Writes `/opt/anouma/.env` with every secret the app needs, freshly generated (`openssl rand -hex 32`) and **never regenerated** on a later run.
|
||||
5. Asks the few things that can't be generated (domain, optionally SMTP, optionally the registry credentials) — skipped automatically if already answered before.
|
||||
6. Creates a restricted `anouma-deploy` system user, generates a dedicated SSH keypair for it, and installs the public key with a **forced command** so that key can only ever run `deploy.sh` — never an interactive shell.
|
||||
5. Asks the few things that can't be generated (domain, optionally SMTP, optionally the registry credentials) - skipped automatically if already answered before.
|
||||
6. Creates a restricted `anouma-deploy` system user, generates a dedicated SSH keypair for it, and installs the public key with a **forced command** so that key can only ever run `deploy.sh` - never an interactive shell.
|
||||
7. Copies `docker-compose.yml` and `deploy.sh` into `/opt/anouma`.
|
||||
8. Starts Postgres (so it's ready for the first deploy).
|
||||
9. Prints the exact values to paste into **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.
|
||||
|
||||
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
|
||||
|
||||
@@ -50,19 +50,19 @@ Copy the deployment values `setup.sh` printed into **GitHub → whitehai11/anoum
|
||||
| `DEPLOY_PORT` | `22` (or your SSH port) |
|
||||
| `DEPLOY_USER` | `anouma-deploy` |
|
||||
| `DEPLOY_SSH_KEY` | the private key setup.sh printed |
|
||||
| `DEPLOY_HOST_KEY` | optional — pin the server's SSH host public key instead of trusting it on first connect (`ssh-keyscan -p <port> <host>` on a machine you already trust) |
|
||||
| `DEPLOY_HOST_KEY` | optional - pin the server's SSH host public key instead of trusting it on first connect (`ssh-keyscan -p <port> <host>` on a machine you already trust) |
|
||||
| `REGISTRY_USERNAME` / `REGISTRY_PASSWORD` | **not needed in GitHub Actions**; these belong in `/opt/anouma/.env` on the server for pulling the private GHCR image |
|
||||
|
||||
### First deploy
|
||||
|
||||
Nothing runs on the server yet at this point — no release has been built. Push the first tag:
|
||||
Nothing runs on the server yet at this point - no release has been built. Push the first tag:
|
||||
|
||||
```bash
|
||||
git tag v1.0.0
|
||||
git push 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
|
||||
|
||||
@@ -91,7 +91,7 @@ Release-Workflows ausführen.
|
||||
|
||||
## `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
|
||||
Lock (refuse a second concurrent deploy)
|
||||
@@ -105,13 +105,13 @@ Lock (refuse a second concurrent deploy)
|
||||
→ success, or: automatic rollback to the previous version
|
||||
```
|
||||
|
||||
If the post-deploy healthcheck fails, `deploy.sh` automatically redeploys the **previous** version (recorded from `ANOUMA_IMAGE` before the attempt) and reports the failed deploy as exit code 1 — 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.
|
||||
|
||||
## 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:
|
||||
|
||||
@@ -137,7 +137,7 @@ In Nginx Proxy Manager, add a Proxy Host:
|
||||
| Scheme | `http` |
|
||||
| Forward Host | `app` (the Compose service name) |
|
||||
| Forward Port | `3000` |
|
||||
| **Websockets Support** | **enabled** — required for the LiveKit signal connection |
|
||||
| **Websockets Support** | **enabled** - required for the LiveKit signal connection |
|
||||
|
||||
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
|
||||
`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
|
||||
und die serverseitige Token-Route gegeben. Für den Browser ist ausschließlich
|
||||
`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 |
|
||||
| `BACKUP_RETENTION_DAYS` | Deploy-backup retention |
|
||||
| `REGISTRY`, `REGISTRY_REPO`, `REGISTRY_USERNAME`, `REGISTRY_PASSWORD` | Where `deploy.sh` pulls the image from |
|
||||
| `ANOUMA_IMAGE` | Set automatically by `deploy.sh` on every deploy — don't edit by hand |
|
||||
| `ANOUMA_IMAGE` | Set automatically by `deploy.sh` on every deploy - don't edit by hand |
|
||||
|
||||
## Persistent data
|
||||
|
||||
Deployments never delete data:
|
||||
|
||||
- **Database** — the `pgdata` named volume, independent of the `postgres` container's lifecycle.
|
||||
- **Uploaded media** — the `/opt/anouma/media` bind mount, independent of the `app` container's lifecycle.
|
||||
- **Backups** — `/opt/anouma/backups`.
|
||||
- **Database** - the `pgdata` named volume, independent of the `postgres` container's lifecycle.
|
||||
- **Uploaded media** - the `/opt/anouma/media` bind mount, independent of the `app` container's lifecycle.
|
||||
- **Backups** - `/opt/anouma/backups`.
|
||||
|
||||
Neither `setup.sh` nor `deploy.sh` ever runs `docker system prune`, `docker volume prune`, `docker network prune`, or `docker compose down -v`. Neither touches a container or network it doesn't own (an existing NPM install is never modified beyond optionally joining its network).
|
||||
|
||||
## Healthchecks
|
||||
|
||||
- **app**: `GET /api/health` (built into the image's `HEALTHCHECK`) — checks that the app can actually query Postgres, not just that the process is listening.
|
||||
- **app**: `GET /api/health` (built into the image's `HEALTHCHECK`) - checks that the app can actually query Postgres, not just that the process is listening.
|
||||
- **postgres**: `pg_isready`.
|
||||
|
||||
`docker compose ps` (from `/opt/anouma`) shows the app, Postgres and LiveKit services.
|
||||
|
||||
## Security
|
||||
|
||||
- `anouma-deploy` has **no sudo rights at all** and **no password** — the only way in is its dedicated SSH key.
|
||||
- `anouma-deploy` has **no sudo rights at all** and **no password** - the only way in is its dedicated SSH key.
|
||||
- That SSH key is restricted with `restrict,command="/opt/anouma/deploy.sh"` in `authorized_keys`: whatever command an SSH client requests is ignored and `deploy.sh` runs instead (the client's actual request only reaches it via `$SSH_ORIGINAL_COMMAND`, which `deploy.sh` validates against a strict `deploy vX.Y.Z` pattern before doing anything). No arbitrary shell access is possible through this key, even if it leaks.
|
||||
- `anouma-deploy` **is** a member of the `docker` group, because running `docker compose` requires it — and Docker-group membership is well known to be effectively root-equivalent (a container can bind-mount the host filesystem). This is a deliberate, documented trade-off: the blast radius is contained by the two points above (no interactive access, and the only reachable code path is the fixed `deploy.sh` logic), not eliminated. For a stricter setup, consider rootless Docker or a dedicated deployment tool with finer-grained Docker API ACLs — out of scope here.
|
||||
- `anouma-deploy` **is** a member of the `docker` group, because running `docker compose` requires it - and Docker-group membership is well known to be effectively root-equivalent (a container can bind-mount the host filesystem). This is a deliberate, documented trade-off: the blast radius is contained by the two points above (no interactive access, and the only reachable code path is the fixed `deploy.sh` logic), not eliminated. For a stricter setup, consider rootless Docker or a dedicated deployment tool with finer-grained Docker API ACLs - out of scope here.
|
||||
- `.env` and the deploy SSH private key are `chmod 600`.
|
||||
- Secrets are never committed, never logged, and never printed more than once (`setup.sh` shows the private key only the first time it's generated; on a later run it prints "Existing deployment key found." instead).
|
||||
- `REGISTRY_PASSWORD`/`SMTP_PASSWORD`/etc. are masked (`********`) in `setup.sh`'s configuration summary. 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 |
|
||||
| --- | --- |
|
||||
| `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 |
|
||||
| `setup.sh` fails at "Datenbank wurde nicht rechtzeitig healthy" | `docker compose logs postgres` (from `/opt/anouma`) - usually a bad `POSTGRES_PASSWORD`/`DATABASE_URI` mismatch if you hand-edited `.env` |
|
||||
| Release workflow fails at "Pull the tested image" | The tagged commit was never pushed to `main`, or `main`'s CI/build-image run for it hasn't finished yet - push to `main`, wait for CI to go green, then tag |
|
||||
| Release workflow fails at "Deploy to production" | Check the GitHub repository secrets are all set correctly, and that the server's SSH port is reachable from the runner |
|
||||
| App container unhealthy after a deploy | `ssh` directly to the server (not via the restricted key) and run `docker compose logs app` from `/opt/anouma` |
|
||||
| Video calls connect but no audio/video | 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 |
|
||||
| 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
|
||||
# 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.
|
||||
# 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
|
||||
|
||||
@@ -32,12 +32,12 @@ RUN addgroup --system --gid 1001 nodejs \
|
||||
|
||||
# Copied wholesale (not a hand-picked list of directories) so newly added
|
||||
# source folders (collections, scripts, lib/*, components/*, ...) are never
|
||||
# silently missing at runtime — .dockerignore already excludes what doesn't
|
||||
# silently missing at runtime - .dockerignore already excludes what doesn't
|
||||
# belong in the image (node_modules is re-added explicitly below, .next is
|
||||
# the build output we do want).
|
||||
COPY --from=builder --chown=nextjs:nodejs /app /app
|
||||
|
||||
# Writable at runtime, independent of the image — actual data lives in the
|
||||
# Writable at runtime, independent of the image - actual data lives in the
|
||||
# Docker volumes mounted over these paths (see docker-compose.yml).
|
||||
RUN mkdir -p media backups && chown -R nextjs:nodejs media backups
|
||||
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
# 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
|
||||
|
||||
- `app/(frontend)/` — die öffentliche Website (bestehendes ANOUMA-Design, eigener Root-Layout)
|
||||
- `app/(call)/` — die Video-Call-Oberfläche (`/termine/[slug]/call`), eigenes minimalistisches dunkles Layout ohne Navbar/Footer
|
||||
- `app/(payload)/` — der Admin-Bereich unter `/admin`, Payloads REST/GraphQL-API sowie die Meeting-/Cron-API-Routen (`api/meetings/...`, `api/cron/...`)
|
||||
- `app/global-not-found.tsx` — statische 404-Seite (siehe unten, warum sie nötig ist)
|
||||
- `collections/`, `globals/`, `access/`, `fields/`, `payload.config.ts` — die CMS-Konfiguration
|
||||
- `lib/payload/` — Local-API-Zugriffe für die öffentliche Website (mit `React.cache` pro Request memoisiert)
|
||||
- `lib/meeting/` — Meeting-Passwort, Zeitfenster/Status, signierte Beitritts-Tokens, LiveKit-SFU-Token und Reminder-Logik
|
||||
- `lib/email/` — SMTP-Versand (nodemailer) und das E-Mail-Template für Reminder
|
||||
- `lib/texte.ts` — die ursprünglichen Anouma-Texte, nur noch als Seed-Quelle verwendet
|
||||
- `scripts/seed.ts` — überträgt die vorhandenen Inhalte ins CMS
|
||||
- `server.ts` — eigener Node-Server (statt `next start`), passend zur bestehenden Payload-Konfiguration
|
||||
- `app/(frontend)/` - die öffentliche Website (bestehendes ANOUMA-Design, eigener Root-Layout)
|
||||
- `app/(call)/` - die Video-Call-Oberfläche (`/termine/[slug]/call`), eigenes minimalistisches dunkles Layout ohne Navbar/Footer
|
||||
- `app/(payload)/` - der Admin-Bereich unter `/admin`, Payloads REST/GraphQL-API sowie die Meeting-/Cron-API-Routen (`api/meetings/...`, `api/cron/...`)
|
||||
- `app/global-not-found.tsx` - statische 404-Seite (siehe unten, warum sie nötig ist)
|
||||
- `collections/`, `globals/`, `access/`, `fields/`, `payload.config.ts` - die CMS-Konfiguration
|
||||
- `lib/payload/` - Local-API-Zugriffe für die öffentliche Website (mit `React.cache` pro Request memoisiert)
|
||||
- `lib/meeting/` - Meeting-Passwort, Zeitfenster/Status, signierte Beitritts-Tokens, LiveKit-SFU-Token und Reminder-Logik
|
||||
- `lib/email/` - SMTP-Versand (nodemailer) und das E-Mail-Template für Reminder
|
||||
- `lib/texte.ts` - die ursprünglichen Anouma-Texte, nur noch als Seed-Quelle verwendet
|
||||
- `scripts/seed.ts` - überträgt die vorhandenen Inhalte ins CMS
|
||||
- `server.ts` - eigener Node-Server (statt `next start`), passend zur bestehenden Payload-Konfiguration
|
||||
|
||||
Zwei bzw. drei Root-Layouts (`(frontend)`, `(payload)`, `(call)`) bedeuten: Next.js kann daraus keine einzelne 404-Seite komponieren, deshalb gibt es `app/global-not-found.tsx` (siehe `experimental.globalNotFound` in `next.config.ts`).
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
Der Versand ist idempotent (`reminder60Sent`/`reminder30Sent` je Anmeldung, `hostReminder60Sent`/`hostReminder30Sent` je Termin) — ein häufiger laufender Cron verschickt also nie doppelt.
|
||||
Der Versand ist idempotent (`reminder60Sent`/`reminder30Sent` je Anmeldung, `hostReminder60Sent`/`hostReminder30Sent` je Termin) - ein häufiger laufender Cron verschickt also nie doppelt.
|
||||
|
||||
## Setup (Produktion / Deployment)
|
||||
|
||||
Produktion läuft komplett über 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
|
||||
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
|
||||
```
|
||||
|
||||
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)**.
|
||||
|
||||
@@ -88,7 +88,7 @@ Nur Postgres per Docker, die App läuft direkt auf dem Host:
|
||||
docker compose up -d postgres
|
||||
```
|
||||
|
||||
(oder eine gehostete Postgres-Instanz, z. B. Neon/Supabase — einfach `DATABASE_URI` in `.env` entsprechend setzen).
|
||||
(oder eine gehostete Postgres-Instanz, z. B. Neon/Supabase - einfach `DATABASE_URI` in `.env` entsprechend setzen).
|
||||
|
||||
### Entwicklung
|
||||
|
||||
@@ -97,7 +97,7 @@ npm run dev
|
||||
```
|
||||
|
||||
- Website: http://localhost:3000
|
||||
- Admin: http://localhost:3000/admin — beim ersten Aufruf legst du dort direkt deinen eigenen Admin-Zugang (E-Mail + Passwort) an. Es gibt kein Standardpasswort im Code.
|
||||
- Admin: http://localhost:3000/admin - beim ersten Aufruf legst du dort direkt deinen eigenen Admin-Zugang (E-Mail + Passwort) an. Es gibt kein Standardpasswort im Code.
|
||||
|
||||
### Vorhandene Inhalte ins CMS übertragen
|
||||
|
||||
@@ -105,7 +105,7 @@ npm run dev
|
||||
npm run seed
|
||||
```
|
||||
|
||||
Überträgt die 7 Angebote sowie die Texte für Startseite, Über mich, Angebote-Einleitung, Aktuelles-Einleitung, Kontakt und Termin buchen aus `lib/texte.ts` ins CMS. Kann gefahrlos mehrfach ausgeführt werden. Kontaktdaten (E-Mail/Telefon/Region) werden zunächst als Platzhalter gesetzt — bitte im Admin unter „Kontakt“ durch die echten Angaben ersetzen.
|
||||
Überträgt die 7 Angebote sowie die Texte für Startseite, Über mich, Angebote-Einleitung, Aktuelles-Einleitung, Kontakt und Termin buchen aus `lib/texte.ts` ins CMS. Kann gefahrlos mehrfach ausgeführt werden. Kontaktdaten (E-Mail/Telefon/Region) werden zunächst als Platzhalter gesetzt - bitte im Admin unter „Kontakt“ durch die echten Angaben ersetzen.
|
||||
|
||||
### Produktions-Build ohne Docker
|
||||
|
||||
@@ -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 migrate:create` | Neue Datenbank-Migration aus Config-Änderungen bauen |
|
||||
| `npm run lint` | ESLint |
|
||||
| `npm test` | Test-Suite (aktuell ein Platzhalter — noch keine Tests vorhanden) |
|
||||
| `npm run seo:check` | Optionale SEO-Prüfung (doppelte Slugs, fehlende Alt-Texte) — nicht produktionskritisch |
|
||||
| `npm test` | Test-Suite (aktuell ein Platzhalter - noch keine Tests vorhanden) |
|
||||
| `npm run seo:check` | Optionale SEO-Prüfung (doppelte Slugs, fehlende Alt-Texte) - nicht produktionskritisch |
|
||||
|
||||
## Design
|
||||
|
||||
|
||||
+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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 4. P2 — Krankheits- und Urlaubsgründe sind öffentlich abrufbar
|
||||
## 4. P2 - Krankheits- und Urlaubsgründe sind öffentlich abrufbar
|
||||
|
||||
**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.
|
||||
|
||||
## 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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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`.
|
||||
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import type { Access, FieldAccess } from "payload";
|
||||
|
||||
/**
|
||||
* Only the "users" auth collection (Anna/admins) may perform the action —
|
||||
* Only the "users" auth collection (Anna/admins) may perform the action -
|
||||
* and only with the "admin" role, so adding further roles to Users later
|
||||
* only means extending this one check. Site visitors authenticate against
|
||||
* the separate "customers" collection (see collections/Customers.ts) and
|
||||
@@ -15,7 +15,7 @@ export const isAdminFieldLevel: FieldAccess = ({ req: { user } }) =>
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Used both as a Payload Access/FieldAccess function and called directly
|
||||
* (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 "../(frontend)/globals.css";
|
||||
|
||||
// Its own root layout — the video-call UI is intentionally full-bleed and
|
||||
// Its own root layout - the video-call UI is intentionally full-bleed and
|
||||
// dark, without the marketing navbar/footer around it.
|
||||
|
||||
const inter = Inter({ variable: "--font-inter", subsets: ["latin"] });
|
||||
|
||||
@@ -38,7 +38,7 @@ export async function generateMetadata({ params }: Args): Promise<Metadata> {
|
||||
|
||||
const resolved = resolveSeo({
|
||||
seo: post.seo,
|
||||
fallbackTitle: `${post.title} – ${siteConfig.name}`,
|
||||
fallbackTitle: `${post.title} - ${siteConfig.name}`,
|
||||
fallbackDescription: () => post.teaser,
|
||||
fallbackImage: post.coverImage,
|
||||
defaultOgImage: seoSettings.defaultOgImage,
|
||||
|
||||
@@ -45,7 +45,7 @@ export async function generateMetadata({ params }: Args): Promise<Metadata> {
|
||||
|
||||
const resolved = resolveSeo({
|
||||
seo: offer.seo,
|
||||
fallbackTitle: `${offer.title} – ${siteConfig.name}`,
|
||||
fallbackTitle: `${offer.title} - ${siteConfig.name}`,
|
||||
fallbackDescription: () => offer.shortDescription,
|
||||
fallbackImage: offer.image,
|
||||
defaultOgImage: seoSettings.defaultOgImage,
|
||||
@@ -133,7 +133,7 @@ export default async function OfferDetailPage({ params }: Args) {
|
||||
{offer.groupSchedule && (
|
||||
<div className="mb-8 rounded-2xl bg-white p-5 text-sm text-anouma-plum">
|
||||
<p className="font-medium">Fester Termin</p>
|
||||
<p className="mt-1">{offer.groupSchedule.recurrence === "monthly" ? "Monatlich" : "Wöchentlich"}{offer.groupSchedule.weekday ? ` · ${WEEKDAY_LABELS[offer.groupSchedule.weekday] ?? offer.groupSchedule.weekday}` : ""}{offer.groupSchedule.startTime ? ` · ${offer.groupSchedule.startTime}` : ""}{offer.groupSchedule.endTime ? `–${offer.groupSchedule.endTime}` : ""}</p>
|
||||
<p className="mt-1">{offer.groupSchedule.recurrence === "monthly" ? "Monatlich" : "Wöchentlich"}{offer.groupSchedule.weekday ? ` · ${WEEKDAY_LABELS[offer.groupSchedule.weekday] ?? offer.groupSchedule.weekday}` : ""}{offer.groupSchedule.startTime ? ` · ${offer.groupSchedule.startTime}` : ""}{offer.groupSchedule.endTime ? `-${offer.groupSchedule.endTime}` : ""}</p>
|
||||
</div>
|
||||
)}
|
||||
{offer.groupCapacity && <p className="mb-2 text-sm text-anouma-plum/75">Plätze: {offer.groupCapacity}</p>}
|
||||
|
||||
@@ -19,13 +19,13 @@ import { siteConfig } from "@/lib/site";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const FALLBACK_DESCRIPTION =
|
||||
"Prozessbegleitung, Doula-Begleitung, Kindergruppen und Singkreise – die Angebote von Anouma im Überblick.";
|
||||
"Prozessbegleitung, Doula-Begleitung, Kindergruppen und Singkreise - die Angebote von Anouma im Überblick.";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const [intro, seoSettings] = await Promise.all([getAngeboteIntroGlobal(), getSEOSettingsGlobal()]);
|
||||
const resolved = resolveSeo({
|
||||
seo: intro.seo,
|
||||
fallbackTitle: `Begleitung für dich und deine Familie – ${siteConfig.name}`,
|
||||
fallbackTitle: `Begleitung für dich und deine Familie - ${siteConfig.name}`,
|
||||
fallbackDescription: () => intro.lead || seoSettings.defaultDescription || FALLBACK_DESCRIPTION,
|
||||
defaultOgImage: seoSettings.defaultOgImage,
|
||||
});
|
||||
@@ -170,7 +170,7 @@ export default async function AngebotePage() {
|
||||
|
||||
<CTA
|
||||
title="Unsicher, welcher Raum passt?"
|
||||
lead="Melde dich gerne für ein unverbindliches Kennenlerngespräch – gemeinsam schauen wir, was dein Anliegen gerade braucht."
|
||||
lead="Melde dich gerne für ein unverbindliches Kennenlerngespräch - gemeinsam schauen wir, was dein Anliegen gerade braucht."
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return buildMetadata({
|
||||
title: `Singkreise – ${siteConfig.name}`,
|
||||
title: `Singkreise - ${siteConfig.name}`,
|
||||
description: "Gemeinsam singen. Verbinden. Heilen. Für Herz und Seele.",
|
||||
path: "/angebote/singkreise",
|
||||
});
|
||||
@@ -30,7 +30,7 @@ export default async function SingkreisePage() {
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow="Singkreise"
|
||||
title="Singkreise – miteinander singen, klingen und sein"
|
||||
title="Singkreise - miteinander singen, klingen und sein"
|
||||
lead="Gemeinsam singen. Verbinden. Heilen. Für Herz und Seele."
|
||||
crumbs={[{ title: "Angebote", href: "/angebote" }, { title: "Singkreise" }]}
|
||||
/>
|
||||
@@ -53,7 +53,7 @@ export default async function SingkreisePage() {
|
||||
|
||||
<CTA
|
||||
title="Deine Stimme erklingen lassen"
|
||||
lead="Ob offen für alle, für Schwangere oder für Mamas mit Baby – melde dich, wenn du einen Kreis besuchen möchtest."
|
||||
lead="Ob offen für alle, für Schwangere oder für Mamas mit Baby - melde dich, wenn du einen Kreis besuchen möchtest."
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ const TOKEN_SHAPE = /^[0-9a-f]{64}$/i;
|
||||
type Args = { params: Promise<{ tokenFile: string }> };
|
||||
|
||||
/**
|
||||
* GET /calendar/<token>.ics — a private iCalendar feed, subscribable from
|
||||
* GET /calendar/<token>.ics - a private iCalendar feed, subscribable from
|
||||
* Apple Calendar, Google Calendar, Outlook, Thunderbird etc. The token
|
||||
* itself is the only credential (there's no login here), so it's treated
|
||||
* like a secret: looked up by hash only, never logged, and the response is
|
||||
|
||||
@@ -33,9 +33,9 @@ export default async function DatenschutzPage() {
|
||||
<div>
|
||||
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Verantwortliche Stelle</h2>
|
||||
<p className="mt-4 leading-relaxed text-anouma-plum">
|
||||
[Platzhalter – Vor- und Nachname]
|
||||
[Platzhalter - Vor- und Nachname]
|
||||
<br />
|
||||
[Platzhalter – Anschrift]
|
||||
[Platzhalter - Anschrift]
|
||||
<br />
|
||||
E-Mail: {contact.email}
|
||||
</p>
|
||||
@@ -48,7 +48,7 @@ export default async function DatenschutzPage() {
|
||||
<p className="mt-4 leading-relaxed text-anouma-plum">
|
||||
Beim Besuch dieser Website werden aus technischen Gründen automatisch Informationen
|
||||
erfasst, die dein Browser übermittelt (z. B. IP-Adresse, Datum und Uhrzeit des
|
||||
Zugriffs). [Platzhalter – Details zum verwendeten Hosting-Anbieter ergänzen.]
|
||||
Zugriffs). [Platzhalter - Details zum verwendeten Hosting-Anbieter ergänzen.]
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
@font-face {
|
||||
font-family: "Antropos";
|
||||
src: url("/fonts/antropos.ttf") format("truetype");
|
||||
src: url("/fonts/baar-antropos.ttf") format("truetype");
|
||||
font-display: swap;
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
@@ -80,6 +80,10 @@ body {
|
||||
font-family: "Antropos", ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.font-antropos {
|
||||
font-family: "Antropos", ui-serif, Georgia, serif;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--color-anouma-rose-soft);
|
||||
color: var(--color-anouma-plum);
|
||||
|
||||
@@ -10,7 +10,7 @@ import { JsonLd, webPageJsonLd } from "@/lib/seo/jsonld";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const DESCRIPTION = "Bildeindrücke aus der Arbeit von Anouma – Natur, Gemeinschaft und gemeinsame Räume.";
|
||||
const DESCRIPTION = "Bildeindrücke aus der Arbeit von Anouma - Natur, Gemeinschaft und gemeinsame Räume.";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return buildMetadata({ title: "Impressionen", description: DESCRIPTION, path: "/impressionen" });
|
||||
@@ -36,7 +36,7 @@ export default async function ImpressionenPage() {
|
||||
<PageHeader
|
||||
eyebrow="Impressionen"
|
||||
title="Einblicke in gemeinsame Räume"
|
||||
lead="Diese Galerie ist als Platzhalter-System angelegt – echte Fotografien lassen sich hier später direkt einsetzen, ohne das Layout zu verändern."
|
||||
lead="Diese Galerie ist als Platzhalter-System angelegt - echte Fotografien lassen sich hier später direkt einsetzen, ohne das Layout zu verändern."
|
||||
crumbs={[{ title: "Impressionen" }]}
|
||||
/>
|
||||
<Section tone="plain">
|
||||
|
||||
@@ -12,7 +12,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
return {
|
||||
title: "Impressum",
|
||||
description: "Impressum von Anouma gemäß § 5 TMG.",
|
||||
// noindex, but still crawlable — legal boilerplate deliberately kept
|
||||
// noindex, but still crawlable - legal boilerplate deliberately kept
|
||||
// out of search results without cutting off the internal links on it.
|
||||
robots: { index: false, follow: true },
|
||||
alternates: { canonical: `${siteUrl}/impressum` },
|
||||
@@ -35,11 +35,11 @@ export default async function ImpressumPage() {
|
||||
<div>
|
||||
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Angaben gemäß § 5 TMG</h2>
|
||||
<p className="mt-4 leading-relaxed text-anouma-plum">
|
||||
[Platzhalter – Vor- und Nachname]
|
||||
[Platzhalter - Vor- und Nachname]
|
||||
<br />
|
||||
[Platzhalter – Straße und Hausnummer]
|
||||
[Platzhalter - Straße und Hausnummer]
|
||||
<br />
|
||||
[Platzhalter – PLZ und Ort]
|
||||
[Platzhalter - PLZ und Ort]
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -61,7 +61,7 @@ export default async function ImpressumPage() {
|
||||
Umsatzsteuer-Identifikationsnummer
|
||||
</h2>
|
||||
<p className="mt-4 leading-relaxed text-anouma-plum">
|
||||
[Platzhalter – sofern vorhanden, gemäß § 27 a Umsatzsteuergesetz]
|
||||
[Platzhalter - sofern vorhanden, gemäß § 27 a Umsatzsteuergesetz]
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -70,7 +70,7 @@ export default async function ImpressumPage() {
|
||||
Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV
|
||||
</h2>
|
||||
<p className="mt-4 leading-relaxed text-anouma-plum">
|
||||
[Platzhalter – Vor- und Nachname, Anschrift wie oben]
|
||||
[Platzhalter - Vor- und Nachname, Anschrift wie oben]
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
const [contact, seoSettings] = await Promise.all([getContactGlobal(), getSEOSettingsGlobal()]);
|
||||
const resolved = resolveSeo({
|
||||
seo: contact.seo,
|
||||
fallbackTitle: `Kontakt – ${siteConfig.name}`,
|
||||
fallbackTitle: `Kontakt - ${siteConfig.name}`,
|
||||
fallbackDescription: () => contact.lead || seoSettings.defaultDescription || FALLBACK_DESCRIPTION,
|
||||
defaultOgImage: seoSettings.defaultOgImage,
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ResendVerificationButton } from "@/components/auth/ResendVerificationBu
|
||||
import { getCurrentCustomer } from "@/lib/auth/customer";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
// Applies to every /konto/* page at once — private account area, never indexable.
|
||||
// Applies to every /konto/* page at once - private account area, never indexable.
|
||||
export const metadata: Metadata = { robots: { index: false, follow: false } };
|
||||
|
||||
const navItems = [
|
||||
|
||||
@@ -14,7 +14,7 @@ export default async function KontoProfilPage() {
|
||||
<div className="mt-6 rounded-3xl bg-white p-6">
|
||||
<ProfileForm id={customer.id} name={customer.name} phone={customer.phone ?? ""} />
|
||||
<p className="mt-6 text-xs text-anouma-plum/60">
|
||||
E-Mail: {customer.email} – für eine Änderung der E-Mail-Adresse melde dich bitte direkt bei Anna.
|
||||
E-Mail: {customer.email} - für eine Änderung der E-Mail-Adresse melde dich bitte direkt bei Anna.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,7 @@ export const metadata: Metadata = {
|
||||
metadataBase: new URL(siteConfig.domain),
|
||||
title: {
|
||||
default: siteConfig.title,
|
||||
template: `%s – ${siteConfig.name}`,
|
||||
template: `%s - ${siteConfig.name}`,
|
||||
},
|
||||
description: siteConfig.description,
|
||||
openGraph: {
|
||||
@@ -47,7 +47,7 @@ export const metadata: Metadata = {
|
||||
title: siteConfig.title,
|
||||
description: siteConfig.description,
|
||||
},
|
||||
// Configurable via env only — never a hardcoded verification ID (see .env.example).
|
||||
// Configurable via env only - never a hardcoded verification ID (see .env.example).
|
||||
...(process.env.GOOGLE_SITE_VERIFICATION ? { verification: { google: process.env.GOOGLE_SITE_VERIFICATION } } : {}),
|
||||
};
|
||||
|
||||
|
||||
@@ -27,11 +27,11 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
const [home, seoSettings] = await Promise.all([getHomeGlobal(), getSEOSettingsGlobal()]);
|
||||
const resolved = resolveSeo({
|
||||
seo: home.seo,
|
||||
fallbackTitle: `${siteConfig.name} – Begleitung für dich und deine Familie`,
|
||||
fallbackTitle: `${siteConfig.name} - Begleitung für dich und deine Familie`,
|
||||
fallbackDescription: () =>
|
||||
home.heroSupporting ||
|
||||
seoSettings.defaultDescription ||
|
||||
"Prozessbegleitung, Doula-Begleitung, Kindergruppen und Singkreise – Räume für Verbindung mit dir selbst, miteinander und mit der Natur.",
|
||||
"Prozessbegleitung, Doula-Begleitung, Kindergruppen und Singkreise - Räume für Verbindung mit dir selbst, miteinander und mit der Natur.",
|
||||
fallbackImage: home.heroImage,
|
||||
defaultOgImage: seoSettings.defaultOgImage,
|
||||
});
|
||||
@@ -42,8 +42,8 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
ogImageUrl: resolved.ogImageUrl,
|
||||
keywords: resolved.keywords,
|
||||
});
|
||||
// Homepage bypasses the "%s — Anouma" title template — the brand is
|
||||
// already part of the fallback/CMS title itself, avoiding "Anouma — Anouma".
|
||||
// Homepage bypasses the "%s - Anouma" title template - the brand is
|
||||
// already part of the fallback/CMS title itself, avoiding "Anouma - Anouma".
|
||||
return { ...meta, title: { absolute: resolved.title } };
|
||||
}
|
||||
|
||||
@@ -93,10 +93,10 @@ export default async function Home() {
|
||||
<SectionHeading
|
||||
eyebrow="Angebote"
|
||||
title="Räume, die zu deinem Weg passen"
|
||||
lead="Von persönlicher Prozessbegleitung über Doula-Begleitung bis zu Kinder- und Singkreisen – jedes Angebot ist ein eigener, geschützter Raum."
|
||||
lead="Von persönlicher Prozessbegleitung über Doula-Begleitung bis zu Kinder- und Singkreisen - jedes Angebot ist ein eigener, geschützter Raum."
|
||||
/>
|
||||
<div className="mt-14 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 && (
|
||||
<Reveal>
|
||||
<AngebotCard
|
||||
@@ -263,7 +263,7 @@ export default async function Home() {
|
||||
title={home.ctaTitle || "Kennenlernen & Termine vereinbaren"}
|
||||
lead={
|
||||
home.ctaLead ||
|
||||
"Ich freue mich, von dir zu hören – schreib mir oder buche direkt ein unverbindliches Kennenlerngespräch."
|
||||
"Ich freue mich, von dir zu hören - schreib mir oder buche direkt ein unverbindliches Kennenlerngespräch."
|
||||
}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -22,7 +22,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
const [booking, seoSettings] = await Promise.all([getBookingGlobal(), getSEOSettingsGlobal()]);
|
||||
const resolved = resolveSeo({
|
||||
seo: booking.seo,
|
||||
fallbackTitle: `Termin buchen – ${siteConfig.name}`,
|
||||
fallbackTitle: `Termin buchen - ${siteConfig.name}`,
|
||||
fallbackDescription: () => booking.lead || seoSettings.defaultDescription || FALLBACK_DESCRIPTION,
|
||||
defaultOgImage: seoSettings.defaultOgImage,
|
||||
});
|
||||
@@ -115,7 +115,7 @@ export default async function TerminBuchenPage() {
|
||||
title="Allgemeine Anfrage"
|
||||
lead={
|
||||
bookableOffers.length > 0
|
||||
? "Für einen konkreten Termin wähle oben ein Angebot – hier kannst du auch einfach eine allgemeine Nachricht schreiben."
|
||||
? "Für einen konkreten Termin wähle oben ein Angebot - hier kannst du auch einfach eine allgemeine Nachricht schreiben."
|
||||
: undefined
|
||||
}
|
||||
align="left"
|
||||
|
||||
@@ -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="cream" className="-left-32 bottom-0 h-96 w-96" />
|
||||
<div className="relative mx-auto w-full max-w-md px-6 text-center">
|
||||
<Link href="/" className="font-serif text-2xl font-semibold tracking-[0.12em] text-anouma-mauve-dark">
|
||||
<Link href="/" className="font-antropos text-2xl font-semibold tracking-[0.12em] text-anouma-mauve-dark">
|
||||
ANOUMA
|
||||
</Link>
|
||||
<p className="mt-8 text-sm font-medium uppercase tracking-[0.18em] text-anouma-plum/70">
|
||||
|
||||
@@ -47,7 +47,7 @@ export async function generateMetadata({ params }: Args): Promise<Metadata> {
|
||||
|
||||
const resolved = resolveSeo({
|
||||
seo: event.seo,
|
||||
fallbackTitle: `${event.title} – ${siteConfig.name}`,
|
||||
fallbackTitle: `${event.title} - ${siteConfig.name}`,
|
||||
fallbackDescription: () => excerptFromRichText(event.description) || `${event.title} bei ${siteConfig.name}.`,
|
||||
fallbackImage: event.image,
|
||||
defaultOgImage: seoSettings.defaultOgImage,
|
||||
@@ -74,7 +74,7 @@ export default async function EventDetailPage({ params }: Args) {
|
||||
if (event.isOnline) {
|
||||
const payload = await getPayload({ config });
|
||||
const { user } = await payload.auth({ headers: await headers() });
|
||||
// Must check the collection, not just presence — a logged-in customer
|
||||
// Must check the collection, not just presence - a logged-in customer
|
||||
// (collection "customers") must never be treated as host.
|
||||
const isHost = user?.collection === "users" && user.role === "admin";
|
||||
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">
|
||||
<p className="font-serif text-xl font-medium text-anouma-plum">Aktuell sind keine Termine geplant</p>
|
||||
<p className="mt-2 text-base leading-relaxed text-anouma-plum/90">
|
||||
Schau gerne bald wieder vorbei, oder melde dich direkt – ich freue mich von dir zu hören.
|
||||
Schau gerne bald wieder vorbei, oder melde dich direkt - ich freue mich von dir zu hören.
|
||||
</p>
|
||||
<div className="mt-5 flex justify-center">
|
||||
<ArrowLink href="/kontakt">Zur Kontaktseite</ArrowLink>
|
||||
|
||||
@@ -21,7 +21,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
const [about, seoSettings] = await Promise.all([getAboutGlobal(), getSEOSettingsGlobal()]);
|
||||
const resolved = resolveSeo({
|
||||
seo: about.seo,
|
||||
fallbackTitle: `Über mich – ${siteConfig.name}`,
|
||||
fallbackTitle: `Über mich - ${siteConfig.name}`,
|
||||
fallbackDescription: () => excerptFromRichText(about.body) || seoSettings.defaultDescription || "Mein Weg, meine Werte und was mich bewegt.",
|
||||
fallbackImage: about.portrait,
|
||||
defaultOgImage: seoSettings.defaultOgImage,
|
||||
|
||||
@@ -24,6 +24,7 @@ import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0
|
||||
import { CalendarNavLink as CalendarNavLink_4d06fac03127b339306d5f5ecb073251 } from '../../../components/admin/CalendarNavLink'
|
||||
import { AnalyticsNavLink as AnalyticsNavLink_29fa5c324cecba6cafb11381cbfac962 } from '../../../components/admin/AnalyticsNavLink'
|
||||
import { ConversationsNavLink as ConversationsNavLink_67a2594cf28806bc76971e67212a6181 } from '../../../components/admin/ConversationsNavLink'
|
||||
import { AdminWelcome as AdminWelcome_b3c24bec21c663ad093f131aaae3d0a6 } from '../../../components/admin/AdminWelcome'
|
||||
import { AdminCalendarView as AdminCalendarView_a2760c3ae85f4fd51d79a465baaf023a } from '../../../components/admin/AdminCalendarView'
|
||||
import { AdminAnalyticsView as AdminAnalyticsView_55d38702e4d86d52435b5ac4df3f55d0 } from '../../../components/admin/AdminAnalyticsView'
|
||||
import { AdminConversationsView as AdminConversationsView_180f5c400a066a0a8f248da44d961055 } from '../../../components/admin/AdminConversationsView'
|
||||
@@ -57,6 +58,7 @@ export const importMap = {
|
||||
"/components/admin/CalendarNavLink#CalendarNavLink": CalendarNavLink_4d06fac03127b339306d5f5ecb073251,
|
||||
"/components/admin/AnalyticsNavLink#AnalyticsNavLink": AnalyticsNavLink_29fa5c324cecba6cafb11381cbfac962,
|
||||
"/components/admin/ConversationsNavLink#ConversationsNavLink": ConversationsNavLink_67a2594cf28806bc76971e67212a6181,
|
||||
"/components/admin/AdminWelcome#AdminWelcome": AdminWelcome_b3c24bec21c663ad093f131aaae3d0a6,
|
||||
"/components/admin/AdminCalendarView#AdminCalendarView": AdminCalendarView_a2760c3ae85f4fd51d79a465baaf023a,
|
||||
"/components/admin/AdminAnalyticsView#AdminAnalyticsView": AdminAnalyticsView_55d38702e4d86d52435b5ac4df3f55d0,
|
||||
"/components/admin/AdminConversationsView#AdminConversationsView": AdminConversationsView_180f5c400a066a0a8f248da44d961055,
|
||||
|
||||
@@ -6,10 +6,10 @@ import { calendarFeedUrl, generateCalendarFeedToken, hashCalendarFeedToken } fro
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* POST /api/admin/calendar-feed/regenerate — (re)generates Anna's personal
|
||||
* POST /api/admin/calendar-feed/regenerate - (re)generates Anna's personal
|
||||
* ICS feed link. Only the new token's hash is stored; the plaintext URL is
|
||||
* returned exactly once in this response and can never be retrieved again
|
||||
* — generating a new one immediately invalidates whichever link was issued
|
||||
* - generating a new one immediately invalidates whichever link was issued
|
||||
* before (see collections/Users.ts).
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
|
||||
@@ -23,7 +23,7 @@ function normalizeEventName(value: unknown): string | null {
|
||||
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 {
|
||||
if (typeof referrer !== "string" || !referrer) return undefined;
|
||||
try {
|
||||
@@ -35,7 +35,7 @@ function referrerHostFrom(referrer: unknown): string | undefined {
|
||||
|
||||
/**
|
||||
* Public tracking beacon, called by lib/analytics/client.ts only after the
|
||||
* visitor has granted statistics consent — but the client's own claim is
|
||||
* visitor has granted statistics consent - but the client's own claim is
|
||||
* never trusted alone: this route re-checks the CookieSettings global's
|
||||
* `statistics.enabled` flag server-side before writing anything, so a
|
||||
* global kill-switch (or a stale/tampered client) can never bypass it.
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { Customer } from "@/payload-types";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Deliberately identical whether or not an account exists / is already
|
||||
// verified — this endpoint must never let a caller find out either of
|
||||
// verified - this endpoint must never let a caller find out either of
|
||||
// those things about an email address that isn't their own session.
|
||||
const GENERIC_RESPONSE = {
|
||||
ok: true,
|
||||
@@ -60,7 +60,7 @@ export async function POST(request: Request) {
|
||||
body = await request.json();
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
|
||||
} catch {
|
||||
// no body — handled below as a missing email
|
||||
// no body - handled below as a missing email
|
||||
}
|
||||
const email = typeof body?.email === "string" ? body.email.trim().toLowerCase() : "";
|
||||
if (!email || 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
|
||||
// (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
|
||||
// proposed.
|
||||
await payload.update({
|
||||
|
||||
@@ -16,7 +16,7 @@ export async function POST(request: Request, { params }: Args) {
|
||||
if (!user || user.collection !== "customers") {
|
||||
return NextResponse.json({ error: "Bitte melde dich an." }, { status: 401 });
|
||||
}
|
||||
// Cancelling is deliberately allowed regardless of verification status —
|
||||
// Cancelling is deliberately allowed regardless of verification status -
|
||||
// only creating/confirming a booking is a gated "protected" action.
|
||||
|
||||
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);
|
||||
|
||||
// Re-validate the slot is actually free right now (defense against a
|
||||
// stale slot list) — the hard, race-safe check still runs again when
|
||||
// stale slot list) - the hard, race-safe check still runs again when
|
||||
// Anna confirms (see collections/BookingRequests.ts).
|
||||
const dayBounds = berlinDayBounds(start);
|
||||
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.:
|
||||
* 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)
|
||||
*/
|
||||
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
|
||||
* setup.sh/deploy.sh to confirm the app can actually reach Postgres before
|
||||
* being considered "up" — not just that the Node process is listening.
|
||||
* being considered "up" - not just that the Node process is listening.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
|
||||
@@ -57,7 +57,7 @@ export async function POST(request: Request, { params }: Args) {
|
||||
}
|
||||
|
||||
const { user } = await payload.auth({ headers: request.headers });
|
||||
// Must check the collection, not just presence — a logged-in customer
|
||||
// Must check the collection, not just presence - a logged-in customer
|
||||
// (collection "customers") must never be treated as host.
|
||||
const isHost = user?.collection === "users" && user.role === "admin";
|
||||
|
||||
@@ -81,7 +81,7 @@ export async function POST(request: Request, { params }: Args) {
|
||||
role: isHost ? "host" : "participant",
|
||||
accessVersion: access.accessVersion,
|
||||
ttlMs: Math.min(6 * 3600000, access.expiresAt - Date.now()),
|
||||
// Only meaningful for participants — a host authenticates via a real
|
||||
// Only meaningful for participants - a host authenticates via a real
|
||||
// session already, so there's no need to anchor a kick to it (and
|
||||
// hosts can't be kicked in the first place.
|
||||
clientId: isHost ? undefined : clientId,
|
||||
|
||||
+383
-3
@@ -1,3 +1,383 @@
|
||||
/* Optional custom overrides for the Payload admin UI. Left empty intentionally —
|
||||
the admin panel uses Payload's own default styling, only the public site
|
||||
uses the ANOUMA design system (see app/(frontend)/globals.css). */
|
||||
@font-face {
|
||||
font-family: "Anouma Admin";
|
||||
src: url("/fonts/baar-antropos.ttf") format("truetype");
|
||||
font-display: swap;
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
}
|
||||
|
||||
:root {
|
||||
--anouma-ink: #342c31;
|
||||
--anouma-plum: #66505f;
|
||||
--anouma-mauve: #8b616d;
|
||||
--anouma-rose: #d19ca1;
|
||||
--anouma-cream: #f7f2ed;
|
||||
--anouma-paper: #fffdfb;
|
||||
--anouma-line: #e4d9d2;
|
||||
--font-body: "Anouma Admin", Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: "SFMono-Regular", Consolas, monospace;
|
||||
}
|
||||
|
||||
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 "./(frontend)/globals.css";
|
||||
|
||||
// Required because the app has two root layouts — (frontend) and (payload) —
|
||||
// Required because the app has two root layouts - (frontend) and (payload) -
|
||||
// so there is no single layout Next.js could compose a 404 page from.
|
||||
// See node_modules/next/dist/docs/.../not-found.md ("global-not-found.js").
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function GlobalNotFound() {
|
||||
>
|
||||
<body className="flex min-h-full flex-col bg-background text-foreground">
|
||||
{/* This page is always statically prerendered, so it can't read live
|
||||
CMS data — the mega menu is intentionally omitted here. */}
|
||||
CMS data - the mega menu is intentionally omitted here. */}
|
||||
<Navbar offers={[]} />
|
||||
<main className="flex-1">
|
||||
<section className="relative overflow-hidden py-24 sm:py-32">
|
||||
@@ -67,7 +67,7 @@ export default function GlobalNotFound() {
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
{/* Minimal static footer — the full Footer reads live CMS data,
|
||||
{/* Minimal static footer - the full Footer reads live CMS data,
|
||||
which isn't available on this always-static 404 page. */}
|
||||
<footer className="bg-anouma-plum py-8 text-center text-sm text-anouma-cream-light">
|
||||
© {new Date().getFullYear()} {siteConfig.name}
|
||||
|
||||
+4
-4
@@ -2,7 +2,7 @@ import type { MetadataRoute } from "next";
|
||||
import { getSiteUrl, isSiteIndexable } from "@/lib/seo/config";
|
||||
|
||||
// Reads the CMS's SEO settings (site URL override, robots kill switch) on
|
||||
// every request — must not be statically prerendered at build time.
|
||||
// every request - must not be statically prerendered at build time.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function robots(): Promise<MetadataRoute.Robots> {
|
||||
@@ -10,7 +10,7 @@ export default async function robots(): Promise<MetadataRoute.Robots> {
|
||||
|
||||
if (!indexable) {
|
||||
// Global kill switch (SEO-Einstellungen → "Website für Suchmaschinen
|
||||
// sichtbar") — block everything rather than list exceptions.
|
||||
// sichtbar") - block everything rather than list exceptions.
|
||||
return { rules: [{ userAgent: "*", disallow: "/" }] };
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export default async function robots(): Promise<MetadataRoute.Robots> {
|
||||
"/admin",
|
||||
"/admin/*",
|
||||
"/api/*",
|
||||
// Customer accounts — never public.
|
||||
// Customer accounts - never public.
|
||||
"/konto",
|
||||
"/konto/*",
|
||||
"/login",
|
||||
@@ -32,7 +32,7 @@ export default async function robots(): Promise<MetadataRoute.Robots> {
|
||||
"/auth/*",
|
||||
// Private calendar-subscription feed (secret-token URLs).
|
||||
"/calendar/*",
|
||||
// Meeting join/call flow — session-gated, never a page worth indexing.
|
||||
// Meeting join/call flow - session-gated, never a page worth indexing.
|
||||
"/termine/*/beitreten",
|
||||
"/termine/*/call",
|
||||
],
|
||||
|
||||
+3
-3
@@ -2,7 +2,7 @@ import type { MetadataRoute } from "next";
|
||||
import { getAllEvents, getOffers, getPosts } from "@/lib/payload/content";
|
||||
import { getSiteUrl, isSiteIndexable } from "@/lib/seo/config";
|
||||
|
||||
// Reads live CMS content on every request — must not be statically
|
||||
// Reads live CMS content on every request - must not be statically
|
||||
// prerendered at build time (no DB is available during `next build`; see
|
||||
// AGENTS.md / the same convention used by every other CMS-backed route).
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -10,7 +10,7 @@ export const dynamic = "force-dynamic";
|
||||
// Static, always-public routes that aren't backed by a dynamic [slug]
|
||||
// collection. Deliberately excludes: /login, /registrieren, /konto*,
|
||||
// /admin*, /calendar/*, /auth/*, /termine/*/beitreten, /termine/*/call,
|
||||
// /api/* — none of those are public content (see app/robots.ts, which
|
||||
// /api/* - none of those are public content (see app/robots.ts, which
|
||||
// mirrors this same exclusion list).
|
||||
const staticRoutes: { path: string; changeFrequency: MetadataRoute.Sitemap[number]["changeFrequency"]; priority: number }[] = [
|
||||
{ path: "", changeFrequency: "weekly", priority: 1 },
|
||||
@@ -40,7 +40,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
}));
|
||||
|
||||
// getOffers/getAllEvents/getPosts already only return published,
|
||||
// publicly-readable documents (overrideAccess: false — see
|
||||
// publicly-readable documents (overrideAccess: false - see
|
||||
// lib/payload/content.ts's header comment).
|
||||
const [offers, events, posts] = await Promise.all([getOffers(), getAllEvents(), getPosts()]);
|
||||
|
||||
|
||||
@@ -5,21 +5,22 @@ import { canViewAnalytics } from "@/access";
|
||||
* One row per calendar day, upserted by the cleanup cron
|
||||
* (lib/analytics/cleanup.ts) from raw analytics-events BEFORE those raw rows
|
||||
* age out. This is what lets aggregated history survive well beyond the raw
|
||||
* retention window (default 90 days raw vs. 12 months aggregated — see
|
||||
* retention window (default 90 days raw vs. 12 months aggregated - see
|
||||
* globals/AnalyticsSettings.ts) without keeping individual events around.
|
||||
*
|
||||
* Not currently read by the main Analytics dashboard filters (those all
|
||||
* stay within raw retention by design — see the implementation plan); this
|
||||
* stay within raw retention by design - see the implementation plan); this
|
||||
* exists so long-term aggregated history isn't silently lost once raw
|
||||
* events are purged, and is available for a future longer-range view.
|
||||
*/
|
||||
export const AnalyticsDailyStats: CollectionConfig = {
|
||||
slug: "analytics-daily-stats",
|
||||
labels: {
|
||||
singular: "Analytics-Tagesstatistik",
|
||||
plural: "Analytics-Tagesstatistiken",
|
||||
singular: "Tagesstatistik",
|
||||
plural: "Tagesstatistiken",
|
||||
},
|
||||
admin: {
|
||||
group: "Auswertung",
|
||||
hidden: true,
|
||||
},
|
||||
access: {
|
||||
|
||||
@@ -3,25 +3,26 @@ import { canViewAnalytics } from "@/access";
|
||||
|
||||
/**
|
||||
* One row per tracked event (page_view, booking_started, ...). Deliberately
|
||||
* append-only and never writable via the public/admin API — the only writer
|
||||
* append-only and never writable via the public/admin API - the only writer
|
||||
* is app/(payload)/api/analytics/track/route.ts, which uses the trusted
|
||||
* Local API (overrideAccess: true by default) to insert rows despite
|
||||
* `create` being locked to `() => false` here. The only deleter is the
|
||||
* cleanup cron (lib/analytics/cleanup.ts), same mechanism.
|
||||
*
|
||||
* No name/email/IP/UA is ever stored — see lib/analytics/salt.ts for how
|
||||
* No name/email/IP/UA is ever stored - see lib/analytics/salt.ts for how
|
||||
* `visitorHash` is derived without persisting anything identifying.
|
||||
*/
|
||||
export const AnalyticsEvents: CollectionConfig = {
|
||||
slug: "analytics-events",
|
||||
labels: {
|
||||
singular: "Analytics-Event",
|
||||
plural: "Analytics-Events",
|
||||
singular: "Messereignis",
|
||||
plural: "Messereignisse",
|
||||
},
|
||||
admin: {
|
||||
// High-volume, low-signal raw data — not meant for manual browsing.
|
||||
// The Analytics dashboard (a custom admin view) reads it programmatically.
|
||||
group: "Auswertung",
|
||||
hidden: true,
|
||||
// High-volume, low-signal raw data - not meant for manual browsing.
|
||||
// The Analytics dashboard (a custom admin view) reads it programmatically.
|
||||
},
|
||||
access: {
|
||||
create: () => false,
|
||||
@@ -42,7 +43,7 @@ export const AnalyticsEvents: CollectionConfig = {
|
||||
name: "visitorHash",
|
||||
type: "text",
|
||||
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.
|
||||
},
|
||||
{
|
||||
|
||||
@@ -11,7 +11,7 @@ export const AvailabilityOverrides: CollectionConfig = {
|
||||
useAsTitle: "date",
|
||||
defaultColumns: ["date", "type", "reason"],
|
||||
group: "Buchungen",
|
||||
description: "Einzelne Abweichungen von den wiederkehrenden Verfügbarkeiten — Urlaub, Feiertage, Krankheit oder Sonderöffnungszeiten.",
|
||||
description: "Einzelne Abweichungen von den wiederkehrenden Verfügbarkeiten - Urlaub, Feiertage, Krankheit oder Sonderöffnungszeiten.",
|
||||
},
|
||||
access: {
|
||||
read: isAdmin,
|
||||
|
||||
@@ -183,7 +183,7 @@ const notifyByEmail: CollectionAfterChangeHook = async ({ doc, previousDoc, oper
|
||||
})) as Event;
|
||||
online = {
|
||||
joinUrl: `${serverUrl}/termine/${event.slug}/beitreten`,
|
||||
password: event.meetingPassword || "—",
|
||||
password: event.meetingPassword || "-",
|
||||
};
|
||||
} else if (doc.appointmentType === "onsite") {
|
||||
const settings = await req.payload.findGlobal({ slug: "booking-settings", req });
|
||||
@@ -198,7 +198,7 @@ const notifyByEmail: CollectionAfterChangeHook = async ({ doc, previousDoc, oper
|
||||
name: user.name,
|
||||
offerTitle: offer.title,
|
||||
dateLabel: fmtDate(doc.date),
|
||||
timeLabel: `${fmtTime(doc.startTime)} – ${fmtTime(doc.endTime)}`,
|
||||
timeLabel: `${fmtTime(doc.startTime)} - ${fmtTime(doc.endTime)}`,
|
||||
appointmentTypeLabel: appointmentTypeLabel(doc.appointmentType),
|
||||
online,
|
||||
onsite,
|
||||
@@ -260,12 +260,12 @@ export const BookingRequests: CollectionConfig = {
|
||||
useAsTitle: "id",
|
||||
defaultColumns: ["offer", "user", "date", "startTime", "status"],
|
||||
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: {
|
||||
// Customer-initiated writes always go through the vetted /api/booking/*
|
||||
// routes (Local API, elevated access, after the route verifies
|
||||
// ownership) — never directly through REST/GraphQL. This keeps a
|
||||
// ownership) - never directly through REST/GraphQL. This keeps a
|
||||
// customer from ever reading/writing another customer's booking, or
|
||||
// setting their own status to "confirmed" directly.
|
||||
create: isAdmin,
|
||||
|
||||
@@ -48,7 +48,8 @@ export const ConversationMessages: CollectionConfig = {
|
||||
useAsTitle: "body",
|
||||
defaultColumns: ["conversation", "senderType", "senderName", "createdAt"],
|
||||
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: {
|
||||
read: isAdmin,
|
||||
@@ -79,7 +80,7 @@ export const ConversationMessages: CollectionConfig = {
|
||||
{ label: "Anfragende Person", value: "visitor" },
|
||||
{ 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: "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
|
||||
// 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 user = args.req.user;
|
||||
if (!user) return false;
|
||||
@@ -21,12 +21,12 @@ const isSelfOrAdmin: typeof isAdmin = (args) => {
|
||||
// customer changes their email address: issues a random, single-use,
|
||||
// time-limited email-verification token and resets emailVerified to false.
|
||||
// Only the token's hash is ever persisted (see emailVerificationTokenHash
|
||||
// below) — the plaintext exists only in req.context for the afterChange
|
||||
// below) - the plaintext exists only in req.context for the afterChange
|
||||
// hook below to email out, and is never written to the database or logged.
|
||||
//
|
||||
// The email-change case matters because `update` access on this collection
|
||||
// is isSelfOrAdmin and the auto-added `email` field carries no field-level
|
||||
// access of its own — without this, a customer could verify a throwaway
|
||||
// access of its own - without this, a customer could verify a throwaway
|
||||
// address once and then repoint `email` at an address they don't own while
|
||||
// keeping emailVerified: true (which gates booking-confirmation emails).
|
||||
const issueVerificationToken: CollectionBeforeChangeHook = ({ data, originalDoc, operation, req }) => {
|
||||
@@ -110,7 +110,7 @@ export const Customers: CollectionConfig = {
|
||||
label: "Telefonnummer",
|
||||
required: false,
|
||||
admin: {
|
||||
description: "Optional — für eine spätere Nutzung vorbereitet, aktuell nicht verpflichtend.",
|
||||
description: "Optional - für eine spätere Nutzung vorbereitet, aktuell nicht verpflichtend.",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -118,7 +118,7 @@ export const Customers: CollectionConfig = {
|
||||
type: "checkbox",
|
||||
label: "E-Mail bestätigt",
|
||||
defaultValue: false,
|
||||
// "create" AND "update" must both be locked down — otherwise a
|
||||
// "create" AND "update" must both be locked down - otherwise a
|
||||
// customer could simply include emailVerified: true in their own
|
||||
// registration or profile request and skip verification entirely.
|
||||
access: { create: isAdminFieldLevel, update: isAdminFieldLevel },
|
||||
@@ -130,7 +130,7 @@ export const Customers: CollectionConfig = {
|
||||
{
|
||||
name: "emailVerificationTokenHash",
|
||||
type: "text",
|
||||
// Never exposed or settable via any API — only the two hooks above
|
||||
// Never exposed or settable via any API - only the two hooks above
|
||||
// (running through the trusted Local API) ever touch this field.
|
||||
access: { create: () => false, read: () => false, update: () => false },
|
||||
admin: { hidden: true },
|
||||
|
||||
@@ -11,7 +11,7 @@ export const EventRegistrations: CollectionConfig = {
|
||||
useAsTitle: "name",
|
||||
defaultColumns: ["name", "email", "event", "status", "registeredAt"],
|
||||
group: "Inhalte",
|
||||
description: "Anmeldungen zu Terminen — wird beim öffentlichen Anmeldeformular befüllt.",
|
||||
description: "Anmeldungen zu Terminen - wird beim öffentlichen Anmeldeformular befüllt.",
|
||||
},
|
||||
access: {
|
||||
// Public registrations must go through /api/meetings/[slug]/register,
|
||||
@@ -65,14 +65,14 @@ export const EventRegistrations: CollectionConfig = {
|
||||
{
|
||||
name: "reminder60Sent",
|
||||
type: "checkbox",
|
||||
label: "Reminder 60 Min. gesendet",
|
||||
label: "Erinnerung 60 Min. gesendet",
|
||||
defaultValue: false,
|
||||
admin: { width: "50%" },
|
||||
},
|
||||
{
|
||||
name: "reminder30Sent",
|
||||
type: "checkbox",
|
||||
label: "Reminder 30 Min. gesendet",
|
||||
label: "Erinnerung 30 Min. gesendet",
|
||||
defaultValue: false,
|
||||
admin: { width: "50%" },
|
||||
},
|
||||
|
||||
@@ -18,7 +18,7 @@ export const EVENT_CATEGORIES = [
|
||||
|
||||
// Auto-generates the meeting password for new online events, and
|
||||
// regenerates it whenever the admin ticks "Neues Passwort generieren".
|
||||
// Never derived from the event id/slug — always a fresh random value.
|
||||
// Never derived from the event id/slug - always a fresh random value.
|
||||
const setMeetingPassword: CollectionBeforeChangeHook = ({ data, originalDoc }) => {
|
||||
if (!(data.isOnline ?? originalDoc?.isOnline)) return data;
|
||||
if (!(data.meetingPassword ?? originalDoc?.meetingPassword) || data.regeneratePassword || originalDoc?.isOnline === false) {
|
||||
@@ -38,7 +38,7 @@ export const Events: CollectionConfig = {
|
||||
useAsTitle: "title",
|
||||
defaultColumns: ["title", "date", "category", "_status"],
|
||||
group: "Inhalte",
|
||||
description: "Termine und Veranstaltungen — erscheinen automatisch unter /termine.",
|
||||
description: "Termine und Veranstaltungen - erscheinen automatisch unter /termine.",
|
||||
},
|
||||
versions: {
|
||||
drafts: {
|
||||
@@ -149,7 +149,7 @@ export const Events: CollectionConfig = {
|
||||
},
|
||||
{
|
||||
type: "collapsible",
|
||||
label: "Online-Termin (Video-Call)",
|
||||
label: "Online-Termin (Videoanruf)",
|
||||
fields: [
|
||||
{
|
||||
name: "isOnline",
|
||||
@@ -166,7 +166,7 @@ export const Events: CollectionConfig = {
|
||||
label: "Meeting-Passwort",
|
||||
// Events are publicly readable (publishedOrAdmin) via the REST/
|
||||
// GraphQL API, so without this the plaintext password would be
|
||||
// returned to any visitor fetching the event — defeating the
|
||||
// returned to any visitor fetching the event - defeating the
|
||||
// password check in app/(payload)/api/meetings/[slug]/join. Only
|
||||
// exposed to admins there; the join route reads it via the Local
|
||||
// API, which bypasses field access by default.
|
||||
@@ -220,14 +220,14 @@ export const Events: CollectionConfig = {
|
||||
{
|
||||
name: "hostReminder60Sent",
|
||||
type: "checkbox",
|
||||
label: "Host-Reminder 60 Min. gesendet",
|
||||
label: "Erinnerung an Anna 60 Min. gesendet",
|
||||
defaultValue: false,
|
||||
admin: { readOnly: true, width: "50%" },
|
||||
},
|
||||
{
|
||||
name: "hostReminder30Sent",
|
||||
type: "checkbox",
|
||||
label: "Host-Reminder 30 Min. gesendet",
|
||||
label: "Erinnerung an Anna 30 Min. gesendet",
|
||||
defaultValue: false,
|
||||
admin: { readOnly: true, width: "50%" },
|
||||
},
|
||||
|
||||
@@ -12,8 +12,9 @@ export const Media: CollectionConfig = {
|
||||
plural: "Bilder",
|
||||
},
|
||||
admin: {
|
||||
group: "Inhalte",
|
||||
useAsTitle: "alt",
|
||||
description: "Bilder für die Website — Alt-Text ist für Barrierefreiheit und SEO Pflicht.",
|
||||
description: "Bilder für die Website - Alt-Text ist für Barrierefreiheit und SEO Pflicht.",
|
||||
},
|
||||
access: {
|
||||
// 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"),
|
||||
// SVG deliberately excluded: it can carry inline <script>/event-handler
|
||||
// payloads that execute if the raw file is ever opened directly (not via
|
||||
// <img>) — not currently needed by any part of the site.
|
||||
// <img>) - not currently needed by any part of the site.
|
||||
mimeTypes: ["image/jpeg", "image/png", "image/webp"],
|
||||
imageSizes: [
|
||||
{ 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
|
||||
// overview page (Kindergruppen bundles Erdenkinder + Mädchenkreis, Singkreise
|
||||
// bundles the three singing circles — matching the site's navigation).
|
||||
// bundles the three singing circles - matching the site's navigation).
|
||||
export const OFFER_CATEGORIES = [
|
||||
{ label: "Prozessbegleitung", value: "prozessbegleitung" },
|
||||
{ label: "Doula-Begleitung", value: "doula-begleitung" },
|
||||
@@ -23,7 +23,7 @@ export const Offers: CollectionConfig = {
|
||||
useAsTitle: "title",
|
||||
defaultColumns: ["title", "category", "order", "_status"],
|
||||
group: "Inhalte",
|
||||
description: "Die Angebote von Anouma — erscheinen im Menü und auf /angebote.",
|
||||
description: "Die Angebote von Anouma - erscheinen im Menü und auf /angebote.",
|
||||
},
|
||||
defaultSort: "order",
|
||||
versions: {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { isAdmin } from "@/access";
|
||||
|
||||
function normalizePath(value: string): string {
|
||||
// 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
|
||||
// on fromPath, letting near-duplicate redirects coexist.
|
||||
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
|
||||
* scoped to the three dynamic [slug] detail routes (Angebote/Termine/
|
||||
* Aktuelles) — that's the only place slugs actually live and can change, so
|
||||
* Aktuelles) - that's the only place slugs actually live and can change, so
|
||||
* looking this up only there avoids adding a database lookup to every
|
||||
* single request the way a catch-all middleware would.
|
||||
*/
|
||||
@@ -66,8 +66,8 @@ export const Redirects: CollectionConfig = {
|
||||
required: true,
|
||||
defaultValue: "permanent",
|
||||
options: [
|
||||
{ label: "301 – Dauerhaft", value: "permanent" },
|
||||
{ label: "302 – Vorübergehend", value: "temporary" },
|
||||
{ label: "301 - Dauerhaft", value: "permanent" },
|
||||
{ label: "302 - Vorübergehend", value: "temporary" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -10,12 +10,13 @@ export const Users: CollectionConfig = {
|
||||
admin: {
|
||||
useAsTitle: "name",
|
||||
defaultColumns: ["name", "email", "role"],
|
||||
group: "Verwaltung",
|
||||
description: "Zugänge für den geschützten Admin-Bereich.",
|
||||
},
|
||||
auth: {
|
||||
// Payload shows a "create first user" screen automatically when this
|
||||
// collection is empty, bypassing normal access control just for that
|
||||
// one-time setup — no hardcoded default password is ever needed.
|
||||
// one-time setup - no hardcoded default password is ever needed.
|
||||
maxLoginAttempts: 5,
|
||||
lockTime: 10 * 60 * 1000,
|
||||
},
|
||||
@@ -48,24 +49,24 @@ export const Users: CollectionConfig = {
|
||||
{
|
||||
name: "canViewAnalytics",
|
||||
type: "checkbox",
|
||||
label: "Analytics sichtbar",
|
||||
label: "Auswertung sichtbar",
|
||||
defaultValue: true,
|
||||
// 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
|
||||
// visibility per account without changing anyone's role.
|
||||
access: {
|
||||
update: isAdminFieldLevel,
|
||||
},
|
||||
admin: {
|
||||
description: "Steuert, ob dieser Zugang das Analytics-Dashboard im Admin-Bereich sehen kann.",
|
||||
description: "Steuert, ob dieser Zugang die Auswertung im Admin-Bereich sehen kann.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "calendarFeedTokenHash",
|
||||
type: "text",
|
||||
// Only ever set by /api/admin/calendar-feed/regenerate (see
|
||||
// lib/calendar/feedToken.ts) — never exposed or directly settable via
|
||||
// lib/calendar/feedToken.ts) - never exposed or directly settable via
|
||||
// any API, so the plaintext token is never retrievable again after
|
||||
// it's shown once at generation time.
|
||||
access: { create: () => false, read: () => false, update: () => false },
|
||||
|
||||
@@ -22,7 +22,7 @@ const buttonBase =
|
||||
* Mounted once in app/(frontend)/layout.tsx. Combines two independent jobs:
|
||||
* (1) the cookie-consent banner, and (2) automatic page_view tracking on
|
||||
* every client-side route change. Both only ever act through track()/
|
||||
* setAnalyticsConfig() from lib/analytics/client.ts — no network call is
|
||||
* setAnalyticsConfig() from lib/analytics/client.ts - no network call is
|
||||
* possible from this component before a visitor has actually consented.
|
||||
*/
|
||||
export function Analytics({ consentVersion, preferencesEnabled, statisticsEnabled, necessary, preferences, statistics }: AnalyticsProps) {
|
||||
|
||||
@@ -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 }) {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -27,7 +27,7 @@ type ArrowLinkProps = {
|
||||
/**
|
||||
* The site's one secondary-action pattern: a text link with a small arrow
|
||||
* that nudges forward on hover. Used instead of a pill button wherever the
|
||||
* action isn't the single primary one on screen — keeps most CTAs feeling
|
||||
* action isn't the single primary one on screen - keeps most CTAs feeling
|
||||
* like an invitation to read on rather than another button to click.
|
||||
*/
|
||||
export function ArrowLink({ href, children, tone = "mauve", size = "sm", className = "" }: ArrowLinkProps) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ReactNode } from "react";
|
||||
type Variant = "primary" | "secondary" | "ghost" | "invert" | "invertOutline";
|
||||
|
||||
// Text colors are chosen to meet WCAG AA (4.5:1) against every background
|
||||
// tone the button appears on — see components/Section.tsx for the tones.
|
||||
// tone the button appears on - see components/Section.tsx for the tones.
|
||||
const variants: Record<Variant, string> = {
|
||||
primary: "bg-anouma-mauve-dark text-white hover:bg-anouma-plum",
|
||||
secondary:
|
||||
|
||||
@@ -12,11 +12,11 @@ export async function Footer() {
|
||||
<div className="mx-auto max-w-6xl px-6 py-16 sm:px-8 lg:px-12">
|
||||
<div className="grid gap-12 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<Link href="/" className="font-serif text-2xl font-semibold tracking-[0.08em]">
|
||||
<Link href="/" className="font-antropos text-2xl font-semibold tracking-[0.08em]">
|
||||
{siteConfig.name.toUpperCase()}
|
||||
</Link>
|
||||
<p className="mt-4 max-w-xs text-sm leading-relaxed text-anouma-cream-light/90">
|
||||
Räume für Verbindung – mit dir selbst, miteinander und mit der Natur.
|
||||
Räume für Verbindung - mit dir selbst, miteinander und mit der Natur.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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
|
||||
* 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
|
||||
* input/textarea props through, so controlled and uncontrolled forms,
|
||||
* 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> & {
|
||||
label: string;
|
||||
id: string;
|
||||
/** Keeps the label for screen readers only — for compact inline fields identified by a placeholder instead (e.g. ResendVerificationButton's email prompt). */
|
||||
/** Keeps the label for screen readers only - for compact inline fields identified by a placeholder instead (e.g. ResendVerificationButton's email prompt). */
|
||||
hideLabel?: boolean;
|
||||
wrapperClassName?: string;
|
||||
};
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ export function Hero({ eyebrow, title, children, actions, mood = "rose", imageSr
|
||||
<div className="relative mx-auto aspect-[4/5] w-full max-w-md animate-fade-in [animation-delay:200ms] lg:max-w-none">
|
||||
<ImagePlaceholder
|
||||
mood={mood}
|
||||
label="Anouma – warme, naturverbundene Begleitung"
|
||||
label="Anouma - warme, naturverbundene Begleitung"
|
||||
src={imageSrc}
|
||||
alt={imageAlt}
|
||||
priority
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ImageMood } from "@/lib/angebote";
|
||||
/**
|
||||
* Renders a photo when `src` is provided, otherwise falls back to a warm,
|
||||
* organic gradient placeholder in the given mood. Pages should always pass
|
||||
* `src` once real photography exists — no other prop changes are needed.
|
||||
* `src` once real photography exists - no other prop changes are needed.
|
||||
*/
|
||||
|
||||
const moodGradients: Record<ImageMood, string> = {
|
||||
|
||||
@@ -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">
|
||||
<Link
|
||||
href="/"
|
||||
className="font-serif text-2xl font-semibold tracking-[0.08em] text-anouma-mauve-dark"
|
||||
className="font-antropos text-2xl font-semibold tracking-[0.08em] text-anouma-mauve-dark"
|
||||
>
|
||||
{siteConfig.name.toUpperCase()}
|
||||
</Link>
|
||||
|
||||
@@ -16,7 +16,7 @@ type PageHeaderProps = {
|
||||
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({
|
||||
eyebrow,
|
||||
title,
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ReactNode } from "react";
|
||||
export function PlaceholderNote({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-anouma-dustyrose/50 bg-anouma-rose-pale/20 px-5 py-4 text-sm leading-relaxed text-anouma-plum">
|
||||
<span className="font-medium">Platzhalter – noch zu ergänzen:</span> {children}
|
||||
<span className="font-medium">Platzhalter - noch zu ergänzen:</span> {children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { analyticsEventLabel } from "@/lib/analytics/events";
|
||||
import { AnalyticsChart } from "./AnalyticsChart";
|
||||
import styles from "./AdminAnalyticsView.module.css";
|
||||
|
||||
const DEVICE_LABELS: Record<string, string> = { mobile: "Mobile", desktop: "Desktop", tablet: "Tablet" };
|
||||
const DEVICE_LABELS: Record<string, string> = { mobile: "Handy", desktop: "Computer", tablet: "Tablet" };
|
||||
const BROWSER_LABELS: Record<string, string> = { chrome: "Chrome", firefox: "Firefox", safari: "Safari", edge: "Edge", other: "Sonstige" };
|
||||
|
||||
function fmtInt(n: number): string {
|
||||
@@ -73,7 +73,7 @@ export async function AdminAnalyticsView({ initPageResult }: AdminViewServerProp
|
||||
if (!canViewAnalytics({ req })) {
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
@@ -102,8 +102,8 @@ export async function AdminAnalyticsView({ initPageResult }: AdminViewServerProp
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<h1 className={styles.title}>Analytics</h1>
|
||||
<p className={styles.subtitle}>Datensparsame Reichweitenmessung — keine personenbezogenen Profile. Rohdaten werden {maxDaysBack} Tage aufbewahrt.</p>
|
||||
<h1 className={styles.title}>Auswertung</h1>
|
||||
<p className={styles.subtitle}>Datensparsame Reichweitenmessung ohne personenbezogene Profile. Rohdaten werden {maxDaysBack} Tage aufbewahrt.</p>
|
||||
|
||||
<div className={styles.filterBar}>
|
||||
{RANGE_OPTIONS.map((option) => (
|
||||
@@ -132,12 +132,12 @@ export async function AdminAnalyticsView({ initPageResult }: AdminViewServerProp
|
||||
|
||||
{RANGE_OPTIONS.some((o) => typeof o.days === "number" && o.days > maxDaysBack) && (
|
||||
<p style={{ color: "#888", fontSize: 12, marginTop: -16, marginBottom: 20 }}>
|
||||
Zeiträume sind auf die konfigurierte Rohdaten-Aufbewahrung von {maxDaysBack} Tagen begrenzt (siehe „Analytics-Einstellungen“).
|
||||
Zeiträume sind auf die konfigurierte Rohdaten-Aufbewahrung von {maxDaysBack} Tagen begrenzt (siehe „Auswertungs-Einstellungen“).
|
||||
</p>
|
||||
)}
|
||||
|
||||
<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="Eindeutige Besucher" value={current.uniqueVisitors} delta={delta("uniqueVisitors")} />
|
||||
<Tile label="Ø Seiten / Session" value={current.avgPagesPerSession} delta={delta("avgPagesPerSession")} />
|
||||
@@ -146,7 +146,7 @@ export async function AdminAnalyticsView({ initPageResult }: AdminViewServerProp
|
||||
</div>
|
||||
|
||||
<div className={styles.chartCard}>
|
||||
<h2 className={styles.sectionTitle}>Seitenaufrufe & Sessions</h2>
|
||||
<h2 className={styles.sectionTitle}>Seitenaufrufe und Besuche</h2>
|
||||
<AnalyticsChart series={current.series} />
|
||||
</div>
|
||||
|
||||
@@ -155,7 +155,7 @@ export async function AdminAnalyticsView({ initPageResult }: AdminViewServerProp
|
||||
<EntryTable title="Einstiegsseiten" entries={topEntries(current.entryPaths)} />
|
||||
<EntryTable title="Ausstiegsseiten" entries={topEntries(current.exitPaths)} />
|
||||
<EntryTable
|
||||
title="Events"
|
||||
title="Ereignisse"
|
||||
entries={topEntries(current.eventCounts).map(([name, count]) => [analyticsEventLabel(name), count])}
|
||||
/>
|
||||
<EntryTable
|
||||
@@ -169,25 +169,25 @@ export async function AdminAnalyticsView({ initPageResult }: AdminViewServerProp
|
||||
</div>
|
||||
|
||||
<div className={styles.privacyPanel}>
|
||||
<h3>Analytics & Datenschutz</h3>
|
||||
<h3>Auswertung und Datenschutz</h3>
|
||||
<p>Erhobene Daten:</p>
|
||||
<ul>
|
||||
<li>Seitenaufrufe und Events (siehe „Events“-Tabelle)</li>
|
||||
<li>Gerätetyp (Mobile/Desktop/Tablet)</li>
|
||||
<li>Seitenaufrufe und Ereignisse (siehe Tabelle „Ereignisse“)</li>
|
||||
<li>Gerätetyp (Handy/Computer/Tablet)</li>
|
||||
<li>Browser-Familie</li>
|
||||
<li>Referrer-Hostname (nur die Domain, keine vollständige URL)</li>
|
||||
</ul>
|
||||
<p>
|
||||
Keine Namen, E-Mail-Adressen, Telefonnummern, vollständigen IP-Adressen oder Formularinhalte. Kein Fingerprinting, keine
|
||||
dauerhaften individuellen Nutzerprofile — Besucher werden über einen täglich rotierenden, nicht zurückverfolgbaren Hash
|
||||
dauerhaften individuellen Nutzerprofile - Besucher werden über einen täglich rotierenden, nicht zurückverfolgbaren Hash
|
||||
gruppiert.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Aufbewahrung:</strong> {settings.rawRetentionDays} Tage für Rohdaten, {settings.aggregateRetentionMonths} Monate für
|
||||
aggregierte Tagesstatistiken (konfigurierbar unter „Analytics-Einstellungen“).
|
||||
aggregierte Tagesstatistiken (konfigurierbar unter „Auswertungs-Einstellungen“).
|
||||
</p>
|
||||
<p style={{ fontStyle: "italic", color: "#777" }}>
|
||||
Rechtliche Einordnung (z. B. ob dies für eure Website ausreichend ist) ist keine technische Aussage dieses Panels — bitte im
|
||||
Rechtliche Einordnung (z. B. ob dies für eure Website ausreichend ist) ist keine technische Aussage dieses Panels - bitte im
|
||||
Zweifel rechtlich prüfen lassen, wie auch beim Impressum/Datenschutztext der Website.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -71,7 +71,7 @@ export async function AdminCalendarView({ initPageResult }: AdminViewServerProps
|
||||
|
||||
// Local API (overrideAccess: true by default) so this can see
|
||||
// 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 calendarFeedConfigured = Boolean((currentUser as { calendarFeedTokenHash?: string | null } | null)?.calendarFeedTokenHash);
|
||||
const itemsByDay = new Map<string, AdminCalendarItem[]>();
|
||||
@@ -181,7 +181,7 @@ export async function AdminCalendarView({ initPageResult }: AdminViewServerProps
|
||||
</span>
|
||||
<strong>{entry.title}</strong>
|
||||
<span>
|
||||
{fmtTime(entry.startTime)}–{fmtTime(entry.endTime)}
|
||||
{fmtTime(entry.startTime)}-{fmtTime(entry.endTime)}
|
||||
</span>
|
||||
{entry.subtitle && <span>· {entry.subtitle}</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(() => ({}));
|
||||
if (!response.ok) {
|
||||
setError(data.error || "Der Video-Call konnte nicht gestartet werden.");
|
||||
setError(data.error || "Der Videoanruf konnte nicht gestartet werden.");
|
||||
return;
|
||||
}
|
||||
setUrl(data.testUrl);
|
||||
@@ -46,7 +46,7 @@ export function AdminCallButton({ eventSlug }: { eventSlug: string }) {
|
||||
disabled={loading}
|
||||
className="rounded-full bg-anouma-mauve-dark px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-anouma-plum disabled:cursor-wait disabled:opacity-60"
|
||||
>
|
||||
{loading ? "Test-Link wird erzeugt …" : copied ? "Test-Link kopiert" : "Call testen"}
|
||||
{loading ? "Test-Link wird erzeugt ..." : copied ? "Test-Link kopiert" : "Videoanruf testen"}
|
||||
</button>
|
||||
{url && !copied && (
|
||||
<input
|
||||
|
||||
@@ -19,7 +19,7 @@ export function AdminTestCallButton() {
|
||||
});
|
||||
const data = (await response.json().catch(() => ({}))) as { testUrl?: string; error?: string };
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -38,25 +38,14 @@ export function AdminTestCallButton() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: "0 8px 8px" }}>
|
||||
<div className="anouma-custom-nav-action">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void createTestCall()}
|
||||
disabled={loading}
|
||||
style={{
|
||||
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,
|
||||
}}
|
||||
className="anouma-custom-nav-button"
|
||||
>
|
||||
{loading ? "Test-Link wird erzeugt …" : copied ? "✓ Test-Link kopiert" : "▶ Test-Videoanruf"}
|
||||
{loading ? "Test-Link wird erzeugt ..." : copied ? "Test-Link kopiert" : "Videoanruf testen"}
|
||||
</button>
|
||||
{url && (
|
||||
<input
|
||||
@@ -65,10 +54,10 @@ export function AdminTestCallButton() {
|
||||
value={url}
|
||||
aria-label="Test-Videoanruf-Link"
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}`}
|
||||
/>
|
||||
<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>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
|
||||
@@ -3,27 +3,16 @@ import type { ServerProps } from "payload";
|
||||
import { canViewAnalytics } from "@/access";
|
||||
|
||||
export function AnalyticsNavLink({ user }: ServerProps) {
|
||||
// Payload's custom views/nav links have no declarative `access` option —
|
||||
// Payload's custom views/nav links have no declarative `access` option -
|
||||
// this component must gate itself (see also AdminAnalyticsView.tsx, which
|
||||
// re-checks the same thing for direct navigation to /admin/analytics).
|
||||
if (!canViewAnalytics({ req: { user } } as Parameters<typeof canViewAnalytics>[0])) return null;
|
||||
|
||||
return (
|
||||
<div style={{ padding: "0 8px 8px" }}>
|
||||
<Link
|
||||
href="/admin/analytics"
|
||||
style={{
|
||||
display: "block",
|
||||
padding: "8px 12px",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "#8b616d",
|
||||
textDecoration: "none",
|
||||
border: "1px solid #e2e2e2",
|
||||
}}
|
||||
>
|
||||
📊 Analytics
|
||||
<div className="anouma-custom-nav-item">
|
||||
<Link href="/admin/analytics" className="anouma-custom-nav-link">
|
||||
<span className="anouma-custom-nav-link__icon">03</span>
|
||||
<span>Auswertung</span>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -36,7 +36,7 @@ export function CalendarFeedPanel({ initiallyConfigured }: { initiallyConfigured
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
} 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.
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ export function CalendarFeedPanel({ initiallyConfigured }: { initiallyConfigured
|
||||
<h2 className={styles.heading}>Kalender synchronisieren</h2>
|
||||
<p className={styles.hint}>
|
||||
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>
|
||||
|
||||
{!url && (
|
||||
@@ -65,7 +65,7 @@ export function CalendarFeedPanel({ initiallyConfigured }: { initiallyConfigured
|
||||
|
||||
{url && (
|
||||
<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}>
|
||||
<input className={styles.urlInput} type="text" readOnly value={url} onFocus={(e) => e.currentTarget.select()} />
|
||||
<button type="button" className={styles.secondaryButton} onClick={copy}>
|
||||
|
||||
@@ -4,21 +4,10 @@ import { AdminTestCallButton } from "./AdminTestCallButton";
|
||||
export function CalendarNavLink() {
|
||||
return (
|
||||
<>
|
||||
<div style={{ padding: "0 8px 8px" }}>
|
||||
<Link
|
||||
href="/admin/kalender"
|
||||
style={{
|
||||
display: "block",
|
||||
padding: "8px 12px",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "#8b616d",
|
||||
textDecoration: "none",
|
||||
border: "1px solid #e2e2e2",
|
||||
}}
|
||||
>
|
||||
📅 Kalender
|
||||
<div className="anouma-custom-nav-item">
|
||||
<Link href="/admin/kalender" className="anouma-custom-nav-link">
|
||||
<span className="anouma-custom-nav-link__icon">02</span>
|
||||
<span>Kalender</span>
|
||||
</Link>
|
||||
</div>
|
||||
<AdminTestCallButton />
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import Link from "next/link";
|
||||
|
||||
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>
|
||||
<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">
|
||||
{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>
|
||||
</div>
|
||||
<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">
|
||||
<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">
|
||||
{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" })}
|
||||
</p>
|
||||
<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>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -119,7 +119,7 @@ export function BookingWidget({
|
||||
return;
|
||||
}
|
||||
setResult({ ok: true });
|
||||
// Event only — never the offer/date/message content.
|
||||
// Event only - never the offer/date/message content.
|
||||
track("booking_started");
|
||||
} catch {
|
||||
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
|
||||
// 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
|
||||
// and the "Erneut beitreten" link going back through this form again.
|
||||
function getOrCreateClientId(): string {
|
||||
@@ -20,7 +20,7 @@ function getOrCreateClientId(): string {
|
||||
localStorage.setItem(MEETING_CLIENT_ID_KEY, created);
|
||||
return created;
|
||||
} 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.
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export function RegisterForm({ slug }: { slug: string }) {
|
||||
if (status === "done") {
|
||||
return (
|
||||
<p className="rounded-2xl bg-anouma-cream-light p-5 text-sm leading-relaxed text-anouma-plum" role="status">
|
||||
Danke für deine Anmeldung – wir freuen uns auf dich!
|
||||
Danke für deine Anmeldung - wir freuen uns auf dich!
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
#!/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
|
||||
# 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
|
||||
# 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.
|
||||
#
|
||||
# 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
|
||||
# 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
|
||||
# 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
|
||||
# argument text, which the strict version regex below then validates.
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -41,13 +41,13 @@ fi
|
||||
ACTION="${1:-}"
|
||||
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"
|
||||
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"
|
||||
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
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# .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; }
|
||||
# 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
|
||||
# break parsing.
|
||||
env_set() {
|
||||
@@ -98,7 +98,7 @@ backup_database() {
|
||||
$COMPOSE exec -T postgres pg_dump -U "${POSTGRES_USER:-postgres}" "${POSTGRES_DB:-anouma}" | gzip > "$file"
|
||||
log_success "Backup created ($(du -h "$file" | cut -f1))."
|
||||
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
|
||||
local retention="${BACKUP_RETENTION_DAYS:-14}"
|
||||
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
|
||||
echo "$REGISTRY_PASSWORD" | docker login "${REGISTRY:-ghcr.io}" -u "$REGISTRY_USERNAME" --password-stdin >/dev/null 2>&1 \
|
||||
&& 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
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ set_image_version() {
|
||||
|
||||
# One full deploy attempt for a given version tag: pull → migrate → start →
|
||||
# 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() {
|
||||
local version="$1"
|
||||
set_image_version "$version"
|
||||
@@ -159,18 +159,18 @@ do_deploy() {
|
||||
|
||||
log_error "${version} FAILED post-deploy healthcheck."
|
||||
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
|
||||
|
||||
log_warn "Rolling back to ${previous_version} …"
|
||||
if deploy_version "$previous_version"; then
|
||||
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 "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."
|
||||
echo "$previous_version" > .current-version
|
||||
exit 1
|
||||
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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# Nginx Proxy Manager) that has its own Docker network.
|
||||
#
|
||||
# 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
|
||||
# alongside docker-compose.yml, no extra -f flags needed.
|
||||
#
|
||||
@@ -21,5 +21,5 @@ services:
|
||||
- anouma-network
|
||||
- npm-network
|
||||
# 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.
|
||||
|
||||
+5
-5
@@ -17,7 +17,7 @@ services:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-anouma}
|
||||
volumes:
|
||||
- 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
|
||||
# (e.g. with a GUI DB client) during development:
|
||||
# ports:
|
||||
@@ -29,7 +29,7 @@ services:
|
||||
retries: 20
|
||||
|
||||
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
|
||||
# ever pulls a specific, already-tested tag (see deploy.sh, which is the
|
||||
# only thing that ever changes ANOUMA_IMAGE in .env).
|
||||
@@ -40,7 +40,7 @@ services:
|
||||
env_file:
|
||||
- .env
|
||||
# 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.
|
||||
ports:
|
||||
- "127.0.0.1:${APP_PORT:-3000}:3000"
|
||||
@@ -51,7 +51,7 @@ services:
|
||||
condition: service_healthy
|
||||
# Container-level healthcheck is inherited from the image's own
|
||||
# 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
|
||||
# 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)
|
||||
# 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.
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
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
|
||||
* sensible, automatically derived value when a field is left empty (see
|
||||
* lib/seo/resolve.ts), per the "SEO fields are optional with fallbacks"
|
||||
@@ -12,7 +12,7 @@ export function seoFields(): Field {
|
||||
type: "collapsible",
|
||||
label: "SEO",
|
||||
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: [
|
||||
{
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ export function slugField(sourceField = "title"): Field {
|
||||
return {
|
||||
name: "slug",
|
||||
type: "text",
|
||||
label: "Slug (URL)",
|
||||
label: "URL-Kürzel",
|
||||
unique: true,
|
||||
index: true,
|
||||
admin: {
|
||||
|
||||
@@ -8,9 +8,9 @@ import { canViewAnalytics, isAdmin } from "@/access";
|
||||
*/
|
||||
export const AnalyticsSettings: GlobalConfig = {
|
||||
slug: "analytics-settings",
|
||||
label: "Analytics-Einstellungen",
|
||||
label: "Auswertungs-Einstellungen",
|
||||
admin: {
|
||||
description: "Aufbewahrungsdauer für Analytics-Rohdaten und aggregierte Tagesstatistiken.",
|
||||
description: "Aufbewahrungsdauer für Rohdaten und zusammengefasste Tagesstatistiken.",
|
||||
group: "Datenschutz",
|
||||
},
|
||||
access: {
|
||||
@@ -27,7 +27,7 @@ export const AnalyticsSettings: GlobalConfig = {
|
||||
min: 7,
|
||||
admin: {
|
||||
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
|
||||
* (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
|
||||
* enabled/disabled flags before a visitor has logged in anywhere.
|
||||
*
|
||||
* "necessary" deliberately has no `enabled` field at all, anywhere in this
|
||||
* schema — there is no toggle to accidentally switch off, so it can never
|
||||
* schema - there is no toggle to accidentally switch off, so it can never
|
||||
* be disabled through the admin UI (see requirement: technically required
|
||||
* functionality must not be disable-able by an admin).
|
||||
*/
|
||||
@@ -32,14 +32,14 @@ export const CookieSettings: GlobalConfig = {
|
||||
defaultValue: "1.0",
|
||||
admin: {
|
||||
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",
|
||||
name: "necessary",
|
||||
label: "Notwendig",
|
||||
admin: { description: "Immer aktiv — technisch erforderlich, kann nicht deaktiviert werden." },
|
||||
admin: { description: "Immer aktiv - technisch erforderlich, kann nicht deaktiviert werden." },
|
||||
fields: [
|
||||
{ name: "label", type: "text", label: "Bezeichnung", required: true, defaultValue: "Notwendig" },
|
||||
{
|
||||
@@ -93,7 +93,7 @@ export const CookieSettings: GlobalConfig = {
|
||||
defaultValue: true,
|
||||
admin: {
|
||||
description:
|
||||
"Globaler Schalter für das gesamte Analytics-System. Wenn deaktiviert, wird diese Kategorie im Banner nicht mehr angeboten und Analytics-Daten werden serverseitig abgelehnt — auch bei einer noch gespeicherten alten Zustimmung.",
|
||||
"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
|
||||
* 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
|
||||
* emits LocalBusiness structured data once real address data exists).
|
||||
*/
|
||||
@@ -21,7 +21,7 @@ export const SEOSettings: GlobalConfig = {
|
||||
slug: "seo-settings",
|
||||
label: "SEO-Einstellungen",
|
||||
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",
|
||||
},
|
||||
access: {
|
||||
@@ -40,7 +40,7 @@ export const SEOSettings: GlobalConfig = {
|
||||
type: "text",
|
||||
label: "Website-URL",
|
||||
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) => {
|
||||
if (!value) return true;
|
||||
@@ -58,8 +58,8 @@ export const SEOSettings: GlobalConfig = {
|
||||
name: "defaultOgImage",
|
||||
type: "upload",
|
||||
relationTo: "media",
|
||||
label: "Standard Social-Media-Bild",
|
||||
admin: { description: "Wird verwendet, wenn eine Seite kein eigenes Open-Graph-Bild hat." },
|
||||
label: "Standardbild für soziale Netzwerke",
|
||||
admin: { description: "Wird verwendet, wenn eine Seite kein eigenes Bild für soziale Netzwerke hat." },
|
||||
},
|
||||
{
|
||||
name: "robotsIndexable",
|
||||
@@ -67,14 +67,14 @@ export const SEOSettings: GlobalConfig = {
|
||||
label: "Website für Suchmaschinen sichtbar",
|
||||
defaultValue: true,
|
||||
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",
|
||||
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: [
|
||||
{
|
||||
name: "businessName",
|
||||
@@ -99,7 +99,7 @@ export const SEOSettings: GlobalConfig = {
|
||||
name: "openingHours",
|
||||
type: "array",
|
||||
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: [
|
||||
{
|
||||
type: "row",
|
||||
|
||||
@@ -18,7 +18,7 @@ function bump(map: Record<string, number>, key: string | null | undefined) {
|
||||
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][] {
|
||||
return Object.entries(map)
|
||||
.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
|
||||
* live queries). Kept side-effect free and DB-agnostic so both callers
|
||||
* (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
|
||||
* analytics-events into analytics-daily-stats, THEN purges raw events older
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export async function runAnalyticsCleanup(payload: Payload) {
|
||||
@@ -60,7 +60,7 @@ export async function runAnalyticsCleanup(payload: Payload) {
|
||||
{ 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,
|
||||
});
|
||||
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
|
||||
/**
|
||||
* Public developer API + consent storage for the analytics system. Importing
|
||||
* this module has zero side effects — no network call, nothing initialized
|
||||
* — until `track()` is actually called, and even then it's a no-op unless
|
||||
* this module has zero side effects - no network call, nothing initialized
|
||||
* - until `track()` is actually called, and even then it's a no-op unless
|
||||
* consent was actually granted. Callers never check consent themselves:
|
||||
*
|
||||
* track("booking_started");
|
||||
*
|
||||
* The CMS-configured part of consent (whether the "statistics" category is
|
||||
* enabled at all, and the current consent version) is pushed in once by
|
||||
* components/Analytics.tsx via setAnalyticsConfig() — see that file. Until
|
||||
* components/Analytics.tsx via setAnalyticsConfig() - see that file. Until
|
||||
* that happens, `cmsConfig` is null and track() fails safe (no-op), so a
|
||||
* component whose effects happen to run before Analytics.tsx's can never
|
||||
* accidentally send data before the live config is known.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Central, extensible registry of known analytics event names. Adding a new
|
||||
* event elsewhere in the app never requires a migration — the database
|
||||
* column (collections/AnalyticsEvents.ts#eventName) is free-text — this
|
||||
* event elsewhere in the app never requires a migration - the database
|
||||
* column (collections/AnalyticsEvents.ts#eventName) is free-text - this
|
||||
* list exists purely so `track()` calls get autocomplete/type-checking and
|
||||
* the dashboard can show a nice German label instead of a raw event name.
|
||||
*
|
||||
@@ -18,7 +18,7 @@ export const 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 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
|
||||
* 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
|
||||
* has already been purged. Also computes the matching "previous period" for
|
||||
* comparison (same length, immediately preceding).
|
||||
|
||||
@@ -2,14 +2,14 @@ import { createHmac } from "node:crypto";
|
||||
|
||||
/**
|
||||
* 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)
|
||||
* 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
|
||||
* 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
|
||||
* 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
|
||||
* the most recent event from the same `visitorHash` within the last 30
|
||||
* minutes and reuses its sessionId, or mints a new one. One extra indexed
|
||||
* read per tracked request — acceptable at this site's traffic volume, and
|
||||
* read per tracked request - acceptable at this site's traffic volume, and
|
||||
* simpler/safer than trying to hold a lock across an unauthenticated,
|
||||
* high-frequency public endpoint (unlike lib/booking/lock.ts's advisory
|
||||
* lock, an occasional extra spurious session here has no correctness
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 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
|
||||
* precise (e.g. WebView vs. real browser); good enough for the "device
|
||||
* type" / "browser" breakdown in the Analytics dashboard.
|
||||
@@ -19,7 +19,7 @@ export function classifyDevice(userAgent: string): DeviceType {
|
||||
export function classifyBrowser(userAgent: string): BrowserFamily {
|
||||
const ua = userAgent.toLowerCase();
|
||||
// Order matters: Edge/Chrome UAs also contain "safari"; Chrome UAs also
|
||||
// contain "chrome" but so do Edge/Opera — check the most specific tokens first.
|
||||
// contain "chrome" but so do Edge/Opera - check the most specific tokens first.
|
||||
if (/edg\//.test(ua)) return "edge";
|
||||
if (/chrome\/|crios\//.test(ua)) return "chrome";
|
||||
if (/firefox\/|fxios\//.test(ua)) return "firefox";
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@ export type ImageMood =
|
||||
| "peach";
|
||||
|
||||
// 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> = {
|
||||
prozessbegleitung: "plum",
|
||||
"doula-begleitung": "dustyrose",
|
||||
@@ -50,7 +50,7 @@ const categoryHref: Record<string, string> = {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function groupOffersByCategory(offers: Offer[]): OfferCategoryGroup[] {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 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
|
||||
* external store; resets on deploy/restart, which is an acceptable
|
||||
* 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 {
|
||||
// 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`
|
||||
// *appends* the real client IP to any X-Forwarded-For it already
|
||||
// received rather than replacing it, so a directly-connecting client
|
||||
// could prepend arbitrary spoofed entries of their own. Only the
|
||||
// right-most (last) entry — the one the trusted proxy itself appended —
|
||||
// right-most (last) entry - the one the trusted proxy itself appended -
|
||||
// is trustworthy; taking the first entry would let a client bypass this
|
||||
// rate limit entirely by sending a fake X-Forwarded-For header.
|
||||
const forwarded = request.headers.get("x-forwarded-for");
|
||||
|
||||
@@ -3,13 +3,13 @@ import { createHash, randomBytes } from "node:crypto";
|
||||
/** How long a freshly issued email-verification link stays valid. */
|
||||
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 {
|
||||
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
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
// accessors, and not manual UTC+1/+2 arithmetic), so the server process's
|
||||
// own timezone (this app runs in UTC in production) never leaks into the
|
||||
// computed availability — see lib/calendar/timezone.ts for the same pattern.
|
||||
// computed availability - see lib/calendar/timezone.ts for the same pattern.
|
||||
function dateKey(date: Date): string {
|
||||
const { year, month, day } = berlinParts(date);
|
||||
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 {
|
||||
// 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.
|
||||
const utc = new Date(Date.UTC(year, month - 1, day + delta));
|
||||
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
|
||||
* 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.
|
||||
*/
|
||||
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