This commit is contained in:
Timo Knuth 2026-03-12 14:23:32 +01:00
parent d93f43bf01
commit 0084c5f05b
60 changed files with 7526 additions and 7522 deletions

View File

@ -1,47 +1,47 @@
# ============================================= # =============================================
# DATABASE (PostgreSQL) # DATABASE (PostgreSQL)
# ============================================= # =============================================
POSTGRES_DB="innungsapp" POSTGRES_DB="innungsapp"
POSTGRES_USER="innungsapp" POSTGRES_USER="innungsapp"
POSTGRES_PASSWORD="innungsapp" POSTGRES_PASSWORD="innungsapp"
DATABASE_URL="postgresql://innungsapp:innungsapp@localhost:5432/innungsapp?schema=public" DATABASE_URL="postgresql://innungsapp:innungsapp@localhost:5432/innungsapp?schema=public"
# ============================================= # =============================================
# BETTER-AUTH # BETTER-AUTH
# ============================================= # =============================================
BETTER_AUTH_SECRET="change-me-to-a-random-32-char-string" BETTER_AUTH_SECRET="change-me-to-a-random-32-char-string"
BETTER_AUTH_URL="http://localhost:3000" BETTER_AUTH_URL="http://localhost:3000"
# ============================================= # =============================================
# EMAIL (SMTP for magic links & invitations) # EMAIL (SMTP for magic links & invitations)
# ============================================= # =============================================
EMAIL_FROM="noreply@innungsapp.de" EMAIL_FROM="noreply@innungsapp.de"
SMTP_HOST="smtp.example.com" SMTP_HOST="smtp.example.com"
SMTP_PORT="587" SMTP_PORT="587"
SMTP_SECURE="false" SMTP_SECURE="false"
SMTP_USER="" SMTP_USER=""
SMTP_PASS="" SMTP_PASS=""
# ============================================= # =============================================
# ADMIN APP (Next.js) # ADMIN APP (Next.js)
# ============================================= # =============================================
NEXT_PUBLIC_APP_URL="http://localhost:3000" NEXT_PUBLIC_APP_URL="http://localhost:3000"
NEXT_PUBLIC_POSTHOG_KEY="" NEXT_PUBLIC_POSTHOG_KEY=""
NEXT_PUBLIC_POSTHOG_HOST="https://us.i.posthog.com" NEXT_PUBLIC_POSTHOG_HOST="https://us.i.posthog.com"
# ============================================= # =============================================
# SUPERADMIN SEED # SUPERADMIN SEED
# ============================================= # =============================================
SUPERADMIN_EMAIL="superadmin@innungsapp.de" SUPERADMIN_EMAIL="superadmin@innungsapp.de"
SUPERADMIN_PASSWORD="change-me-strong-password" SUPERADMIN_PASSWORD="change-me-strong-password"
# ============================================= # =============================================
# MOBILE APP (Expo) # MOBILE APP (Expo)
# ============================================= # =============================================
EXPO_PUBLIC_API_URL="http://localhost:3000" EXPO_PUBLIC_API_URL="http://localhost:3000"
# ============================================= # =============================================
# FILE UPLOADS # FILE UPLOADS
# ============================================= # =============================================
UPLOAD_DIR="./uploads" UPLOAD_DIR="./uploads"
UPLOAD_MAX_SIZE_MB="10" UPLOAD_MAX_SIZE_MB="10"

View File

@ -1,34 +1,34 @@
# ============================================= # =============================================
# Produktion — .env Vorlage # Produktion — .env Vorlage
# Kopieren als: innungsapp/.env # Kopieren als: innungsapp/.env
# ============================================= # =============================================
# Database (PostgreSQL) # Database (PostgreSQL)
POSTGRES_DB="innungsapp" POSTGRES_DB="innungsapp"
POSTGRES_USER="innungsapp" POSTGRES_USER="innungsapp"
POSTGRES_PASSWORD="change-this-db-password" POSTGRES_PASSWORD="change-this-db-password"
DATABASE_URL="postgresql://innungsapp:change-this-db-password@postgres:5432/innungsapp?schema=public" DATABASE_URL="postgresql://innungsapp:change-this-db-password@postgres:5432/innungsapp?schema=public"
# Auth — UNBEDINGT ändern! # Auth — UNBEDINGT ändern!
BETTER_AUTH_SECRET="min-32-zeichen-langer-zufalls-string" BETTER_AUTH_SECRET="min-32-zeichen-langer-zufalls-string"
BETTER_AUTH_URL="https://yourdomain.com" BETTER_AUTH_URL="https://yourdomain.com"
# Email (SMTP) # Email (SMTP)
EMAIL_FROM="noreply@yourdomain.com" EMAIL_FROM="noreply@yourdomain.com"
SMTP_HOST="smtp.example.com" SMTP_HOST="smtp.example.com"
SMTP_PORT="587" SMTP_PORT="587"
SMTP_SECURE="false" SMTP_SECURE="false"
SMTP_USER="user@example.com" SMTP_USER="user@example.com"
SMTP_PASS="your-smtp-password" SMTP_PASS="your-smtp-password"
# Öffentliche URLs # Öffentliche URLs
NEXT_PUBLIC_APP_URL="https://yourdomain.com" NEXT_PUBLIC_APP_URL="https://yourdomain.com"
NEXT_PUBLIC_POSTHOG_KEY="" NEXT_PUBLIC_POSTHOG_KEY=""
NEXT_PUBLIC_POSTHOG_HOST="https://us.i.posthog.com" NEXT_PUBLIC_POSTHOG_HOST="https://us.i.posthog.com"
# Superadmin Seed # Superadmin Seed
SUPERADMIN_EMAIL="superadmin@yourdomain.com" SUPERADMIN_EMAIL="superadmin@yourdomain.com"
SUPERADMIN_PASSWORD="change-this-superadmin-password" SUPERADMIN_PASSWORD="change-this-superadmin-password"
# Uploads # Uploads
UPLOAD_MAX_SIZE_MB="10" UPLOAD_MAX_SIZE_MB="10"

72
innungsapp/.gitignore vendored
View File

@ -1,36 +1,36 @@
# Dependencies # Dependencies
node_modules node_modules
.pnp .pnp
.pnp.js .pnp.js
# Build outputs # Build outputs
.next .next
dist dist
build build
out out
# Turbo # Turbo
.turbo .turbo
# Environment # Environment
.env .env
.env.local .env.local
.env.production .env.production
.env.staging .env.staging
# Uploads (local file storage) # Uploads (local file storage)
apps/admin/uploads/ apps/admin/uploads/
# Expo # Expo
apps/mobile/.expo apps/mobile/.expo
apps/mobile/android apps/mobile/android
apps/mobile/ios apps/mobile/ios
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Editor # Editor
.vscode .vscode
.idea .idea
*.swp *.swp

View File

@ -1,327 +1,327 @@
# InnungsApp # InnungsApp
Digitale Plattform fuer Innungen mit Admin-Dashboard (Next.js) und Mobile App (Expo). Digitale Plattform fuer Innungen mit Admin-Dashboard (Next.js) und Mobile App (Expo).
## Stack ## Stack
| Layer | Technology | | Layer | Technology |
|---|---| |---|---|
| Monorepo | pnpm Workspaces + Turborepo | | Monorepo | pnpm Workspaces + Turborepo |
| Admin Dashboard | Next.js 15 (App Router) | | Admin Dashboard | Next.js 15 (App Router) |
| Mobile App | Expo + React Native | | Mobile App | Expo + React Native |
| API | tRPC v11 | | API | tRPC v11 |
| Auth | better-auth (magic links + credential login) | | Auth | better-auth (magic links + credential login) |
| Database | PostgreSQL + Prisma ORM (`jsonb` fuer Landing-Page-Felder) | | Database | PostgreSQL + Prisma ORM (`jsonb` fuer Landing-Page-Felder) |
| Styling | Tailwind CSS (admin), NativeWind (mobile) | | Styling | Tailwind CSS (admin), NativeWind (mobile) |
## Projektstruktur ## Projektstruktur
```text ```text
innungsapp/ innungsapp/
|-- apps/ |-- apps/
| |-- admin/ | |-- admin/
| `-- mobile/ | `-- mobile/
|-- packages/ |-- packages/
| `-- shared/ | `-- shared/
| `-- prisma/ | `-- prisma/
|-- docker-compose.yml |-- docker-compose.yml
`-- README.md `-- README.md
``` ```
## Local Setup ## Local Setup
Port-Hinweis: Port-Hinweis:
- Ohne Docker (lokales `pnpm dev`): App typischerweise auf `http://localhost:3000` - Ohne Docker (lokales `pnpm dev`): App typischerweise auf `http://localhost:3000`
- Mit Docker Compose: App auf `http://localhost:3010` (Container-intern weiter `3000`) - Mit Docker Compose: App auf `http://localhost:3010` (Container-intern weiter `3000`)
### Voraussetzungen ### Voraussetzungen
- Node.js >= 20 - Node.js >= 20
- pnpm >= 9 - pnpm >= 9
- SMTP-Zugang (fuer Einladungen und Magic Links) - SMTP-Zugang (fuer Einladungen und Magic Links)
### 1. Abhaengigkeiten installieren ### 1. Abhaengigkeiten installieren
```bash ```bash
pnpm install pnpm install
``` ```
### 2. Umgebungsvariablen setzen (Admin lokal) ### 2. Umgebungsvariablen setzen (Admin lokal)
```bash ```bash
cp .env.example .env cp .env.example .env
``` ```
Danach `.env` anpassen (mindestens `DATABASE_URL`, `BETTER_AUTH_SECRET`, SMTP-Werte). Danach `.env` anpassen (mindestens `DATABASE_URL`, `BETTER_AUTH_SECRET`, SMTP-Werte).
### 3. DB vorbereiten (lokal) ### 3. DB vorbereiten (lokal)
Lokale PostgreSQL-DB starten (nur falls noch nicht aktiv): Lokale PostgreSQL-DB starten (nur falls noch nicht aktiv):
```bash ```bash
docker compose up -d postgres docker compose up -d postgres
``` ```
Prisma vorbereiten: Prisma vorbereiten:
```bash ```bash
pnpm db:generate pnpm db:generate
pnpm db:push pnpm db:push
``` ```
Optional Demo-Daten: Optional Demo-Daten:
```bash ```bash
pnpm db:seed pnpm db:seed
pnpm db:seed-superadmin pnpm db:seed-superadmin
``` ```
### 4. Entwicklung starten ### 4. Entwicklung starten
```bash ```bash
pnpm --filter @innungsapp/admin dev pnpm --filter @innungsapp/admin dev
pnpm --filter @innungsapp/mobile dev pnpm --filter @innungsapp/mobile dev
``` ```
Oder parallel: Oder parallel:
```bash ```bash
pnpm dev pnpm dev
``` ```
## Production Deployment (Docker, Admin) ## Production Deployment (Docker, Admin)
Dieser Abschnitt ist der verbindliche Weg fuer den Productive-Server. Dieser Abschnitt ist der verbindliche Weg fuer den Productive-Server.
### Voraussetzungen ### Voraussetzungen
- Linux Server mit Docker + Docker Compose - Linux Server mit Docker + Docker Compose
- DNS-Eintrag auf den Server - DNS-Eintrag auf den Server
- SMTP-Zugangsdaten - SMTP-Zugangsdaten
- Reverse Proxy (z. B. Nginx) fuer HTTPS - Reverse Proxy (z. B. Nginx) fuer HTTPS
### 1. Repository klonen ### 1. Repository klonen
```bash ```bash
git clone <repo-url> git clone <repo-url>
cd innungsapp cd innungsapp
``` ```
### 2. Production-Env anlegen ### 2. Production-Env anlegen
```bash ```bash
cp .env.production.example .env cp .env.production.example .env
``` ```
Pflichtwerte in `.env`: Pflichtwerte in `.env`:
- `DATABASE_URL` (PostgreSQL DSN, z. B. `postgresql://innungsapp:...@postgres:5432/innungsapp?schema=public`) - `DATABASE_URL` (PostgreSQL DSN, z. B. `postgresql://innungsapp:...@postgres:5432/innungsapp?schema=public`)
- `POSTGRES_DB` - `POSTGRES_DB`
- `POSTGRES_USER` - `POSTGRES_USER`
- `POSTGRES_PASSWORD` - `POSTGRES_PASSWORD`
- `BETTER_AUTH_SECRET` (mindestens 32 Zeichen) - `BETTER_AUTH_SECRET` (mindestens 32 Zeichen)
- `BETTER_AUTH_URL` (z. B. `https://app.deine-innung.de`) - `BETTER_AUTH_URL` (z. B. `https://app.deine-innung.de`)
- `NEXT_PUBLIC_APP_URL` (gleich wie `BETTER_AUTH_URL`) - `NEXT_PUBLIC_APP_URL` (gleich wie `BETTER_AUTH_URL`)
- `EMAIL_FROM` - `EMAIL_FROM`
- `SMTP_HOST` - `SMTP_HOST`
- `SMTP_PORT` - `SMTP_PORT`
- `SMTP_SECURE` - `SMTP_SECURE`
- `SMTP_USER` - `SMTP_USER`
- `SMTP_PASS` - `SMTP_PASS`
- `SUPERADMIN_EMAIL` - `SUPERADMIN_EMAIL`
- `SUPERADMIN_PASSWORD` - `SUPERADMIN_PASSWORD`
### 3. Container bauen und starten ### 3. Container bauen und starten
```bash ```bash
docker compose up -d --build docker compose up -d --build
``` ```
Hinweis zum DB-Start: Hinweis zum DB-Start:
- Wenn Prisma-Migrationen vorhanden sind, wird `prisma migrate deploy` ausgefuehrt. - Wenn Prisma-Migrationen vorhanden sind, wird `prisma migrate deploy` ausgefuehrt.
- Wenn keine Migrationen vorhanden sind, wird einmalig `prisma db push` ausgefuehrt. - Wenn keine Migrationen vorhanden sind, wird einmalig `prisma db push` ausgefuehrt.
### 4. Healthcheck und Logs pruefen ### 4. Healthcheck und Logs pruefen
```bash ```bash
docker compose logs -f admin docker compose logs -f admin
curl -fsS http://localhost:3010/api/health curl -fsS http://localhost:3010/api/health
``` ```
Erwartet: JSON mit `"status":"ok"`, z. B. Erwartet: JSON mit `"status":"ok"`, z. B.
```json ```json
{"status":"ok","timestamp":"2026-03-04T12:34:56.789Z"} {"status":"ok","timestamp":"2026-03-04T12:34:56.789Z"}
``` ```
### 5. Superadmin anlegen (nur beim ersten Start) ### 5. Superadmin anlegen (nur beim ersten Start)
```bash ```bash
docker compose exec -w /app admin node packages/shared/prisma/seed-superadmin.js docker compose exec -w /app admin node packages/shared/prisma/seed-superadmin.js
``` ```
Login-Daten kommen aus `.env`: Login-Daten kommen aus `.env`:
- E-Mail: `SUPERADMIN_EMAIL` - E-Mail: `SUPERADMIN_EMAIL`
- Passwort: `SUPERADMIN_PASSWORD` - Passwort: `SUPERADMIN_PASSWORD`
Hinweis: Hinweis:
- In `NODE_ENV=production` bricht der Seed ab, wenn `SUPERADMIN_PASSWORD` fehlt. - In `NODE_ENV=production` bricht der Seed ab, wenn `SUPERADMIN_PASSWORD` fehlt.
- In Entwicklung wird ohne `SUPERADMIN_PASSWORD` als Fallback `demo1234` genutzt. - In Entwicklung wird ohne `SUPERADMIN_PASSWORD` als Fallback `demo1234` genutzt.
- Der Seed ist idempotent (`upsert`) und kann bei Bedarf erneut ausgefuehrt werden. - Der Seed ist idempotent (`upsert`) und kann bei Bedarf erneut ausgefuehrt werden.
### 6. HTTPS (Reverse Proxy) ### 6. HTTPS (Reverse Proxy)
Nginx sollte auf `localhost:3010` weiterleiten und TLS terminieren. Nginx sollte auf `localhost:3010` weiterleiten und TLS terminieren.
Beispiel: Beispiel:
```nginx ```nginx
server { server {
listen 80; listen 80;
server_name app.deine-innung.de; server_name app.deine-innung.de;
return 301 https://$host$request_uri; return 301 https://$host$request_uri;
} }
server { server {
listen 443 ssl; listen 443 ssl;
server_name app.deine-innung.de; server_name app.deine-innung.de;
ssl_certificate /etc/letsencrypt/live/app.deine-innung.de/fullchain.pem; ssl_certificate /etc/letsencrypt/live/app.deine-innung.de/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.deine-innung.de/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/app.deine-innung.de/privkey.pem;
location / { location / {
proxy_pass http://localhost:3010; proxy_pass http://localhost:3010;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
} }
``` ```
### 7. Updates einspielen ### 7. Updates einspielen
```bash ```bash
git pull git pull
docker compose up -d --build docker compose up -d --build
docker compose logs -f admin docker compose logs -f admin
``` ```
### 8. Backup und Restore (Docker Volumes) ### 8. Backup und Restore (Docker Volumes)
Vorher die exakten Volumenamen pruefen: Vorher die exakten Volumenamen pruefen:
```bash ```bash
docker volume ls | grep pg_data docker volume ls | grep pg_data
docker volume ls | grep uploads_data docker volume ls | grep uploads_data
``` ```
Backup: Backup:
```bash ```bash
mkdir -p backups mkdir -p backups
docker run --rm \ docker run --rm \
-v innungsapp_pg_data:/volume \ -v innungsapp_pg_data:/volume \
-v "$(pwd)/backups:/backup" \ -v "$(pwd)/backups:/backup" \
alpine sh -c "tar czf /backup/pg_data_$(date +%F_%H%M).tar.gz -C /volume ." alpine sh -c "tar czf /backup/pg_data_$(date +%F_%H%M).tar.gz -C /volume ."
``` ```
Restore (nur bei gestoppter App): Restore (nur bei gestoppter App):
```bash ```bash
docker compose down docker compose down
docker run --rm \ docker run --rm \
-v innungsapp_pg_data:/volume \ -v innungsapp_pg_data:/volume \
-v "$(pwd)/backups:/backup" \ -v "$(pwd)/backups:/backup" \
alpine sh -c "rm -rf /volume/* && tar xzf /backup/<backup-file>.tar.gz -C /volume" alpine sh -c "rm -rf /volume/* && tar xzf /backup/<backup-file>.tar.gz -C /volume"
docker compose up -d docker compose up -d
``` ```
### 9. Verifizierte Kommandos (Stand 4. Maerz 2026) ### 9. Verifizierte Kommandos (Stand 4. Maerz 2026)
Die folgenden Befehle wurden in dieser Umgebung erfolgreich ausgefuehrt: Die folgenden Befehle wurden in dieser Umgebung erfolgreich ausgefuehrt:
```bash ```bash
# 1) Postgres starten (falls noch nicht aktiv) # 1) Postgres starten (falls noch nicht aktiv)
docker compose up -d postgres docker compose up -d postgres
# 2) Prisma Client generieren # 2) Prisma Client generieren
(cd packages/shared && npx prisma generate) (cd packages/shared && npx prisma generate)
# 3) Initiale PostgreSQL-Migration erstellen (einmalig) # 3) Initiale PostgreSQL-Migration erstellen (einmalig)
(cd packages/shared && \ (cd packages/shared && \
DATABASE_URL="postgresql://innungsapp:innungsapp@localhost:5432/innungsapp?schema=public" \ DATABASE_URL="postgresql://innungsapp:innungsapp@localhost:5432/innungsapp?schema=public" \
npx prisma migrate dev --name init_postgres --schema=prisma/schema.prisma --create-only) npx prisma migrate dev --name init_postgres --schema=prisma/schema.prisma --create-only)
# 4) Migration anwenden # 4) Migration anwenden
(cd packages/shared && \ (cd packages/shared && \
DATABASE_URL="postgresql://innungsapp:innungsapp@localhost:5432/innungsapp?schema=public" \ DATABASE_URL="postgresql://innungsapp:innungsapp@localhost:5432/innungsapp?schema=public" \
npx prisma migrate deploy --schema=prisma/schema.prisma) npx prisma migrate deploy --schema=prisma/schema.prisma)
# 5) Gesamtes Setup bauen und starten # 5) Gesamtes Setup bauen und starten
docker compose up -d --build docker compose up -d --build
# 6) Superadmin seeden (mit ENV-Werten) # 6) Superadmin seeden (mit ENV-Werten)
docker compose exec -e SUPERADMIN_EMAIL=superadmin@innungsapp.de \ docker compose exec -e SUPERADMIN_EMAIL=superadmin@innungsapp.de \
-e SUPERADMIN_PASSWORD='demo1234' \ -e SUPERADMIN_PASSWORD='demo1234' \
-w /app admin node packages/shared/prisma/seed-superadmin.js -w /app admin node packages/shared/prisma/seed-superadmin.js
# 7) Laufzeitstatus pruefen # 7) Laufzeitstatus pruefen
docker compose ps docker compose ps
docker compose logs --tail 80 admin docker compose logs --tail 80 admin
curl -fsS http://localhost:3010/api/health curl -fsS http://localhost:3010/api/health
``` ```
Optionale SQL-Verifikation (wurde ebenfalls erfolgreich getestet): Optionale SQL-Verifikation (wurde ebenfalls erfolgreich getestet):
```bash ```bash
# JSONB-Spalten pruefen # JSONB-Spalten pruefen
docker compose exec -T postgres psql -U innungsapp -d innungsapp -c \ docker compose exec -T postgres psql -U innungsapp -d innungsapp -c \
"SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'organizations' AND column_name IN ('landing_page_features','landing_page_footer') ORDER BY column_name;" "SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'organizations' AND column_name IN ('landing_page_features','landing_page_footer') ORDER BY column_name;"
# Seeded Superadmin pruefen # Seeded Superadmin pruefen
docker compose exec -T postgres psql -U innungsapp -d innungsapp -c \ docker compose exec -T postgres psql -U innungsapp -d innungsapp -c \
"SELECT u.email, u.role, u.email_verified, a.provider_id, (a.password IS NOT NULL) AS has_password FROM \"user\" u LEFT JOIN account a ON a.user_id = u.id AND a.provider_id = 'credential' WHERE u.email = 'superadmin@innungsapp.de';" "SELECT u.email, u.role, u.email_verified, a.provider_id, (a.password IS NOT NULL) AS has_password FROM \"user\" u LEFT JOIN account a ON a.user_id = u.id AND a.provider_id = 'credential' WHERE u.email = 'superadmin@innungsapp.de';"
``` ```
## Mobile Release (EAS) ## Mobile Release (EAS)
```bash ```bash
cd apps/mobile cd apps/mobile
eas build --platform all --profile production eas build --platform all --profile production
eas submit --platform all eas submit --platform all
``` ```
Wichtig: Wichtig:
- In `apps/mobile/eas.json` sind Submit-Placeholders vorhanden und muessen ersetzt werden. - In `apps/mobile/eas.json` sind Submit-Placeholders vorhanden und muessen ersetzt werden.
- Fuer Production darf keine API-URL auf `localhost` zeigen. - Fuer Production darf keine API-URL auf `localhost` zeigen.
## Troubleshooting ## Troubleshooting
### `migrate deploy` oder `db push` fehlschlaegt ### `migrate deploy` oder `db push` fehlschlaegt
- `DATABASE_URL` pruefen - `DATABASE_URL` pruefen
- `postgres` Container Healthcheck pruefen (`docker compose ps`) - `postgres` Container Healthcheck pruefen (`docker compose ps`)
- Logs: `docker compose logs -f admin` - Logs: `docker compose logs -f admin`
### Healthcheck liefert Fehler ### Healthcheck liefert Fehler
- Containerstatus: `docker compose ps` - Containerstatus: `docker compose ps`
- App-Logs lesen - App-Logs lesen
- Reverse Proxy testweise umgehen und direkt `http://localhost:3010/api/health` pruefen - Reverse Proxy testweise umgehen und direkt `http://localhost:3010/api/health` pruefen
### Login funktioniert nicht nach Seed ### Login funktioniert nicht nach Seed
- Seed-Command erneut ausfuehren - Seed-Command erneut ausfuehren
- In DB pruefen, ob `user` und `account` Eintraege fuer `superadmin@innungsapp.de` existieren - In DB pruefen, ob `user` und `account` Eintraege fuer `superadmin@innungsapp.de` existieren
## Weiterfuehrende Doku ## Weiterfuehrende Doku
- Produkt-Roadmap: `../ROADMAP.md` - Produkt-Roadmap: `../ROADMAP.md`
- Architektur: `../ARCHITECTURE.md` - Architektur: `../ARCHITECTURE.md`
- API Design: `../API_DESIGN.md` - API Design: `../API_DESIGN.md`

View File

@ -1,107 +1,110 @@
# ============================================= # =============================================
# Stage 1: Dependencies # Stage 1: Dependencies
# ============================================= # =============================================
FROM node:20-slim AS deps FROM node:20-slim AS deps
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
# Install OpenSSL for Prisma # Install OpenSSL for Prisma
RUN apt-get update && apt-get install -y openssl ca-certificates && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y openssl ca-certificates && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
# Copy workspace config files # Copy workspace config files
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
COPY apps/admin/package.json ./apps/admin/ COPY apps/admin/package.json ./apps/admin/
COPY packages/shared/package.json ./packages/shared/ COPY packages/shared/package.json ./packages/shared/
# Install all dependencies # Install all dependencies
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
# ============================================= # =============================================
# Stage 2: Build # Stage 2: Build
# ============================================= # =============================================
FROM node:20-slim AS builder FROM node:20-slim AS builder
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
# Install OpenSSL for Prisma # Install OpenSSL for Prisma
RUN apt-get update && apt-get install -y openssl ca-certificates && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y openssl ca-certificates && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/admin/node_modules ./apps/admin/node_modules COPY --from=deps /app/apps/admin/node_modules ./apps/admin/node_modules
COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules
COPY . . COPY . .
# Generate Prisma client for Alpine Linux # Generate Prisma client for Alpine Linux
RUN pnpm --filter @innungsapp/shared prisma:generate RUN pnpm --filter @innungsapp/shared prisma:generate
# Accept build arguments for environment variables # Accept build arguments for environment variables
ARG BETTER_AUTH_SECRET ARG BETTER_AUTH_SECRET
ARG BETTER_AUTH_URL ARG BETTER_AUTH_URL
ARG BETTER_AUTH_BASE_URL ARG BETTER_AUTH_BASE_URL
ARG NEXT_PUBLIC_APP_URL ARG NEXT_PUBLIC_APP_URL
# Build the admin app # Build the admin app
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
ENV DOCKER_BUILD=1 ENV DOCKER_BUILD=1
# Set environment variables from build args for Next.js build # Set environment variables from build args for Next.js build
ENV BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET ENV BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET
ENV BETTER_AUTH_URL=$BETTER_AUTH_URL ENV BETTER_AUTH_URL=$BETTER_AUTH_URL
ENV BETTER_AUTH_BASE_URL=$BETTER_AUTH_BASE_URL ENV BETTER_AUTH_BASE_URL=$BETTER_AUTH_BASE_URL
ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL
RUN pnpm --filter @innungsapp/admin build RUN pnpm --filter @innungsapp/admin build
# ============================================= # =============================================
# Stage 3: Production Runner # Stage 3: Production Runner
# ============================================= # =============================================
FROM node:20-slim AS runner FROM node:20-slim AS runner
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
# Install OpenSSL for Prisma # Install OpenSSL for Prisma
RUN apt-get update && apt-get install -y openssl ca-certificates wget && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y openssl ca-certificates wget && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
# Create non-root user # Create non-root user
RUN addgroup --system --gid 1001 nodejs && \ RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs adduser --system --uid 1001 nextjs
# Copy built output (standalone includes all necessary node_modules) # Copy built output (standalone includes all necessary node_modules)
COPY --from=builder /app/apps/admin/.next/standalone ./ COPY --from=builder /app/apps/admin/.next/standalone ./
COPY --from=builder /app/apps/admin/.next/static ./apps/admin/.next/static COPY --from=builder /app/apps/admin/.next/static ./apps/admin/.next/static
COPY --from=builder /app/apps/admin/public ./apps/admin/public COPY --from=builder /app/apps/admin/public ./apps/admin/public
# Copy Prisma schema + migrations for runtime migrations # Fix permissions so nextjs user can write to .next/cache at runtime
COPY --from=builder /app/packages/shared/prisma ./packages/shared/prisma RUN chown -R nextjs:nodejs /app/apps/admin/.next
# Copy Prisma Client package for runtime seed scripts. # Copy Prisma schema + migrations for runtime migrations
COPY --from=builder /app/node_modules/.pnpm/@prisma+client@5.22.0_prisma@5.22.0/node_modules/@prisma ./node_modules/@prisma COPY --from=builder /app/packages/shared/prisma ./packages/shared/prisma
COPY --from=builder /app/node_modules/.pnpm/@prisma+client@5.22.0_prisma@5.22.0/node_modules/.prisma ./node_modules/.prisma
# Copy Prisma Client package for runtime seed scripts.
# Copy Prisma Engine binaries directly to .next/server (where Next.js looks for them) COPY --from=builder /app/node_modules/.pnpm/@prisma+client@5.22.0_prisma@5.22.0/node_modules/@prisma ./node_modules/@prisma
COPY --from=builder /app/node_modules/.pnpm/@prisma+client@5.22.0_prisma@5.22.0/node_modules/.prisma/client/libquery_engine-debian-openssl-3.0.x.so.node /app/apps/admin/.next/server/ COPY --from=builder /app/node_modules/.pnpm/@prisma+client@5.22.0_prisma@5.22.0/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/node_modules/.pnpm/@prisma+client@5.22.0_prisma@5.22.0/node_modules/.prisma/client/schema.prisma /app/apps/admin/.next/server/
# Copy Prisma Engine binaries directly to .next/server (where Next.js looks for them)
# Install Prisma CLI globally for runtime migrations COPY --from=builder /app/node_modules/.pnpm/@prisma+client@5.22.0_prisma@5.22.0/node_modules/.prisma/client/libquery_engine-debian-openssl-3.0.x.so.node /app/apps/admin/.next/server/
RUN npm install -g prisma@5.22.0 COPY --from=builder /app/node_modules/.pnpm/@prisma+client@5.22.0_prisma@5.22.0/node_modules/.prisma/client/schema.prisma /app/apps/admin/.next/server/
# Create uploads directory # Install Prisma CLI globally for runtime migrations
RUN mkdir -p /app/uploads && chown nextjs:nodejs /app/uploads RUN npm install -g prisma@5.22.0
# Copy entrypoint # Create uploads directory
COPY --from=builder /app/apps/admin/docker-entrypoint.sh ./docker-entrypoint.sh RUN mkdir -p /app/uploads && chown nextjs:nodejs /app/uploads
RUN chmod +x ./docker-entrypoint.sh
# Copy entrypoint
USER nextjs COPY --from=builder /app/apps/admin/docker-entrypoint.sh ./docker-entrypoint.sh
RUN chmod +x ./docker-entrypoint.sh
EXPOSE 3000
USER nextjs
ENV PORT=3000
ENV HOSTNAME="0.0.0.0" EXPOSE 3000
ENTRYPOINT ["./docker-entrypoint.sh"] ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
ENTRYPOINT ["./docker-entrypoint.sh"]

View File

@ -1,70 +1,70 @@
'use server' 'use server'
import { auth, getSanitizedHeaders } from '@/lib/auth' import { auth, getSanitizedHeaders } from '@/lib/auth'
import { prisma } from '@innungsapp/shared' import { prisma } from '@innungsapp/shared'
// @ts-ignore // @ts-ignore
import { hashPassword } from 'better-auth/crypto' import { hashPassword } from 'better-auth/crypto'
export async function changePasswordAndDisableMustChange(prevState: any, formData: FormData) { export async function changePasswordAndDisableMustChange(prevState: any, formData: FormData) {
const newPassword = formData.get('newPassword') as string const newPassword = formData.get('newPassword') as string
const confirmPassword = formData.get('confirmPassword') as string const confirmPassword = formData.get('confirmPassword') as string
if (newPassword !== confirmPassword) { if (newPassword !== confirmPassword) {
return { success: false, error: 'Passwörter stimmen nicht überein.' } return { success: false, error: 'Passwörter stimmen nicht überein.' }
} }
if (newPassword.length < 8) { if (newPassword.length < 8) {
return { success: false, error: 'Das Passwort muss mindestens 8 Zeichen lang sein.' } return { success: false, error: 'Das Passwort muss mindestens 8 Zeichen lang sein.' }
} }
const sanitizedHeaders = await getSanitizedHeaders() const sanitizedHeaders = await getSanitizedHeaders()
const session = await auth.api.getSession({ headers: sanitizedHeaders }) const session = await auth.api.getSession({ headers: sanitizedHeaders })
if (!session?.user) { if (!session?.user) {
return { success: false, error: 'Nicht authentifiziert.' } return { success: false, error: 'Nicht authentifiziert.' }
} }
const userId = session.user.id const userId = session.user.id
// Hash and save new password directly — user is already authenticated so no old password needed // Hash and save new password directly — user is already authenticated so no old password needed
const newHash = await hashPassword(newPassword) const newHash = await hashPassword(newPassword)
const credAccount = await prisma.account.findFirst({ const credAccount = await prisma.account.findFirst({
where: { userId, providerId: 'credential' }, where: { userId, providerId: 'credential' },
}) })
if (credAccount) { if (credAccount) {
await prisma.account.update({ await prisma.account.update({
where: { id: credAccount.id }, where: { id: credAccount.id },
data: { password: newHash }, data: { password: newHash },
}) })
} else { } else {
await prisma.account.create({ await prisma.account.create({
data: { data: {
id: crypto.randomUUID(), id: crypto.randomUUID(),
accountId: userId, accountId: userId,
providerId: 'credential', providerId: 'credential',
userId, userId,
password: newHash, password: newHash,
}, },
}) })
} }
// Clear mustChangePassword // Clear mustChangePassword
await prisma.user.update({ await prisma.user.update({
where: { id: userId }, where: { id: userId },
data: { mustChangePassword: false }, data: { mustChangePassword: false },
}) })
// Sign out so the user logs in fresh with the new password // Sign out so the user logs in fresh with the new password
try { try {
await auth.api.signOut({ headers: sanitizedHeaders }) await auth.api.signOut({ headers: sanitizedHeaders })
} catch { } catch {
// ignore // ignore
} }
return { return {
success: true, success: true,
error: '', error: '',
redirectTo: `/login?message=password_changed&callbackUrl=/dashboard`, redirectTo: `/login?message=password_changed&callbackUrl=/dashboard`,
} }
} }

View File

@ -1,66 +1,66 @@
'use client' 'use client'
import { useEffect } from 'react' import { useEffect } from 'react'
import { useActionState } from 'react' import { useActionState } from 'react'
import { changePasswordAndDisableMustChange } from '../actions' import { changePasswordAndDisableMustChange } from '../actions'
export function ForcePasswordChange({ slug }: { slug: string }) { export function ForcePasswordChange({ slug }: { slug: string }) {
const [state, action, isPending] = useActionState(changePasswordAndDisableMustChange, { success: false, error: '', redirectTo: '' }) const [state, action, isPending] = useActionState(changePasswordAndDisableMustChange, { success: false, error: '', redirectTo: '' })
useEffect(() => { useEffect(() => {
if (state?.success && state?.redirectTo) { if (state?.success && state?.redirectTo) {
window.location.href = state.redirectTo window.location.href = state.redirectTo
} }
}, [state?.success, state?.redirectTo]) }, [state?.success, state?.redirectTo])
return ( return (
<div className="bg-white border rounded-xl p-8 max-w-md w-full shadow-sm"> <div className="bg-white border rounded-xl p-8 max-w-md w-full shadow-sm">
<div className="mb-6"> <div className="mb-6">
<h1 className="text-xl font-bold text-gray-900 mb-2">Passwort festlegen</h1> <h1 className="text-xl font-bold text-gray-900 mb-2">Passwort festlegen</h1>
<p className="text-gray-500 text-sm"> <p className="text-gray-500 text-sm">
Bitte vergeben Sie jetzt ein persönliches Passwort für Ihren Account. Bitte vergeben Sie jetzt ein persönliches Passwort für Ihren Account.
</p> </p>
</div> </div>
<form action={action} className="space-y-4"> <form action={action} className="space-y-4">
<input type="hidden" name="slug" value={slug} /> <input type="hidden" name="slug" value={slug} />
<div> <div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">Neues Passwort</label> <label className="block text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">Neues Passwort</label>
<input <input
name="newPassword" name="newPassword"
type="password" type="password"
required required
minLength={8} minLength={8}
placeholder="Mindestens 8 Zeichen" placeholder="Mindestens 8 Zeichen"
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500 outline-none transition-all" className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500 outline-none transition-all"
/> />
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">Passwort wiederholen</label> <label className="block text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">Passwort wiederholen</label>
<input <input
name="confirmPassword" name="confirmPassword"
type="password" type="password"
required required
minLength={8} minLength={8}
placeholder="••••••••" placeholder="••••••••"
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500 outline-none transition-all" className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500 outline-none transition-all"
/> />
</div> </div>
{state?.error && ( {state?.error && (
<p className="text-xs text-red-600 bg-red-50 p-2 rounded">{state?.error}</p> <p className="text-xs text-red-600 bg-red-50 p-2 rounded">{state?.error}</p>
)} )}
<button <button
type="submit" type="submit"
disabled={isPending} disabled={isPending}
className="w-full bg-gray-900 text-white py-2.5 rounded-lg text-sm font-medium hover:bg-gray-800 disabled:opacity-50 transition-all shadow-sm" className="w-full bg-gray-900 text-white py-2.5 rounded-lg text-sm font-medium hover:bg-gray-800 disabled:opacity-50 transition-all shadow-sm"
> >
{isPending ? 'Speichern...' : 'Passwort festlegen'} {isPending ? 'Speichern...' : 'Passwort festlegen'}
</button> </button>
</form> </form>
</div> </div>
) )
} }

View File

@ -1,213 +1,213 @@
import { prisma } from '@innungsapp/shared' import { prisma } from '@innungsapp/shared'
import { auth, getSanitizedHeaders } from '@/lib/auth' import { auth, getSanitizedHeaders } from '@/lib/auth'
import { headers } from 'next/headers' import { headers } from 'next/headers'
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import { MEMBER_STATUS_LABELS } from '@innungsapp/shared' import { MEMBER_STATUS_LABELS } from '@innungsapp/shared'
import { format } from 'date-fns' import { format } from 'date-fns'
import { de } from 'date-fns/locale' import { de } from 'date-fns/locale'
const STATUS_COLORS: Record<string, string> = { const STATUS_COLORS: Record<string, string> = {
aktiv: 'bg-green-100 text-green-700', aktiv: 'bg-green-100 text-green-700',
ruhend: 'bg-yellow-100 text-yellow-700', ruhend: 'bg-yellow-100 text-yellow-700',
ausgetreten: 'bg-red-100 text-red-700', ausgetreten: 'bg-red-100 text-red-700',
} }
export default async function MitgliederPage(props: { export default async function MitgliederPage(props: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }> searchParams: Promise<{ [key: string]: string | string[] | undefined }>
}) { }) {
const searchParams = await props.searchParams const searchParams = await props.searchParams
const search = typeof searchParams.q === 'string' ? searchParams.q : '' const search = typeof searchParams.q === 'string' ? searchParams.q : ''
const statusFilter = typeof searchParams.status === 'string' ? searchParams.status : undefined const statusFilter = typeof searchParams.status === 'string' ? searchParams.status : undefined
const sanitizedHeaders = await getSanitizedHeaders() const sanitizedHeaders = await getSanitizedHeaders()
const session = await auth.api.getSession({ headers: sanitizedHeaders }) const session = await auth.api.getSession({ headers: sanitizedHeaders })
if (!session?.user) redirect('/login') if (!session?.user) redirect('/login')
const userRole = await prisma.userRole.findFirst({ const userRole = await prisma.userRole.findFirst({
where: { userId: session.user.id }, where: { userId: session.user.id },
orderBy: { createdAt: 'asc' }, orderBy: { createdAt: 'asc' },
}) })
if (!userRole || userRole.role !== 'admin') redirect('/dashboard') if (!userRole || userRole.role !== 'admin') redirect('/dashboard')
const members = await prisma.member.findMany({ const members = await prisma.member.findMany({
where: { where: {
orgId: userRole.orgId, orgId: userRole.orgId,
...(statusFilter && { status: statusFilter as never }), ...(statusFilter && { status: statusFilter as never }),
...(search && { ...(search && {
OR: [ OR: [
{ name: { contains: search } }, { name: { contains: search } },
{ betrieb: { contains: search } }, { betrieb: { contains: search } },
{ ort: { contains: search } }, { ort: { contains: search } },
], ],
}), }),
}, },
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
}) })
// Also fetch admins to display them in the list if no status filter or status matches "aktiv" // Also fetch admins to display them in the list if no status filter or status matches "aktiv"
const admins = await prisma.userRole.findMany({ const admins = await prisma.userRole.findMany({
where: { where: {
orgId: userRole.orgId, orgId: userRole.orgId,
role: 'admin', role: 'admin',
...(search && { ...(search && {
user: { user: {
OR: [ OR: [
{ name: { contains: search } }, { name: { contains: search } },
{ email: { contains: search } }, { email: { contains: search } },
] ]
} }
}) })
}, },
include: { include: {
user: true user: true
} }
}) })
const adminUserIds = new Set(admins.map((a: typeof admins[number]) => a.userId)) const adminUserIds = new Set(admins.map((a: typeof admins[number]) => a.userId))
// Map userId → member record so admin entries show real member data // Map userId → member record so admin entries show real member data
const memberByUserId = new Map<string, typeof members[number]>(members.filter((m: typeof members[number]) => m.userId).map((m: typeof members[number]) => [m.userId!, m])) const memberByUserId = new Map<string, typeof members[number]>(members.filter((m: typeof members[number]) => m.userId).map((m: typeof members[number]) => [m.userId!, m]))
const combinedList = [ const combinedList = [
// Include admins only if there's no status filter, or if filtering for 'aktiv' // Include admins only if there's no status filter, or if filtering for 'aktiv'
...(!statusFilter || statusFilter === 'aktiv' ? admins.map((a: typeof admins[number]) => { ...(!statusFilter || statusFilter === 'aktiv' ? admins.map((a: typeof admins[number]) => {
const m = memberByUserId.get(a.user.id) const m = memberByUserId.get(a.user.id)
return { return {
id: m ? m.id : `admin-${a.user.id}`, id: m ? m.id : `admin-${a.user.id}`,
name: m?.name ?? a.user.name, name: m?.name ?? a.user.name,
betrieb: m?.betrieb ?? a.user.email, betrieb: m?.betrieb ?? a.user.email,
sparte: m?.sparte ?? 'Sonderfunktion', sparte: m?.sparte ?? 'Sonderfunktion',
ort: m?.ort ?? '—', ort: m?.ort ?? '—',
seit: m?.seit ?? null as number | null, seit: m?.seit ?? null as number | null,
status: m?.status ?? 'aktiv', status: m?.status ?? 'aktiv',
userId: a.user.id, userId: a.user.id,
isAdmin: true, isAdmin: true,
realId: m ? m.id : a.user.id, realId: m ? m.id : a.user.id,
role: 'Administrator', role: 'Administrator',
} }
}) : []), }) : []),
...members.filter((m: typeof members[number]) => !adminUserIds.has(m.userId ?? '')).map((m: typeof members[number]) => ({ ...members.filter((m: typeof members[number]) => !adminUserIds.has(m.userId ?? '')).map((m: typeof members[number]) => ({
id: m.id, id: m.id,
name: m.name, name: m.name,
betrieb: m.betrieb, betrieb: m.betrieb,
sparte: m.sparte, sparte: m.sparte,
ort: m.ort, ort: m.ort,
seit: m.seit, seit: m.seit,
status: m.status, status: m.status,
userId: m.userId, userId: m.userId,
isAdmin: false, isAdmin: false,
realId: m.id, realId: m.id,
role: 'Mitglied', role: 'Mitglied',
})) }))
] ]
combinedList.sort((a: typeof combinedList[number], b: typeof combinedList[number]) => a.name.localeCompare(b.name)) combinedList.sort((a: typeof combinedList[number], b: typeof combinedList[number]) => a.name.localeCompare(b.name))
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-gray-900">Mitglieder</h1> <h1 className="text-2xl font-bold text-gray-900">Mitglieder</h1>
<p className="text-gray-500 mt-1">{combinedList.length} Einträge</p> <p className="text-gray-500 mt-1">{combinedList.length} Einträge</p>
</div> </div>
<Link <Link
href="/dashboard/mitglieder/neu" href="/dashboard/mitglieder/neu"
className="bg-brand-500 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 transition-colors" className="bg-brand-500 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 transition-colors"
> >
+ Mitglied anlegen + Mitglied anlegen
</Link> </Link>
</div> </div>
{/* Filters */} {/* Filters */}
<div className="bg-white rounded-lg border p-4 flex gap-4"> <div className="bg-white rounded-lg border p-4 flex gap-4">
<form className="flex gap-4 w-full"> <form className="flex gap-4 w-full">
<input <input
name="q" name="q"
defaultValue={search} defaultValue={search}
placeholder="Name, Betrieb, Ort suchen..." placeholder="Name, Betrieb, Ort suchen..."
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500" className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
/> />
<select <select
name="status" name="status"
defaultValue={statusFilter ?? ''} defaultValue={statusFilter ?? ''}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500" className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
> >
<option value="">Alle Status</option> <option value="">Alle Status</option>
<option value="aktiv">Aktiv</option> <option value="aktiv">Aktiv</option>
<option value="ruhend">Ruhend</option> <option value="ruhend">Ruhend</option>
<option value="ausgetreten">Ausgetreten</option> <option value="ausgetreten">Ausgetreten</option>
</select> </select>
<button <button
type="submit" type="submit"
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg text-sm hover:bg-gray-200 transition-colors" className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg text-sm hover:bg-gray-200 transition-colors"
> >
Suchen Suchen
</button> </button>
</form> </form>
</div> </div>
{/* Table */} {/* Table */}
<div className="bg-white rounded-lg border overflow-hidden"> <div className="bg-white rounded-lg border overflow-hidden">
<table className="w-full data-table"> <table className="w-full data-table">
<thead> <thead>
<tr> <tr>
<th>Name / Betrieb</th> <th>Name / Betrieb</th>
<th>Rolle</th> <th>Rolle</th>
<th>Ort</th> <th>Ort</th>
<th>Mitglied seit</th> <th>Mitglied seit</th>
<th>Status</th> <th>Status</th>
<th>Eingeladen</th> <th>Eingeladen</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{combinedList.map((m) => ( {combinedList.map((m) => (
<tr key={m.id}> <tr key={m.id}>
<td> <td>
<div> <div>
<p className="font-medium text-gray-900">{m.name}</p> <p className="font-medium text-gray-900">{m.name}</p>
<p className="text-xs text-gray-500">{m.betrieb}</p> <p className="text-xs text-gray-500">{m.betrieb}</p>
</div> </div>
</td> </td>
<td> <td>
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium ${m.role === 'Administrator' ? 'bg-purple-100 text-purple-700' : 'bg-gray-100 text-gray-700'}`}> <span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium ${m.role === 'Administrator' ? 'bg-purple-100 text-purple-700' : 'bg-gray-100 text-gray-700'}`}>
{m.role} {m.role}
</span> </span>
</td> </td>
<td>{m.ort}</td> <td>{m.ort}</td>
<td>{m.seit ?? '—'}</td> <td>{m.seit ?? '—'}</td>
<td> <td>
<span <span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium ${STATUS_COLORS[m.status]}`} className={`inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium ${STATUS_COLORS[m.status]}`}
> >
{MEMBER_STATUS_LABELS[m.status as keyof typeof MEMBER_STATUS_LABELS] || 'Aktiv'} {MEMBER_STATUS_LABELS[m.status as keyof typeof MEMBER_STATUS_LABELS] || 'Aktiv'}
</span> </span>
</td> </td>
<td> <td>
{m.userId ? ( {m.userId ? (
<span className="text-[11px] font-medium text-green-600 bg-green-50 px-2 py-0.5 rounded-full">Aktiv</span> <span className="text-[11px] font-medium text-green-600 bg-green-50 px-2 py-0.5 rounded-full">Aktiv</span>
) : ( ) : (
<span className="text-[11px] text-gray-400"></span> <span className="text-[11px] text-gray-400"></span>
)} )}
</td> </td>
<td> <td>
<Link <Link
href={`/dashboard/mitglieder/${m.realId}`} href={`/dashboard/mitglieder/${m.realId}`}
className="text-sm text-brand-600 hover:underline" className="text-sm text-brand-600 hover:underline"
> >
Bearbeiten Bearbeiten
</Link> </Link>
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
{combinedList.length === 0 && ( {combinedList.length === 0 && (
<div className="text-center py-12 text-gray-500"> <div className="text-center py-12 text-gray-500">
Keine Mitglieder gefunden Keine Mitglieder gefunden
</div> </div>
)} )}
</div> </div>
</div> </div>
) )
} }

View File

@ -1,235 +1,235 @@
'use client' 'use client'
import { use, useState, useEffect } from 'react' import { use, useState, useEffect } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc-client' import { trpc } from '@/lib/trpc-client'
import { getTrpcErrorMessage } from '@/lib/trpc-error' import { getTrpcErrorMessage } from '@/lib/trpc-error'
import Link from 'next/link' import Link from 'next/link'
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { ssr: false }) const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { ssr: false })
const KATEGORIEN = [ const KATEGORIEN = [
{ value: 'Wichtig', label: 'Wichtig' }, { value: 'Wichtig', label: 'Wichtig' },
{ value: 'Pruefung', label: 'Prüfung' }, { value: 'Pruefung', label: 'Prüfung' },
{ value: 'Foerderung', label: 'Förderung' }, { value: 'Foerderung', label: 'Förderung' },
{ value: 'Veranstaltung', label: 'Veranstaltung' }, { value: 'Veranstaltung', label: 'Veranstaltung' },
{ value: 'Allgemein', label: 'Allgemein' }, { value: 'Allgemein', label: 'Allgemein' },
] ]
export default function NewsEditPage({ params }: { params: Promise<{ id: string }> }) { export default function NewsEditPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params) const { id } = use(params)
const router = useRouter() const router = useRouter()
const { data: news, isLoading } = trpc.news.byId.useQuery({ id }) const { data: news, isLoading } = trpc.news.byId.useQuery({ id })
const updateMutation = trpc.news.update.useMutation({ const updateMutation = trpc.news.update.useMutation({
onSuccess: () => router.push('/dashboard/news'), onSuccess: () => router.push('/dashboard/news'),
}) })
const deleteMutation = trpc.news.delete.useMutation({ const deleteMutation = trpc.news.delete.useMutation({
onSuccess: () => router.push('/dashboard/news'), onSuccess: () => router.push('/dashboard/news'),
}) })
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [body, setBody] = useState('') const [body, setBody] = useState('')
const [kategorie, setKategorie] = useState('Allgemein') const [kategorie, setKategorie] = useState('Allgemein')
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [attachments, setAttachments] = useState< const [attachments, setAttachments] = useState<
Array<{ name: string; storagePath: string; sizeBytes: number; mimeType?: string | null }> Array<{ name: string; storagePath: string; sizeBytes: number; mimeType?: string | null }>
>([]) >([])
useEffect(() => { useEffect(() => {
if (news) { if (news) {
setTitle(news.title) setTitle(news.title)
setBody(news.body) setBody(news.body)
setKategorie(news.kategorie) setKategorie(news.kategorie)
if (news.attachments) { if (news.attachments) {
setAttachments(news.attachments.map((a: typeof news.attachments[number]) => ({ ...a, sizeBytes: a.sizeBytes ?? 0 }))) setAttachments(news.attachments.map((a: typeof news.attachments[number]) => ({ ...a, sizeBytes: a.sizeBytes ?? 0 })))
} }
} }
}, [news]) }, [news])
if (isLoading) return <div className="text-gray-500 text-sm">Wird geladen...</div> if (isLoading) return <div className="text-gray-500 text-sm">Wird geladen...</div>
if (!news) return <div className="text-gray-500 text-sm">Beitrag nicht gefunden.</div> if (!news) return <div className="text-gray-500 text-sm">Beitrag nicht gefunden.</div>
async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) { async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0] const file = e.target.files?.[0]
if (!file) return if (!file) return
setUploading(true) setUploading(true)
const formData = new FormData() const formData = new FormData()
formData.append('file', file) formData.append('file', file)
try { try {
const res = await fetch('/api/upload', { method: 'POST', body: formData }) const res = await fetch('/api/upload', { method: 'POST', body: formData })
const data = await res.json() const data = await res.json()
setAttachments((prev) => [...prev, data]) setAttachments((prev) => [...prev, data])
} catch { } catch {
alert('Upload fehlgeschlagen') alert('Upload fehlgeschlagen')
} finally { } finally {
setUploading(false) setUploading(false)
} }
} }
function handleSave(publishNow: boolean) { function handleSave(publishNow: boolean) {
if (!title.trim() || !body.trim()) return if (!title.trim() || !body.trim()) return
updateMutation.mutate({ updateMutation.mutate({
id, id,
data: { data: {
title, title,
body, body,
kategorie: kategorie as never, kategorie: kategorie as never,
publishedAt: publishNow ? new Date().toISOString() : undefined, publishedAt: publishNow ? new Date().toISOString() : undefined,
attachments: attachments.map((a) => ({ attachments: attachments.map((a) => ({
name: a.name, name: a.name,
storagePath: a.storagePath, storagePath: a.storagePath,
sizeBytes: a.sizeBytes, sizeBytes: a.sizeBytes,
mimeType: a.mimeType || 'application/pdf', mimeType: a.mimeType || 'application/pdf',
})), })),
}, },
}) })
} }
function handleUnpublish() { function handleUnpublish() {
updateMutation.mutate({ id, data: { publishedAt: null } }) updateMutation.mutate({ id, data: { publishedAt: null } })
} }
const isPublished = !!news.publishedAt const isPublished = !!news.publishedAt
return ( return (
<div className="max-w-4xl space-y-6"> <div className="max-w-4xl space-y-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Link href="/dashboard/news" className="text-xs text-gray-400 hover:text-gray-600 uppercase tracking-wide"> <Link href="/dashboard/news" className="text-xs text-gray-400 hover:text-gray-600 uppercase tracking-wide">
Zurück Zurück
</Link> </Link>
<span className="text-gray-200">/</span> <span className="text-gray-200">/</span>
<h1 className="text-2xl font-bold text-gray-900">Beitrag bearbeiten</h1> <h1 className="text-2xl font-bold text-gray-900">Beitrag bearbeiten</h1>
{isPublished && ( {isPublished && (
<span className="text-[11px] font-medium bg-green-100 text-green-700 px-2 py-0.5 rounded-full"> <span className="text-[11px] font-medium bg-green-100 text-green-700 px-2 py-0.5 rounded-full">
Publiziert Publiziert
</span> </span>
)} )}
{!isPublished && ( {!isPublished && (
<span className="text-[11px] font-medium bg-gray-100 text-gray-500 px-2 py-0.5 rounded-full"> <span className="text-[11px] font-medium bg-gray-100 text-gray-500 px-2 py-0.5 rounded-full">
Entwurf Entwurf
</span> </span>
)} )}
</div> </div>
<div className="bg-white rounded-lg border p-6 space-y-4"> <div className="bg-white rounded-lg border p-6 space-y-4">
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
<div className="col-span-2"> <div className="col-span-2">
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Titel</label> <label className="block text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Titel</label>
<input <input
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder="Titel..." placeholder="Titel..."
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent" className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
/> />
</div> </div>
<div> <div>
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Kategorie</label> <label className="block text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Kategorie</label>
<select <select
value={kategorie} value={kategorie}
onChange={(e) => setKategorie(e.target.value)} onChange={(e) => setKategorie(e.target.value)}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent" className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
> >
{KATEGORIEN.map((k) => ( {KATEGORIEN.map((k) => (
<option key={k.value} value={k.value}>{k.label}</option> <option key={k.value} value={k.value}>{k.label}</option>
))} ))}
</select> </select>
</div> </div>
</div> </div>
<div> <div>
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Inhalt</label> <label className="block text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Inhalt</label>
<div data-color-mode="light"> <div data-color-mode="light">
<MDEditor <MDEditor
value={body} value={body}
onChange={(v) => setBody(v ?? '')} onChange={(v) => setBody(v ?? '')}
height={400} height={400}
preview="live" preview="live"
/> />
</div> </div>
</div> </div>
{/* Attachments */} {/* Attachments */}
<div> <div>
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Anhänge (PDF)</label> <label className="block text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Anhänge (PDF)</label>
<label className="cursor-pointer inline-flex items-center gap-2 px-4 py-2 border border-dashed border-gray-300 rounded-lg text-sm text-gray-500 hover:border-brand-500 hover:text-brand-500 transition-colors"> <label className="cursor-pointer inline-flex items-center gap-2 px-4 py-2 border border-dashed border-gray-300 rounded-lg text-sm text-gray-500 hover:border-brand-500 hover:text-brand-500 transition-colors">
{uploading ? '⏳ Hochladen...' : '📎 Datei anhängen'} {uploading ? '⏳ Hochladen...' : '📎 Datei anhängen'}
<input <input
type="file" type="file"
accept=".pdf,image/*" accept=".pdf,image/*"
onChange={handleFileUpload} onChange={handleFileUpload}
disabled={uploading} disabled={uploading}
className="hidden" className="hidden"
/> />
</label> </label>
{attachments.length > 0 && ( {attachments.length > 0 && (
<ul className="mt-2 space-y-1"> <ul className="mt-2 space-y-1">
{attachments.map((a, i) => ( {attachments.map((a, i) => (
<li key={i} className="flex items-center gap-2 text-sm text-gray-600"> <li key={i} className="flex items-center gap-2 text-sm text-gray-600">
<span>📄</span> <span>📄</span>
<span>{a.name}</span> <span>{a.name}</span>
{a.sizeBytes != null && ( {a.sizeBytes != null && (
<span className="text-gray-400">({Math.round(a.sizeBytes / 1024)} KB)</span> <span className="text-gray-400">({Math.round(a.sizeBytes / 1024)} KB)</span>
)} )}
<button <button
onClick={() => setAttachments(prev => prev.filter((_, idx) => idx !== i))} onClick={() => setAttachments(prev => prev.filter((_, idx) => idx !== i))}
className="text-red-500 hover:text-red-700 ml-2" className="text-red-500 hover:text-red-700 ml-2"
title="Entfernen" title="Entfernen"
> >
× ×
</button> </button>
</li> </li>
))} ))}
</ul> </ul>
)} )}
</div> </div>
{updateMutation.error && ( {updateMutation.error && (
<p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg"> <p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg">
{getTrpcErrorMessage(updateMutation.error)} {getTrpcErrorMessage(updateMutation.error)}
</p> </p>
)} )}
<div className="flex items-center justify-between pt-2 border-t"> <div className="flex items-center justify-between pt-2 border-t">
<div className="flex gap-3"> <div className="flex gap-3">
{!isPublished && ( {!isPublished && (
<button <button
onClick={() => handleSave(true)} onClick={() => handleSave(true)}
disabled={updateMutation.isPending} disabled={updateMutation.isPending}
className="bg-brand-500 text-white px-5 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 disabled:opacity-60 transition-colors" className="bg-brand-500 text-white px-5 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 disabled:opacity-60 transition-colors"
> >
Publizieren Publizieren
</button> </button>
)} )}
<button <button
onClick={() => handleSave(false)} onClick={() => handleSave(false)}
disabled={updateMutation.isPending} disabled={updateMutation.isPending}
className="px-5 py-2 rounded-lg text-sm font-medium text-gray-700 border border-gray-200 hover:bg-gray-50 transition-colors" className="px-5 py-2 rounded-lg text-sm font-medium text-gray-700 border border-gray-200 hover:bg-gray-50 transition-colors"
> >
{updateMutation.isPending ? 'Wird gespeichert...' : 'Speichern'} {updateMutation.isPending ? 'Wird gespeichert...' : 'Speichern'}
</button> </button>
{isPublished && ( {isPublished && (
<button <button
onClick={handleUnpublish} onClick={handleUnpublish}
disabled={updateMutation.isPending} disabled={updateMutation.isPending}
className="px-5 py-2 rounded-lg text-sm font-medium text-gray-500 hover:text-gray-700 hover:bg-gray-50 transition-colors" className="px-5 py-2 rounded-lg text-sm font-medium text-gray-500 hover:text-gray-700 hover:bg-gray-50 transition-colors"
> >
Depublizieren Depublizieren
</button> </button>
)} )}
</div> </div>
<button <button
onClick={() => { onClick={() => {
if (confirm('Beitrag wirklich löschen?')) deleteMutation.mutate({ id }) if (confirm('Beitrag wirklich löschen?')) deleteMutation.mutate({ id })
}} }}
disabled={deleteMutation.isPending} disabled={deleteMutation.isPending}
className="text-sm text-red-500 hover:text-red-700 transition-colors" className="text-sm text-red-500 hover:text-red-700 transition-colors"
> >
Löschen Löschen
</button> </button>
</div> </div>
</div> </div>
</div> </div>
) )
} }

View File

@ -1,124 +1,124 @@
import { prisma } from '@innungsapp/shared' import { prisma } from '@innungsapp/shared'
import { auth, getSanitizedHeaders } from '@/lib/auth' import { auth, getSanitizedHeaders } from '@/lib/auth'
import { headers } from 'next/headers' import { headers } from 'next/headers'
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import { NEWS_KATEGORIE_LABELS } from '@innungsapp/shared' import { NEWS_KATEGORIE_LABELS } from '@innungsapp/shared'
import { format } from 'date-fns' import { format } from 'date-fns'
import { de } from 'date-fns/locale' import { de } from 'date-fns/locale'
const KATEGORIE_COLORS: Record<string, string> = { const KATEGORIE_COLORS: Record<string, string> = {
Wichtig: 'bg-red-100 text-red-700', Wichtig: 'bg-red-100 text-red-700',
Pruefung: 'bg-blue-100 text-blue-700', Pruefung: 'bg-blue-100 text-blue-700',
Foerderung: 'bg-green-100 text-green-700', Foerderung: 'bg-green-100 text-green-700',
Veranstaltung: 'bg-purple-100 text-purple-700', Veranstaltung: 'bg-purple-100 text-purple-700',
Allgemein: 'bg-gray-100 text-gray-700', Allgemein: 'bg-gray-100 text-gray-700',
} }
export default async function NewsPage() { export default async function NewsPage() {
const sanitizedHeaders = await getSanitizedHeaders() const sanitizedHeaders = await getSanitizedHeaders()
const session = await auth.api.getSession({ headers: sanitizedHeaders }) const session = await auth.api.getSession({ headers: sanitizedHeaders })
if (!session?.user) redirect('/login') if (!session?.user) redirect('/login')
const userRole = await prisma.userRole.findFirst({ const userRole = await prisma.userRole.findFirst({
where: { userId: session.user.id, role: 'admin' }, where: { userId: session.user.id, role: 'admin' },
}) })
if (!userRole) redirect('/dashboard') if (!userRole) redirect('/dashboard')
const news = await prisma.news.findMany({ const news = await prisma.news.findMany({
where: { orgId: userRole.orgId }, where: { orgId: userRole.orgId },
include: { author: { select: { name: true } } }, include: { author: { select: { name: true } } },
orderBy: [{ publishedAt: 'desc' }, { createdAt: 'desc' }], orderBy: [{ publishedAt: 'desc' }, { createdAt: 'desc' }],
}) })
const published = news.filter((n: typeof news[number]) => n.publishedAt) const published = news.filter((n: typeof news[number]) => n.publishedAt)
const drafts = news.filter((n: typeof news[number]) => !n.publishedAt) const drafts = news.filter((n: typeof news[number]) => !n.publishedAt)
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-gray-900">News</h1> <h1 className="text-2xl font-bold text-gray-900">News</h1>
<p className="text-gray-500 mt-1">{published.length} publiziert · {drafts.length} Entwürfe</p> <p className="text-gray-500 mt-1">{published.length} publiziert · {drafts.length} Entwürfe</p>
</div> </div>
<Link <Link
href="/dashboard/news/neu" href="/dashboard/news/neu"
className="bg-brand-500 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 transition-colors" className="bg-brand-500 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 transition-colors"
> >
+ Beitrag erstellen + Beitrag erstellen
</Link> </Link>
</div> </div>
{drafts.length > 0 && ( {drafts.length > 0 && (
<section> <section>
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3"> <h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
Entwürfe Entwürfe
</h2> </h2>
<div className="bg-white rounded-lg border overflow-hidden"> <div className="bg-white rounded-lg border overflow-hidden">
<table className="w-full data-table"> <table className="w-full data-table">
<tbody> <tbody>
{drafts.map((n: typeof drafts[number]) => ( {drafts.map((n: typeof drafts[number]) => (
<tr key={n.id}> <tr key={n.id}>
<td className="w-full"> <td className="w-full">
<p className="font-medium text-gray-900">{n.title}</p> <p className="font-medium text-gray-900">{n.title}</p>
<p className="text-xs text-gray-400">Erstellt {format(n.createdAt, 'dd. MMM yyyy', { locale: de })}</p> <p className="text-xs text-gray-400">Erstellt {format(n.createdAt, 'dd. MMM yyyy', { locale: de })}</p>
</td> </td>
<td> <td>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${KATEGORIE_COLORS[n.kategorie]}`}> <span className={`px-2 py-0.5 rounded-full text-xs font-medium ${KATEGORIE_COLORS[n.kategorie]}`}>
{NEWS_KATEGORIE_LABELS[n.kategorie]} {NEWS_KATEGORIE_LABELS[n.kategorie]}
</span> </span>
</td> </td>
<td> <td>
<Link href={`/dashboard/news/${n.id}`} className="text-sm text-brand-600 hover:underline"> <Link href={`/dashboard/news/${n.id}`} className="text-sm text-brand-600 hover:underline">
Bearbeiten Bearbeiten
</Link> </Link>
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
</section> </section>
)} )}
<section> <section>
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3"> <h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
Publiziert Publiziert
</h2> </h2>
<div className="bg-white rounded-lg border overflow-hidden"> <div className="bg-white rounded-lg border overflow-hidden">
<table className="w-full data-table"> <table className="w-full data-table">
<thead> <thead>
<tr> <tr>
<th>Titel</th> <th>Titel</th>
<th>Kategorie</th> <th>Kategorie</th>
<th>Autor</th> <th>Autor</th>
<th>Datum</th> <th>Datum</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{published.map((n: typeof published[number]) => ( {published.map((n: typeof published[number]) => (
<tr key={n.id}> <tr key={n.id}>
<td className="font-medium text-gray-900">{n.title}</td> <td className="font-medium text-gray-900">{n.title}</td>
<td> <td>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${KATEGORIE_COLORS[n.kategorie]}`}> <span className={`px-2 py-0.5 rounded-full text-xs font-medium ${KATEGORIE_COLORS[n.kategorie]}`}>
{NEWS_KATEGORIE_LABELS[n.kategorie]} {NEWS_KATEGORIE_LABELS[n.kategorie]}
</span> </span>
</td> </td>
<td className="text-gray-500">{n.author?.name ?? '—'}</td> <td className="text-gray-500">{n.author?.name ?? '—'}</td>
<td className="text-gray-500"> <td className="text-gray-500">
{n.publishedAt ? format(n.publishedAt, 'dd.MM.yyyy', { locale: de }) : '—'} {n.publishedAt ? format(n.publishedAt, 'dd.MM.yyyy', { locale: de }) : '—'}
</td> </td>
<td> <td>
<Link href={`/dashboard/news/${n.id}`} className="text-sm text-brand-600 hover:underline"> <Link href={`/dashboard/news/${n.id}`} className="text-sm text-brand-600 hover:underline">
Bearbeiten Bearbeiten
</Link> </Link>
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
</section> </section>
</div> </div>
) )
} }

View File

@ -1,126 +1,126 @@
import { prisma } from '@innungsapp/shared' import { prisma } from '@innungsapp/shared'
import { auth, getSanitizedHeaders } from '@/lib/auth' import { auth, getSanitizedHeaders } from '@/lib/auth'
import { headers } from 'next/headers' import { headers } from 'next/headers'
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import { StatsCards } from '@/components/stats/StatsCards' import { StatsCards } from '@/components/stats/StatsCards'
import Link from 'next/link' import Link from 'next/link'
import { format } from 'date-fns' import { format } from 'date-fns'
import { de } from 'date-fns/locale' import { de } from 'date-fns/locale'
import { NEWS_KATEGORIE_LABELS, TERMIN_TYP_LABELS } from '@innungsapp/shared' import { NEWS_KATEGORIE_LABELS, TERMIN_TYP_LABELS } from '@innungsapp/shared'
export default async function DashboardPage() { export default async function DashboardPage() {
const sanitizedHeaders = await getSanitizedHeaders() const sanitizedHeaders = await getSanitizedHeaders()
const session = await auth.api.getSession({ headers: sanitizedHeaders }) const session = await auth.api.getSession({ headers: sanitizedHeaders })
if (!session?.user) redirect('/login') if (!session?.user) redirect('/login')
const userRole = await prisma.userRole.findFirst({ const userRole = await prisma.userRole.findFirst({
where: { userId: session.user.id }, where: { userId: session.user.id },
include: { org: true }, include: { org: true },
}) })
if (!userRole) redirect('/login') if (!userRole) redirect('/login')
const orgId = userRole.orgId const orgId = userRole.orgId
const now = new Date() const now = new Date()
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
const [activeMembers, newsThisWeek, upcomingTermine, activeStellen, recentNews, nextTermine] = const [activeMembers, newsThisWeek, upcomingTermine, activeStellen, recentNews, nextTermine] =
await Promise.all([ await Promise.all([
prisma.member.count({ where: { orgId, status: 'aktiv' } }), prisma.member.count({ where: { orgId, status: 'aktiv' } }),
prisma.news.count({ where: { orgId, publishedAt: { gte: weekAgo, not: null } } }), prisma.news.count({ where: { orgId, publishedAt: { gte: weekAgo, not: null } } }),
prisma.termin.count({ where: { orgId, datum: { gte: now } } }), prisma.termin.count({ where: { orgId, datum: { gte: now } } }),
prisma.stelle.count({ where: { orgId, aktiv: true } }), prisma.stelle.count({ where: { orgId, aktiv: true } }),
prisma.news.findMany({ prisma.news.findMany({
where: { orgId, publishedAt: { not: null } }, where: { orgId, publishedAt: { not: null } },
orderBy: { publishedAt: 'desc' }, orderBy: { publishedAt: 'desc' },
take: 5, take: 5,
include: { author: { select: { name: true } } }, include: { author: { select: { name: true } } },
}), }),
prisma.termin.findMany({ prisma.termin.findMany({
where: { orgId, datum: { gte: now } }, where: { orgId, datum: { gte: now } },
orderBy: { datum: 'asc' }, orderBy: { datum: 'asc' },
take: 3, take: 3,
}), }),
]) ])
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<div> <div>
<h1 className="text-2xl font-bold text-gray-900">Übersicht</h1> <h1 className="text-2xl font-bold text-gray-900">Übersicht</h1>
<p className="text-gray-500 mt-1">{userRole.org.name}</p> <p className="text-gray-500 mt-1">{userRole.org.name}</p>
</div> </div>
<StatsCards <StatsCards
stats={[ stats={[
{ label: 'Aktive Mitglieder', value: activeMembers, icon: '👥' }, { label: 'Aktive Mitglieder', value: activeMembers, icon: '👥' },
{ label: 'News diese Woche', value: newsThisWeek, icon: '📰' }, { label: 'News diese Woche', value: newsThisWeek, icon: '📰' },
{ label: 'Bevorstehende Termine', value: upcomingTermine, icon: '📅' }, { label: 'Bevorstehende Termine', value: upcomingTermine, icon: '📅' },
{ label: 'Aktive Stellen', value: activeStellen, icon: '🎓' }, { label: 'Aktive Stellen', value: activeStellen, icon: '🎓' },
]} ]}
/> />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Recent News */} {/* Recent News */}
<div className="bg-white rounded-lg border p-6"> <div className="bg-white rounded-lg border p-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="font-semibold text-gray-900">Neueste Beiträge</h2> <h2 className="font-semibold text-gray-900">Neueste Beiträge</h2>
<Link href="/dashboard/news" className="text-sm text-brand-600 hover:underline"> <Link href="/dashboard/news" className="text-sm text-brand-600 hover:underline">
Alle anzeigen Alle anzeigen
</Link> </Link>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{recentNews.map((n: typeof recentNews[number]) => ( {recentNews.map((n: typeof recentNews[number]) => (
<div key={n.id} className="flex items-start gap-3 py-2 border-b last:border-0"> <div key={n.id} className="flex items-start gap-3 py-2 border-b last:border-0">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-medium text-sm text-gray-900 truncate">{n.title}</p> <p className="font-medium text-sm text-gray-900 truncate">{n.title}</p>
<p className="text-xs text-gray-500 mt-0.5"> <p className="text-xs text-gray-500 mt-0.5">
{n.publishedAt {n.publishedAt
? format(n.publishedAt, 'dd. MMM yyyy', { locale: de }) ? format(n.publishedAt, 'dd. MMM yyyy', { locale: de })
: 'Entwurf'}{' '} : 'Entwurf'}{' '}
· {n.author?.name ?? 'Unbekannt'} · {n.author?.name ?? 'Unbekannt'}
</p> </p>
</div> </div>
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full whitespace-nowrap"> <span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full whitespace-nowrap">
{NEWS_KATEGORIE_LABELS[n.kategorie]} {NEWS_KATEGORIE_LABELS[n.kategorie]}
</span> </span>
</div> </div>
))} ))}
</div> </div>
</div> </div>
{/* Upcoming Termine */} {/* Upcoming Termine */}
<div className="bg-white rounded-lg border p-6"> <div className="bg-white rounded-lg border p-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="font-semibold text-gray-900">Nächste Termine</h2> <h2 className="font-semibold text-gray-900">Nächste Termine</h2>
<Link href="/dashboard/termine" className="text-sm text-brand-600 hover:underline"> <Link href="/dashboard/termine" className="text-sm text-brand-600 hover:underline">
Alle anzeigen Alle anzeigen
</Link> </Link>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{nextTermine.length === 0 && ( {nextTermine.length === 0 && (
<p className="text-sm text-gray-500">Keine bevorstehenden Termine</p> <p className="text-sm text-gray-500">Keine bevorstehenden Termine</p>
)} )}
{nextTermine.map((t: typeof nextTermine[number]) => ( {nextTermine.map((t: typeof nextTermine[number]) => (
<div key={t.id} className="flex items-start gap-3 py-2 border-b last:border-0"> <div key={t.id} className="flex items-start gap-3 py-2 border-b last:border-0">
<div className="text-center min-w-[40px]"> <div className="text-center min-w-[40px]">
<p className="text-lg font-bold text-brand-500 leading-none"> <p className="text-lg font-bold text-brand-500 leading-none">
{format(t.datum, 'dd', { locale: de })} {format(t.datum, 'dd', { locale: de })}
</p> </p>
<p className="text-xs text-gray-500 uppercase"> <p className="text-xs text-gray-500 uppercase">
{format(t.datum, 'MMM', { locale: de })} {format(t.datum, 'MMM', { locale: de })}
</p> </p>
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-medium text-sm text-gray-900 truncate">{t.titel}</p> <p className="font-medium text-sm text-gray-900 truncate">{t.titel}</p>
<p className="text-xs text-gray-500">{t.ort ?? 'Kein Ort angegeben'}</p> <p className="text-xs text-gray-500">{t.ort ?? 'Kein Ort angegeben'}</p>
</div> </div>
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full whitespace-nowrap"> <span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full whitespace-nowrap">
{TERMIN_TYP_LABELS[t.typ]} {TERMIN_TYP_LABELS[t.typ]}
</span> </span>
</div> </div>
))} ))}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
) )
} }

View File

@ -1,191 +1,191 @@
'use client' 'use client'
import { useState } from 'react' import { useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc-client' import { trpc } from '@/lib/trpc-client'
import { getTrpcErrorMessage } from '@/lib/trpc-error' import { getTrpcErrorMessage } from '@/lib/trpc-error'
import Link from 'next/link' import Link from 'next/link'
import { AIGenerator } from '@/components/ai-generator' import { AIGenerator } from '@/components/ai-generator'
export default function StelleNeuPage() { export default function StelleNeuPage() {
const router = useRouter() const router = useRouter()
const { data: members } = trpc.members.list.useQuery({}) const { data: members } = trpc.members.list.useQuery({})
const createMutation = trpc.stellen.createForMember.useMutation({ const createMutation = trpc.stellen.createForMember.useMutation({
onSuccess: () => router.push('/dashboard/stellen'), onSuccess: () => router.push('/dashboard/stellen'),
}) })
const [form, setForm] = useState({ const [form, setForm] = useState({
memberId: '', memberId: '',
sparte: '', sparte: '',
stellenAnz: 1, stellenAnz: 1,
verguetung: '', verguetung: '',
lehrjahr: '', lehrjahr: '',
beschreibung: '', beschreibung: '',
kontaktEmail: '', kontaktEmail: '',
kontaktName: '', kontaktName: '',
}) })
function handleSubmit(e: React.FormEvent) { function handleSubmit(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
if (!form.memberId) return if (!form.memberId) return
createMutation.mutate({ createMutation.mutate({
...form, ...form,
stellenAnz: Number(form.stellenAnz), stellenAnz: Number(form.stellenAnz),
verguetung: form.verguetung || undefined, verguetung: form.verguetung || undefined,
lehrjahr: form.lehrjahr || undefined, lehrjahr: form.lehrjahr || undefined,
beschreibung: form.beschreibung || undefined, beschreibung: form.beschreibung || undefined,
kontaktName: form.kontaktName || undefined, kontaktName: form.kontaktName || undefined,
}) })
} }
const inputClass = const inputClass =
'w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent' 'w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent'
return ( return (
<div className="max-w-6xl space-y-6"> <div className="max-w-6xl space-y-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Link href="/dashboard/stellen" className="text-xs text-gray-400 hover:text-gray-600 uppercase tracking-wide"> <Link href="/dashboard/stellen" className="text-xs text-gray-400 hover:text-gray-600 uppercase tracking-wide">
Zurück Zurück
</Link> </Link>
<span className="text-gray-200">/</span> <span className="text-gray-200">/</span>
<h1 className="text-2xl font-bold text-gray-900">Stelle anlegen</h1> <h1 className="text-2xl font-bold text-gray-900">Stelle anlegen</h1>
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-start"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-start">
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<form onSubmit={handleSubmit} className="bg-white rounded-lg border p-6 space-y-6"> <form onSubmit={handleSubmit} className="bg-white rounded-lg border p-6 space-y-6">
{/* Betrieb */} {/* Betrieb */}
<div> <div>
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Betrieb</p> <p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Betrieb</p>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Mitglied / Betrieb *</label> <label className="block text-sm font-medium text-gray-700 mb-1">Mitglied / Betrieb *</label>
<select <select
required required
value={form.memberId} value={form.memberId}
onChange={(e) => { onChange={(e) => {
const selected = members?.find((m: NonNullable<typeof members>[number]) => m.id === e.target.value) const selected = members?.find((m: NonNullable<typeof members>[number]) => m.id === e.target.value)
setForm({ ...form, memberId: e.target.value, sparte: selected?.sparte ?? form.sparte }) setForm({ ...form, memberId: e.target.value, sparte: selected?.sparte ?? form.sparte })
}} }}
className={inputClass} className={inputClass}
> >
<option value="">Mitglied auswählen...</option> <option value="">Mitglied auswählen...</option>
{members?.map((m: NonNullable<typeof members>[number]) => ( {members?.map((m: NonNullable<typeof members>[number]) => (
<option key={m.id} value={m.id}> <option key={m.id} value={m.id}>
{m.betrieb} {m.name} {m.betrieb} {m.name}
</option> </option>
))} ))}
</select> </select>
</div> </div>
</div> </div>
{/* Stellendetails */} {/* Stellendetails */}
<div className="border-t pt-5"> <div className="border-t pt-5">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Stellendetails</p> <p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Stellendetails</p>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Sparte *</label> <label className="block text-sm font-medium text-gray-700 mb-1">Sparte *</label>
<input <input
required required
value={form.sparte} value={form.sparte}
onChange={(e) => setForm({ ...form, sparte: e.target.value })} onChange={(e) => setForm({ ...form, sparte: e.target.value })}
placeholder="z.B. Elektrotechnik" placeholder="z.B. Elektrotechnik"
className={inputClass} className={inputClass}
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Anzahl Stellen</label> <label className="block text-sm font-medium text-gray-700 mb-1">Anzahl Stellen</label>
<input <input
type="number" type="number"
min={1} min={1}
value={form.stellenAnz} value={form.stellenAnz}
onChange={(e) => setForm({ ...form, stellenAnz: Number(e.target.value) })} onChange={(e) => setForm({ ...form, stellenAnz: Number(e.target.value) })}
className={inputClass} className={inputClass}
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Lehrjahr</label> <label className="block text-sm font-medium text-gray-700 mb-1">Lehrjahr</label>
<input <input
value={form.lehrjahr} value={form.lehrjahr}
onChange={(e) => setForm({ ...form, lehrjahr: e.target.value })} onChange={(e) => setForm({ ...form, lehrjahr: e.target.value })}
placeholder="z.B. 1. Lehrjahr" placeholder="z.B. 1. Lehrjahr"
className={inputClass} className={inputClass}
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Vergütung</label> <label className="block text-sm font-medium text-gray-700 mb-1">Vergütung</label>
<input <input
value={form.verguetung} value={form.verguetung}
onChange={(e) => setForm({ ...form, verguetung: e.target.value })} onChange={(e) => setForm({ ...form, verguetung: e.target.value })}
placeholder="z.B. 650 € / Monat" placeholder="z.B. 650 € / Monat"
className={inputClass} className={inputClass}
/> />
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label> <label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
<textarea <textarea
rows={3} rows={3}
value={form.beschreibung} value={form.beschreibung}
onChange={(e) => setForm({ ...form, beschreibung: e.target.value })} onChange={(e) => setForm({ ...form, beschreibung: e.target.value })}
placeholder="Aufgaben, Anforderungen, ..." placeholder="Aufgaben, Anforderungen, ..."
className={inputClass} className={inputClass}
/> />
</div> </div>
</div> </div>
</div> </div>
{/* Kontakt */} {/* Kontakt */}
<div className="border-t pt-5"> <div className="border-t pt-5">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Kontakt</p> <p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Kontakt</p>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Kontakt-E-Mail *</label> <label className="block text-sm font-medium text-gray-700 mb-1">Kontakt-E-Mail *</label>
<input <input
type="email" type="email"
required required
value={form.kontaktEmail} value={form.kontaktEmail}
onChange={(e) => setForm({ ...form, kontaktEmail: e.target.value })} onChange={(e) => setForm({ ...form, kontaktEmail: e.target.value })}
placeholder="bewerbung@betrieb.de" placeholder="bewerbung@betrieb.de"
className={inputClass} className={inputClass}
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Ansprechpartner</label> <label className="block text-sm font-medium text-gray-700 mb-1">Ansprechpartner</label>
<input <input
value={form.kontaktName} value={form.kontaktName}
onChange={(e) => setForm({ ...form, kontaktName: e.target.value })} onChange={(e) => setForm({ ...form, kontaktName: e.target.value })}
placeholder="Max Mustermann" placeholder="Max Mustermann"
className={inputClass} className={inputClass}
/> />
</div> </div>
</div> </div>
</div> </div>
{createMutation.error && ( {createMutation.error && (
<p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg"> <p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg">
{getTrpcErrorMessage(createMutation.error)} {getTrpcErrorMessage(createMutation.error)}
</p> </p>
)} )}
<div className="flex gap-3 pt-2 border-t"> <div className="flex gap-3 pt-2 border-t">
<button <button
type="submit" type="submit"
disabled={createMutation.isPending || !form.memberId} disabled={createMutation.isPending || !form.memberId}
className="bg-brand-500 text-white px-6 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 disabled:opacity-60 transition-colors" className="bg-brand-500 text-white px-6 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 disabled:opacity-60 transition-colors"
> >
{createMutation.isPending ? 'Wird gespeichert...' : 'Stelle anlegen'} {createMutation.isPending ? 'Wird gespeichert...' : 'Stelle anlegen'}
</button> </button>
<Link href="/dashboard/stellen" className="px-6 py-2 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-100 transition-colors"> <Link href="/dashboard/stellen" className="px-6 py-2 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-100 transition-colors">
Abbrechen Abbrechen
</Link> </Link>
</div> </div>
</form> </form>
</div> </div>
<div className="lg:col-span-1 sticky top-6"> <div className="lg:col-span-1 sticky top-6">
<AIGenerator type="stelle" onApply={(text) => setForm({ ...form, beschreibung: (form.beschreibung || '') + (form.beschreibung?.trim() ? '\n\n' : '') + text })} /> <AIGenerator type="stelle" onApply={(text) => setForm({ ...form, beschreibung: (form.beschreibung || '') + (form.beschreibung?.trim() ? '\n\n' : '') + text })} />
</div> </div>
</div> </div>
</div> </div>
) )
} }

View File

@ -1,397 +1,397 @@
import { prisma } from '@innungsapp/shared' import { prisma } from '@innungsapp/shared'
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
function jsonToText(value: unknown): string { function jsonToText(value: unknown): string {
if (value == null) { if (value == null) {
return '' return ''
} }
if (typeof value === 'string') { if (typeof value === 'string') {
return value return value
} }
if (Array.isArray(value)) { if (Array.isArray(value)) {
return value return value
.map((item) => (typeof item === 'string' ? item : JSON.stringify(item))) .map((item) => (typeof item === 'string' ? item : JSON.stringify(item)))
.join('\n') .join('\n')
} }
return JSON.stringify(value) return JSON.stringify(value)
} }
export default async function TenantLandingPage({ export default async function TenantLandingPage({
params, params,
}: { }: {
params: Promise<{ slug: string }> params: Promise<{ slug: string }>
}) { }) {
const { slug } = await params const { slug } = await params
// Exclude dashboard routes // Exclude dashboard routes
if (slug === 'dashboard' || slug === 'login' || slug === 'superadmin') { if (slug === 'dashboard' || slug === 'login' || slug === 'superadmin') {
return notFound() return notFound()
} }
const org = await prisma.organization.findUnique({ const org = await prisma.organization.findUnique({
where: { slug } where: { slug }
}) })
if (!org) { if (!org) {
return notFound() return notFound()
} }
const primaryColor = org.primaryColor || '#E63946' const primaryColor = org.primaryColor || '#E63946'
const secondaryColor = org.secondaryColor || undefined const secondaryColor = org.secondaryColor || undefined
const title = org.landingPageTitle || org.name || 'Zukunft durch Handwerk' const title = org.landingPageTitle || org.name || 'Zukunft durch Handwerk'
const text = org.landingPageText || 'Wir sind Ihre lokale Vertretung des Handwerks. Mit starker Gemeinschaft und klaren Zielen setzen wir uns für die Betriebe in unserer Region ein.' const text = org.landingPageText || 'Wir sind Ihre lokale Vertretung des Handwerks. Mit starker Gemeinschaft und klaren Zielen setzen wir uns für die Betriebe in unserer Region ein.'
const features = jsonToText(org.landingPageFeatures) || '✅ Exzellente Ausbildung\n✅ Starke Gemeinschaft\n✅ Politische Interessenvertretung' const features = jsonToText(org.landingPageFeatures) || '✅ Exzellente Ausbildung\n✅ Starke Gemeinschaft\n✅ Politische Interessenvertretung'
const footer = jsonToText(org.landingPageFooter) || `© ${new Date().getFullYear()} ${org.name}` const footer = jsonToText(org.landingPageFooter) || `© ${new Date().getFullYear()} ${org.name}`
const sectionTitle = org.landingPageSectionTitle || `${org.name || 'Ihre Innung'} Gemeinsam stark fürs Handwerk` const sectionTitle = org.landingPageSectionTitle || `${org.name || 'Ihre Innung'} Gemeinsam stark fürs Handwerk`
const buttonText = org.landingPageButtonText || 'Jetzt App laden' const buttonText = org.landingPageButtonText || 'Jetzt App laden'
return ( return (
<div className="w-full h-full bg-white overflow-y-auto font-sans flex flex-col relative selection:bg-gray-900 selection:text-white" style={{ '--color-brand-primary': primaryColor } as React.CSSProperties}> <div className="w-full h-full bg-white overflow-y-auto font-sans flex flex-col relative selection:bg-gray-900 selection:text-white" style={{ '--color-brand-primary': primaryColor } as React.CSSProperties}>
{/* Header */} {/* Header */}
<header className="px-8 py-6 flex items-center justify-between sticky top-0 z-50 shadow-sm" style={{ <header className="px-8 py-6 flex items-center justify-between sticky top-0 z-50 shadow-sm" style={{
background: `linear-gradient(to right, #ffffff 0%, ${primaryColor}20 50%, ${primaryColor} 100%)` background: `linear-gradient(to right, #ffffff 0%, ${primaryColor}20 50%, ${primaryColor} 100%)`
}}> }}>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{org.logoUrl ? ( {org.logoUrl ? (
<img src={org.logoUrl} alt="Logo" className="h-10 object-contain" /> <img src={org.logoUrl} alt="Logo" className="h-10 object-contain" />
) : ( ) : (
<div className="w-10 h-10 bg-white rounded-lg flex items-center justify-center text-xs font-bold text-gray-400 shadow-sm">LOGO</div> <div className="w-10 h-10 bg-white rounded-lg flex items-center justify-center text-xs font-bold text-gray-400 shadow-sm">LOGO</div>
)} )}
<span className="font-bold text-lg text-gray-800">{org.name || 'Innungs-Logo'}</span> <span className="font-bold text-lg text-gray-800">{org.name || 'Innungs-Logo'}</span>
</div> </div>
<nav className="flex gap-6 text-sm font-medium text-gray-800 hidden md:flex"> <nav className="flex gap-6 text-sm font-medium text-gray-800 hidden md:flex">
<a href="#about" className="hover:text-black">Über uns</a> <a href="#about" className="hover:text-black">Über uns</a>
<a href="#leistungen" className="hover:text-black">Leistungen</a> <a href="#leistungen" className="hover:text-black">Leistungen</a>
<a href="#app" className="hover:text-black">App</a> <a href="#app" className="hover:text-black">App</a>
</nav> </nav>
<Link <Link
href={`/login`} href={`/login`}
className="px-5 py-2.5 rounded-full bg-white font-semibold text-sm cursor-pointer shadow-md hover:bg-gray-50 transition-all" className="px-5 py-2.5 rounded-full bg-white font-semibold text-sm cursor-pointer shadow-md hover:bg-gray-50 transition-all"
style={{ color: primaryColor }} style={{ color: primaryColor }}
> >
Mitglieder verwalten Mitglieder verwalten
</Link> </Link>
</header> </header>
{/* Hero Section */} {/* Hero Section */}
<section id="about" className="relative px-8 py-20 flex flex-col items-center justify-center text-center overflow-hidden min-h-[400px]"> <section id="about" className="relative px-8 py-20 flex flex-col items-center justify-center text-center overflow-hidden min-h-[400px]">
{/* Background Image / Pattern */} {/* Background Image / Pattern */}
{org.landingPageHeroImage ? ( {org.landingPageHeroImage ? (
<div className="absolute inset-0 z-0"> <div className="absolute inset-0 z-0">
<img src={org.landingPageHeroImage} alt="Hero Background" className="w-full h-full object-cover" /> <img src={org.landingPageHeroImage} alt="Hero Background" className="w-full h-full object-cover" />
<div <div
className="absolute inset-0 bg-white" className="absolute inset-0 bg-white"
// If you have a specific overlay opacity field you could use it here. Defaulting to 0.5. // If you have a specific overlay opacity field you could use it here. Defaulting to 0.5.
style={{ opacity: 0.5 }} style={{ opacity: 0.5 }}
></div> ></div>
<div className="absolute inset-0 bg-gradient-to-b from-white/30 via-transparent to-white/90"></div> <div className="absolute inset-0 bg-gradient-to-b from-white/30 via-transparent to-white/90"></div>
</div> </div>
) : ( ) : (
<div className="absolute inset-0 z-0 opacity-[0.03]" style={{ backgroundImage: 'radial-gradient(#000 1px, transparent 1px)', backgroundSize: '24px 24px' }}></div> <div className="absolute inset-0 z-0 opacity-[0.03]" style={{ backgroundImage: 'radial-gradient(#000 1px, transparent 1px)', backgroundSize: '24px 24px' }}></div>
)} )}
<div className="relative z-10 max-w-3xl mx-auto space-y-6"> <div className="relative z-10 max-w-3xl mx-auto space-y-6">
<div className="inline-block px-4 py-1.5 rounded-full text-xs font-bold tracking-wider uppercase mb-2 shadow-sm" style={{ backgroundColor: `${primaryColor}15`, color: primaryColor }}> <div className="inline-block px-4 py-1.5 rounded-full text-xs font-bold tracking-wider uppercase mb-2 shadow-sm" style={{ backgroundColor: `${primaryColor}15`, color: primaryColor }}>
{org.name || 'Ihre Innung'} {org.name || 'Ihre Innung'}
</div> </div>
<h1 className="text-4xl md:text-5xl font-black text-gray-900 tracking-tight leading-[1.1]"> <h1 className="text-4xl md:text-5xl font-black text-gray-900 tracking-tight leading-[1.1]">
{title} {title}
</h1> </h1>
<p className="text-lg text-gray-600 leading-relaxed max-w-2xl mx-auto font-medium"> <p className="text-lg text-gray-600 leading-relaxed max-w-2xl mx-auto font-medium">
{text} {text}
</p> </p>
<div className="pt-6 flex gap-4 justify-center"> <div className="pt-6 flex gap-4 justify-center">
<a <a
href="#apps" href="#apps"
className="px-8 py-3.5 rounded-full text-white font-semibold shadow-lg hover:opacity-90 transition-all cursor-pointer transform hover:-translate-y-0.5 block" className="px-8 py-3.5 rounded-full text-white font-semibold shadow-lg hover:opacity-90 transition-all cursor-pointer transform hover:-translate-y-0.5 block"
style={{ backgroundColor: primaryColor }} style={{ backgroundColor: primaryColor }}
> >
{buttonText} {buttonText}
</a> </a>
<a <a
href="#leistungen" href="#leistungen"
className="px-8 py-3.5 rounded-full font-semibold border shadow-sm transition-all cursor-pointer block hover:opacity-80" className="px-8 py-3.5 rounded-full font-semibold border shadow-sm transition-all cursor-pointer block hover:opacity-80"
style={{ style={{
backgroundColor: 'white', backgroundColor: 'white',
borderColor: secondaryColor || '#e5e7eb', borderColor: secondaryColor || '#e5e7eb',
color: secondaryColor || '#374151' color: secondaryColor || '#374151'
}} }}
> >
Mehr erfahren Mehr erfahren
</a> </a>
</div> </div>
</div> </div>
</section> </section>
{/* Features / Benefits */} {/* Features / Benefits */}
<section id="leistungen" className="px-8 py-16" style={{ backgroundColor: secondaryColor ? `${secondaryColor}08` : '#f9fafb' }}> <section id="leistungen" className="px-8 py-16" style={{ backgroundColor: secondaryColor ? `${secondaryColor}08` : '#f9fafb' }}>
<div className="max-w-5xl mx-auto"> <div className="max-w-5xl mx-auto">
<h2 className="text-2xl font-bold text-center mb-12 text-gray-800">Ihre Vorteile als Mitglied</h2> <h2 className="text-2xl font-bold text-center mb-12 text-gray-800">Ihre Vorteile als Mitglied</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8"> <div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{features.split('\n').filter((f: string) => f.trim() !== '').map((feature: string, idx: number) => ( {features.split('\n').filter((f: string) => f.trim() !== '').map((feature: string, idx: number) => (
<div key={idx} className="bg-white p-6 rounded-2xl shadow-sm border border-gray-100 flex flex-col items-center text-center space-y-4 hover:shadow-md transition-shadow"> <div key={idx} className="bg-white p-6 rounded-2xl shadow-sm border border-gray-100 flex flex-col items-center text-center space-y-4 hover:shadow-md transition-shadow">
<div className="w-12 h-12 rounded-full flex items-center justify-center" style={{ backgroundColor: secondaryColor ? `${secondaryColor}15` : `${primaryColor}15`, color: secondaryColor || primaryColor }}> <div className="w-12 h-12 rounded-full flex items-center justify-center" style={{ backgroundColor: secondaryColor ? `${secondaryColor}15` : `${primaryColor}15`, color: secondaryColor || primaryColor }}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-6 h-6"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /> <path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg> </svg>
</div> </div>
<h3 className="font-semibold text-gray-800">{feature.replace(/^[-\*\✅\ ]+/, '')}</h3> <h3 className="font-semibold text-gray-800">{feature.replace(/^[-\*\✅\ ]+/, '')}</h3>
</div> </div>
))} ))}
</div> </div>
</div> </div>
</section> </section>
{/* App Features Grid */} {/* App Features Grid */}
<section id="app" className="px-8 py-20 bg-white"> <section id="app" className="px-8 py-20 bg-white">
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
<div className="text-center mb-16 space-y-4"> <div className="text-center mb-16 space-y-4">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full text-sm font-semibold mb-2" style={{ backgroundColor: `${primaryColor}10`, color: primaryColor }}> <div className="inline-flex items-center gap-2 px-3 py-1 rounded-full text-sm font-semibold mb-2" style={{ backgroundColor: `${primaryColor}10`, color: primaryColor }}>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg> <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
Alles in einer App Alles in einer App
</div> </div>
<h2 className="text-3xl md:text-4xl font-black text-gray-900">{sectionTitle}</h2> <h2 className="text-3xl md:text-4xl font-black text-gray-900">{sectionTitle}</h2>
<p className="text-lg text-gray-500 max-w-2xl mx-auto"> <p className="text-lg text-gray-500 max-w-2xl mx-auto">
Verpassen Sie keine wichtigen Branchen-Updates mehr. Vernetzen Sie sich mit anderen Betrieben und verwalten Sie Termine bequem auf dem Smartphone. Verpassen Sie keine wichtigen Branchen-Updates mehr. Vernetzen Sie sich mit anderen Betrieben und verwalten Sie Termine bequem auf dem Smartphone.
</p> </p>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Feature 1: Aktuelles */} {/* Feature 1: Aktuelles */}
<div className="p-8 rounded-[2rem] border border-gray-100 bg-white shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 group"> <div className="p-8 rounded-[2rem] border border-gray-100 bg-white shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 group">
<div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300" style={{ color: primaryColor }}> <div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300" style={{ color: primaryColor }}>
<svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" /></svg> <svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" /></svg>
</div> </div>
<h3 className="text-xl font-bold text-gray-900 mb-3">Aktuelles</h3> <h3 className="text-xl font-bold text-gray-900 mb-3">Aktuelles</h3>
<p className="text-gray-500 leading-relaxed"> <p className="text-gray-500 leading-relaxed">
Lesen Sie die wichtigsten Branchen-News und bleiben Sie immer auf dem aktuellsten Stand. Lesen Sie die wichtigsten Branchen-News und bleiben Sie immer auf dem aktuellsten Stand.
</p> </p>
</div> </div>
{/* Feature 2: Termine */} {/* Feature 2: Termine */}
<div className="p-8 rounded-[2rem] border border-gray-100 bg-white shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 group"> <div className="p-8 rounded-[2rem] border border-gray-100 bg-white shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 group">
<div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300" style={{ color: primaryColor }}> <div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300" style={{ color: primaryColor }}>
<svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg> <svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
</div> </div>
<h3 className="text-xl font-bold text-gray-900 mb-3">Termine</h3> <h3 className="text-xl font-bold text-gray-900 mb-3">Termine</h3>
<p className="text-gray-500 leading-relaxed"> <p className="text-gray-500 leading-relaxed">
Verwalten Sie Veranstaltungen, Fortbildungen und Innungsversammlungen direkt in Ihrem Kalender. Verwalten Sie Veranstaltungen, Fortbildungen und Innungsversammlungen direkt in Ihrem Kalender.
</p> </p>
</div> </div>
{/* Feature 3: Stellen */} {/* Feature 3: Stellen */}
<div className="p-8 rounded-[2rem] border border-gray-100 bg-white shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 group"> <div className="p-8 rounded-[2rem] border border-gray-100 bg-white shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 group">
<div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300" style={{ color: primaryColor }}> <div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300" style={{ color: primaryColor }}>
<svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg> <svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
</div> </div>
<h3 className="text-xl font-bold text-gray-900 mb-3">Stellenbörse</h3> <h3 className="text-xl font-bold text-gray-900 mb-3">Stellenbörse</h3>
<p className="text-gray-500 leading-relaxed"> <p className="text-gray-500 leading-relaxed">
Finden Sie neue Fachkräfte oder Auszubildende. Veröffentlichen Sie Ihre offenen Stellenangebote branchenintern. Finden Sie neue Fachkräfte oder Auszubildende. Veröffentlichen Sie Ihre offenen Stellenangebote branchenintern.
</p> </p>
</div> </div>
{/* Feature 4: Nachrichten */} {/* Feature 4: Nachrichten */}
<div className="p-8 rounded-[2rem] border border-gray-100 bg-white shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 group"> <div className="p-8 rounded-[2rem] border border-gray-100 bg-white shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 group">
<div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300" style={{ color: primaryColor }}> <div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300" style={{ color: primaryColor }}>
<svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" /></svg> <svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" /></svg>
</div> </div>
<h3 className="text-xl font-bold text-gray-900 mb-3">Nachrichten</h3> <h3 className="text-xl font-bold text-gray-900 mb-3">Nachrichten</h3>
<p className="text-gray-500 leading-relaxed"> <p className="text-gray-500 leading-relaxed">
Tauschen Sie sich mit anderen Betrieben aus. Schnelle Kontaktaufnahme über direkte Einzel- und Gruppenchats. Tauschen Sie sich mit anderen Betrieben aus. Schnelle Kontaktaufnahme über direkte Einzel- und Gruppenchats.
</p> </p>
</div> </div>
{/* Feature 5: Profil */} {/* Feature 5: Profil */}
<div className="p-8 rounded-[2rem] border border-gray-100 bg-white shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 group"> <div className="p-8 rounded-[2rem] border border-gray-100 bg-white shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 group">
<div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300" style={{ color: primaryColor }}> <div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300" style={{ color: primaryColor }}>
<svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg> <svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>
</div> </div>
<h3 className="text-xl font-bold text-gray-900 mb-3">Profil & Ausweis</h3> <h3 className="text-xl font-bold text-gray-900 mb-3">Profil & Ausweis</h3>
<p className="text-gray-500 leading-relaxed"> <p className="text-gray-500 leading-relaxed">
Ihr digitaler Mitgliedsausweis immer griffbereit. Verwalten Sie das Profil Ihres Betriebs komfortabel in der App. Ihr digitaler Mitgliedsausweis immer griffbereit. Verwalten Sie das Profil Ihres Betriebs komfortabel in der App.
</p> </p>
</div> </div>
{/* Feature 6: Partner */} {/* Feature 6: Partner */}
<div className="p-8 rounded-[2rem] border border-gray-100 bg-white shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 group"> <div className="p-8 rounded-[2rem] border border-gray-100 bg-white shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 group">
<div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300" style={{ color: primaryColor }}> <div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300" style={{ color: primaryColor }}>
<svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /></svg> <svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /></svg>
</div> </div>
<h3 className="text-xl font-bold text-gray-900 mb-3">Netzwerk</h3> <h3 className="text-xl font-bold text-gray-900 mb-3">Netzwerk</h3>
<p className="text-gray-500 leading-relaxed"> <p className="text-gray-500 leading-relaxed">
Profitieren Sie von starken Kooperationen und Angeboten ausgewählter Partnerbetriebe in der Region. Profitieren Sie von starken Kooperationen und Angeboten ausgewählter Partnerbetriebe in der Region.
</p> </p>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
{/* Application Mock */} {/* Application Mock */}
<section id="apps" className="px-8 py-32 relative overflow-hidden" style={{ <section id="apps" className="px-8 py-32 relative overflow-hidden" style={{
background: `linear-gradient(to bottom, #ffffff 0%, ${primaryColor} 40%, #111827 100%)` background: `linear-gradient(to bottom, #ffffff 0%, ${primaryColor} 40%, #111827 100%)`
}}> }}>
{/* Decorative background elements */} {/* Decorative background elements */}
<div className="absolute inset-0 opacity-10" style={{ backgroundImage: 'radial-gradient(circle at 2px 2px, white 1px, transparent 0)', backgroundSize: '40px 40px' }}></div> <div className="absolute inset-0 opacity-10" style={{ backgroundImage: 'radial-gradient(circle at 2px 2px, white 1px, transparent 0)', backgroundSize: '40px 40px' }}></div>
<div className="absolute top-0 right-0 -mr-40 -mt-40 w-[500px] h-[500px] rounded-full bg-white/20 blur-[100px] pointer-events-none"></div> <div className="absolute top-0 right-0 -mr-40 -mt-40 w-[500px] h-[500px] rounded-full bg-white/20 blur-[100px] pointer-events-none"></div>
<div className="absolute bottom-0 left-0 -ml-40 -mb-40 w-[500px] h-[500px] rounded-full border-[40px] border-white/5 pointer-events-none"></div> <div className="absolute bottom-0 left-0 -ml-40 -mb-40 w-[500px] h-[500px] rounded-full border-[40px] border-white/5 pointer-events-none"></div>
<div className="max-w-6xl mx-auto flex flex-col md:flex-row items-center gap-16 relative z-10"> <div className="max-w-6xl mx-auto flex flex-col md:flex-row items-center gap-16 relative z-10">
<div className="flex-1 text-left space-y-8 text-white"> <div className="flex-1 text-left space-y-8 text-white">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white/10 backdrop-blur-md border border-white/20 text-sm font-medium"> <div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white/10 backdrop-blur-md border border-white/20 text-sm font-medium">
<span className="w-2 h-2 rounded-full bg-green-400 animate-pulse"></span> <span className="w-2 h-2 rounded-full bg-green-400 animate-pulse"></span>
Jetzt verfügbar Jetzt verfügbar
</div> </div>
<h2 className="text-4xl md:text-5xl font-black leading-tight"> <h2 className="text-4xl md:text-5xl font-black leading-tight">
Laden Sie unsere App herunter Laden Sie unsere App herunter
</h2> </h2>
<p className="text-white/80 text-xl leading-relaxed max-w-lg"> <p className="text-white/80 text-xl leading-relaxed max-w-lg">
Bleiben Sie immer auf dem Laufenden mit der {org.name || 'Innungs'}-App für Mitglieder. Alle News, Termine und Ihr digitaler Mitgliedsausweis direkt auf Ihrem Smartphone. Bleiben Sie immer auf dem Laufenden mit der {org.name || 'Innungs'}-App für Mitglieder. Alle News, Termine und Ihr digitaler Mitgliedsausweis direkt auf Ihrem Smartphone.
</p> </p>
<div className="flex flex-wrap gap-4 pt-4"> <div className="flex flex-wrap gap-4 pt-4">
{(!org.appStoreUrl && !org.playStoreUrl) || org.appStoreUrl ? ( {(!org.appStoreUrl && !org.playStoreUrl) || org.appStoreUrl ? (
<a href={org.appStoreUrl || "#"} target="_blank" rel="noreferrer" className="bg-black hover:bg-black/80 text-white px-8 py-4 rounded-2xl cursor-pointer transition-all flex items-center gap-4 shadow-xl hover:shadow-2xl transform hover:-translate-y-1"> <a href={org.appStoreUrl || "#"} target="_blank" rel="noreferrer" className="bg-black hover:bg-black/80 text-white px-8 py-4 rounded-2xl cursor-pointer transition-all flex items-center gap-4 shadow-xl hover:shadow-2xl transform hover:-translate-y-1">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512" className="w-8 h-8 fill-current"><path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z" /></svg> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512" className="w-8 h-8 fill-current"><path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z" /></svg>
<div> <div>
<div className="text-xs text-white/70">Download on the</div> <div className="text-xs text-white/70">Download on the</div>
<div className="text-lg font-semibold leading-none">App Store</div> <div className="text-lg font-semibold leading-none">App Store</div>
</div> </div>
</a> </a>
) : null} ) : null}
{(!org.appStoreUrl && !org.playStoreUrl) || org.playStoreUrl ? ( {(!org.appStoreUrl && !org.playStoreUrl) || org.playStoreUrl ? (
<a href={org.playStoreUrl || "#"} target="_blank" rel="noreferrer" className="bg-black hover:bg-black/80 text-white px-8 py-4 rounded-2xl cursor-pointer transition-all flex items-center gap-4 shadow-xl hover:shadow-2xl transform hover:-translate-y-1"> <a href={org.playStoreUrl || "#"} target="_blank" rel="noreferrer" className="bg-black hover:bg-black/80 text-white px-8 py-4 rounded-2xl cursor-pointer transition-all flex items-center gap-4 shadow-xl hover:shadow-2xl transform hover:-translate-y-1">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" className="w-8 h-8 fill-current"><path d="M325.3 234.3L104.6 13l280.8 161.2-60.1 60.1zM47 0C34 6.8 25.3 19.2 25.3 35.3v441.3c0 16.1 8.7 28.5 21.7 35.3l256.6-256L47 0zm425.2 225.6l-58.9-34.1-65.7 64.5 65.7 64.5 60.1-34.1c18-14.3 18-46.5-1.2-60.8zM104.6 499l280.8-161.2-60.1-60.1L104.6 499z" /></svg> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" className="w-8 h-8 fill-current"><path d="M325.3 234.3L104.6 13l280.8 161.2-60.1 60.1zM47 0C34 6.8 25.3 19.2 25.3 35.3v441.3c0 16.1 8.7 28.5 21.7 35.3l256.6-256L47 0zm425.2 225.6l-58.9-34.1-65.7 64.5 65.7 64.5 60.1-34.1c18-14.3 18-46.5-1.2-60.8zM104.6 499l280.8-161.2-60.1-60.1L104.6 499z" /></svg>
<div> <div>
<div className="text-xs text-white/70">GET IT ON</div> <div className="text-xs text-white/70">GET IT ON</div>
<div className="text-lg font-semibold leading-none">Google Play</div> <div className="text-lg font-semibold leading-none">Google Play</div>
</div> </div>
</a> </a>
) : null} ) : null}
</div> </div>
</div> </div>
<div className="flex-1 w-full flex justify-center mt-12 md:mt-0 perspective-[2000px]"> <div className="flex-1 w-full flex justify-center mt-12 md:mt-0 perspective-[2000px]">
<div className="relative w-[280px] h-[580px] rounded-[3rem] border-[12px] border-black bg-black shadow-2xl overflow-hidden transform rotate-y-[-15deg] rotate-x-[10deg] rotate-z-[5deg] hover:rotate-y-[0deg] hover:rotate-x-[0deg] hover:rotate-z-[0deg] transition-all duration-700 ease-out"> <div className="relative w-[280px] h-[580px] rounded-[3rem] border-[12px] border-black bg-black shadow-2xl overflow-hidden transform rotate-y-[-15deg] rotate-x-[10deg] rotate-z-[5deg] hover:rotate-y-[0deg] hover:rotate-x-[0deg] hover:rotate-z-[0deg] transition-all duration-700 ease-out">
{/* Notch */} {/* Notch */}
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-32 h-6 bg-black rounded-b-3xl z-20"></div> <div className="absolute top-0 left-1/2 -translate-x-1/2 w-32 h-6 bg-black rounded-b-3xl z-20"></div>
{/* App Screenshot Mockup */} {/* App Screenshot Mockup */}
<div className="w-full h-full bg-gray-50 flex flex-col pt-6"> <div className="w-full h-full bg-gray-50 flex flex-col pt-6">
{/* App Header */} {/* App Header */}
<div className="px-5 py-4 flex items-center justify-between bg-white border-b border-gray-100"> <div className="px-5 py-4 flex items-center justify-between bg-white border-b border-gray-100">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{org.logoUrl ? ( {org.logoUrl ? (
<img src={org.logoUrl} alt="Logo" className="w-8 h-8 object-contain" /> <img src={org.logoUrl} alt="Logo" className="w-8 h-8 object-contain" />
) : ( ) : (
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white font-bold text-xs shadow-sm" style={{ backgroundColor: primaryColor }}> <div className="w-8 h-8 rounded-full flex items-center justify-center text-white font-bold text-xs shadow-sm" style={{ backgroundColor: primaryColor }}>
{org.name ? org.name.charAt(0).toUpperCase() : 'I'} {org.name ? org.name.charAt(0).toUpperCase() : 'I'}
</div> </div>
)} )}
<div className="font-bold text-sm text-gray-800 truncate w-28">{org.name || 'Ihre Innung'}</div> <div className="font-bold text-sm text-gray-800 truncate w-28">{org.name || 'Ihre Innung'}</div>
</div> </div>
<div className="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center"> <div className="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center">
<svg className="w-4 h-4 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /></svg> <svg className="w-4 h-4 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /></svg>
</div> </div>
</div> </div>
{/* App Content */} {/* App Content */}
<div className="p-5 space-y-6 flex-1 overflow-hidden"> <div className="p-5 space-y-6 flex-1 overflow-hidden">
<div className="w-full h-32 rounded-2xl relative overflow-hidden flex items-end p-4 shadow-sm" style={{ backgroundColor: primaryColor }}> <div className="w-full h-32 rounded-2xl relative overflow-hidden flex items-end p-4 shadow-sm" style={{ backgroundColor: primaryColor }}>
<div className="absolute inset-0 bg-black/10"></div> <div className="absolute inset-0 bg-black/10"></div>
<div className="absolute -top-10 -right-10 w-32 h-32 bg-white/10 rounded-full blur-2xl"></div> <div className="absolute -top-10 -right-10 w-32 h-32 bg-white/10 rounded-full blur-2xl"></div>
<div className="relative z-10 text-white font-bold text-lg leading-tight">Willkommen,<br />Max Mustermann</div> <div className="relative z-10 text-white font-bold text-lg leading-tight">Willkommen,<br />Max Mustermann</div>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="text-sm font-bold text-gray-800">Aktuelle News</div> <div className="text-sm font-bold text-gray-800">Aktuelle News</div>
<div className="text-xs text-gray-400 font-medium">Alle ansehen</div> <div className="text-xs text-gray-400 font-medium">Alle ansehen</div>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<div className="bg-white rounded-xl border border-gray-100 p-3 flex gap-3 shadow-sm items-center"> <div className="bg-white rounded-xl border border-gray-100 p-3 flex gap-3 shadow-sm items-center">
<div className="w-12 h-12 rounded-lg flex-shrink-0" style={{ backgroundColor: `${primaryColor}15` }}></div> <div className="w-12 h-12 rounded-lg flex-shrink-0" style={{ backgroundColor: `${primaryColor}15` }}></div>
<div className="flex-1 space-y-2"> <div className="flex-1 space-y-2">
<div className="h-3 w-5/6 bg-gray-200 rounded-full"></div> <div className="h-3 w-5/6 bg-gray-200 rounded-full"></div>
<div className="h-2 w-full bg-gray-100 rounded-full"></div> <div className="h-2 w-full bg-gray-100 rounded-full"></div>
</div> </div>
</div> </div>
<div className="bg-white rounded-xl border border-gray-100 p-3 flex gap-3 shadow-sm items-center"> <div className="bg-white rounded-xl border border-gray-100 p-3 flex gap-3 shadow-sm items-center">
<div className="w-12 h-12 rounded-lg flex-shrink-0" style={{ backgroundColor: `${primaryColor}15` }}></div> <div className="w-12 h-12 rounded-lg flex-shrink-0" style={{ backgroundColor: `${primaryColor}15` }}></div>
<div className="flex-1 space-y-2"> <div className="flex-1 space-y-2">
<div className="h-3 w-2/3 bg-gray-200 rounded-full"></div> <div className="h-3 w-2/3 bg-gray-200 rounded-full"></div>
<div className="h-2 w-4/5 bg-gray-100 rounded-full"></div> <div className="h-2 w-4/5 bg-gray-100 rounded-full"></div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{/* App Bottom Nav */} {/* App Bottom Nav */}
<div className="h-[72px] bg-white border-t border-gray-100 flex items-center justify-between px-4 pb-2 pt-2 shadow-[0_-4px_20px_rgba(0,0,0,0.03)] z-20"> <div className="h-[72px] bg-white border-t border-gray-100 flex items-center justify-between px-4 pb-2 pt-2 shadow-[0_-4px_20px_rgba(0,0,0,0.03)] z-20">
<div className="flex flex-col items-center gap-1 w-1/6"> <div className="flex flex-col items-center gap-1 w-1/6">
<svg className="w-5 h-5" style={{ color: primaryColor }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /></svg> <svg className="w-5 h-5" style={{ color: primaryColor }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /></svg>
<span className="text-[9px] font-semibold" style={{ color: primaryColor }}>Start</span> <span className="text-[9px] font-semibold" style={{ color: primaryColor }}>Start</span>
</div> </div>
<div className="flex flex-col items-center gap-1 text-gray-400 w-1/6"> <div className="flex flex-col items-center gap-1 text-gray-400 w-1/6">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" /></svg> <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" /></svg>
<span className="text-[9px] font-medium">Aktuelles</span> <span className="text-[9px] font-medium">Aktuelles</span>
</div> </div>
<div className="flex flex-col items-center gap-1 text-gray-400 w-1/6"> <div className="flex flex-col items-center gap-1 text-gray-400 w-1/6">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg> <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
<span className="text-[9px] font-medium">Termine</span> <span className="text-[9px] font-medium">Termine</span>
</div> </div>
<div className="flex flex-col items-center gap-1 text-gray-400 w-1/6"> <div className="flex flex-col items-center gap-1 text-gray-400 w-1/6">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg> <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
<span className="text-[9px] font-medium">Stellen</span> <span className="text-[9px] font-medium">Stellen</span>
</div> </div>
<div className="flex flex-col items-center gap-1 text-gray-400 w-1/6"> <div className="flex flex-col items-center gap-1 text-gray-400 w-1/6">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" /></svg> <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" /></svg>
<span className="text-[9px] font-medium">Nachricht..</span> <span className="text-[9px] font-medium">Nachricht..</span>
</div> </div>
<div className="flex flex-col items-center gap-1 text-gray-400 w-1/6"> <div className="flex flex-col items-center gap-1 text-gray-400 w-1/6">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg> <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>
<span className="text-[9px] font-medium">Profil</span> <span className="text-[9px] font-medium">Profil</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
{/* CTA Section */} {/* CTA Section */}
<section id="mitglied-werden" className="px-8 py-24 bg-gray-50 text-center relative z-20"> <section id="mitglied-werden" className="px-8 py-24 bg-gray-50 text-center relative z-20">
<div className="max-w-3xl mx-auto space-y-8"> <div className="max-w-3xl mx-auto space-y-8">
<h2 className="text-3xl md:text-4xl font-bold text-gray-900">Werden Sie jetzt Teil der Gemeinschaft</h2> <h2 className="text-3xl md:text-4xl font-bold text-gray-900">Werden Sie jetzt Teil der Gemeinschaft</h2>
<p className="text-lg text-gray-600"> <p className="text-lg text-gray-600">
Profitieren Sie von unserem starken Netzwerk, exklusiven Brancheninformationen und unserer digitalen Innungs-App. Profitieren Sie von unserem starken Netzwerk, exklusiven Brancheninformationen und unserer digitalen Innungs-App.
</p> </p>
<a <a
href="#apps" href="#apps"
className="inline-block px-10 py-4 rounded-full text-white font-bold text-lg shadow-xl hover:shadow-2xl hover:-translate-y-1 transition-all" className="inline-block px-10 py-4 rounded-full text-white font-bold text-lg shadow-xl hover:shadow-2xl hover:-translate-y-1 transition-all"
style={{ backgroundColor: primaryColor }} style={{ backgroundColor: primaryColor }}
> >
Jetzt Mitglied werden Jetzt Mitglied werden
</a> </a>
</div> </div>
</section> </section>
{/* Footer */} {/* Footer */}
<footer className="bg-gray-900 text-gray-400 py-12 px-8 text-center text-sm"> <footer className="bg-gray-900 text-gray-400 py-12 px-8 text-center text-sm">
<div className="max-w-4xl mx-auto space-y-4"> <div className="max-w-4xl mx-auto space-y-4">
<div className="text-gray-300 font-bold text-lg mb-6">{org.name || 'Innungs-Logo'}</div> <div className="text-gray-300 font-bold text-lg mb-6">{org.name || 'Innungs-Logo'}</div>
<div className="whitespace-pre-wrap">{footer}</div> <div className="whitespace-pre-wrap">{footer}</div>
<div className="pt-8 border-t border-gray-800 flex justify-center gap-6"> <div className="pt-8 border-t border-gray-800 flex justify-center gap-6">
<Link href="/impressum" className="hover:text-white transition-colors">Impressum</Link> <Link href="/impressum" className="hover:text-white transition-colors">Impressum</Link>
<Link href="/datenschutz" className="hover:text-white transition-colors">Datenschutz</Link> <Link href="/datenschutz" className="hover:text-white transition-colors">Datenschutz</Link>
<Link href="/kontakt" className="hover:text-white transition-colors">Kontakt</Link> <Link href="/kontakt" className="hover:text-white transition-colors">Kontakt</Link>
</div> </div>
</div> </div>
</footer> </footer>
</div> </div>
) )
} }

View File

@ -1,126 +1,126 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import OpenAI from 'openai' import OpenAI from 'openai'
type LlmProvider = 'openai' | 'openrouter' type LlmProvider = 'openai' | 'openrouter'
function getProvider(): LlmProvider { function getProvider(): LlmProvider {
const configured = (process.env.LLM_PROVIDER ?? '').toLowerCase() const configured = (process.env.LLM_PROVIDER ?? '').toLowerCase()
if (configured === 'openrouter') return 'openrouter' if (configured === 'openrouter') return 'openrouter'
if (configured === 'openai') return 'openai' if (configured === 'openai') return 'openai'
return process.env.OPENROUTER_API_KEY ? 'openrouter' : 'openai' return process.env.OPENROUTER_API_KEY ? 'openrouter' : 'openai'
} }
function createClient(provider: LlmProvider) { function createClient(provider: LlmProvider) {
if (provider === 'openrouter') { if (provider === 'openrouter') {
const apiKey = process.env.OPENROUTER_API_KEY || '' const apiKey = process.env.OPENROUTER_API_KEY || ''
return new OpenAI({ return new OpenAI({
apiKey, apiKey,
baseURL: process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1', baseURL: process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1',
defaultHeaders: { defaultHeaders: {
...(process.env.OPENROUTER_SITE_URL ...(process.env.OPENROUTER_SITE_URL
? { 'HTTP-Referer': process.env.OPENROUTER_SITE_URL } ? { 'HTTP-Referer': process.env.OPENROUTER_SITE_URL }
: {}), : {}),
...(process.env.OPENROUTER_APP_NAME ...(process.env.OPENROUTER_APP_NAME
? { 'X-Title': process.env.OPENROUTER_APP_NAME } ? { 'X-Title': process.env.OPENROUTER_APP_NAME }
: {}), : {}),
}, },
}) })
} }
return new OpenAI({ return new OpenAI({
apiKey: process.env.OPENAI_API_KEY || '', apiKey: process.env.OPENAI_API_KEY || '',
}) })
} }
function getModel(provider: LlmProvider): string { function getModel(provider: LlmProvider): string {
if (provider === 'openrouter') { if (provider === 'openrouter') {
return process.env.OPENROUTER_MODEL || 'minimax/minimax-m2.5' return process.env.OPENROUTER_MODEL || 'minimax/minimax-m2.5'
} }
return process.env.OPENAI_MODEL || 'gpt-4o-mini' return process.env.OPENAI_MODEL || 'gpt-4o-mini'
} }
function hasApiKey(provider: LlmProvider): boolean { function hasApiKey(provider: LlmProvider): boolean {
if (provider === 'openrouter') return !!process.env.OPENROUTER_API_KEY if (provider === 'openrouter') return !!process.env.OPENROUTER_API_KEY
return !!process.env.OPENAI_API_KEY return !!process.env.OPENAI_API_KEY
} }
function buildFallbackLandingContent(orgName: string, context: string) { function buildFallbackLandingContent(orgName: string, context: string) {
const cleanOrg = orgName.trim() const cleanOrg = orgName.trim()
const cleanContext = context.trim().replace(/\s+/g, ' ') const cleanContext = context.trim().replace(/\s+/g, ' ')
const shortContext = cleanContext.slice(0, 180) const shortContext = cleanContext.slice(0, 180)
const detailSentence = shortContext const detailSentence = shortContext
? `Dabei stehen insbesondere ${shortContext}.` ? `Dabei stehen insbesondere ${shortContext}.`
: 'Dabei stehen regionale Vernetzung, starke Ausbildung und praxisnahe Unterstützung im Mittelpunkt.' : 'Dabei stehen regionale Vernetzung, starke Ausbildung und praxisnahe Unterstützung im Mittelpunkt.'
return { return {
title: `${cleanOrg} - Stark im Handwerk`, title: `${cleanOrg} - Stark im Handwerk`,
text: `${cleanOrg} verbindet Betriebe, stärkt die Gemeinschaft und setzt sich für die Interessen des Handwerks vor Ort ein. ${detailSentence}`, text: `${cleanOrg} verbindet Betriebe, stärkt die Gemeinschaft und setzt sich für die Interessen des Handwerks vor Ort ein. ${detailSentence}`,
fallbackUsed: true, fallbackUsed: true,
} }
} }
export async function POST(req: Request) { export async function POST(req: Request) {
let parsedBody: any = null let parsedBody: any = null
try { try {
const body = await req.json() const body = await req.json()
parsedBody = body parsedBody = body
const { orgName, context } = body const { orgName, context } = body
if (!orgName || !context) { if (!orgName || !context) {
return NextResponse.json({ error: 'orgName and context are required' }, { status: 400 }) return NextResponse.json({ error: 'orgName and context are required' }, { status: 400 })
} }
const provider = getProvider() const provider = getProvider()
const model = getModel(provider) const model = getModel(provider)
if (!hasApiKey(provider)) { if (!hasApiKey(provider)) {
return NextResponse.json(buildFallbackLandingContent(orgName, context)) return NextResponse.json(buildFallbackLandingContent(orgName, context))
} }
const client = createClient(provider) const client = createClient(provider)
const systemMessage = `Sie sind ein professioneller Copywriter für eine moderne deutsche Innung oder Kreishandwerkerschaft. const systemMessage = `Sie sind ein professioneller Copywriter für eine moderne deutsche Innung oder Kreishandwerkerschaft.
Erstellen Sie eine moderne, ansprechende Überschrift (Heading) und einen Einleitungstext für eine Landingpage. Erstellen Sie eine moderne, ansprechende Überschrift (Heading) und einen Einleitungstext für eine Landingpage.
WICHTIG: Geben Sie AUSSCHLIESSLICH ein valides JSON-Objekt zurück, komplett ohne Markdown-Formatierung (kein \`\`\`json ... \`\`\`), in dieser Struktur: WICHTIG: Geben Sie AUSSCHLIESSLICH ein valides JSON-Objekt zurück, komplett ohne Markdown-Formatierung (kein \`\`\`json ... \`\`\`), in dieser Struktur:
{ {
"title": "Eine moderne, ansprechende Überschrift (max. 6-8 Wörter)", "title": "Eine moderne, ansprechende Überschrift (max. 6-8 Wörter)",
"text": "Ein überzeugender Einleitungstext, der erklärt, wofür die Organisation steht, fokussiert auf die Region und den Kontext (max. 3-4 Sätze)." "text": "Ein überzeugender Einleitungstext, der erklärt, wofür die Organisation steht, fokussiert auf die Region und den Kontext (max. 3-4 Sätze)."
}` }`
const userMessage = `Name der Organisation: ${orgName}\nZusätzliche Stichpunkte vom Benutzer:\n${context}` const userMessage = `Name der Organisation: ${orgName}\nZusätzliche Stichpunkte vom Benutzer:\n${context}`
const completion = await client.chat.completions.create({ const completion = await client.chat.completions.create({
model, model,
messages: [ messages: [
{ role: 'system', content: systemMessage }, { role: 'system', content: systemMessage },
{ role: 'user', content: userMessage }, { role: 'user', content: userMessage },
], ],
// some openrouter models ignore response_format, so doing it purely by prompt // some openrouter models ignore response_format, so doing it purely by prompt
temperature: 0.7 temperature: 0.7
}) })
let textResponse = completion.choices[0]?.message?.content || '' let textResponse = completion.choices[0]?.message?.content || ''
// safely remove potential markdown blocks just in case // safely remove potential markdown blocks just in case
textResponse = textResponse.trim() textResponse = textResponse.trim()
if (textResponse.startsWith('```json')) { if (textResponse.startsWith('```json')) {
textResponse = textResponse.replace(/^```json\n?/, '').replace(/\n?```$/, '').trim() textResponse = textResponse.replace(/^```json\n?/, '').replace(/\n?```$/, '').trim()
} else if (textResponse.startsWith('```')) { } else if (textResponse.startsWith('```')) {
textResponse = textResponse.replace(/^```\n?/, '').replace(/\n?```$/, '').trim() textResponse = textResponse.replace(/^```\n?/, '').replace(/\n?```$/, '').trim()
} }
const result = JSON.parse(textResponse) const result = JSON.parse(textResponse)
return NextResponse.json(result) return NextResponse.json(result)
} catch (error: any) { } catch (error: any) {
console.error('Error generating AI landing page content:', error) console.error('Error generating AI landing page content:', error)
if (parsedBody?.orgName && parsedBody?.context) { if (parsedBody?.orgName && parsedBody?.context) {
return NextResponse.json(buildFallbackLandingContent(parsedBody.orgName, parsedBody.context)) return NextResponse.json(buildFallbackLandingContent(parsedBody.orgName, parsedBody.context))
} }
return NextResponse.json({ error: error?.message || 'Failed to generate content' }, { status: 500 }) return NextResponse.json({ error: error?.message || 'Failed to generate content' }, { status: 500 })
} }
} }

View File

@ -78,17 +78,17 @@ export async function POST(req: Request) {
} }
let systemMessage = '' let systemMessage = ''
if (type === 'news') { if (type === 'news') {
systemMessage = `Du bist ein erfahrener Newsletter- und PR-Experte für eine Innung (Handwerksverband). systemMessage = `Du bist ein erfahrener Newsletter- und PR-Experte für eine Innung (Handwerksverband).
Deine Aufgabe ist es, professionelle, ansprechende und informative News-Beiträge zu schreiben. Deine Aufgabe ist es, professionelle, ansprechende und informative News-Beiträge zu schreiben.
Achte auf eine klare Struktur, eine einladende Tonalität und hohe inhaltliche Qualität. Achte auf eine klare Struktur, eine einladende Tonalität und hohe inhaltliche Qualität.
Das gewünschte Ausgabeformat ist: ${format === 'markdown' ? 'Markdown' : 'Einfacher unformatierter Text'}.` Das gewünschte Ausgabeformat ist: ${format === 'markdown' ? 'Markdown' : 'Einfacher unformatierter Text'}.`
} else if (type === 'stelle') { } else if (type === 'stelle') {
systemMessage = `Du bist ein erfahrener HR- und Recruiting-Experte für das Handwerk. systemMessage = `Du bist ein erfahrener HR- und Recruiting-Experte für das Handwerk.
Deine Aufgabe ist es, attraktive und präzise Stellenanzeigen (Lehrlingsbörse / Jobbörse) zu verfassen. Deine Aufgabe ist es, attraktive und präzise Stellenanzeigen (Lehrlingsbörse / Jobbörse) zu verfassen.
Die Stellenanzeige soll Begeisterung wecken und klar die Aufgaben sowie Anforderungen kommunizieren. Die Stellenanzeige soll Begeisterung wecken und klar die Aufgaben sowie Anforderungen kommunizieren.
Das gewünschte Ausgabeformat ist: ${format === 'markdown' ? 'Markdown' : 'Einfacher unformatierter Text'}.` Das gewünschte Ausgabeformat ist: ${format === 'markdown' ? 'Markdown' : 'Einfacher unformatierter Text'}.`
} else { } else {
systemMessage = `Du bist ein hilfreicher KI-Assistent. Antworte immer auf Deutsch.` systemMessage = `Du bist ein hilfreicher KI-Assistent. Antworte immer auf Deutsch.`
} }
@ -153,8 +153,8 @@ Das gewünschte Ausgabeformat ist: ${format === 'markdown' ? 'Markdown' : 'Einfa
} catch (error: any) { } catch (error: any) {
console.error('AI Generate Error:', error) console.error('AI Generate Error:', error)
return NextResponse.json( return NextResponse.json(
{ error: error?.message || 'Internal Server Error' }, { error: error?.message || 'Internal Server Error' },
{ status: 500 } { status: 500 }
) )
} }
} }

View File

@ -1,17 +1,17 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { auth, getSanitizedHeaders } from '@/lib/auth' import { auth, getSanitizedHeaders } from '@/lib/auth'
import { prisma } from '@innungsapp/shared' import { prisma } from '@innungsapp/shared'
export async function POST() { export async function POST() {
const session = await auth.api.getSession({ headers: await getSanitizedHeaders() }) const session = await auth.api.getSession({ headers: await getSanitizedHeaders() })
if (!session?.user?.id) { if (!session?.user?.id) {
return NextResponse.json({ error: 'Nicht eingeloggt' }, { status: 401 }) return NextResponse.json({ error: 'Nicht eingeloggt' }, { status: 401 })
} }
await prisma.user.update({ await prisma.user.update({
where: { id: session.user.id }, where: { id: session.user.id },
data: { mustChangePassword: false }, data: { mustChangePassword: false },
}) })
return NextResponse.json({ success: true }) return NextResponse.json({ success: true })
} }

View File

@ -1,37 +1,37 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { auth, getSanitizedHeaders } from '@/lib/auth' import { auth, getSanitizedHeaders } from '@/lib/auth'
import { prisma } from '@innungsapp/shared' import { prisma } from '@innungsapp/shared'
// @ts-ignore // @ts-ignore
import { hashPassword } from 'better-auth/crypto' import { hashPassword } from 'better-auth/crypto'
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const session = await auth.api.getSession({ headers: await getSanitizedHeaders() }) const session = await auth.api.getSession({ headers: await getSanitizedHeaders() })
if (!session?.user?.id) { if (!session?.user?.id) {
return NextResponse.json({ error: 'Nicht eingeloggt' }, { status: 401 }) return NextResponse.json({ error: 'Nicht eingeloggt' }, { status: 401 })
} }
const { newPassword } = await req.json() const { newPassword } = await req.json()
if (!newPassword || newPassword.length < 8) { if (!newPassword || newPassword.length < 8) {
return NextResponse.json({ error: 'Passwort muss mindestens 8 Zeichen haben.' }, { status: 400 }) return NextResponse.json({ error: 'Passwort muss mindestens 8 Zeichen haben.' }, { status: 400 })
} }
const userId = session.user.id const userId = session.user.id
const newHash = await hashPassword(newPassword) const newHash = await hashPassword(newPassword)
const credAccount = await prisma.account.findFirst({ const credAccount = await prisma.account.findFirst({
where: { userId, providerId: 'credential' }, where: { userId, providerId: 'credential' },
}) })
if (credAccount) { if (credAccount) {
await prisma.account.update({ where: { id: credAccount.id }, data: { password: newHash } }) await prisma.account.update({ where: { id: credAccount.id }, data: { password: newHash } })
} else { } else {
const { randomUUID } = await import('node:crypto') const { randomUUID } = await import('node:crypto')
await prisma.account.create({ await prisma.account.create({
data: { id: randomUUID(), accountId: userId, providerId: 'credential', userId, password: newHash }, data: { id: randomUUID(), accountId: userId, providerId: 'credential', userId, password: newHash },
}) })
} }
await prisma.user.update({ where: { id: userId }, data: { mustChangePassword: false } }) await prisma.user.update({ where: { id: userId }, data: { mustChangePassword: false } })
return NextResponse.json({ success: true }) return NextResponse.json({ success: true })
} }

View File

@ -1,50 +1,50 @@
import { NextRequest } from 'next/server' import { NextRequest } from 'next/server'
import { auth, getSanitizedHeaders } from '@/lib/auth' import { auth, getSanitizedHeaders } from '@/lib/auth'
import { prisma } from '@innungsapp/shared' import { prisma } from '@innungsapp/shared'
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await auth.api.getSession({ headers: await getSanitizedHeaders(req.headers) }) const session = await auth.api.getSession({ headers: await getSanitizedHeaders(req.headers) })
if (!session?.user) { if (!session?.user) {
return new Response('Unauthorized', { status: 401 }) return new Response('Unauthorized', { status: 401 })
} }
const { id } = await params const { id } = await params
// Verify admin role via UserRole table // Verify admin role via UserRole table
const userRole = await prisma.userRole.findFirst({ const userRole = await prisma.userRole.findFirst({
where: { userId: session.user.id, role: 'admin' }, where: { userId: session.user.id, role: 'admin' },
}) })
if (!userRole) { if (!userRole) {
return new Response('Forbidden', { status: 403 }) return new Response('Forbidden', { status: 403 })
} }
const termin = await prisma.termin.findUnique({ const termin = await prisma.termin.findUnique({
where: { id, orgId: userRole.orgId }, where: { id, orgId: userRole.orgId },
include: { anmeldungen: { include: { member: true } } }, include: { anmeldungen: { include: { member: true } } },
}) })
if (!termin) { if (!termin) {
return new Response('Not found', { status: 404 }) return new Response('Not found', { status: 404 })
} }
if (termin.anmeldungen.length === 0) { if (termin.anmeldungen.length === 0) {
return new Response('Keine Anmeldungen vorhanden', { status: 404 }) return new Response('Keine Anmeldungen vorhanden', { status: 404 })
} }
const rows = termin.anmeldungen.map((a) => ({ const rows = termin.anmeldungen.map((a) => ({
Name: a.member.name, Name: a.member.name,
Email: a.member.email, Email: a.member.email,
Betrieb: a.member.betrieb ?? '', Betrieb: a.member.betrieb ?? '',
Angemeldet: new Date(a.angemeldetAt).toLocaleDateString('de-DE'), Angemeldet: new Date(a.angemeldetAt).toLocaleDateString('de-DE'),
})) }))
const header = Object.keys(rows[0]).join(';') const header = Object.keys(rows[0]).join(';')
const csv = [header, ...rows.map((r) => Object.values(r).join(';'))].join('\n') const csv = [header, ...rows.map((r) => Object.values(r).join(';'))].join('\n')
return new Response('\uFEFF' + csv, { return new Response('\uFEFF' + csv, {
headers: { headers: {
'Content-Type': 'text/csv; charset=utf-8', 'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="teilnehmer-${id}.csv"`, 'Content-Disposition': `attachment; filename="teilnehmer-${id}.csv"`,
}, },
}) })
} }

View File

@ -1,23 +1,23 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { auth, getSanitizedHeaders } from '@/lib/auth' import { auth, getSanitizedHeaders } from '@/lib/auth'
import { prisma } from '@innungsapp/shared' import { prisma } from '@innungsapp/shared'
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const session = await auth.api.getSession({ headers: await getSanitizedHeaders(req.headers) }) const session = await auth.api.getSession({ headers: await getSanitizedHeaders(req.headers) })
if (!session?.user) { if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const { token } = await req.json() const { token } = await req.json()
if (!token || typeof token !== 'string') { if (!token || typeof token !== 'string') {
return NextResponse.json({ error: 'Invalid token' }, { status: 400 }) return NextResponse.json({ error: 'Invalid token' }, { status: 400 })
} }
// Store push token on the member record // Store push token on the member record
await prisma.member.updateMany({ await prisma.member.updateMany({
where: { userId: session.user.id }, where: { userId: session.user.id },
data: { pushToken: token }, data: { pushToken: token },
}) })
return NextResponse.json({ success: true }) return NextResponse.json({ success: true })
} }

View File

@ -1,60 +1,60 @@
/** /**
* DEV-ONLY: Sets a password for the demo admin user via better-auth. * DEV-ONLY: Sets a password for the demo admin user via better-auth.
* Call once after seeding: GET http://localhost:3010/api/setup * Call once after seeding: GET http://localhost:3010/api/setup
* Remove this file before going to production. * Remove this file before going to production.
*/ */
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth' import { auth } from '@/lib/auth'
import { prisma } from '@innungsapp/shared' import { prisma } from '@innungsapp/shared'
export async function GET() { export async function GET() {
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
return NextResponse.json({ error: 'Not available in production' }, { status: 403 }) return NextResponse.json({ error: 'Not available in production' }, { status: 403 })
} }
// Delete the pre-seeded user so better-auth can create it fresh with a hashed password // Delete the pre-seeded user so better-auth can create it fresh with a hashed password
await prisma.account.deleteMany({ where: { userId: 'demo-admin-user-id' } }) await prisma.account.deleteMany({ where: { userId: 'demo-admin-user-id' } })
await prisma.member.deleteMany({ where: { userId: 'demo-admin-user-id' } }) await prisma.member.deleteMany({ where: { userId: 'demo-admin-user-id' } })
await prisma.userRole.deleteMany({ where: { userId: 'demo-admin-user-id' } }) await prisma.userRole.deleteMany({ where: { userId: 'demo-admin-user-id' } })
await prisma.user.deleteMany({ where: { id: 'demo-admin-user-id' } }) await prisma.user.deleteMany({ where: { id: 'demo-admin-user-id' } })
// Re-create via better-auth so the password is properly hashed // Re-create via better-auth so the password is properly hashed
const result = await auth.api.signUpEmail({ const result = await auth.api.signUpEmail({
body: { email: 'admin@demo.de', password: 'demo1234', name: 'Demo Admin' }, body: { email: 'admin@demo.de', password: 'demo1234', name: 'Demo Admin' },
}) })
if (!result?.user) { if (!result?.user) {
return NextResponse.json({ error: 'signUp failed', result }, { status: 500 }) return NextResponse.json({ error: 'signUp failed', result }, { status: 500 })
} }
const newUserId = result.user.id const newUserId = result.user.id
// Restore org membership for the new user ID // Restore org membership for the new user ID
const org = await prisma.organization.findFirst({ where: { slug: 'innung-elektro-stuttgart' } }) const org = await prisma.organization.findFirst({ where: { slug: 'innung-elektro-stuttgart' } })
if (org) { if (org) {
await prisma.userRole.upsert({ await prisma.userRole.upsert({
where: { orgId_userId: { orgId: org.id, userId: newUserId } }, where: { orgId_userId: { orgId: org.id, userId: newUserId } },
update: {}, update: {},
create: { orgId: org.id, userId: newUserId, role: 'admin' }, create: { orgId: org.id, userId: newUserId, role: 'admin' },
}) })
await prisma.member.upsert({ await prisma.member.upsert({
where: { userId: newUserId }, where: { userId: newUserId },
update: {}, update: {},
create: { create: {
orgId: org.id, orgId: org.id,
userId: newUserId, userId: newUserId,
name: 'Demo Admin', name: 'Demo Admin',
betrieb: 'Innungsgeschäftsstelle', betrieb: 'Innungsgeschäftsstelle',
sparte: 'Elektrotechnik', sparte: 'Elektrotechnik',
ort: 'Stuttgart', ort: 'Stuttgart',
email: 'admin@demo.de', email: 'admin@demo.de',
status: 'aktiv', status: 'aktiv',
}, },
}) })
} }
return NextResponse.json({ return NextResponse.json({
ok: true, ok: true,
message: 'Setup complete. Login: admin@demo.de / demo1234', message: 'Setup complete. Login: admin@demo.de / demo1234',
}) })
} }

View File

@ -1,61 +1,61 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { writeFile, mkdir } from 'fs/promises' import { writeFile, mkdir } from 'fs/promises'
import path from 'path' import path from 'path'
import { randomUUID } from 'crypto' import { randomUUID } from 'crypto'
import { auth, getSanitizedHeaders } from '@/lib/auth' import { auth, getSanitizedHeaders } from '@/lib/auth'
const UPLOAD_DIR = process.env.UPLOAD_DIR ?? (process.env.NODE_ENV === 'production' ? '/app/uploads' : './uploads') const UPLOAD_DIR = process.env.UPLOAD_DIR ?? (process.env.NODE_ENV === 'production' ? '/app/uploads' : './uploads')
const MAX_SIZE_BYTES = Number(process.env.UPLOAD_MAX_SIZE_MB ?? 10) * 1024 * 1024 const MAX_SIZE_BYTES = Number(process.env.UPLOAD_MAX_SIZE_MB ?? 10) * 1024 * 1024
function getUploadRoot() { function getUploadRoot() {
if (path.isAbsolute(UPLOAD_DIR)) { if (path.isAbsolute(UPLOAD_DIR)) {
return UPLOAD_DIR return UPLOAD_DIR
} }
return path.resolve(process.cwd(), UPLOAD_DIR) return path.resolve(process.cwd(), UPLOAD_DIR)
} }
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
// Auth check // Auth check
const session = await auth.api.getSession({ headers: await getSanitizedHeaders(req.headers) }) const session = await auth.api.getSession({ headers: await getSanitizedHeaders(req.headers) })
if (!session?.user) { if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const formData = await req.formData() const formData = await req.formData()
const file = formData.get('file') as File | null const file = formData.get('file') as File | null
if (!file) { if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 }) return NextResponse.json({ error: 'No file provided' }, { status: 400 })
} }
if (file.size > MAX_SIZE_BYTES) { if (file.size > MAX_SIZE_BYTES) {
return NextResponse.json({ error: 'File too large' }, { status: 413 }) return NextResponse.json({ error: 'File too large' }, { status: 413 })
} }
// Only allow safe file types // Only allow safe file types
const allowedTypes = [ const allowedTypes = [
'application/pdf', 'application/pdf',
'image/png', 'image/png',
'image/jpeg', 'image/jpeg',
'image/webp', 'image/webp',
'image/gif', 'image/gif',
] ]
if (!allowedTypes.includes(file.type)) { if (!allowedTypes.includes(file.type)) {
return NextResponse.json({ error: 'File type not allowed' }, { status: 415 }) return NextResponse.json({ error: 'File type not allowed' }, { status: 415 })
} }
const ext = path.extname(file.name) const ext = path.extname(file.name)
const fileName = `${randomUUID()}${ext}` const fileName = `${randomUUID()}${ext}`
const uploadPath = getUploadRoot() const uploadPath = getUploadRoot()
await mkdir(uploadPath, { recursive: true }) await mkdir(uploadPath, { recursive: true })
const buffer = Buffer.from(await file.arrayBuffer()) const buffer = Buffer.from(await file.arrayBuffer())
await writeFile(path.join(uploadPath, fileName), buffer) await writeFile(path.join(uploadPath, fileName), buffer)
return NextResponse.json({ return NextResponse.json({
storagePath: fileName, storagePath: fileName,
name: file.name, name: file.name,
sizeBytes: file.size, sizeBytes: file.size,
url: `/uploads/${fileName}`, url: `/uploads/${fileName}`,
}) })
} }

View File

@ -1,50 +1,50 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { readFile } from 'fs/promises' import { readFile } from 'fs/promises'
import path from 'path' import path from 'path'
const UPLOAD_DIR = process.env.UPLOAD_DIR ?? (process.env.NODE_ENV === 'production' ? '/app/uploads' : './uploads') const UPLOAD_DIR = process.env.UPLOAD_DIR ?? (process.env.NODE_ENV === 'production' ? '/app/uploads' : './uploads')
function getUploadRoot() { function getUploadRoot() {
if (path.isAbsolute(UPLOAD_DIR)) { if (path.isAbsolute(UPLOAD_DIR)) {
return UPLOAD_DIR return UPLOAD_DIR
} }
return path.resolve(process.cwd(), UPLOAD_DIR) return path.resolve(process.cwd(), UPLOAD_DIR)
} }
export async function GET( export async function GET(
req: NextRequest, req: NextRequest,
{ params }: { params: Promise<{ path: string[] }> } { params }: { params: Promise<{ path: string[] }> }
) { ) {
try { try {
const { path: filePathParams } = await params const { path: filePathParams } = await params
const uploadRoot = getUploadRoot() const uploadRoot = getUploadRoot()
const filePath = path.join(uploadRoot, ...filePathParams) const filePath = path.join(uploadRoot, ...filePathParams)
// Security: prevent path traversal // Security: prevent path traversal
const resolved = path.resolve(filePath) const resolved = path.resolve(filePath)
const uploadDir = path.resolve(uploadRoot) const uploadDir = path.resolve(uploadRoot)
if (!resolved.startsWith(uploadDir + path.sep) && resolved !== uploadDir) { if (!resolved.startsWith(uploadDir + path.sep) && resolved !== uploadDir) {
return new NextResponse('Forbidden', { status: 403 }) return new NextResponse('Forbidden', { status: 403 })
} }
const file = await readFile(resolved) const file = await readFile(resolved)
const ext = path.extname(resolved).toLowerCase() const ext = path.extname(resolved).toLowerCase()
const mimeTypes: Record<string, string> = { const mimeTypes: Record<string, string> = {
'.pdf': 'application/pdf', '.pdf': 'application/pdf',
'.png': 'image/png', '.png': 'image/png',
'.jpg': 'image/jpeg', '.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg', '.jpeg': 'image/jpeg',
'.gif': 'image/gif', '.gif': 'image/gif',
'.webp': 'image/webp', '.webp': 'image/webp',
} }
return new NextResponse(file, { return new NextResponse(file, {
headers: { headers: {
'Content-Type': mimeTypes[ext] ?? 'application/octet-stream', 'Content-Type': mimeTypes[ext] ?? 'application/octet-stream',
'Cache-Control': 'public, max-age=86400', 'Cache-Control': 'public, max-age=86400',
}, },
}) })
} catch { } catch {
return new NextResponse('Not Found', { status: 404 }) return new NextResponse('Not Found', { status: 404 })
} }
} }

View File

@ -1,107 +1,107 @@
import { auth, getSanitizedHeaders } from '@/lib/auth' import { auth, getSanitizedHeaders } from '@/lib/auth'
import { prisma } from '@innungsapp/shared' import { prisma } from '@innungsapp/shared'
import { headers } from 'next/headers' import { headers } from 'next/headers'
import Link from 'next/link' import Link from 'next/link'
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
export default async function GlobalDashboardRedirect() { export default async function GlobalDashboardRedirect() {
const headerList = await headers() const headerList = await headers()
const host = headerList.get('host') || '' const host = headerList.get('host') || ''
const session = await auth.api.getSession({ headers: await getSanitizedHeaders(headerList) }) const session = await auth.api.getSession({ headers: await getSanitizedHeaders(headerList) })
if (!session?.user) { if (!session?.user) {
redirect('/login') redirect('/login')
} }
// Superadmin logic // Superadmin logic
const superAdminEmail = process.env.SUPERADMIN_EMAIL || 'superadmin@innungsapp.de' const superAdminEmail = process.env.SUPERADMIN_EMAIL || 'superadmin@innungsapp.de'
const isSuperAdmin = session.user.email === superAdminEmail || session.user.role === 'admin' const isSuperAdmin = session.user.email === superAdminEmail || session.user.role === 'admin'
if (isSuperAdmin) { if (isSuperAdmin) {
redirect('/superadmin') redirect('/superadmin')
} }
const userRoles = await prisma.userRole.findMany({ const userRoles = await prisma.userRole.findMany({
where: { userId: session.user.id, role: 'admin' }, where: { userId: session.user.id, role: 'admin' },
include: { include: {
org: { org: {
select: { id: true, name: true, slug: true }, select: { id: true, name: true, slug: true },
}, },
}, },
orderBy: { createdAt: 'asc' }, orderBy: { createdAt: 'asc' },
}) })
if (userRoles.length === 1) { if (userRoles.length === 1) {
const slug = userRoles[0].org.slug const slug = userRoles[0].org.slug
const protocol = host.includes('localhost') ? 'http' : 'https' const protocol = host.includes('localhost') ? 'http' : 'https'
// Construct the subdomain URL // Construct the subdomain URL
let newHost = host let newHost = host
if (host.includes('localhost')) { if (host.includes('localhost')) {
const port = host.includes(':') ? `:${host.split(':')[1]}` : '' const port = host.includes(':') ? `:${host.split(':')[1]}` : ''
newHost = `${slug}.localhost${port}` newHost = `${slug}.localhost${port}`
} else { } else {
// Assumes domain.tld // Assumes domain.tld
const parts = host.split('.') const parts = host.split('.')
if (parts.length === 2) { if (parts.length === 2) {
newHost = `${slug}.${host}` newHost = `${slug}.${host}`
} else if (parts.length > 2) { } else if (parts.length > 2) {
newHost = `${slug}.${parts.slice(-2).join('.')}` newHost = `${slug}.${parts.slice(-2).join('.')}`
} }
} }
redirect(`${protocol}://${newHost}/dashboard`) redirect(`${protocol}://${newHost}/dashboard`)
} }
const getOrgUrl = (slug: string, currentHost: string) => { const getOrgUrl = (slug: string, currentHost: string) => {
const protocol = currentHost.includes('localhost') ? 'http' : 'https' const protocol = currentHost.includes('localhost') ? 'http' : 'https'
let newHost = currentHost let newHost = currentHost
if (currentHost.includes('localhost')) { if (currentHost.includes('localhost')) {
const port = currentHost.includes(':') ? `:${currentHost.split(':')[1]}` : '' const port = currentHost.includes(':') ? `:${currentHost.split(':')[1]}` : ''
newHost = `${slug}.localhost${port}` newHost = `${slug}.localhost${port}`
} else { } else {
const parts = currentHost.split('.') const parts = currentHost.split('.')
newHost = parts.length >= 2 ? `${slug}.${parts.slice(-2).join('.')}` : `${slug}.${currentHost}` newHost = parts.length >= 2 ? `${slug}.${parts.slice(-2).join('.')}` : `${slug}.${currentHost}`
} }
return `${protocol}://${newHost}/dashboard` return `${protocol}://${newHost}/dashboard`
} }
return ( return (
<div className="min-h-screen bg-gray-50 flex flex-col items-center justify-center p-4"> <div className="min-h-screen bg-gray-50 flex flex-col items-center justify-center p-4">
<div className="bg-white border rounded-xl p-8 max-w-md w-full text-center shadow-sm"> <div className="bg-white border rounded-xl p-8 max-w-md w-full text-center shadow-sm">
<h1 className="text-xl font-bold text-gray-900 mb-2"> <h1 className="text-xl font-bold text-gray-900 mb-2">
{userRoles.length > 1 ? 'Bitte Innung auswählen' : 'Kein Mandant zugeordnet'} {userRoles.length > 1 ? 'Bitte Innung auswählen' : 'Kein Mandant zugeordnet'}
</h1> </h1>
{userRoles.length > 1 ? ( {userRoles.length > 1 ? (
<div className="space-y-2 mb-6"> <div className="space-y-2 mb-6">
{userRoles.map((userRole) => ( {userRoles.map((userRole) => (
<Link <Link
key={userRole.org.id} key={userRole.org.id}
href={getOrgUrl(userRole.org.slug, host)} href={getOrgUrl(userRole.org.slug, host)}
className="block w-full rounded-lg border border-gray-200 px-4 py-3 text-sm text-gray-700 hover:border-brand-500 hover:text-brand-700 transition-colors" className="block w-full rounded-lg border border-gray-200 px-4 py-3 text-sm text-gray-700 hover:border-brand-500 hover:text-brand-700 transition-colors"
> >
{userRole.org.name} {userRole.org.name}
</Link> </Link>
))} ))}
</div> </div>
) : ( ) : (
<p className="text-gray-500 mb-6 text-sm"> <p className="text-gray-500 mb-6 text-sm">
Ihr Konto hat aktuell keinen Admin-Zugriff auf eine Innung. Ihr Konto hat aktuell keinen Admin-Zugriff auf eine Innung.
</p> </p>
)} )}
<form action={async () => { <form action={async () => {
'use server' 'use server'
const { auth, getSanitizedHeaders } = await import('@/lib/auth') const { auth, getSanitizedHeaders } = await import('@/lib/auth')
await auth.api.signOut({ headers: await getSanitizedHeaders() }) await auth.api.signOut({ headers: await getSanitizedHeaders() })
redirect('/login') redirect('/login')
}}> }}>
<button type="submit" className="text-sm font-medium text-brand-600 hover:text-brand-700"> <button type="submit" className="text-sm font-medium text-brand-600 hover:text-brand-700">
Abmelden Abmelden
</button> </button>
</form> </form>
</div> </div>
</div> </div>
) )
} }

View File

@ -1,133 +1,133 @@
'use client' 'use client'
import { useState } from 'react' import { useState } from 'react'
import { createAuthClient } from 'better-auth/react' import { createAuthClient } from 'better-auth/react'
const authClient = createAuthClient({ const authClient = createAuthClient({
baseURL: typeof window !== 'undefined' baseURL: typeof window !== 'undefined'
? window.location.origin ? window.location.origin
: (process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3010'), : (process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3010'),
}) })
export default function PasswortAendernPage() { export default function PasswortAendernPage() {
const [oldPassword, setOldPassword] = useState('') const [oldPassword, setOldPassword] = useState('')
const [newPassword, setNewPassword] = useState('') const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
setError('') setError('')
if (newPassword !== confirmPassword) { if (newPassword !== confirmPassword) {
setError('Die neuen Passwörter stimmen nicht überein.') setError('Die neuen Passwörter stimmen nicht überein.')
return return
} }
if (newPassword.length < 8) { if (newPassword.length < 8) {
setError('Das neue Passwort muss mindestens 8 Zeichen haben.') setError('Das neue Passwort muss mindestens 8 Zeichen haben.')
return return
} }
if (newPassword === oldPassword) { if (newPassword === oldPassword) {
setError('Das neue Passwort muss sich vom alten unterscheiden.') setError('Das neue Passwort muss sich vom alten unterscheiden.')
return return
} }
setLoading(true) setLoading(true)
const result = await authClient.changePassword({ const result = await authClient.changePassword({
currentPassword: oldPassword, currentPassword: oldPassword,
newPassword, newPassword,
revokeOtherSessions: false, revokeOtherSessions: false,
}) })
if (result.error) { if (result.error) {
setLoading(false) setLoading(false)
setError(result.error.message ?? 'Das alte Passwort ist falsch.') setError(result.error.message ?? 'Das alte Passwort ist falsch.')
return return
} }
// Mark mustChangePassword as done // Mark mustChangePassword as done
await fetch('/api/auth/clear-must-change-password', { method: 'POST' }) await fetch('/api/auth/clear-must-change-password', { method: 'POST' })
window.location.href = '/dashboard' window.location.href = '/dashboard'
} }
return ( return (
<div className="min-h-screen overflow-y-auto bg-gray-50 flex items-center justify-center p-4"> <div className="min-h-screen overflow-y-auto bg-gray-50 flex items-center justify-center p-4">
<div className="w-full max-w-sm"> <div className="w-full max-w-sm">
<div className="bg-white rounded-lg border p-8"> <div className="bg-white rounded-lg border p-8">
<div className="mb-6"> <div className="mb-6">
<h1 className="text-xl font-semibold text-gray-900">Passwort ändern</h1> <h1 className="text-xl font-semibold text-gray-900">Passwort ändern</h1>
<p className="text-sm text-gray-500 mt-1"> <p className="text-sm text-gray-500 mt-1">
Bitte legen Sie jetzt Ihr persönliches Passwort fest. Bitte legen Sie jetzt Ihr persönliches Passwort fest.
</p> </p>
</div> </div>
<div className="mb-4 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2 text-sm text-amber-800"> <div className="mb-4 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2 text-sm text-amber-800">
Aus Sicherheitsgründen müssen Sie das temporäre Passwort durch ein eigenes ersetzen. Aus Sicherheitsgründen müssen Sie das temporäre Passwort durch ein eigenes ersetzen.
</div> </div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label htmlFor="oldPassword" className="block text-xs font-medium text-gray-600 mb-1 uppercase tracking-wide"> <label htmlFor="oldPassword" className="block text-xs font-medium text-gray-600 mb-1 uppercase tracking-wide">
Temporäres Passwort (aus der Einladung) Temporäres Passwort (aus der Einladung)
</label> </label>
<input <input
id="oldPassword" id="oldPassword"
type="password" type="password"
required required
value={oldPassword} value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)} onChange={(e) => setOldPassword(e.target.value)}
placeholder="Temporäres Passwort" placeholder="Temporäres Passwort"
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent" className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
/> />
</div> </div>
<div> <div>
<label htmlFor="newPassword" className="block text-xs font-medium text-gray-600 mb-1 uppercase tracking-wide"> <label htmlFor="newPassword" className="block text-xs font-medium text-gray-600 mb-1 uppercase tracking-wide">
Neues Passwort Neues Passwort
</label> </label>
<input <input
id="newPassword" id="newPassword"
type="password" type="password"
required required
value={newPassword} value={newPassword}
onChange={(e) => setNewPassword(e.target.value)} onChange={(e) => setNewPassword(e.target.value)}
placeholder="Mindestens 8 Zeichen" placeholder="Mindestens 8 Zeichen"
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent" className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
/> />
</div> </div>
<div> <div>
<label htmlFor="confirmPassword" className="block text-xs font-medium text-gray-600 mb-1 uppercase tracking-wide"> <label htmlFor="confirmPassword" className="block text-xs font-medium text-gray-600 mb-1 uppercase tracking-wide">
Neues Passwort bestätigen Neues Passwort bestätigen
</label> </label>
<input <input
id="confirmPassword" id="confirmPassword"
type="password" type="password"
required required
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Passwort wiederholen" placeholder="Passwort wiederholen"
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent" className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
/> />
</div> </div>
{error && ( {error && (
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg">{error}</p> <p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg">{error}</p>
)} )}
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
className="w-full bg-brand-500 text-white py-2.5 px-4 rounded-lg text-sm font-medium disabled:opacity-60 disabled:cursor-not-allowed transition-colors hover:bg-brand-600" className="w-full bg-brand-500 text-white py-2.5 px-4 rounded-lg text-sm font-medium disabled:opacity-60 disabled:cursor-not-allowed transition-colors hover:bg-brand-600"
> >
{loading ? 'Bitte warten...' : 'Passwort festlegen'} {loading ? 'Bitte warten...' : 'Passwort festlegen'}
</button> </button>
</form> </form>
</div> </div>
</div> </div>
</div> </div>
) )
} }

View File

@ -1,429 +1,429 @@
'use client' 'use client'
import { useActionState, useState } from 'react' import { useActionState, useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { createOrganization } from './actions' import { createOrganization } from './actions'
import { LandingPagePreview } from './LandingPagePreview' import { LandingPagePreview } from './LandingPagePreview'
const initialState = { success: false, error: '' } const initialState = { success: false, error: '' }
export function CreateOrgForm() { export function CreateOrgForm() {
const [state, formAction, isPending] = useActionState(createOrganization, initialState) const [state, formAction, isPending] = useActionState(createOrganization, initialState)
const router = useRouter() const router = useRouter()
const [step, setStep] = useState(1) const [step, setStep] = useState(1)
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
slug: '', slug: '',
contactEmail: '', contactEmail: '',
adminEmail: '', adminEmail: '',
adminPassword: '', adminPassword: '',
logoUrl: '', logoUrl: '',
plan: 'pilot', plan: 'pilot',
primaryColor: '#E63946', primaryColor: '#E63946',
secondaryColor: '', secondaryColor: '',
landingPageTitle: '', landingPageTitle: '',
landingPageText: '', landingPageText: '',
landingPageHeroImage: '', landingPageHeroImage: '',
landingPageHeroOverlayOpacity: 50, landingPageHeroOverlayOpacity: 50,
landingPageFeatures: '', landingPageFeatures: '',
landingPageFooter: '', landingPageFooter: '',
appStoreUrl: '', appStoreUrl: '',
playStoreUrl: '' playStoreUrl: ''
}) })
const [aiContext, setAiContext] = useState('') const [aiContext, setAiContext] = useState('')
const [isGenerating, setIsGenerating] = useState(false) const [isGenerating, setIsGenerating] = useState(false)
const handleGenerateContent = async () => { const handleGenerateContent = async () => {
if (!formData.name || !aiContext) return if (!formData.name || !aiContext) return
setIsGenerating(true) setIsGenerating(true)
try { try {
const res = await fetch('/api/ai/generate-landing-page', { const res = await fetch('/api/ai/generate-landing-page', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ orgName: formData.name, context: aiContext }) body: JSON.stringify({ orgName: formData.name, context: aiContext })
}) })
const data = await res.json() const data = await res.json()
if (data.title && data.text) { if (data.title && data.text) {
setFormData(prev => ({ setFormData(prev => ({
...prev, ...prev,
landingPageTitle: data.title, landingPageTitle: data.title,
landingPageText: data.text landingPageText: data.text
})) }))
} }
} catch (err) { } catch (err) {
console.error('AI generation failed', err) console.error('AI generation failed', err)
} finally { } finally {
setIsGenerating(false) setIsGenerating(false)
} }
} }
const [isUploading, setIsUploading] = useState(false) const [isUploading, setIsUploading] = useState(false)
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] const file = e.target.files?.[0]
if (!file) return if (!file) return
setIsUploading(true) setIsUploading(true)
const uploadFormData = new FormData() const uploadFormData = new FormData()
uploadFormData.append('file', file) uploadFormData.append('file', file)
try { try {
const res = await fetch('/api/upload', { const res = await fetch('/api/upload', {
method: 'POST', method: 'POST',
body: uploadFormData body: uploadFormData
}) })
const data = await res.json() const data = await res.json()
if (data.url) { if (data.url) {
setFormData(prev => ({ ...prev, logoUrl: data.url })) setFormData(prev => ({ ...prev, logoUrl: data.url }))
} }
} catch (err) { } catch (err) {
console.error('Upload failed', err) console.error('Upload failed', err)
} finally { } finally {
setIsUploading(false) setIsUploading(false)
} }
} }
const [isHeroUploading, setIsHeroUploading] = useState(false) const [isHeroUploading, setIsHeroUploading] = useState(false)
const appBaseUrl = (typeof window !== 'undefined' const appBaseUrl = (typeof window !== 'undefined'
? window.location.origin ? window.location.origin
: (process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3010')).replace(/\/$/, '') : (process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3010')).replace(/\/$/, '')
const handleHeroUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleHeroUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] const file = e.target.files?.[0]
if (!file) return if (!file) return
setIsHeroUploading(true) setIsHeroUploading(true)
const uploadFormData = new FormData() const uploadFormData = new FormData()
uploadFormData.append('file', file) uploadFormData.append('file', file)
try { try {
const res = await fetch('/api/upload', { const res = await fetch('/api/upload', {
method: 'POST', method: 'POST',
body: uploadFormData body: uploadFormData
}) })
const data = await res.json() const data = await res.json()
if (data.url) { if (data.url) {
setFormData(prev => ({ ...prev, landingPageHeroImage: data.url })) setFormData(prev => ({ ...prev, landingPageHeroImage: data.url }))
} }
} catch (err) { } catch (err) {
console.error('Upload failed', err) console.error('Upload failed', err)
} finally { } finally {
setIsHeroUploading(false) setIsHeroUploading(false)
} }
} }
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => { const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
setFormData(prev => ({ ...prev, [e.target.name]: e.target.value })) setFormData(prev => ({ ...prev, [e.target.name]: e.target.value }))
} }
const nextStep = () => setStep(prev => prev + 1) const nextStep = () => setStep(prev => prev + 1)
const prevStep = () => setStep(prev => prev - 1) const prevStep = () => setStep(prev => prev - 1)
// Reset wizard after success // Reset wizard after success
if (state.success && step !== 5) { if (state.success && step !== 5) {
setStep(5) setStep(5)
} }
return ( return (
<div className="flex w-full h-full gap-6"> <div className="flex w-full h-full gap-6">
<div className="flex-[3] bg-gray-100 rounded-3xl overflow-hidden relative shadow-inner border border-gray-200 hidden lg:block"> <div className="flex-[3] bg-gray-100 rounded-3xl overflow-hidden relative shadow-inner border border-gray-200 hidden lg:block">
<LandingPagePreview formData={formData} /> <LandingPagePreview formData={formData} />
</div> </div>
<div className="flex-1 bg-white p-6 sm:p-8 rounded-3xl border shadow-sm overflow-y-auto min-w-[320px] max-w-lg w-full flex flex-col"> <div className="flex-1 bg-white p-6 sm:p-8 rounded-3xl border shadow-sm overflow-y-auto min-w-[320px] max-w-lg w-full flex flex-col">
<h2 className="text-xl font-bold text-gray-900 mb-6 font-outfit shrink-0">Neue Innung anlegen</h2> <h2 className="text-xl font-bold text-gray-900 mb-6 font-outfit shrink-0">Neue Innung anlegen</h2>
{state.error && ( {state.error && (
<div className="mb-6 p-4 bg-red-50 text-red-700 rounded-xl text-sm border border-red-100 animate-in fade-in slide-in-from-top-2 shrink-0"> <div className="mb-6 p-4 bg-red-50 text-red-700 rounded-xl text-sm border border-red-100 animate-in fade-in slide-in-from-top-2 shrink-0">
{state.error} {state.error}
</div> </div>
)} )}
{/* Stepper Header (matched to screenshot) */} {/* Stepper Header (matched to screenshot) */}
<div className="flex items-center justify-start gap-2 sm:gap-4 mb-8 shrink-0 overflow-x-auto pb-2"> <div className="flex items-center justify-start gap-2 sm:gap-4 mb-8 shrink-0 overflow-x-auto pb-2">
{[1, 2, 3, 4, 5].map((s) => ( {[1, 2, 3, 4, 5].map((s) => (
<div key={s} className="flex items-center gap-2 sm:gap-4 shrink-0"> <div key={s} className="flex items-center gap-2 sm:gap-4 shrink-0">
<div className={`shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold transition-all duration-300 ${step >= s ? 'bg-[#E63946] text-white' : 'bg-gray-100 text-gray-400'}`}> <div className={`shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold transition-all duration-300 ${step >= s ? 'bg-[#E63946] text-white' : 'bg-gray-100 text-gray-400'}`}>
{s} {s}
</div> </div>
{s < 5 && ( {s < 5 && (
<div className={`h-[3px] w-8 sm:w-12 rounded-full transition-all duration-500 ${step > s ? 'bg-[#E63946]' : 'bg-gray-100'}`} /> <div className={`h-[3px] w-8 sm:w-12 rounded-full transition-all duration-500 ${step > s ? 'bg-[#E63946]' : 'bg-gray-100'}`} />
)} )}
</div> </div>
))} ))}
</div> </div>
<form action={formAction} className="flex-1 shrink-0 space-y-6"> <form action={formAction} className="flex-1 shrink-0 space-y-6">
{step !== 1 && ( {step !== 1 && (
<> <>
<input type="hidden" name="name" value={formData.name} /> <input type="hidden" name="name" value={formData.name} />
<input type="hidden" name="slug" value={formData.slug} /> <input type="hidden" name="slug" value={formData.slug} />
</> </>
)} )}
<input type="hidden" name="contactEmail" value={formData.contactEmail} /> <input type="hidden" name="contactEmail" value={formData.contactEmail} />
<input type="hidden" name="adminEmail" value={formData.adminEmail} /> <input type="hidden" name="adminEmail" value={formData.adminEmail} />
<input type="hidden" name="adminPassword" value={formData.adminPassword} /> <input type="hidden" name="adminPassword" value={formData.adminPassword} />
<input type="hidden" name="logoUrl" value={formData.logoUrl} /> <input type="hidden" name="logoUrl" value={formData.logoUrl} />
<input type="hidden" name="plan" value={formData.plan} /> <input type="hidden" name="plan" value={formData.plan} />
<input type="hidden" name="primaryColor" value={formData.primaryColor} /> <input type="hidden" name="primaryColor" value={formData.primaryColor} />
<input type="hidden" name="secondaryColor" value={formData.secondaryColor} /> <input type="hidden" name="secondaryColor" value={formData.secondaryColor} />
<input type="hidden" name="landingPageTitle" value={formData.landingPageTitle} /> <input type="hidden" name="landingPageTitle" value={formData.landingPageTitle} />
<input type="hidden" name="landingPageText" value={formData.landingPageText} /> <input type="hidden" name="landingPageText" value={formData.landingPageText} />
<input type="hidden" name="landingPageHeroImage" value={formData.landingPageHeroImage} /> <input type="hidden" name="landingPageHeroImage" value={formData.landingPageHeroImage} />
<input type="hidden" name="landingPageFeatures" value={formData.landingPageFeatures} /> <input type="hidden" name="landingPageFeatures" value={formData.landingPageFeatures} />
<input type="hidden" name="landingPageFooter" value={formData.landingPageFooter} /> <input type="hidden" name="landingPageFooter" value={formData.landingPageFooter} />
<input type="hidden" name="appStoreUrl" value={formData.appStoreUrl} /> <input type="hidden" name="appStoreUrl" value={formData.appStoreUrl} />
<input type="hidden" name="playStoreUrl" value={formData.playStoreUrl} /> <input type="hidden" name="playStoreUrl" value={formData.playStoreUrl} />
{step === 1 && ( {step === 1 && (
<div className="space-y-5 animate-in fade-in slide-in-from-right-4 duration-300"> <div className="space-y-5 animate-in fade-in slide-in-from-right-4 duration-300">
<div> <div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Name der Innung</label> <label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Name der Innung</label>
<input type="text" name="name" required value={formData.name} onChange={handleChange} placeholder="z.B. Tischler-Innung Berlin" className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all placeholder:text-gray-300" /> <input type="text" name="name" required value={formData.name} onChange={handleChange} placeholder="z.B. Tischler-Innung Berlin" className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all placeholder:text-gray-300" />
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Kurzbezeichnung (Slug)</label> <label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Kurzbezeichnung (Slug)</label>
<input type="text" name="slug" required value={formData.slug} onChange={handleChange} placeholder="z.B. tischler-berlin" pattern="^[a-z0-9\-]+$" className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all placeholder:text-gray-300" /> <input type="text" name="slug" required value={formData.slug} onChange={handleChange} placeholder="z.B. tischler-berlin" pattern="^[a-z0-9\-]+$" className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all placeholder:text-gray-300" />
<p className="text-[11px] text-gray-400 mt-2 leading-relaxed">Landingpage unter: <span className="text-[#E63946] font-medium">{formData.slug ? `${appBaseUrl}/${formData.slug}` : `${appBaseUrl}/ihr-slug`}</span></p> <p className="text-[11px] text-gray-400 mt-2 leading-relaxed">Landingpage unter: <span className="text-[#E63946] font-medium">{formData.slug ? `${appBaseUrl}/${formData.slug}` : `${appBaseUrl}/ihr-slug`}</span></p>
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Planungs-Modell</label> <label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Planungs-Modell</label>
<select name="plan" value={formData.plan} onChange={handleChange} className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all bg-white"> <select name="plan" value={formData.plan} onChange={handleChange} className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all bg-white">
<option value="pilot">Pilot</option> <option value="pilot">Pilot</option>
<option value="standard">Standard</option> <option value="standard">Standard</option>
<option value="pro">Pro</option> <option value="pro">Pro</option>
<option value="verband">Verband</option> <option value="verband">Verband</option>
</select> </select>
</div> </div>
<button type="button" onClick={nextStep} disabled={!formData.name || !formData.slug} className="w-full bg-gray-900 text-white font-semibold py-3.5 px-6 rounded-xl hover:bg-black transition-all shadow-md active:scale-[0.98] disabled:opacity-50 disabled:scale-100"> <button type="button" onClick={nextStep} disabled={!formData.name || !formData.slug} className="w-full bg-gray-900 text-white font-semibold py-3.5 px-6 rounded-xl hover:bg-black transition-all shadow-md active:scale-[0.98] disabled:opacity-50 disabled:scale-100">
Weiter zu Branding Weiter zu Branding
</button> </button>
</div> </div>
)} )}
{step === 2 && ( {step === 2 && (
<div className="space-y-5 animate-in fade-in slide-in-from-right-4 duration-300"> <div className="space-y-5 animate-in fade-in slide-in-from-right-4 duration-300">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Initialer Admin (Email)</label> <label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Initialer Admin (Email)</label>
<input type="email" name="adminEmail" value={formData.adminEmail} onChange={handleChange} placeholder="admin@tischler-berlin.de" className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all placeholder:text-gray-300" /> <input type="email" name="adminEmail" value={formData.adminEmail} onChange={handleChange} placeholder="admin@tischler-berlin.de" className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all placeholder:text-gray-300" />
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Passwort setzen</label> <label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Passwort setzen</label>
<input type="text" name="adminPassword" value={formData.adminPassword} onChange={handleChange} placeholder="Sicheres Passwort" className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all placeholder:text-gray-300" /> <input type="text" name="adminPassword" value={formData.adminPassword} onChange={handleChange} placeholder="Sicheres Passwort" className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all placeholder:text-gray-300" />
</div> </div>
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Organisations-Logo</label> <label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Organisations-Logo</label>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{formData.logoUrl ? ( {formData.logoUrl ? (
<div className="w-14 h-14 rounded-xl border border-gray-200 overflow-hidden bg-gray-50 flex items-center justify-center p-2"> <div className="w-14 h-14 rounded-xl border border-gray-200 overflow-hidden bg-gray-50 flex items-center justify-center p-2">
<img src={formData.logoUrl} alt="Logo" className="max-w-full max-h-full object-contain" /> <img src={formData.logoUrl} alt="Logo" className="max-w-full max-h-full object-contain" />
</div> </div>
) : ( ) : (
<div className="w-14 h-14 rounded-xl border-2 border-dashed border-gray-200 flex items-center justify-center text-gray-300"> <div className="w-14 h-14 rounded-xl border-2 border-dashed border-gray-200 flex items-center justify-center text-gray-300">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" /> <path strokeLinecap="round" strokeLinejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
</svg> </svg>
</div> </div>
)} )}
<label className="flex-1"> <label className="flex-1">
<div className={`px-4 py-3 border border-gray-200 rounded-xl w-full text-center text-sm font-semibold cursor-pointer transition-all hover:bg-gray-50 ${isUploading ? 'opacity-50' : ''}`}> <div className={`px-4 py-3 border border-gray-200 rounded-xl w-full text-center text-sm font-semibold cursor-pointer transition-all hover:bg-gray-50 ${isUploading ? 'opacity-50' : ''}`}>
{isUploading ? 'Wird hochgeladen...' : formData.logoUrl ? 'Logo ändern' : 'Bild auswählen'} {isUploading ? 'Wird hochgeladen...' : formData.logoUrl ? 'Logo ändern' : 'Bild auswählen'}
</div> </div>
<input type="file" onChange={handleUpload} accept="image/*" className="hidden" disabled={isUploading} /> <input type="file" onChange={handleUpload} accept="image/*" className="hidden" disabled={isUploading} />
</label> </label>
</div> </div>
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Primärfarbe (CI)</label> <label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Primärfarbe (CI)</label>
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
<input type="color" name="primaryColor" value={formData.primaryColor} onChange={handleChange} className="w-14 h-14 p-1 rounded-xl cursor-pointer border border-gray-200" /> <input type="color" name="primaryColor" value={formData.primaryColor} onChange={handleChange} className="w-14 h-14 p-1 rounded-xl cursor-pointer border border-gray-200" />
<div className="flex-1"> <div className="flex-1">
<input type="text" value={formData.primaryColor?.toUpperCase()} readOnly className="px-4 py-3 border border-gray-200 rounded-xl w-full bg-gray-50 text-gray-500 font-mono text-sm" /> <input type="text" value={formData.primaryColor?.toUpperCase()} readOnly className="px-4 py-3 border border-gray-200 rounded-xl w-full bg-gray-50 text-gray-500 font-mono text-sm" />
</div> </div>
</div> </div>
</div> </div>
<div className="pt-4 flex gap-3"> <div className="pt-4 flex gap-3">
<button type="button" onClick={prevStep} className="flex-1 bg-white text-gray-500 font-semibold py-3.5 px-6 rounded-xl border border-gray-200 hover:bg-gray-50 transition-all"> <button type="button" onClick={prevStep} className="flex-1 bg-white text-gray-500 font-semibold py-3.5 px-6 rounded-xl border border-gray-200 hover:bg-gray-50 transition-all">
Zurück Zurück
</button> </button>
<button type="button" onClick={nextStep} disabled={!formData.adminEmail || !formData.adminPassword} className="flex-[2] bg-gray-900 text-white font-semibold py-3.5 px-6 rounded-xl hover:bg-black transition-all shadow-md active:scale-[0.98] disabled:opacity-50"> <button type="button" onClick={nextStep} disabled={!formData.adminEmail || !formData.adminPassword} className="flex-[2] bg-gray-900 text-white font-semibold py-3.5 px-6 rounded-xl hover:bg-black transition-all shadow-md active:scale-[0.98] disabled:opacity-50">
Weiter zur Landingpage Weiter zur Landingpage
</button> </button>
</div> </div>
</div> </div>
)} )}
{step === 3 && ( {step === 3 && (
<div className="space-y-5 animate-in fade-in slide-in-from-right-4 duration-300"> <div className="space-y-5 animate-in fade-in slide-in-from-right-4 duration-300">
<div className="bg-blue-50/50 p-5 rounded-xl border border-blue-100"> <div className="bg-blue-50/50 p-5 rounded-xl border border-blue-100">
<h3 className="text-sm font-bold text-blue-900 mb-2 font-outfit">KI Content-Erstellung</h3> <h3 className="text-sm font-bold text-blue-900 mb-2 font-outfit">KI Content-Erstellung</h3>
<p className="text-xs text-blue-700 leading-relaxed mb-4"> <p className="text-xs text-blue-700 leading-relaxed mb-4">
Beschreiben Sie in wenigen Stichpunkten, worauf die Innung fokussiert ist (Region, Tradition, Ausbildung, etc.). Die KI generiert daraus eine moderne Landingpage. Beschreiben Sie in wenigen Stichpunkten, worauf die Innung fokussiert ist (Region, Tradition, Ausbildung, etc.). Die KI generiert daraus eine moderne Landingpage.
</p> </p>
<textarea <textarea
value={aiContext} value={aiContext}
onChange={(e) => setAiContext(e.target.value)} onChange={(e) => setAiContext(e.target.value)}
placeholder="z.B. Kreishandwerkerschaft Niederrhein, Fokus auf Ausbildung und Digitalisierung im Handwerk..." placeholder="z.B. Kreishandwerkerschaft Niederrhein, Fokus auf Ausbildung und Digitalisierung im Handwerk..."
className="w-full px-4 py-3 border border-blue-200 bg-white rounded-xl focus:ring-2 focus:ring-blue-500 outline-none transition-all placeholder:text-gray-400 text-sm min-h-[80px]" className="w-full px-4 py-3 border border-blue-200 bg-white rounded-xl focus:ring-2 focus:ring-blue-500 outline-none transition-all placeholder:text-gray-400 text-sm min-h-[80px]"
/> />
<button <button
type="button" type="button"
onClick={handleGenerateContent} onClick={handleGenerateContent}
disabled={isGenerating || !aiContext} disabled={isGenerating || !aiContext}
className="mt-3 w-full bg-blue-600 text-white font-semibold py-2.5 px-6 rounded-lg hover:bg-blue-700 transition-all shadow-sm disabled:opacity-50 flex items-center justify-center gap-2 text-sm" className="mt-3 w-full bg-blue-600 text-white font-semibold py-2.5 px-6 rounded-lg hover:bg-blue-700 transition-all shadow-sm disabled:opacity-50 flex items-center justify-center gap-2 text-sm"
> >
{isGenerating ? ( {isGenerating ? (
<> <>
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Generieren... Generieren...
</> </>
) : '✨ Content generieren'} ) : '✨ Content generieren'}
</button> </button>
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Überschrift</label> <label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Überschrift</label>
<input type="text" name="landingPageTitle" value={formData.landingPageTitle} onChange={handleChange} placeholder="Zukunft durch Handwerk" className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all placeholder:text-gray-300 font-bold" /> <input type="text" name="landingPageTitle" value={formData.landingPageTitle} onChange={handleChange} placeholder="Zukunft durch Handwerk" className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all placeholder:text-gray-300 font-bold" />
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Einleitungstext</label> <label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Einleitungstext</label>
<textarea name="landingPageText" value={formData.landingPageText} onChange={(e) => setFormData(prev => ({ ...prev, landingPageText: e.target.value }))} placeholder="Wir sind..." className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all placeholder:text-gray-300 min-h-[100px] text-sm leading-relaxed" /> <textarea name="landingPageText" value={formData.landingPageText} onChange={(e) => setFormData(prev => ({ ...prev, landingPageText: e.target.value }))} placeholder="Wir sind..." className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all placeholder:text-gray-300 min-h-[100px] text-sm leading-relaxed" />
</div> </div>
<div className="pt-4 flex gap-3"> <div className="pt-4 flex gap-3">
<button type="button" onClick={prevStep} className="flex-1 bg-white text-gray-500 font-semibold py-3.5 px-6 rounded-xl border border-gray-200 hover:bg-gray-50 transition-all"> <button type="button" onClick={prevStep} className="flex-1 bg-white text-gray-500 font-semibold py-3.5 px-6 rounded-xl border border-gray-200 hover:bg-gray-50 transition-all">
Zurück Zurück
</button> </button>
<button type="button" onClick={nextStep} className="flex-[2] bg-gray-900 text-white font-semibold py-3.5 px-6 rounded-xl hover:bg-black transition-all shadow-md active:scale-[0.98]"> <button type="button" onClick={nextStep} className="flex-[2] bg-gray-900 text-white font-semibold py-3.5 px-6 rounded-xl hover:bg-black transition-all shadow-md active:scale-[0.98]">
Weiter zu Erweitert Weiter zu Erweitert
</button> </button>
</div> </div>
</div> </div>
)} )}
{step === 4 && ( {step === 4 && (
<div className="space-y-5 animate-in fade-in slide-in-from-right-4 duration-300"> <div className="space-y-5 animate-in fade-in slide-in-from-right-4 duration-300">
<div> <div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Hero-Titelbild</label> <label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Hero-Titelbild</label>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{formData.landingPageHeroImage ? ( {formData.landingPageHeroImage ? (
<div className="w-24 h-14 rounded-xl border border-gray-200 overflow-hidden bg-gray-50 flex items-center justify-center p-0"> <div className="w-24 h-14 rounded-xl border border-gray-200 overflow-hidden bg-gray-50 flex items-center justify-center p-0">
<img src={formData.landingPageHeroImage} alt="Hero" className="max-w-full max-h-full object-cover" /> <img src={formData.landingPageHeroImage} alt="Hero" className="max-w-full max-h-full object-cover" />
</div> </div>
) : ( ) : (
<div className="w-24 h-14 rounded-xl border-2 border-dashed border-gray-200 flex items-center justify-center text-gray-300"> <div className="w-24 h-14 rounded-xl border-2 border-dashed border-gray-200 flex items-center justify-center text-gray-300">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" /> <path strokeLinecap="round" strokeLinejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
</svg> </svg>
</div> </div>
)} )}
<label className="flex-1"> <label className="flex-1">
<div className={`px-4 py-3 border border-gray-200 rounded-xl w-full text-center text-sm font-semibold cursor-pointer transition-all hover:bg-gray-50 ${isHeroUploading ? 'opacity-50' : ''}`}> <div className={`px-4 py-3 border border-gray-200 rounded-xl w-full text-center text-sm font-semibold cursor-pointer transition-all hover:bg-gray-50 ${isHeroUploading ? 'opacity-50' : ''}`}>
{isHeroUploading ? 'Wird hochgeladen...' : formData.landingPageHeroImage ? 'Bild ändern' : 'Bild auswählen'} {isHeroUploading ? 'Wird hochgeladen...' : formData.landingPageHeroImage ? 'Bild ändern' : 'Bild auswählen'}
</div> </div>
<input type="file" onChange={handleHeroUpload} accept="image/*" className="hidden" disabled={isHeroUploading} /> <input type="file" onChange={handleHeroUpload} accept="image/*" className="hidden" disabled={isHeroUploading} />
</label> </label>
</div> </div>
</div> </div>
{formData.landingPageHeroImage && ( {formData.landingPageHeroImage && (
<div> <div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-1"> <label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-1">
Hero-Deckkraft (Opacity: {formData.landingPageHeroOverlayOpacity}%) Hero-Deckkraft (Opacity: {formData.landingPageHeroOverlayOpacity}%)
</label> </label>
<input <input
type="range" type="range"
name="landingPageHeroOverlayOpacity" name="landingPageHeroOverlayOpacity"
min="0" min="0"
max="100" max="100"
value={formData.landingPageHeroOverlayOpacity} value={formData.landingPageHeroOverlayOpacity}
onChange={handleChange} onChange={handleChange}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-[#E63946]" className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-[#E63946]"
/> />
<p className="text-xs text-gray-400 mt-1">Bestimmt, wie stark das Bild abgedunkelt/aufgehellt wird, um den Text lesbar zu machen.</p> <p className="text-xs text-gray-400 mt-1">Bestimmt, wie stark das Bild abgedunkelt/aufgehellt wird, um den Text lesbar zu machen.</p>
</div> </div>
)} )}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Sekundärfarbe (Optional)</label> <label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Sekundärfarbe (Optional)</label>
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
<input type="color" name="secondaryColor" value={formData.secondaryColor || '#ffffff'} onChange={handleChange} className="w-14 h-14 p-1 rounded-xl cursor-pointer border border-gray-200" /> <input type="color" name="secondaryColor" value={formData.secondaryColor || '#ffffff'} onChange={handleChange} className="w-14 h-14 p-1 rounded-xl cursor-pointer border border-gray-200" />
<div className="flex-1"> <div className="flex-1">
<input type="text" name="secondaryColor" value={formData.secondaryColor?.toUpperCase()} onChange={handleChange} placeholder="#FFFFFF" className="px-4 py-3 border border-gray-200 rounded-xl w-full bg-white text-gray-700 font-mono text-sm" /> <input type="text" name="secondaryColor" value={formData.secondaryColor?.toUpperCase()} onChange={handleChange} placeholder="#FFFFFF" className="px-4 py-3 border border-gray-200 rounded-xl w-full bg-white text-gray-700 font-mono text-sm" />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Vorteile / Features</label> <label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Vorteile / Features</label>
<textarea name="landingPageFeatures" value={formData.landingPageFeatures} onChange={handleChange} placeholder="Ein Benefit pro Zeile..." className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all placeholder:text-gray-300 min-h-[120px] text-sm leading-relaxed" /> <textarea name="landingPageFeatures" value={formData.landingPageFeatures} onChange={handleChange} placeholder="Ein Benefit pro Zeile..." className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all placeholder:text-gray-300 min-h-[120px] text-sm leading-relaxed" />
<p className="text-xs text-gray-400 mt-2">Bitte geben Sie pro Zeile einen Vorteil ein. Diese werden als Checkliste auf der Landingpage angezeigt.</p> <p className="text-xs text-gray-400 mt-2">Bitte geben Sie pro Zeile einen Vorteil ein. Diese werden als Checkliste auf der Landingpage angezeigt.</p>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">App Store URL</label> <label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">App Store URL</label>
<input type="url" name="appStoreUrl" value={formData.appStoreUrl} onChange={handleChange} placeholder="https://apps.apple.com/..." className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all placeholder:text-gray-300 text-sm" /> <input type="url" name="appStoreUrl" value={formData.appStoreUrl} onChange={handleChange} placeholder="https://apps.apple.com/..." className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all placeholder:text-gray-300 text-sm" />
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Google Play URL</label> <label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Google Play URL</label>
<input type="url" name="playStoreUrl" value={formData.playStoreUrl} onChange={handleChange} placeholder="https://play.google.com/..." className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all placeholder:text-gray-300 text-sm" /> <input type="url" name="playStoreUrl" value={formData.playStoreUrl} onChange={handleChange} placeholder="https://play.google.com/..." className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all placeholder:text-gray-300 text-sm" />
</div> </div>
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Footer-Text (Impressum, etc.)</label> <label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Footer-Text (Impressum, etc.)</label>
<textarea name="landingPageFooter" value={formData.landingPageFooter} onChange={handleChange} placeholder="Zusätzliche Infos für den Footer..." className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all placeholder:text-gray-300 min-h-[80px] text-sm leading-relaxed" /> <textarea name="landingPageFooter" value={formData.landingPageFooter} onChange={handleChange} placeholder="Zusätzliche Infos für den Footer..." className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all placeholder:text-gray-300 min-h-[80px] text-sm leading-relaxed" />
</div> </div>
<div className="pt-4 flex gap-3"> <div className="pt-4 flex gap-3">
<button type="button" onClick={prevStep} className="flex-1 bg-white text-gray-500 font-semibold py-3.5 px-6 rounded-xl border border-gray-200 hover:bg-gray-50 transition-all"> <button type="button" onClick={prevStep} className="flex-1 bg-white text-gray-500 font-semibold py-3.5 px-6 rounded-xl border border-gray-200 hover:bg-gray-50 transition-all">
Zurück Zurück
</button> </button>
<button type="submit" disabled={isPending} className="flex-[2] bg-[#E63946] text-white font-semibold py-3.5 px-6 rounded-xl hover:bg-[#D62839] transition-all shadow-md shadow-red-100 active:scale-[0.98] disabled:opacity-50 flex justify-center items-center gap-2"> <button type="submit" disabled={isPending} className="flex-[2] bg-[#E63946] text-white font-semibold py-3.5 px-6 rounded-xl hover:bg-[#D62839] transition-all shadow-md shadow-red-100 active:scale-[0.98] disabled:opacity-50 flex justify-center items-center gap-2">
{isPending ? 'Wird erstellt...' : 'Innung anlegen'} {isPending ? 'Wird erstellt...' : 'Innung anlegen'}
</button> </button>
</div> </div>
</div> </div>
)} )}
{step === 5 && ( {step === 5 && (
<div className="text-center animate-in fade-in zoom-in-95 duration-700 py-4"> <div className="text-center animate-in fade-in zoom-in-95 duration-700 py-4">
<div className="w-24 h-24 bg-[#E8F5E9] text-[#2E7D32] rounded-full flex items-center justify-center mx-auto mb-8 animate-in zoom-in-50 duration-500 delay-150"> <div className="w-24 h-24 bg-[#E8F5E9] text-[#2E7D32] rounded-full flex items-center justify-center mx-auto mb-8 animate-in zoom-in-50 duration-500 delay-150">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2.5} stroke="currentColor" className="w-10 h-10"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2.5} stroke="currentColor" className="w-10 h-10">
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" /> <path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg> </svg>
</div> </div>
<h3 className="text-xl font-bold text-gray-900 mb-2">Innung erfolgreich angelegt!</h3> <h3 className="text-xl font-bold text-gray-900 mb-2">Innung erfolgreich angelegt!</h3>
<p className="text-gray-500 text-sm mb-10">Die Datenumgebung sowie die Subdomain<br />wurden eingerichtet.</p> <p className="text-gray-500 text-sm mb-10">Die Datenumgebung sowie die Subdomain<br />wurden eingerichtet.</p>
<div className="bg-[#F8FEFB] p-6 rounded-2xl border border-[#E1F5EA] text-left mb-8"> <div className="bg-[#F8FEFB] p-6 rounded-2xl border border-[#E1F5EA] text-left mb-8">
<p className="text-[10px] font-bold text-[#8CAB99] uppercase tracking-[0.15em] mb-4">Ihre neue Landingpage (Localhost) / Subdomain</p> <p className="text-[10px] font-bold text-[#8CAB99] uppercase tracking-[0.15em] mb-4">Ihre neue Landingpage (Localhost) / Subdomain</p>
<a href={`${appBaseUrl}/${formData.slug}`} target="_blank" rel="noreferrer" className="text-[#E63946] font-bold text-lg hover:underline block break-all"> <a href={`${appBaseUrl}/${formData.slug}`} target="_blank" rel="noreferrer" className="text-[#E63946] font-bold text-lg hover:underline block break-all">
{appBaseUrl}/{formData.slug} {appBaseUrl}/{formData.slug}
</a> </a>
</div> </div>
<button type="button" onClick={() => { <button type="button" onClick={() => {
router.push('/superadmin') router.push('/superadmin')
}} className="w-full bg-[#F3F4F6] text-[#4B5563] font-bold py-4 px-6 rounded-2xl hover:bg-gray-200 transition-all active:scale-[0.98]"> }} className="w-full bg-[#F3F4F6] text-[#4B5563] font-bold py-4 px-6 rounded-2xl hover:bg-gray-200 transition-all active:scale-[0.98]">
Zurück zur Übersicht Zurück zur Übersicht
</button> </button>
</div> </div>
)} )}
</form> </form>
</div> </div>
</div> </div>
) )
} }

View File

@ -1,355 +1,355 @@
export function LandingPagePreview({ formData }: { formData: any }) { export function LandingPagePreview({ formData }: { formData: any }) {
const primaryColor = formData.primaryColor || '#E63946' const primaryColor = formData.primaryColor || '#E63946'
const secondaryColor = formData.secondaryColor || undefined const secondaryColor = formData.secondaryColor || undefined
const title = formData.landingPageTitle || formData.name || 'Zukunft durch Handwerk' const title = formData.landingPageTitle || formData.name || 'Zukunft durch Handwerk'
const text = formData.landingPageText || 'Wir sind Ihre lokale Vertretung des Handwerks. Mit starker Gemeinschaft und klaren Zielen setzen wir uns für die Betriebe in unserer Region ein.' const text = formData.landingPageText || 'Wir sind Ihre lokale Vertretung des Handwerks. Mit starker Gemeinschaft und klaren Zielen setzen wir uns für die Betriebe in unserer Region ein.'
const features = formData.landingPageFeatures || '✅ Exzellente Ausbildung\n✅ Starke Gemeinschaft\n✅ Politische Interessenvertretung' const features = formData.landingPageFeatures || '✅ Exzellente Ausbildung\n✅ Starke Gemeinschaft\n✅ Politische Interessenvertretung'
const footer = formData.landingPageFooter || '© 2024 Innung' const footer = formData.landingPageFooter || '© 2024 Innung'
const sectionTitle = formData.landingPageSectionTitle || `${formData.name || 'Ihre Innung'} Gemeinsam stark fürs Handwerk` const sectionTitle = formData.landingPageSectionTitle || `${formData.name || 'Ihre Innung'} Gemeinsam stark fürs Handwerk`
const buttonText = formData.landingPageButtonText || 'Jetzt App laden' const buttonText = formData.landingPageButtonText || 'Jetzt App laden'
return ( return (
<div className="w-full h-full bg-white overflow-y-auto font-sans flex flex-col relative"> <div className="w-full h-full bg-white overflow-y-auto font-sans flex flex-col relative">
{/* Header */} {/* Header */}
<header className="px-8 py-6 flex items-center justify-between sticky top-0 z-50 shadow-sm" style={{ <header className="px-8 py-6 flex items-center justify-between sticky top-0 z-50 shadow-sm" style={{
background: `linear-gradient(to right, #ffffff 0%, ${primaryColor}20 50%, ${primaryColor} 100%)` background: `linear-gradient(to right, #ffffff 0%, ${primaryColor}20 50%, ${primaryColor} 100%)`
}}> }}>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{formData.logoUrl ? ( {formData.logoUrl ? (
<img src={formData.logoUrl} alt="Logo" className="h-10 object-contain" /> <img src={formData.logoUrl} alt="Logo" className="h-10 object-contain" />
) : ( ) : (
<div className="w-10 h-10 bg-white rounded-lg flex items-center justify-center text-xs font-bold text-gray-400 shadow-sm">LOGO</div> <div className="w-10 h-10 bg-white rounded-lg flex items-center justify-center text-xs font-bold text-gray-400 shadow-sm">LOGO</div>
)} )}
<span className="font-bold text-lg text-gray-800">{formData.name || 'Innungs-Logo'}</span> <span className="font-bold text-lg text-gray-800">{formData.name || 'Innungs-Logo'}</span>
</div> </div>
<nav className="flex gap-6 text-sm font-medium text-gray-800 hidden md:flex"> <nav className="flex gap-6 text-sm font-medium text-gray-800 hidden md:flex">
<a href="#about" className="hover:text-black">Über uns</a> <a href="#about" className="hover:text-black">Über uns</a>
<a href="#leistungen" className="hover:text-black">Leistungen</a> <a href="#leistungen" className="hover:text-black">Leistungen</a>
<a href="#app" className="hover:text-black">App</a> <a href="#app" className="hover:text-black">App</a>
</nav> </nav>
<a <a
href="#mitglied-werden" href="#mitglied-werden"
className="px-5 py-2.5 rounded-full bg-white font-semibold text-sm cursor-pointer shadow-md hover:bg-gray-50 transition-all" className="px-5 py-2.5 rounded-full bg-white font-semibold text-sm cursor-pointer shadow-md hover:bg-gray-50 transition-all"
style={{ color: primaryColor }} style={{ color: primaryColor }}
> >
Mitglieder verwalten Mitglieder verwalten
</a> </a>
</header> </header>
{/* Hero Section */} {/* Hero Section */}
<section id="about" className="relative px-8 py-20 flex flex-col items-center justify-center text-center overflow-hidden min-h-[400px]"> <section id="about" className="relative px-8 py-20 flex flex-col items-center justify-center text-center overflow-hidden min-h-[400px]">
{/* Background Image / Pattern */} {/* Background Image / Pattern */}
{formData.landingPageHeroImage ? ( {formData.landingPageHeroImage ? (
<div className="absolute inset-0 z-0"> <div className="absolute inset-0 z-0">
<img src={formData.landingPageHeroImage} alt="Hero Background" className="w-full h-full object-cover" /> <img src={formData.landingPageHeroImage} alt="Hero Background" className="w-full h-full object-cover" />
<div <div
className="absolute inset-0 bg-white" className="absolute inset-0 bg-white"
style={{ opacity: formData.landingPageHeroOverlayOpacity !== undefined ? formData.landingPageHeroOverlayOpacity / 100 : 0.5 }} style={{ opacity: formData.landingPageHeroOverlayOpacity !== undefined ? formData.landingPageHeroOverlayOpacity / 100 : 0.5 }}
></div> ></div>
<div className="absolute inset-0 bg-gradient-to-b from-white/30 via-transparent to-white/90"></div> <div className="absolute inset-0 bg-gradient-to-b from-white/30 via-transparent to-white/90"></div>
</div> </div>
) : ( ) : (
<div className="absolute inset-0 z-0 opacity-[0.03]" style={{ backgroundImage: 'radial-gradient(#000 1px, transparent 1px)', backgroundSize: '24px 24px' }}></div> <div className="absolute inset-0 z-0 opacity-[0.03]" style={{ backgroundImage: 'radial-gradient(#000 1px, transparent 1px)', backgroundSize: '24px 24px' }}></div>
)} )}
<div className="relative z-10 max-w-3xl mx-auto space-y-6"> <div className="relative z-10 max-w-3xl mx-auto space-y-6">
<div className="inline-block px-4 py-1.5 rounded-full text-xs font-bold tracking-wider uppercase mb-2 shadow-sm" style={{ backgroundColor: `${primaryColor}15`, color: primaryColor }}> <div className="inline-block px-4 py-1.5 rounded-full text-xs font-bold tracking-wider uppercase mb-2 shadow-sm" style={{ backgroundColor: `${primaryColor}15`, color: primaryColor }}>
{formData.name || 'Ihre Innung'} {formData.name || 'Ihre Innung'}
</div> </div>
<h1 className="text-4xl md:text-5xl font-black text-gray-900 tracking-tight leading-[1.1]"> <h1 className="text-4xl md:text-5xl font-black text-gray-900 tracking-tight leading-[1.1]">
{title} {title}
</h1> </h1>
<p className="text-lg text-gray-600 leading-relaxed max-w-2xl mx-auto font-medium"> <p className="text-lg text-gray-600 leading-relaxed max-w-2xl mx-auto font-medium">
{text} {text}
</p> </p>
<div className="pt-6 flex gap-4 justify-center"> <div className="pt-6 flex gap-4 justify-center">
<a <a
href="#apps" href="#apps"
className="px-8 py-3.5 rounded-full text-white font-semibold shadow-lg hover:opacity-90 transition-all cursor-pointer transform hover:-translate-y-0.5 block" className="px-8 py-3.5 rounded-full text-white font-semibold shadow-lg hover:opacity-90 transition-all cursor-pointer transform hover:-translate-y-0.5 block"
style={{ backgroundColor: primaryColor }} style={{ backgroundColor: primaryColor }}
> >
{buttonText} {buttonText}
</a> </a>
<a <a
href="#leistungen" href="#leistungen"
className="px-8 py-3.5 rounded-full font-semibold border shadow-sm transition-all cursor-pointer block hover:opacity-80" className="px-8 py-3.5 rounded-full font-semibold border shadow-sm transition-all cursor-pointer block hover:opacity-80"
style={{ style={{
backgroundColor: 'white', backgroundColor: 'white',
borderColor: secondaryColor || '#e5e7eb', borderColor: secondaryColor || '#e5e7eb',
color: secondaryColor || '#374151' color: secondaryColor || '#374151'
}} }}
> >
Mehr erfahren Mehr erfahren
</a> </a>
</div> </div>
</div> </div>
</section> </section>
{/* Features / Benefits */} {/* Features / Benefits */}
<section id="leistungen" className="px-8 py-16" style={{ backgroundColor: secondaryColor ? `${secondaryColor}08` : '#f9fafb' }}> <section id="leistungen" className="px-8 py-16" style={{ backgroundColor: secondaryColor ? `${secondaryColor}08` : '#f9fafb' }}>
<div className="max-w-5xl mx-auto"> <div className="max-w-5xl mx-auto">
<h2 className="text-2xl font-bold text-center mb-12 text-gray-800">Ihre Vorteile als Mitglied</h2> <h2 className="text-2xl font-bold text-center mb-12 text-gray-800">Ihre Vorteile als Mitglied</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8"> <div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{features.split('\n').filter((f: string) => f.trim() !== '').map((feature: string, idx: number) => ( {features.split('\n').filter((f: string) => f.trim() !== '').map((feature: string, idx: number) => (
<div key={idx} className="bg-white p-6 rounded-2xl shadow-sm border border-gray-100 flex flex-col items-center text-center space-y-4 hover:shadow-md transition-shadow"> <div key={idx} className="bg-white p-6 rounded-2xl shadow-sm border border-gray-100 flex flex-col items-center text-center space-y-4 hover:shadow-md transition-shadow">
<div className="w-12 h-12 rounded-full flex items-center justify-center" style={{ backgroundColor: secondaryColor ? `${secondaryColor}15` : `${primaryColor}15`, color: secondaryColor || primaryColor }}> <div className="w-12 h-12 rounded-full flex items-center justify-center" style={{ backgroundColor: secondaryColor ? `${secondaryColor}15` : `${primaryColor}15`, color: secondaryColor || primaryColor }}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-6 h-6"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /> <path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg> </svg>
</div> </div>
<h3 className="font-semibold text-gray-800">{feature.replace(/^[-\*\✅\ ]+/, '')}</h3> <h3 className="font-semibold text-gray-800">{feature.replace(/^[-\*\✅\ ]+/, '')}</h3>
</div> </div>
))} ))}
</div> </div>
</div> </div>
</section> </section>
{/* App Features Grid */} {/* App Features Grid */}
<section id="app" className="px-8 py-20 bg-white"> <section id="app" className="px-8 py-20 bg-white">
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
<div className="text-center mb-16 space-y-4"> <div className="text-center mb-16 space-y-4">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full text-sm font-semibold mb-2" style={{ backgroundColor: `${primaryColor}10`, color: primaryColor }}> <div className="inline-flex items-center gap-2 px-3 py-1 rounded-full text-sm font-semibold mb-2" style={{ backgroundColor: `${primaryColor}10`, color: primaryColor }}>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg> <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
Alles in einer App Alles in einer App
</div> </div>
<h2 className="text-3xl md:text-4xl font-black text-gray-900">{sectionTitle}</h2> <h2 className="text-3xl md:text-4xl font-black text-gray-900">{sectionTitle}</h2>
<p className="text-lg text-gray-500 max-w-2xl mx-auto"> <p className="text-lg text-gray-500 max-w-2xl mx-auto">
Verpassen Sie keine wichtigen Branchen-Updates mehr. Vernetzen Sie sich mit anderen Betrieben und verwalten Sie Termine bequem auf dem Smartphone. Verpassen Sie keine wichtigen Branchen-Updates mehr. Vernetzen Sie sich mit anderen Betrieben und verwalten Sie Termine bequem auf dem Smartphone.
</p> </p>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Feature 1: Aktuelles */} {/* Feature 1: Aktuelles */}
<div className="p-8 rounded-[2rem] border border-gray-100 bg-white shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 group"> <div className="p-8 rounded-[2rem] border border-gray-100 bg-white shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 group">
<div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300" style={{ color: primaryColor }}> <div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300" style={{ color: primaryColor }}>
<svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" /></svg> <svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" /></svg>
</div> </div>
<h3 className="text-xl font-bold text-gray-900 mb-3">Aktuelles</h3> <h3 className="text-xl font-bold text-gray-900 mb-3">Aktuelles</h3>
<p className="text-gray-500 leading-relaxed"> <p className="text-gray-500 leading-relaxed">
Lesen Sie die wichtigsten Branchen-News und bleiben Sie immer auf dem aktuellsten Stand. Lesen Sie die wichtigsten Branchen-News und bleiben Sie immer auf dem aktuellsten Stand.
</p> </p>
</div> </div>
{/* Feature 2: Termine */} {/* Feature 2: Termine */}
<div className="p-8 rounded-[2rem] border border-gray-100 bg-white shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 group"> <div className="p-8 rounded-[2rem] border border-gray-100 bg-white shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 group">
<div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300" style={{ color: primaryColor }}> <div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300" style={{ color: primaryColor }}>
<svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg> <svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
</div> </div>
<h3 className="text-xl font-bold text-gray-900 mb-3">Termine</h3> <h3 className="text-xl font-bold text-gray-900 mb-3">Termine</h3>
<p className="text-gray-500 leading-relaxed"> <p className="text-gray-500 leading-relaxed">
Verwalten Sie Veranstaltungen, Fortbildungen und Innungsversammlungen direkt in Ihrem Kalender. Verwalten Sie Veranstaltungen, Fortbildungen und Innungsversammlungen direkt in Ihrem Kalender.
</p> </p>
</div> </div>
{/* Feature 3: Stellen */} {/* Feature 3: Stellen */}
<div className="p-8 rounded-[2rem] border border-gray-100 bg-white shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 group"> <div className="p-8 rounded-[2rem] border border-gray-100 bg-white shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 group">
<div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300" style={{ color: primaryColor }}> <div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300" style={{ color: primaryColor }}>
<svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg> <svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
</div> </div>
<h3 className="text-xl font-bold text-gray-900 mb-3">Stellenbörse</h3> <h3 className="text-xl font-bold text-gray-900 mb-3">Stellenbörse</h3>
<p className="text-gray-500 leading-relaxed"> <p className="text-gray-500 leading-relaxed">
Finden Sie neue Fachkräfte oder Auszubildende. Veröffentlichen Sie Ihre offenen Stellenangebote branchenintern. Finden Sie neue Fachkräfte oder Auszubildende. Veröffentlichen Sie Ihre offenen Stellenangebote branchenintern.
</p> </p>
</div> </div>
{/* Feature 4: Nachrichten */} {/* Feature 4: Nachrichten */}
<div className="p-8 rounded-[2rem] border border-gray-100 bg-white shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 group"> <div className="p-8 rounded-[2rem] border border-gray-100 bg-white shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 group">
<div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300" style={{ color: primaryColor }}> <div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300" style={{ color: primaryColor }}>
<svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" /></svg> <svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" /></svg>
</div> </div>
<h3 className="text-xl font-bold text-gray-900 mb-3">Nachrichten</h3> <h3 className="text-xl font-bold text-gray-900 mb-3">Nachrichten</h3>
<p className="text-gray-500 leading-relaxed"> <p className="text-gray-500 leading-relaxed">
Tauschen Sie sich mit anderen Betrieben aus. Schnelle Kontaktaufnahme über direkte Einzel- und Gruppenchats. Tauschen Sie sich mit anderen Betrieben aus. Schnelle Kontaktaufnahme über direkte Einzel- und Gruppenchats.
</p> </p>
</div> </div>
{/* Feature 5: Profil */} {/* Feature 5: Profil */}
<div className="p-8 rounded-[2rem] border border-gray-100 bg-white shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 group"> <div className="p-8 rounded-[2rem] border border-gray-100 bg-white shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 group">
<div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300" style={{ color: primaryColor }}> <div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300" style={{ color: primaryColor }}>
<svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg> <svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>
</div> </div>
<h3 className="text-xl font-bold text-gray-900 mb-3">Profil & Ausweis</h3> <h3 className="text-xl font-bold text-gray-900 mb-3">Profil & Ausweis</h3>
<p className="text-gray-500 leading-relaxed"> <p className="text-gray-500 leading-relaxed">
Ihr digitaler Mitgliedsausweis immer griffbereit. Verwalten Sie das Profil Ihres Betriebs komfortabel in der App. Ihr digitaler Mitgliedsausweis immer griffbereit. Verwalten Sie das Profil Ihres Betriebs komfortabel in der App.
</p> </p>
</div> </div>
{/* Feature 6: Partner */} {/* Feature 6: Partner */}
<div className="p-8 rounded-[2rem] border border-gray-100 bg-white shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 group"> <div className="p-8 rounded-[2rem] border border-gray-100 bg-white shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 group">
<div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300" style={{ color: primaryColor }}> <div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300" style={{ color: primaryColor }}>
<svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /></svg> <svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /></svg>
</div> </div>
<h3 className="text-xl font-bold text-gray-900 mb-3">Netzwerk</h3> <h3 className="text-xl font-bold text-gray-900 mb-3">Netzwerk</h3>
<p className="text-gray-500 leading-relaxed"> <p className="text-gray-500 leading-relaxed">
Profitieren Sie von starken Kooperationen und Angeboten ausgewählter Partnerbetriebe in der Region. Profitieren Sie von starken Kooperationen und Angeboten ausgewählter Partnerbetriebe in der Region.
</p> </p>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
{/* Application Mock */} {/* Application Mock */}
<section id="apps" className="px-8 py-32 relative overflow-hidden" style={{ <section id="apps" className="px-8 py-32 relative overflow-hidden" style={{
background: `linear-gradient(to bottom, #ffffff 0%, ${primaryColor} 40%, #111827 100%)` background: `linear-gradient(to bottom, #ffffff 0%, ${primaryColor} 40%, #111827 100%)`
}}> }}>
{/* Decorative background elements */} {/* Decorative background elements */}
<div className="absolute inset-0 opacity-10" style={{ backgroundImage: 'radial-gradient(circle at 2px 2px, white 1px, transparent 0)', backgroundSize: '40px 40px' }}></div> <div className="absolute inset-0 opacity-10" style={{ backgroundImage: 'radial-gradient(circle at 2px 2px, white 1px, transparent 0)', backgroundSize: '40px 40px' }}></div>
<div className="absolute top-0 right-0 -mr-40 -mt-40 w-[500px] h-[500px] rounded-full bg-white/20 blur-[100px] pointer-events-none"></div> <div className="absolute top-0 right-0 -mr-40 -mt-40 w-[500px] h-[500px] rounded-full bg-white/20 blur-[100px] pointer-events-none"></div>
<div className="absolute bottom-0 left-0 -ml-40 -mb-40 w-[500px] h-[500px] rounded-full border-[40px] border-white/5 pointer-events-none"></div> <div className="absolute bottom-0 left-0 -ml-40 -mb-40 w-[500px] h-[500px] rounded-full border-[40px] border-white/5 pointer-events-none"></div>
<div className="max-w-6xl mx-auto flex flex-col md:flex-row items-center gap-16 relative z-10"> <div className="max-w-6xl mx-auto flex flex-col md:flex-row items-center gap-16 relative z-10">
<div className="flex-1 text-left space-y-8 text-white"> <div className="flex-1 text-left space-y-8 text-white">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white/10 backdrop-blur-md border border-white/20 text-sm font-medium"> <div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white/10 backdrop-blur-md border border-white/20 text-sm font-medium">
<span className="w-2 h-2 rounded-full bg-green-400 animate-pulse"></span> <span className="w-2 h-2 rounded-full bg-green-400 animate-pulse"></span>
Jetzt verfügbar Jetzt verfügbar
</div> </div>
<h2 className="text-4xl md:text-5xl font-black leading-tight"> <h2 className="text-4xl md:text-5xl font-black leading-tight">
Laden Sie unsere App herunter Laden Sie unsere App herunter
</h2> </h2>
<p className="text-white/80 text-xl leading-relaxed max-w-lg"> <p className="text-white/80 text-xl leading-relaxed max-w-lg">
Bleiben Sie immer auf dem Laufenden mit der {formData.name || 'Innungs'}-App für Mitglieder. Alle News, Termine und Ihr digitaler Mitgliedsausweis direkt auf Ihrem Smartphone. Bleiben Sie immer auf dem Laufenden mit der {formData.name || 'Innungs'}-App für Mitglieder. Alle News, Termine und Ihr digitaler Mitgliedsausweis direkt auf Ihrem Smartphone.
</p> </p>
<div className="flex flex-wrap gap-4 pt-4"> <div className="flex flex-wrap gap-4 pt-4">
{(!formData.appStoreUrl && !formData.playStoreUrl) || formData.appStoreUrl ? ( {(!formData.appStoreUrl && !formData.playStoreUrl) || formData.appStoreUrl ? (
<a href={formData.appStoreUrl || "#"} target="_blank" rel="noreferrer" className="bg-black hover:bg-black/80 text-white px-8 py-4 rounded-2xl cursor-pointer transition-all flex items-center gap-4 shadow-xl hover:shadow-2xl transform hover:-translate-y-1"> <a href={formData.appStoreUrl || "#"} target="_blank" rel="noreferrer" className="bg-black hover:bg-black/80 text-white px-8 py-4 rounded-2xl cursor-pointer transition-all flex items-center gap-4 shadow-xl hover:shadow-2xl transform hover:-translate-y-1">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512" className="w-8 h-8 fill-current"><path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z" /></svg> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512" className="w-8 h-8 fill-current"><path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z" /></svg>
<div> <div>
<div className="text-xs text-white/70">Download on the</div> <div className="text-xs text-white/70">Download on the</div>
<div className="text-lg font-semibold leading-none">App Store</div> <div className="text-lg font-semibold leading-none">App Store</div>
</div> </div>
</a> </a>
) : null} ) : null}
{(!formData.appStoreUrl && !formData.playStoreUrl) || formData.playStoreUrl ? ( {(!formData.appStoreUrl && !formData.playStoreUrl) || formData.playStoreUrl ? (
<a href={formData.playStoreUrl || "#"} target="_blank" rel="noreferrer" className="bg-black hover:bg-black/80 text-white px-8 py-4 rounded-2xl cursor-pointer transition-all flex items-center gap-4 shadow-xl hover:shadow-2xl transform hover:-translate-y-1"> <a href={formData.playStoreUrl || "#"} target="_blank" rel="noreferrer" className="bg-black hover:bg-black/80 text-white px-8 py-4 rounded-2xl cursor-pointer transition-all flex items-center gap-4 shadow-xl hover:shadow-2xl transform hover:-translate-y-1">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" className="w-8 h-8 fill-current"><path d="M325.3 234.3L104.6 13l280.8 161.2-60.1 60.1zM47 0C34 6.8 25.3 19.2 25.3 35.3v441.3c0 16.1 8.7 28.5 21.7 35.3l256.6-256L47 0zm425.2 225.6l-58.9-34.1-65.7 64.5 65.7 64.5 60.1-34.1c18-14.3 18-46.5-1.2-60.8zM104.6 499l280.8-161.2-60.1-60.1L104.6 499z" /></svg> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" className="w-8 h-8 fill-current"><path d="M325.3 234.3L104.6 13l280.8 161.2-60.1 60.1zM47 0C34 6.8 25.3 19.2 25.3 35.3v441.3c0 16.1 8.7 28.5 21.7 35.3l256.6-256L47 0zm425.2 225.6l-58.9-34.1-65.7 64.5 65.7 64.5 60.1-34.1c18-14.3 18-46.5-1.2-60.8zM104.6 499l280.8-161.2-60.1-60.1L104.6 499z" /></svg>
<div> <div>
<div className="text-xs text-white/70">GET IT ON</div> <div className="text-xs text-white/70">GET IT ON</div>
<div className="text-lg font-semibold leading-none">Google Play</div> <div className="text-lg font-semibold leading-none">Google Play</div>
</div> </div>
</a> </a>
) : null} ) : null}
</div> </div>
</div> </div>
<div className="flex-1 w-full flex justify-center mt-12 md:mt-0 perspective-[2000px]"> <div className="flex-1 w-full flex justify-center mt-12 md:mt-0 perspective-[2000px]">
<div className="relative w-[280px] h-[580px] rounded-[3rem] border-[12px] border-black bg-black shadow-2xl overflow-hidden transform rotate-y-[-15deg] rotate-x-[10deg] rotate-z-[5deg] hover:rotate-y-[0deg] hover:rotate-x-[0deg] hover:rotate-z-[0deg] transition-all duration-700 ease-out"> <div className="relative w-[280px] h-[580px] rounded-[3rem] border-[12px] border-black bg-black shadow-2xl overflow-hidden transform rotate-y-[-15deg] rotate-x-[10deg] rotate-z-[5deg] hover:rotate-y-[0deg] hover:rotate-x-[0deg] hover:rotate-z-[0deg] transition-all duration-700 ease-out">
{/* Notch */} {/* Notch */}
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-32 h-6 bg-black rounded-b-3xl z-20"></div> <div className="absolute top-0 left-1/2 -translate-x-1/2 w-32 h-6 bg-black rounded-b-3xl z-20"></div>
{/* App Screenshot Mockup */} {/* App Screenshot Mockup */}
<div className="w-full h-full bg-gray-50 flex flex-col pt-6"> <div className="w-full h-full bg-gray-50 flex flex-col pt-6">
{/* App Header */} {/* App Header */}
<div className="px-5 py-4 flex items-center justify-between bg-white border-b border-gray-100"> <div className="px-5 py-4 flex items-center justify-between bg-white border-b border-gray-100">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{formData.logoUrl ? ( {formData.logoUrl ? (
<img src={formData.logoUrl} alt="Logo" className="w-8 h-8 object-contain" /> <img src={formData.logoUrl} alt="Logo" className="w-8 h-8 object-contain" />
) : ( ) : (
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white font-bold text-xs shadow-sm" style={{ backgroundColor: primaryColor }}> <div className="w-8 h-8 rounded-full flex items-center justify-center text-white font-bold text-xs shadow-sm" style={{ backgroundColor: primaryColor }}>
{formData.name ? formData.name.charAt(0).toUpperCase() : 'I'} {formData.name ? formData.name.charAt(0).toUpperCase() : 'I'}
</div> </div>
)} )}
<div className="font-bold text-sm text-gray-800 truncate w-28">{formData.name || 'Ihre Innung'}</div> <div className="font-bold text-sm text-gray-800 truncate w-28">{formData.name || 'Ihre Innung'}</div>
</div> </div>
<div className="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center"> <div className="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center">
<svg className="w-4 h-4 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /></svg> <svg className="w-4 h-4 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /></svg>
</div> </div>
</div> </div>
{/* App Content */} {/* App Content */}
<div className="p-5 space-y-6 flex-1 overflow-hidden"> <div className="p-5 space-y-6 flex-1 overflow-hidden">
<div className="w-full h-32 rounded-2xl relative overflow-hidden flex items-end p-4 shadow-sm" style={{ backgroundColor: primaryColor }}> <div className="w-full h-32 rounded-2xl relative overflow-hidden flex items-end p-4 shadow-sm" style={{ backgroundColor: primaryColor }}>
<div className="absolute inset-0 bg-black/10"></div> <div className="absolute inset-0 bg-black/10"></div>
<div className="absolute -top-10 -right-10 w-32 h-32 bg-white/10 rounded-full blur-2xl"></div> <div className="absolute -top-10 -right-10 w-32 h-32 bg-white/10 rounded-full blur-2xl"></div>
<div className="relative z-10 text-white font-bold text-lg leading-tight">Willkommen,<br />Max Mustermann</div> <div className="relative z-10 text-white font-bold text-lg leading-tight">Willkommen,<br />Max Mustermann</div>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="text-sm font-bold text-gray-800">Aktuelle News</div> <div className="text-sm font-bold text-gray-800">Aktuelle News</div>
<div className="text-xs text-gray-400 font-medium">Alle ansehen</div> <div className="text-xs text-gray-400 font-medium">Alle ansehen</div>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<div className="bg-white rounded-xl border border-gray-100 p-3 flex gap-3 shadow-sm items-center"> <div className="bg-white rounded-xl border border-gray-100 p-3 flex gap-3 shadow-sm items-center">
<div className="w-12 h-12 rounded-lg flex-shrink-0" style={{ backgroundColor: `${primaryColor}15` }}></div> <div className="w-12 h-12 rounded-lg flex-shrink-0" style={{ backgroundColor: `${primaryColor}15` }}></div>
<div className="flex-1 space-y-2"> <div className="flex-1 space-y-2">
<div className="h-3 w-5/6 bg-gray-200 rounded-full"></div> <div className="h-3 w-5/6 bg-gray-200 rounded-full"></div>
<div className="h-2 w-full bg-gray-100 rounded-full"></div> <div className="h-2 w-full bg-gray-100 rounded-full"></div>
</div> </div>
</div> </div>
<div className="bg-white rounded-xl border border-gray-100 p-3 flex gap-3 shadow-sm items-center"> <div className="bg-white rounded-xl border border-gray-100 p-3 flex gap-3 shadow-sm items-center">
<div className="w-12 h-12 rounded-lg flex-shrink-0" style={{ backgroundColor: `${primaryColor}15` }}></div> <div className="w-12 h-12 rounded-lg flex-shrink-0" style={{ backgroundColor: `${primaryColor}15` }}></div>
<div className="flex-1 space-y-2"> <div className="flex-1 space-y-2">
<div className="h-3 w-2/3 bg-gray-200 rounded-full"></div> <div className="h-3 w-2/3 bg-gray-200 rounded-full"></div>
<div className="h-2 w-4/5 bg-gray-100 rounded-full"></div> <div className="h-2 w-4/5 bg-gray-100 rounded-full"></div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{/* App Bottom Nav */} {/* App Bottom Nav */}
<div className="h-[72px] bg-white border-t border-gray-100 flex items-center justify-between px-4 pb-2 pt-2 shadow-[0_-4px_20px_rgba(0,0,0,0.03)] z-20"> <div className="h-[72px] bg-white border-t border-gray-100 flex items-center justify-between px-4 pb-2 pt-2 shadow-[0_-4px_20px_rgba(0,0,0,0.03)] z-20">
<div className="flex flex-col items-center gap-1 w-1/6"> <div className="flex flex-col items-center gap-1 w-1/6">
<svg className="w-5 h-5" style={{ color: primaryColor }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /></svg> <svg className="w-5 h-5" style={{ color: primaryColor }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /></svg>
<span className="text-[9px] font-semibold" style={{ color: primaryColor }}>Start</span> <span className="text-[9px] font-semibold" style={{ color: primaryColor }}>Start</span>
</div> </div>
<div className="flex flex-col items-center gap-1 text-gray-400 w-1/6"> <div className="flex flex-col items-center gap-1 text-gray-400 w-1/6">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" /></svg> <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" /></svg>
<span className="text-[9px] font-medium">Aktuelles</span> <span className="text-[9px] font-medium">Aktuelles</span>
</div> </div>
<div className="flex flex-col items-center gap-1 text-gray-400 w-1/6"> <div className="flex flex-col items-center gap-1 text-gray-400 w-1/6">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg> <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
<span className="text-[9px] font-medium">Termine</span> <span className="text-[9px] font-medium">Termine</span>
</div> </div>
<div className="flex flex-col items-center gap-1 text-gray-400 w-1/6"> <div className="flex flex-col items-center gap-1 text-gray-400 w-1/6">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg> <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
<span className="text-[9px] font-medium">Stellen</span> <span className="text-[9px] font-medium">Stellen</span>
</div> </div>
<div className="flex flex-col items-center gap-1 text-gray-400 w-1/6"> <div className="flex flex-col items-center gap-1 text-gray-400 w-1/6">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" /></svg> <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" /></svg>
<span className="text-[9px] font-medium">Nachricht..</span> <span className="text-[9px] font-medium">Nachricht..</span>
</div> </div>
<div className="flex flex-col items-center gap-1 text-gray-400 w-1/6"> <div className="flex flex-col items-center gap-1 text-gray-400 w-1/6">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg> <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>
<span className="text-[9px] font-medium">Profil</span> <span className="text-[9px] font-medium">Profil</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
{/* CTA Section */} {/* CTA Section */}
<section id="mitglied-werden" className="px-8 py-24 bg-gray-50 text-center relative z-20"> <section id="mitglied-werden" className="px-8 py-24 bg-gray-50 text-center relative z-20">
<div className="max-w-3xl mx-auto space-y-8"> <div className="max-w-3xl mx-auto space-y-8">
<h2 className="text-3xl md:text-4xl font-bold text-gray-900">Werden Sie jetzt Teil der Gemeinschaft</h2> <h2 className="text-3xl md:text-4xl font-bold text-gray-900">Werden Sie jetzt Teil der Gemeinschaft</h2>
<p className="text-lg text-gray-600"> <p className="text-lg text-gray-600">
Profitieren Sie von unserem starken Netzwerk, exklusiven Brancheninformationen und unserer digitalen Innungs-App. Profitieren Sie von unserem starken Netzwerk, exklusiven Brancheninformationen und unserer digitalen Innungs-App.
</p> </p>
<a <a
href="#apps" href="#apps"
className="inline-block px-10 py-4 rounded-full text-white font-bold text-lg shadow-xl hover:shadow-2xl hover:-translate-y-1 transition-all" className="inline-block px-10 py-4 rounded-full text-white font-bold text-lg shadow-xl hover:shadow-2xl hover:-translate-y-1 transition-all"
style={{ backgroundColor: primaryColor }} style={{ backgroundColor: primaryColor }}
> >
Jetzt Mitglied werden Jetzt Mitglied werden
</a> </a>
</div> </div>
</section> </section>
{/* Footer */} {/* Footer */}
<footer className="bg-gray-900 text-gray-400 py-12 px-8 text-center text-sm"> <footer className="bg-gray-900 text-gray-400 py-12 px-8 text-center text-sm">
<div className="max-w-4xl mx-auto space-y-4"> <div className="max-w-4xl mx-auto space-y-4">
<div className="text-gray-300 font-bold text-lg mb-6">{formData.name || 'Innungs-Logo'}</div> <div className="text-gray-300 font-bold text-lg mb-6">{formData.name || 'Innungs-Logo'}</div>
<div className="whitespace-pre-wrap">{footer}</div> <div className="whitespace-pre-wrap">{footer}</div>
<div className="pt-8 border-t border-gray-800 flex justify-center gap-6"> <div className="pt-8 border-t border-gray-800 flex justify-center gap-6">
<a href="#" className="hover:text-white transition-colors">Impressum</a> <a href="#" className="hover:text-white transition-colors">Impressum</a>
<a href="#" className="hover:text-white transition-colors">Datenschutz</a> <a href="#" className="hover:text-white transition-colors">Datenschutz</a>
<a href="#" className="hover:text-white transition-colors">Kontakt</a> <a href="#" className="hover:text-white transition-colors">Kontakt</a>
</div> </div>
</div> </div>
</footer> </footer>
</div> </div>
) )
} }

View File

@ -1,477 +1,477 @@
'use server' 'use server'
import { prisma, Prisma } from '@innungsapp/shared' import { prisma, Prisma } from '@innungsapp/shared'
import { auth, getSanitizedHeaders } from '@/lib/auth' import { auth, getSanitizedHeaders } from '@/lib/auth'
import { revalidatePath } from 'next/cache' import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import { z } from 'zod' import { z } from 'zod'
import { sendAdminCredentialsEmail } from '@/lib/email' import { sendAdminCredentialsEmail } from '@/lib/email'
// @ts-ignore // @ts-ignore
import { hashPassword } from 'better-auth/crypto' import { hashPassword } from 'better-auth/crypto'
function normalizeEmail(email: string | null | undefined): string { function normalizeEmail(email: string | null | undefined): string {
return (email ?? '').trim().toLowerCase() return (email ?? '').trim().toLowerCase()
} }
function toJsonbText(value: string | undefined): Prisma.InputJsonValue | Prisma.NullableJsonNullValueInput { function toJsonbText(value: string | undefined): Prisma.InputJsonValue | Prisma.NullableJsonNullValueInput {
if (!value) { if (!value) {
return Prisma.DbNull return Prisma.DbNull
} }
return value return value
} }
/** /**
* Sets a credential (email+password) account for a user. * Sets a credential (email+password) account for a user.
* Uses direct DB write with better-auth's hashPassword for compatibility. * Uses direct DB write with better-auth's hashPassword for compatibility.
*/ */
async function setCredentialPassword(userId: string, password: string) { async function setCredentialPassword(userId: string, password: string) {
const hashedPassword = await hashPassword(password) const hashedPassword = await hashPassword(password)
const updated = await prisma.account.updateMany({ const updated = await prisma.account.updateMany({
where: { userId, providerId: 'credential' }, where: { userId, providerId: 'credential' },
data: { password: hashedPassword, accountId: userId }, data: { password: hashedPassword, accountId: userId },
}) })
if (updated.count === 0) { if (updated.count === 0) {
await prisma.account.create({ await prisma.account.create({
data: { data: {
id: crypto.randomUUID(), id: crypto.randomUUID(),
userId, userId,
accountId: userId, accountId: userId,
providerId: 'credential', providerId: 'credential',
password: hashedPassword, password: hashedPassword,
}, },
}) })
} }
} }
async function requireSuperAdmin() { async function requireSuperAdmin() {
const session = await auth.api.getSession({ headers: await getSanitizedHeaders() }) const session = await auth.api.getSession({ headers: await getSanitizedHeaders() })
const superAdminEmail = process.env.SUPERADMIN_EMAIL || 'superadmin@innungsapp.de' const superAdminEmail = process.env.SUPERADMIN_EMAIL || 'superadmin@innungsapp.de'
// An admin is either specifically the superadmin email OR has the 'admin' role from better-auth admin plugin // An admin is either specifically the superadmin email OR has the 'admin' role from better-auth admin plugin
const isSuperAdmin = session?.user && ( const isSuperAdmin = session?.user && (
session.user.email === superAdminEmail || session.user.email === superAdminEmail ||
(session.user as any).role === 'admin' (session.user as any).role === 'admin'
) )
if (!isSuperAdmin) { if (!isSuperAdmin) {
return null return null
} }
return session return session
} }
const createOrgSchema = z.object({ const createOrgSchema = z.object({
name: z.string().min(2, 'Name muss mindestens 2 Zeichen lang sein'), name: z.string().min(2, 'Name muss mindestens 2 Zeichen lang sein'),
slug: z slug: z
.string() .string()
.min(2, 'Slug muss mindestens 2 Zeichen lang sein') .min(2, 'Slug muss mindestens 2 Zeichen lang sein')
.regex(/^[a-z0-9-]+$/, 'Slug darf nur Kleinbuchstaben, Zahlen und Bindestriche enthalten'), .regex(/^[a-z0-9-]+$/, 'Slug darf nur Kleinbuchstaben, Zahlen und Bindestriche enthalten'),
contactEmail: z.string().email('Ungueltige E-Mail Adresse').optional().or(z.literal('')), contactEmail: z.string().email('Ungueltige E-Mail Adresse').optional().or(z.literal('')),
adminEmail: z.string().email('Ungueltige Admin E-Mail').optional().or(z.literal('')), adminEmail: z.string().email('Ungueltige Admin E-Mail').optional().or(z.literal('')),
adminPassword: z.string().min(8, 'Passwort muss mindestens 8 Zeichen lang sein').optional().or(z.literal('')), adminPassword: z.string().min(8, 'Passwort muss mindestens 8 Zeichen lang sein').optional().or(z.literal('')),
logoUrl: z.string().optional().nullable(), logoUrl: z.string().optional().nullable(),
plan: z.enum(['pilot', 'standard', 'pro', 'verband']).default('pilot'), plan: z.enum(['pilot', 'standard', 'pro', 'verband']).default('pilot'),
primaryColor: z.string().regex(/^#([0-9a-fA-F]{6})$/, 'Ungueltige Farbe').optional().or(z.literal('')), primaryColor: z.string().regex(/^#([0-9a-fA-F]{6})$/, 'Ungueltige Farbe').optional().or(z.literal('')),
secondaryColor: z.string().regex(/^#([0-9a-fA-F]{6})$/, 'Ungueltige Farbe').optional().or(z.literal('')), secondaryColor: z.string().regex(/^#([0-9a-fA-F]{6})$/, 'Ungueltige Farbe').optional().or(z.literal('')),
landingPageTitle: z.string().optional(), landingPageTitle: z.string().optional(),
landingPageText: z.string().optional(), landingPageText: z.string().optional(),
landingPageHeroImage: z.string().optional().nullable(), landingPageHeroImage: z.string().optional().nullable(),
landingPageHeroOverlayOpacity: z.number().min(0).max(100).optional().default(50), landingPageHeroOverlayOpacity: z.number().min(0).max(100).optional().default(50),
landingPageFeatures: z.string().optional(), landingPageFeatures: z.string().optional(),
landingPageFooter: z.string().optional(), landingPageFooter: z.string().optional(),
landingPageSectionTitle: z.string().optional(), landingPageSectionTitle: z.string().optional(),
landingPageButtonText: z.string().optional(), landingPageButtonText: z.string().optional(),
appStoreUrl: z.string().url('Ungueltige URL').optional().or(z.literal('')), appStoreUrl: z.string().url('Ungueltige URL').optional().or(z.literal('')),
playStoreUrl: z.string().url('Ungueltige URL').optional().or(z.literal('')), playStoreUrl: z.string().url('Ungueltige URL').optional().or(z.literal('')),
}) })
const updateOrgSchema = z.object({ const updateOrgSchema = z.object({
name: z.string().min(2, 'Name muss mindestens 2 Zeichen lang sein'), name: z.string().min(2, 'Name muss mindestens 2 Zeichen lang sein'),
plan: z.enum(['pilot', 'standard', 'pro', 'verband']), plan: z.enum(['pilot', 'standard', 'pro', 'verband']),
contactEmail: z.string().email('Ungueltige E-Mail Adresse').optional().or(z.literal('')), contactEmail: z.string().email('Ungueltige E-Mail Adresse').optional().or(z.literal('')),
logoUrl: z.string().optional().nullable(), logoUrl: z.string().optional().nullable(),
primaryColor: z.string().regex(/^#([0-9a-fA-F]{6})$/, 'Ungueltige Farbe').optional().or(z.literal('')), primaryColor: z.string().regex(/^#([0-9a-fA-F]{6})$/, 'Ungueltige Farbe').optional().or(z.literal('')),
secondaryColor: z.string().regex(/^#([0-9a-fA-F]{6})$/, 'Ungueltige Farbe').optional().or(z.literal('')), secondaryColor: z.string().regex(/^#([0-9a-fA-F]{6})$/, 'Ungueltige Farbe').optional().or(z.literal('')),
landingPageTitle: z.string().optional(), landingPageTitle: z.string().optional(),
landingPageText: z.string().optional(), landingPageText: z.string().optional(),
landingPageHeroImage: z.string().optional().nullable(), landingPageHeroImage: z.string().optional().nullable(),
landingPageFeatures: z.string().optional(), landingPageFeatures: z.string().optional(),
landingPageFooter: z.string().optional(), landingPageFooter: z.string().optional(),
landingPageSectionTitle: z.string().optional(), landingPageSectionTitle: z.string().optional(),
landingPageButtonText: z.string().optional(), landingPageButtonText: z.string().optional(),
appStoreUrl: z.string().url('Ungueltige URL').optional().or(z.literal('')), appStoreUrl: z.string().url('Ungueltige URL').optional().or(z.literal('')),
playStoreUrl: z.string().url('Ungueltige URL').optional().or(z.literal('')), playStoreUrl: z.string().url('Ungueltige URL').optional().or(z.literal('')),
}) })
const createAdminSchema = z.object({ const createAdminSchema = z.object({
orgId: z.string(), orgId: z.string(),
name: z.string().min(2, 'Name ist zu kurz'), name: z.string().min(2, 'Name ist zu kurz'),
email: z.string().email('Ungueltige E-Mail Adresse'), email: z.string().email('Ungueltige E-Mail Adresse'),
password: z.string().min(8, 'Passwort muss mindestens 8 Zeichen lang sein'), password: z.string().min(8, 'Passwort muss mindestens 8 Zeichen lang sein'),
}) })
const createMemberSchema = z.object({ const createMemberSchema = z.object({
orgId: z.string(), orgId: z.string(),
name: z.string().min(2, 'Name ist zu kurz'), name: z.string().min(2, 'Name ist zu kurz'),
email: z.string().email('Ungueltige E-Mail Adresse'), email: z.string().email('Ungueltige E-Mail Adresse'),
betrieb: z.string().min(2, 'Betrieb ist zu kurz'), betrieb: z.string().min(2, 'Betrieb ist zu kurz'),
sparte: z.string().min(2, 'Sparte ist zu kurz'), sparte: z.string().min(2, 'Sparte ist zu kurz'),
ort: z.string().min(2, 'Ort ist zu kurz'), ort: z.string().min(2, 'Ort ist zu kurz'),
}) })
export async function createOrganization(prevState: any, formData: FormData) { export async function createOrganization(prevState: any, formData: FormData) {
const session = await requireSuperAdmin() const session = await requireSuperAdmin()
if (!session) return { success: false, error: 'Nicht autorisiert.' } if (!session) return { success: false, error: 'Nicht autorisiert.' }
try { try {
const rawData = { const rawData = {
name: (formData.get('name') as string).trim(), name: (formData.get('name') as string).trim(),
slug: (formData.get('slug') as string).trim().toLowerCase(), slug: (formData.get('slug') as string).trim().toLowerCase(),
contactEmail: (formData.get('contactEmail') as string).trim(), contactEmail: (formData.get('contactEmail') as string).trim(),
adminEmail: normalizeEmail(formData.get('adminEmail') as string), adminEmail: normalizeEmail(formData.get('adminEmail') as string),
adminPassword: formData.get('adminPassword') as string, adminPassword: formData.get('adminPassword') as string,
logoUrl: formData.get('logoUrl') as string, logoUrl: formData.get('logoUrl') as string,
plan: (formData.get('plan') as string) || 'pilot', plan: (formData.get('plan') as string) || 'pilot',
primaryColor: formData.get('primaryColor') as string, primaryColor: formData.get('primaryColor') as string,
secondaryColor: formData.get('secondaryColor') as string, secondaryColor: formData.get('secondaryColor') as string,
landingPageTitle: (formData.get('landingPageTitle') as string).trim(), landingPageTitle: (formData.get('landingPageTitle') as string).trim(),
landingPageText: (formData.get('landingPageText') as string).trim(), landingPageText: (formData.get('landingPageText') as string).trim(),
landingPageHeroImage: formData.get('landingPageHeroImage') as string, landingPageHeroImage: formData.get('landingPageHeroImage') as string,
landingPageHeroOverlayOpacity: Number(formData.get('landingPageHeroOverlayOpacity') || '50'), landingPageHeroOverlayOpacity: Number(formData.get('landingPageHeroOverlayOpacity') || '50'),
landingPageFeatures: (formData.get('landingPageFeatures') as string).trim(), landingPageFeatures: (formData.get('landingPageFeatures') as string).trim(),
landingPageFooter: (formData.get('landingPageFooter') as string).trim(), landingPageFooter: (formData.get('landingPageFooter') as string).trim(),
landingPageSectionTitle: (formData.get('landingPageSectionTitle') as string || '').trim(), landingPageSectionTitle: (formData.get('landingPageSectionTitle') as string || '').trim(),
landingPageButtonText: (formData.get('landingPageButtonText') as string || '').trim(), landingPageButtonText: (formData.get('landingPageButtonText') as string || '').trim(),
appStoreUrl: (formData.get('appStoreUrl') as string || '').trim(), appStoreUrl: (formData.get('appStoreUrl') as string || '').trim(),
playStoreUrl: (formData.get('playStoreUrl') as string || '').trim(), playStoreUrl: (formData.get('playStoreUrl') as string || '').trim(),
} }
const validatedData = createOrgSchema.parse(rawData) const validatedData = createOrgSchema.parse(rawData)
const existingOrg = await prisma.organization.findUnique({ const existingOrg = await prisma.organization.findUnique({
where: { slug: validatedData.slug }, where: { slug: validatedData.slug },
}) })
if (existingOrg) { if (existingOrg) {
return { success: false, error: 'Diese Kurzbezeichnung (Slug) existiert bereits.' } return { success: false, error: 'Diese Kurzbezeichnung (Slug) existiert bereits.' }
} }
const org = await prisma.organization.create({ const org = await prisma.organization.create({
data: { data: {
name: validatedData.name, name: validatedData.name,
slug: validatedData.slug, slug: validatedData.slug,
contactEmail: validatedData.contactEmail || validatedData.adminEmail || null, contactEmail: validatedData.contactEmail || validatedData.adminEmail || null,
plan: validatedData.plan, plan: validatedData.plan,
primaryColor: validatedData.primaryColor || '#E63946', primaryColor: validatedData.primaryColor || '#E63946',
secondaryColor: validatedData.secondaryColor || null, secondaryColor: validatedData.secondaryColor || null,
logoUrl: validatedData.logoUrl || null, logoUrl: validatedData.logoUrl || null,
landingPageTitle: validatedData.landingPageTitle || null, landingPageTitle: validatedData.landingPageTitle || null,
landingPageText: validatedData.landingPageText || null, landingPageText: validatedData.landingPageText || null,
landingPageHeroImage: validatedData.landingPageHeroImage || null, landingPageHeroImage: validatedData.landingPageHeroImage || null,
// @ts-ignore // @ts-ignore
landingPageHeroOverlayOpacity: validatedData.landingPageHeroOverlayOpacity, landingPageHeroOverlayOpacity: validatedData.landingPageHeroOverlayOpacity,
landingPageFeatures: toJsonbText(validatedData.landingPageFeatures), landingPageFeatures: toJsonbText(validatedData.landingPageFeatures),
landingPageFooter: toJsonbText(validatedData.landingPageFooter), landingPageFooter: toJsonbText(validatedData.landingPageFooter),
landingPageSectionTitle: validatedData.landingPageSectionTitle || null, landingPageSectionTitle: validatedData.landingPageSectionTitle || null,
landingPageButtonText: validatedData.landingPageButtonText || null, landingPageButtonText: validatedData.landingPageButtonText || null,
appStoreUrl: validatedData.appStoreUrl || null, appStoreUrl: validatedData.appStoreUrl || null,
playStoreUrl: validatedData.playStoreUrl || null, playStoreUrl: validatedData.playStoreUrl || null,
}, },
}) })
if (validatedData.adminEmail) { if (validatedData.adminEmail) {
let user = await prisma.user.findUnique({ where: { email: validatedData.adminEmail } }) let user = await prisma.user.findUnique({ where: { email: validatedData.adminEmail } })
if (!user) { if (!user) {
user = await prisma.user.create({ user = await prisma.user.create({
data: { data: {
id: crypto.randomUUID(), id: crypto.randomUUID(),
name: validatedData.adminEmail.split('@')[0], name: validatedData.adminEmail.split('@')[0],
email: validatedData.adminEmail, email: validatedData.adminEmail,
emailVerified: true, emailVerified: true,
mustChangePassword: !!validatedData.adminPassword, mustChangePassword: !!validatedData.adminPassword,
}, },
}) })
} else { } else {
// If user exists, we still want to make sure they are verified and maybe force password change // If user exists, we still want to make sure they are verified and maybe force password change
user = await prisma.user.update({ user = await prisma.user.update({
where: { id: user.id }, where: { id: user.id },
data: { data: {
emailVerified: true, emailVerified: true,
...(validatedData.adminPassword ? { mustChangePassword: true } : {}), ...(validatedData.adminPassword ? { mustChangePassword: true } : {}),
}, },
}) })
} }
await prisma.userRole.upsert({ await prisma.userRole.upsert({
where: { where: {
orgId_userId: { orgId_userId: {
orgId: org.id, orgId: org.id,
userId: user.id, userId: user.id,
}, },
}, },
update: { role: 'admin' }, update: { role: 'admin' },
create: { create: {
orgId: org.id, orgId: org.id,
userId: user.id, userId: user.id,
role: 'admin', role: 'admin',
}, },
}) })
if (validatedData.adminPassword) { if (validatedData.adminPassword) {
await setCredentialPassword(user.id, validatedData.adminPassword) await setCredentialPassword(user.id, validatedData.adminPassword)
try { try {
await sendAdminCredentialsEmail({ await sendAdminCredentialsEmail({
to: validatedData.adminEmail, to: validatedData.adminEmail,
adminName: user.name || validatedData.adminEmail.split('@')[0], adminName: user.name || validatedData.adminEmail.split('@')[0],
orgName: org.name, orgName: org.name,
password: validatedData.adminPassword, password: validatedData.adminPassword,
loginUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3010', loginUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3010',
}) })
} catch (emailError) { } catch (emailError) {
console.error('E-Mail konnte nicht gesendet werden:', emailError) console.error('E-Mail konnte nicht gesendet werden:', emailError)
} }
} }
} }
revalidatePath('/superadmin') revalidatePath('/superadmin')
return { success: true, error: '' } return { success: true, error: '' }
} catch (error) { } catch (error) {
if (error instanceof z.ZodError) { if (error instanceof z.ZodError) {
return { success: false, error: error.errors[0].message } return { success: false, error: error.errors[0].message }
} }
return { success: false, error: 'Ein unerwarteter Fehler ist aufgetreten.' } return { success: false, error: 'Ein unerwarteter Fehler ist aufgetreten.' }
} }
} }
export async function updateOrganization(id: string, prevState: any, formData: FormData) { export async function updateOrganization(id: string, prevState: any, formData: FormData) {
const session = await requireSuperAdmin() const session = await requireSuperAdmin()
if (!session) return { success: false, error: 'Nicht autorisiert.' } if (!session) return { success: false, error: 'Nicht autorisiert.' }
try { try {
const rawData = { const rawData = {
name: (formData.get('name') as string).trim(), name: (formData.get('name') as string).trim(),
plan: formData.get('plan') as string, plan: formData.get('plan') as string,
contactEmail: (formData.get('contactEmail') as string).trim(), contactEmail: (formData.get('contactEmail') as string).trim(),
logoUrl: formData.get('logoUrl') as string, logoUrl: formData.get('logoUrl') as string,
primaryColor: formData.get('primaryColor') as string, primaryColor: formData.get('primaryColor') as string,
secondaryColor: formData.get('secondaryColor') as string, secondaryColor: formData.get('secondaryColor') as string,
landingPageTitle: (formData.get('landingPageTitle') as string).trim(), landingPageTitle: (formData.get('landingPageTitle') as string).trim(),
landingPageText: (formData.get('landingPageText') as string).trim(), landingPageText: (formData.get('landingPageText') as string).trim(),
landingPageHeroImage: formData.get('landingPageHeroImage') as string, landingPageHeroImage: formData.get('landingPageHeroImage') as string,
landingPageFeatures: (formData.get('landingPageFeatures') as string).trim(), landingPageFeatures: (formData.get('landingPageFeatures') as string).trim(),
landingPageFooter: (formData.get('landingPageFooter') as string).trim(), landingPageFooter: (formData.get('landingPageFooter') as string).trim(),
landingPageSectionTitle: (formData.get('landingPageSectionTitle') as string || '').trim(), landingPageSectionTitle: (formData.get('landingPageSectionTitle') as string || '').trim(),
landingPageButtonText: (formData.get('landingPageButtonText') as string || '').trim(), landingPageButtonText: (formData.get('landingPageButtonText') as string || '').trim(),
appStoreUrl: (formData.get('appStoreUrl') as string || '').trim(), appStoreUrl: (formData.get('appStoreUrl') as string || '').trim(),
playStoreUrl: (formData.get('playStoreUrl') as string || '').trim(), playStoreUrl: (formData.get('playStoreUrl') as string || '').trim(),
} }
const validatedData = updateOrgSchema.parse(rawData) const validatedData = updateOrgSchema.parse(rawData)
await prisma.organization.update({ await prisma.organization.update({
where: { id }, where: { id },
data: { data: {
name: validatedData.name, name: validatedData.name,
plan: validatedData.plan, plan: validatedData.plan,
contactEmail: validatedData.contactEmail || null, contactEmail: validatedData.contactEmail || null,
logoUrl: validatedData.logoUrl || null, logoUrl: validatedData.logoUrl || null,
primaryColor: validatedData.primaryColor || '#E63946', primaryColor: validatedData.primaryColor || '#E63946',
secondaryColor: validatedData.secondaryColor || null, secondaryColor: validatedData.secondaryColor || null,
landingPageTitle: validatedData.landingPageTitle || null, landingPageTitle: validatedData.landingPageTitle || null,
landingPageText: validatedData.landingPageText || null, landingPageText: validatedData.landingPageText || null,
landingPageHeroImage: validatedData.landingPageHeroImage || null, landingPageHeroImage: validatedData.landingPageHeroImage || null,
landingPageFeatures: toJsonbText(validatedData.landingPageFeatures), landingPageFeatures: toJsonbText(validatedData.landingPageFeatures),
landingPageFooter: toJsonbText(validatedData.landingPageFooter), landingPageFooter: toJsonbText(validatedData.landingPageFooter),
landingPageSectionTitle: validatedData.landingPageSectionTitle || null, landingPageSectionTitle: validatedData.landingPageSectionTitle || null,
landingPageButtonText: validatedData.landingPageButtonText || null, landingPageButtonText: validatedData.landingPageButtonText || null,
appStoreUrl: validatedData.appStoreUrl || null, appStoreUrl: validatedData.appStoreUrl || null,
playStoreUrl: validatedData.playStoreUrl || null, playStoreUrl: validatedData.playStoreUrl || null,
}, },
}) })
revalidatePath('/superadmin') revalidatePath('/superadmin')
revalidatePath(`/superadmin/organizations/${id}`) revalidatePath(`/superadmin/organizations/${id}`)
return { success: true, error: '' } return { success: true, error: '' }
} catch (error) { } catch (error) {
if (error instanceof z.ZodError) { if (error instanceof z.ZodError) {
return { success: false, error: error.errors[0].message } return { success: false, error: error.errors[0].message }
} }
return { success: false, error: 'Ein unerwarteter Fehler ist aufgetreten.' } return { success: false, error: 'Ein unerwarteter Fehler ist aufgetreten.' }
} }
} }
export async function toggleAiFeature(id: string, enabled: boolean) { export async function toggleAiFeature(id: string, enabled: boolean) {
const session = await requireSuperAdmin() const session = await requireSuperAdmin()
if (!session) return { success: false, error: 'Nicht autorisiert.' } if (!session) return { success: false, error: 'Nicht autorisiert.' }
await prisma.organization.update({ await prisma.organization.update({
where: { id }, where: { id },
data: { aiEnabled: enabled }, data: { aiEnabled: enabled },
}) })
revalidatePath('/superadmin') revalidatePath('/superadmin')
revalidatePath(`/superadmin/organizations/${id}`) revalidatePath(`/superadmin/organizations/${id}`)
return { success: true, error: '' } return { success: true, error: '' }
} }
export async function deleteOrganization(id: string) { export async function deleteOrganization(id: string) {
const session = await requireSuperAdmin() const session = await requireSuperAdmin()
if (!session) return { success: false, error: 'Nicht autorisiert.' } if (!session) return { success: false, error: 'Nicht autorisiert.' }
await prisma.organization.delete({ where: { id } }) await prisma.organization.delete({ where: { id } })
revalidatePath('/superadmin') revalidatePath('/superadmin')
redirect('/superadmin') redirect('/superadmin')
} }
export async function createAdmin(prevState: any, formData: FormData) { export async function createAdmin(prevState: any, formData: FormData) {
const session = await requireSuperAdmin() const session = await requireSuperAdmin()
if (!session) return { success: false, error: 'Nicht autorisiert.' } if (!session) return { success: false, error: 'Nicht autorisiert.' }
try { try {
const rawData = { const rawData = {
orgId: formData.get('orgId') as string, orgId: formData.get('orgId') as string,
name: (formData.get('name') as string).trim(), name: (formData.get('name') as string).trim(),
email: normalizeEmail(formData.get('email') as string), email: normalizeEmail(formData.get('email') as string),
password: formData.get('password') as string, password: formData.get('password') as string,
} }
const validatedData = createAdminSchema.parse(rawData) const validatedData = createAdminSchema.parse(rawData)
let user = await prisma.user.findUnique({ where: { email: validatedData.email } }) let user = await prisma.user.findUnique({ where: { email: validatedData.email } })
if (!user) { if (!user) {
user = await prisma.user.create({ user = await prisma.user.create({
data: { data: {
id: crypto.randomUUID(), id: crypto.randomUUID(),
name: validatedData.name, name: validatedData.name,
email: validatedData.email, email: validatedData.email,
emailVerified: true, emailVerified: true,
mustChangePassword: true, mustChangePassword: true,
}, },
}) })
} else { } else {
user = await prisma.user.update({ user = await prisma.user.update({
where: { id: user.id }, where: { id: user.id },
data: { data: {
emailVerified: true, emailVerified: true,
mustChangePassword: true, mustChangePassword: true,
}, },
}) })
} }
await setCredentialPassword(user.id, validatedData.password) await setCredentialPassword(user.id, validatedData.password)
await prisma.userRole.upsert({ await prisma.userRole.upsert({
where: { where: {
orgId_userId: { orgId_userId: {
orgId: validatedData.orgId, orgId: validatedData.orgId,
userId: user.id, userId: user.id,
}, },
}, },
update: { role: 'admin' }, update: { role: 'admin' },
create: { create: {
orgId: validatedData.orgId, orgId: validatedData.orgId,
userId: user.id, userId: user.id,
role: 'admin', role: 'admin',
}, },
}) })
const org = await prisma.organization.findUnique({ const org = await prisma.organization.findUnique({
where: { id: validatedData.orgId }, where: { id: validatedData.orgId },
select: { name: true }, select: { name: true },
}) })
try { try {
await sendAdminCredentialsEmail({ await sendAdminCredentialsEmail({
to: validatedData.email, to: validatedData.email,
adminName: validatedData.name, adminName: validatedData.name,
orgName: org?.name || 'Ihre Innung', orgName: org?.name || 'Ihre Innung',
password: validatedData.password, password: validatedData.password,
loginUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3010', loginUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3010',
}) })
} catch (emailError) { } catch (emailError) {
console.error('E-Mail konnte nicht gesendet werden (Admin wurde trotzdem angelegt):', emailError) console.error('E-Mail konnte nicht gesendet werden (Admin wurde trotzdem angelegt):', emailError)
} }
revalidatePath(`/superadmin/organizations/${validatedData.orgId}`) revalidatePath(`/superadmin/organizations/${validatedData.orgId}`)
return { success: true, error: '' } return { success: true, error: '' }
} catch (error) { } catch (error) {
console.error('Failed to create admin:', error) console.error('Failed to create admin:', error)
if (error instanceof z.ZodError) { if (error instanceof z.ZodError) {
return { success: false, error: error.errors[0].message } return { success: false, error: error.errors[0].message }
} }
return { success: false, error: 'Ein Fehler ist aufgetreten.' } return { success: false, error: 'Ein Fehler ist aufgetreten.' }
} }
} }
export async function removeUserRole(id: string, orgId: string) { export async function removeUserRole(id: string, orgId: string) {
const session = await requireSuperAdmin() const session = await requireSuperAdmin()
if (!session) return { success: false, error: 'Nicht autorisiert.' } if (!session) return { success: false, error: 'Nicht autorisiert.' }
await prisma.userRole.delete({ where: { id } }) await prisma.userRole.delete({ where: { id } })
revalidatePath(`/superadmin/organizations/${orgId}`) revalidatePath(`/superadmin/organizations/${orgId}`)
return { success: true, error: '' } return { success: true, error: '' }
} }
export async function updateUserRole(id: string, orgId: string, role: string) { export async function updateUserRole(id: string, orgId: string, role: string) {
const session = await requireSuperAdmin() const session = await requireSuperAdmin()
if (!session) return { success: false, error: 'Nicht autorisiert.' } if (!session) return { success: false, error: 'Nicht autorisiert.' }
await prisma.userRole.update({ await prisma.userRole.update({
where: { id }, where: { id },
data: { role }, data: { role },
}) })
revalidatePath(`/superadmin/organizations/${orgId}`) revalidatePath(`/superadmin/organizations/${orgId}`)
return { success: true, error: '' } return { success: true, error: '' }
} }
export async function removeMember(id: string, orgId: string) { export async function removeMember(id: string, orgId: string) {
const session = await requireSuperAdmin() const session = await requireSuperAdmin()
if (!session) return { success: false, error: 'Nicht autorisiert.' } if (!session) return { success: false, error: 'Nicht autorisiert.' }
await prisma.member.delete({ where: { id } }) await prisma.member.delete({ where: { id } })
revalidatePath(`/superadmin/organizations/${orgId}`) revalidatePath(`/superadmin/organizations/${orgId}`)
return { success: true, error: '' } return { success: true, error: '' }
} }
export async function createMember(prevState: any, formData: FormData) { export async function createMember(prevState: any, formData: FormData) {
const session = await requireSuperAdmin() const session = await requireSuperAdmin()
if (!session) return { success: false, error: 'Nicht autorisiert.' } if (!session) return { success: false, error: 'Nicht autorisiert.' }
try { try {
const rawData = { const rawData = {
orgId: formData.get('orgId') as string, orgId: formData.get('orgId') as string,
name: (formData.get('name') as string).trim(), name: (formData.get('name') as string).trim(),
email: normalizeEmail(formData.get('email') as string), email: normalizeEmail(formData.get('email') as string),
betrieb: (formData.get('betrieb') as string).trim(), betrieb: (formData.get('betrieb') as string).trim(),
sparte: (formData.get('sparte') as string).trim(), sparte: (formData.get('sparte') as string).trim(),
ort: (formData.get('ort') as string).trim(), ort: (formData.get('ort') as string).trim(),
} }
const validatedData = createMemberSchema.parse(rawData) const validatedData = createMemberSchema.parse(rawData)
await prisma.member.create({ await prisma.member.create({
data: { data: {
orgId: validatedData.orgId, orgId: validatedData.orgId,
name: validatedData.name, name: validatedData.name,
email: validatedData.email, email: validatedData.email,
betrieb: validatedData.betrieb, betrieb: validatedData.betrieb,
sparte: validatedData.sparte, sparte: validatedData.sparte,
ort: validatedData.ort, ort: validatedData.ort,
status: 'aktiv', status: 'aktiv',
}, },
}) })
revalidatePath(`/superadmin/organizations/${validatedData.orgId}`) revalidatePath(`/superadmin/organizations/${validatedData.orgId}`)
return { success: true, error: '' } return { success: true, error: '' }
} catch (error) { } catch (error) {
console.error('Failed to create member:', error) console.error('Failed to create member:', error)
if (error instanceof z.ZodError) { if (error instanceof z.ZodError) {
return { success: false, error: error.errors[0].message } return { success: false, error: error.errors[0].message }
} }
return { success: false, error: 'Ein Fehler ist aufgetreten.' } return { success: false, error: 'Ein Fehler ist aufgetreten.' }
} }
} }

View File

@ -1,30 +1,30 @@
import { CreateOrgForm } from '../CreateOrgForm' import { CreateOrgForm } from '../CreateOrgForm'
import Link from 'next/link' import Link from 'next/link'
export default function CreateOrgPage() { export default function CreateOrgPage() {
return ( return (
<div className="h-full w-full flex flex-col p-6 gap-6"> <div className="h-full w-full flex flex-col p-6 gap-6">
<div className="flex items-center gap-4 shrink-0"> <div className="flex items-center gap-4 shrink-0">
<Link <Link
href="/superadmin" href="/superadmin"
className="p-2.5 bg-white border border-gray-200 text-gray-400 rounded-xl hover:bg-gray-50 hover:text-gray-600 transition-colors" className="p-2.5 bg-white border border-gray-200 text-gray-400 rounded-xl hover:bg-gray-50 hover:text-gray-600 transition-colors"
title="Zurück zur Übersicht" title="Zurück zur Übersicht"
> >
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-5 h-5"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" /> <path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
</svg> </svg>
</Link> </Link>
<div className="space-y-1"> <div className="space-y-1">
<h1 className="text-2xl font-black text-gray-900 tracking-tight font-outfit"> <h1 className="text-2xl font-black text-gray-900 tracking-tight font-outfit">
Neue Innung anlegen Neue Innung anlegen
</h1> </h1>
<p className="text-sm text-gray-400 font-medium">Legen Sie hier eine neue Innung an und konfigurieren Sie die Branding-Daten.</p> <p className="text-sm text-gray-400 font-medium">Legen Sie hier eine neue Innung an und konfigurieren Sie die Branding-Daten.</p>
</div> </div>
</div> </div>
<div className="flex-1 overflow-hidden min-h-0"> <div className="flex-1 overflow-hidden min-h-0">
<CreateOrgForm /> <CreateOrgForm />
</div> </div>
</div> </div>
) )
} }

View File

@ -1,53 +1,53 @@
import { auth, getSanitizedHeaders } from '@/lib/auth' import { auth, getSanitizedHeaders } from '@/lib/auth'
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
export default async function SuperAdminLayout({ export default async function SuperAdminLayout({
children, children,
}: { }: {
children: React.ReactNode children: React.ReactNode
}) { }) {
const session = await auth.api.getSession({ headers: await getSanitizedHeaders() }) const session = await auth.api.getSession({ headers: await getSanitizedHeaders() })
if (!session?.user) { if (!session?.user) {
redirect('/login') redirect('/login')
} }
const superAdminEmail = process.env.SUPERADMIN_EMAIL || 'superadmin@innungsapp.de' const superAdminEmail = process.env.SUPERADMIN_EMAIL || 'superadmin@innungsapp.de'
const isSuperAdmin = session.user.email === superAdminEmail || session.user.role === 'admin' const isSuperAdmin = session.user.email === superAdminEmail || session.user.role === 'admin'
if (!isSuperAdmin) { if (!isSuperAdmin) {
redirect('/dashboard') // Normal admins go back to dashboard redirect('/dashboard') // Normal admins go back to dashboard
} }
return ( return (
<div className="min-h-screen bg-gray-50 flex flex-col"> <div className="min-h-screen bg-gray-50 flex flex-col">
{/* Super Admin Header */} {/* Super Admin Header */}
<header className="bg-gray-900 text-white border-t-2 border-brand-500 border-b border-gray-800"> <header className="bg-gray-900 text-white border-t-2 border-brand-500 border-b border-gray-800">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-12 items-center"> <div className="flex justify-between h-12 items-center">
<div className="flex items-center gap-8"> <div className="flex items-center gap-8">
<span <span
className="font-bold text-base tracking-tight hover:text-gray-200 transition-colors" className="font-bold text-base tracking-tight hover:text-gray-200 transition-colors"
style={{ fontFamily: "'Syne', system-ui, sans-serif" }} style={{ fontFamily: "'Syne', system-ui, sans-serif" }}
> >
<Link href="/superadmin">Super Admin</Link> <Link href="/superadmin">Super Admin</Link>
</span> </span>
{/* Super Admin Navigation */} {/* Super Admin Navigation */}
<nav className="hidden md:flex gap-6 text-sm font-medium text-gray-400"> <nav className="hidden md:flex gap-6 text-sm font-medium text-gray-400">
<Link href="/superadmin" className="hover:text-white transition-colors">Übersicht</Link> <Link href="/superadmin" className="hover:text-white transition-colors">Übersicht</Link>
<Link href="/superadmin/landingpages" className="hover:text-white transition-colors">Landingpages</Link> <Link href="/superadmin/landingpages" className="hover:text-white transition-colors">Landingpages</Link>
</nav> </nav>
</div> </div>
<span className="text-xs text-gray-400">{session.user.email}</span> <span className="text-xs text-gray-400">{session.user.email}</span>
</div> </div>
</div> </div>
</header> </header>
{/* Main Content */} {/* Main Content */}
<main className="flex-1 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 w-full"> <main className="flex-1 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 w-full">
{children} {children}
</main> </main>
</div> </div>
) )
} }

View File

@ -1,89 +1,89 @@
'use client' 'use client'
import { useActionState, useState } from 'react' import { useActionState, useState } from 'react'
import { createAdmin } from '../../actions' import { createAdmin } from '../../actions'
export function CreateAdminForm({ orgId }: { orgId: string }) { export function CreateAdminForm({ orgId }: { orgId: string }) {
const [state, action, isPending] = useActionState(createAdmin, { success: false, error: '' }) const [state, action, isPending] = useActionState(createAdmin, { success: false, error: '' })
const [showForm, setShowForm] = useState(false) const [showForm, setShowForm] = useState(false)
if (!showForm) { if (!showForm) {
return ( return (
<button <button
onClick={() => setShowForm(true)} onClick={() => setShowForm(true)}
className="w-full py-2 border-2 border-dashed border-gray-200 rounded-lg text-sm text-gray-500 hover:border-brand-500 hover:text-brand-500 transition-all font-medium" className="w-full py-2 border-2 border-dashed border-gray-200 rounded-lg text-sm text-gray-500 hover:border-brand-500 hover:text-brand-500 transition-all font-medium"
> >
+ Administrator hinzufügen + Administrator hinzufügen
</button> </button>
) )
} }
return ( return (
<div className="bg-gray-50 border rounded-xl p-4 space-y-4"> <div className="bg-gray-50 border rounded-xl p-4 space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-900">Neuen Admin anlegen</h3> <h3 className="text-sm font-semibold text-gray-900">Neuen Admin anlegen</h3>
<button <button
onClick={() => setShowForm(false)} onClick={() => setShowForm(false)}
className="text-xs text-gray-400 hover:text-gray-600" className="text-xs text-gray-400 hover:text-gray-600"
> >
Abbrechen Abbrechen
</button> </button>
</div> </div>
<form action={action} className="space-y-3"> <form action={action} className="space-y-3">
<input type="hidden" name="orgId" value={orgId} /> <input type="hidden" name="orgId" value={orgId} />
<div> <div>
<label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">Name</label> <label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">Name</label>
<input <input
name="name" name="name"
required required
placeholder="z.B. Max Mustermann" placeholder="z.B. Max Mustermann"
className="w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-brand-500 outline-none" className="w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-brand-500 outline-none"
/> />
</div> </div>
<div> <div>
<label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">E-Mail</label> <label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">E-Mail</label>
<input <input
name="email" name="email"
type="email" type="email"
required required
placeholder="admin@beispiel.de" placeholder="admin@beispiel.de"
className="w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-brand-500 outline-none" className="w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-brand-500 outline-none"
/> />
</div> </div>
<div> <div>
<label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">Passwort</label> <label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">Passwort</label>
<div className="relative"> <div className="relative">
<input <input
name="password" name="password"
type="text" type="text"
required required
defaultValue={Math.random().toString(36).slice(-10)} defaultValue={Math.random().toString(36).slice(-10)}
className="w-full px-3 py-2 border rounded-lg text-sm font-mono focus:ring-2 focus:ring-brand-500 outline-none" className="w-full px-3 py-2 border rounded-lg text-sm font-mono focus:ring-2 focus:ring-brand-500 outline-none"
/> />
<p className="text-[10px] text-gray-400 mt-1">Das Passwort muss dem Admin manuell mitgeteilt werden.</p> <p className="text-[10px] text-gray-400 mt-1">Das Passwort muss dem Admin manuell mitgeteilt werden.</p>
</div> </div>
</div> </div>
{state.error && ( {state.error && (
<p className="text-xs text-red-600 bg-red-50 p-2 rounded">{state.error}</p> <p className="text-xs text-red-600 bg-red-50 p-2 rounded">{state.error}</p>
)} )}
{state.success && ( {state.success && (
<p className="text-xs text-green-600 bg-green-50 p-2 rounded">Administrator erfolgreich angelegt.</p> <p className="text-xs text-green-600 bg-green-50 p-2 rounded">Administrator erfolgreich angelegt.</p>
)} )}
<button <button
type="submit" type="submit"
disabled={isPending} disabled={isPending}
className="w-full bg-gray-900 text-white py-2 rounded-lg text-sm font-medium hover:bg-gray-800 disabled:opacity-50 transition-colors" className="w-full bg-gray-900 text-white py-2 rounded-lg text-sm font-medium hover:bg-gray-800 disabled:opacity-50 transition-colors"
> >
{isPending ? 'Wird angelegt...' : 'Admin anlegen'} {isPending ? 'Wird angelegt...' : 'Admin anlegen'}
</button> </button>
</form> </form>
</div> </div>
) )
} }

View File

@ -1,94 +1,94 @@
'use client' 'use client'
import { useActionState } from 'react' import { useActionState } from 'react'
import { createMember } from '../../actions' import { createMember } from '../../actions'
const initialState = { const initialState = {
success: false, success: false,
error: '', error: '',
} }
export function CreateMemberForm({ orgId }: { orgId: string }) { export function CreateMemberForm({ orgId }: { orgId: string }) {
const [state, action, isPending] = useActionState(createMember, initialState) const [state, action, isPending] = useActionState(createMember, initialState)
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-sm font-semibold text-gray-900 uppercase tracking-wider">Mitglied manuell hinzufügen</h3> <h3 className="text-sm font-semibold text-gray-900 uppercase tracking-wider">Mitglied manuell hinzufügen</h3>
<form action={action} className="space-y-3"> <form action={action} className="space-y-3">
<input type="hidden" name="orgId" value={orgId} /> <input type="hidden" name="orgId" value={orgId} />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div> <div>
<label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">Name (Ansprechpartner)</label> <label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">Name (Ansprechpartner)</label>
<input <input
name="name" name="name"
type="text" type="text"
required required
placeholder="Anrede Vorname Nachname" placeholder="Anrede Vorname Nachname"
className="w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-brand-500 outline-none" className="w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-brand-500 outline-none"
/> />
</div> </div>
<div> <div>
<label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">E-Mail</label> <label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">E-Mail</label>
<input <input
name="email" name="email"
type="email" type="email"
required required
placeholder="email@beispiel.de" placeholder="email@beispiel.de"
className="w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-brand-500 outline-none" className="w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-brand-500 outline-none"
/> />
</div> </div>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div> <div>
<label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">Betrieb</label> <label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">Betrieb</label>
<input <input
name="betrieb" name="betrieb"
type="text" type="text"
required required
placeholder="Name des Betriebs" placeholder="Name des Betriebs"
className="w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-brand-500 outline-none" className="w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-brand-500 outline-none"
/> />
</div> </div>
<div> <div>
<label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">Sparte</label> <label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">Sparte</label>
<input <input
name="sparte" name="sparte"
type="text" type="text"
required required
placeholder="z.B. Sanitär" placeholder="z.B. Sanitär"
className="w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-brand-500 outline-none" className="w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-brand-500 outline-none"
/> />
</div> </div>
<div> <div>
<label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">Ort</label> <label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">Ort</label>
<input <input
name="ort" name="ort"
type="text" type="text"
required required
placeholder="Stadt" placeholder="Stadt"
className="w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-brand-500 outline-none" className="w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-brand-500 outline-none"
/> />
</div> </div>
</div> </div>
{state.error && ( {state.error && (
<p className="text-xs text-red-600 bg-red-50 p-2 rounded">{state.error}</p> <p className="text-xs text-red-600 bg-red-50 p-2 rounded">{state.error}</p>
)} )}
{state.success && ( {state.success && (
<p className="text-xs text-green-600 bg-green-50 p-2 rounded">Mitglied erfolgreich angelegt.</p> <p className="text-xs text-green-600 bg-green-50 p-2 rounded">Mitglied erfolgreich angelegt.</p>
)} )}
<button <button
type="submit" type="submit"
disabled={isPending} disabled={isPending}
className="w-full bg-gray-900 text-white py-2 rounded-lg text-sm font-medium hover:bg-gray-800 disabled:opacity-50 transition-colors" className="w-full bg-gray-900 text-white py-2 rounded-lg text-sm font-medium hover:bg-gray-800 disabled:opacity-50 transition-colors"
> >
{isPending ? 'Wird angelegt...' : 'Mitglied anlegen'} {isPending ? 'Wird angelegt...' : 'Mitglied anlegen'}
</button> </button>
</form> </form>
</div> </div>
) )
} }

View File

@ -1,351 +1,351 @@
'use client' 'use client'
import { useActionState, useState } from 'react' import { useActionState, useState } from 'react'
import { updateOrganization } from '../../actions' import { updateOrganization } from '../../actions'
function jsonToText(value: unknown): string { function jsonToText(value: unknown): string {
if (value == null) { if (value == null) {
return '' return ''
} }
if (typeof value === 'string') { if (typeof value === 'string') {
return value return value
} }
if (Array.isArray(value)) { if (Array.isArray(value)) {
return value return value
.map((item) => (typeof item === 'string' ? item : JSON.stringify(item))) .map((item) => (typeof item === 'string' ? item : JSON.stringify(item)))
.join('\n') .join('\n')
} }
return JSON.stringify(value) return JSON.stringify(value)
} }
interface Props { interface Props {
org: { org: {
id: string id: string
name: string name: string
plan: string plan: string
contactEmail: string | null contactEmail: string | null
logoUrl: string | null logoUrl: string | null
primaryColor: string | null primaryColor: string | null
secondaryColor: string | null secondaryColor: string | null
landingPageTitle: string | null landingPageTitle: string | null
landingPageText: string | null landingPageText: string | null
landingPageSectionTitle: string | null landingPageSectionTitle: string | null
landingPageButtonText: string | null landingPageButtonText: string | null
landingPageHeroImage: string | null landingPageHeroImage: string | null
landingPageHeroOverlayOpacity: number | null landingPageHeroOverlayOpacity: number | null
landingPageFeatures: unknown landingPageFeatures: unknown
landingPageFooter: unknown landingPageFooter: unknown
appStoreUrl: string | null appStoreUrl: string | null
playStoreUrl: string | null playStoreUrl: string | null
} }
} }
const initialState = { success: false, error: '' } const initialState = { success: false, error: '' }
export function EditOrgForm({ org }: Props) { export function EditOrgForm({ org }: Props) {
const boundAction = updateOrganization.bind(null, org.id) const boundAction = updateOrganization.bind(null, org.id)
const [state, formAction, isPending] = useActionState(boundAction, initialState) const [state, formAction, isPending] = useActionState(boundAction, initialState)
const [logoUrl, setLogoUrl] = useState(org.logoUrl || '') const [logoUrl, setLogoUrl] = useState(org.logoUrl || '')
const [heroImageUrl, setHeroImageUrl] = useState(org.landingPageHeroImage || '') const [heroImageUrl, setHeroImageUrl] = useState(org.landingPageHeroImage || '')
const [isUploading, setIsUploading] = useState<{ logo?: boolean; hero?: boolean }>({}) const [isUploading, setIsUploading] = useState<{ logo?: boolean; hero?: boolean }>({})
const [themeColor, setThemeColor] = useState(org.primaryColor || '#E63946') const [themeColor, setThemeColor] = useState(org.primaryColor || '#E63946')
const [secondaryColor, setSecondaryColor] = useState(org.secondaryColor || '#FFFFFF') const [secondaryColor, setSecondaryColor] = useState(org.secondaryColor || '#FFFFFF')
const initialFeatures = jsonToText(org.landingPageFeatures) const initialFeatures = jsonToText(org.landingPageFeatures)
const initialFooter = jsonToText(org.landingPageFooter) const initialFooter = jsonToText(org.landingPageFooter)
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>, type: 'logo' | 'hero') => { const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>, type: 'logo' | 'hero') => {
const file = e.target.files?.[0] const file = e.target.files?.[0]
if (!file) return if (!file) return
setIsUploading(prev => ({ ...prev, [type]: true })) setIsUploading(prev => ({ ...prev, [type]: true }))
const uploadFormData = new FormData() const uploadFormData = new FormData()
uploadFormData.append('file', file) uploadFormData.append('file', file)
try { try {
const res = await fetch('/api/upload', { const res = await fetch('/api/upload', {
method: 'POST', method: 'POST',
body: uploadFormData body: uploadFormData
}) })
const data = await res.json() const data = await res.json()
if (data.url) { if (data.url) {
if (type === 'logo') setLogoUrl(data.url) if (type === 'logo') setLogoUrl(data.url)
if (type === 'hero') setHeroImageUrl(data.url) if (type === 'hero') setHeroImageUrl(data.url)
} }
} catch (err) { } catch (err) {
console.error('Upload failed', err) console.error('Upload failed', err)
} finally { } finally {
setIsUploading(prev => ({ ...prev, [type]: false })) setIsUploading(prev => ({ ...prev, [type]: false }))
} }
} }
return ( return (
<div className="bg-white rounded-xl border p-6"> <div className="bg-white rounded-xl border p-6">
<h2 className="text-base font-semibold text-gray-900 mb-4">Innung bearbeiten</h2> <h2 className="text-base font-semibold text-gray-900 mb-4">Innung bearbeiten</h2>
{state.success && ( {state.success && (
<div className="mb-4 p-3 bg-green-50 text-green-700 rounded-lg text-sm">Änderungen gespeichert.</div> <div className="mb-4 p-3 bg-green-50 text-green-700 rounded-lg text-sm">Änderungen gespeichert.</div>
)} )}
{state.error && ( {state.error && (
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{state.error}</div> <div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{state.error}</div>
)} )}
<form action={formAction} className="space-y-6"> <form action={formAction} className="space-y-6">
{/* BASISDATEN */} {/* BASISDATEN */}
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-sm font-semibold text-gray-900 border-b pb-2">Basisdaten</h3> <h3 className="text-sm font-semibold text-gray-900 border-b pb-2">Basisdaten</h3>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name der Innung</label> <label className="block text-sm font-medium text-gray-700 mb-1">Name der Innung</label>
<input <input
type="text" type="text"
name="name" name="name"
required required
defaultValue={org.name} defaultValue={org.name}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500" className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Plan</label> <label className="block text-sm font-medium text-gray-700 mb-1">Plan</label>
<select <select
name="plan" name="plan"
defaultValue={org.plan} defaultValue={org.plan}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500 bg-white" className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500 bg-white"
> >
<option value="pilot">Pilot</option> <option value="pilot">Pilot</option>
<option value="standard">Standard</option> <option value="standard">Standard</option>
<option value="pro">Pro</option> <option value="pro">Pro</option>
<option value="verband">Verband</option> <option value="verband">Verband</option>
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Kontakt E-Mail</label> <label className="block text-sm font-medium text-gray-700 mb-1">Kontakt E-Mail</label>
<input <input
type="email" type="email"
name="contactEmail" name="contactEmail"
defaultValue={org.contactEmail ?? ''} defaultValue={org.contactEmail ?? ''}
placeholder="info@innung.de" placeholder="info@innung.de"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500" className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
/> />
</div> </div>
</div> </div>
{/* BRANDING */} {/* BRANDING */}
<div className="space-y-4 pt-4"> <div className="space-y-4 pt-4">
<h3 className="text-sm font-semibold text-gray-900 border-b pb-2">Branding</h3> <h3 className="text-sm font-semibold text-gray-900 border-b pb-2">Branding</h3>
<input type="hidden" name="logoUrl" value={logoUrl} /> <input type="hidden" name="logoUrl" value={logoUrl} />
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Logo</label> <label className="block text-sm font-medium text-gray-700 mb-1">Logo</label>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{logoUrl ? ( {logoUrl ? (
<div className="w-10 h-10 rounded border bg-gray-50 flex items-center justify-center p-1"> <div className="w-10 h-10 rounded border bg-gray-50 flex items-center justify-center p-1">
<img src={logoUrl} alt="Logo" className="max-w-full max-h-full object-contain" /> <img src={logoUrl} alt="Logo" className="max-w-full max-h-full object-contain" />
</div> </div>
) : ( ) : (
<div className="w-10 h-10 rounded border-2 border-dashed flex items-center justify-center text-gray-300"> <div className="w-10 h-10 rounded border-2 border-dashed flex items-center justify-center text-gray-300">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" /> <path strokeLinecap="round" strokeLinejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
</svg> </svg>
</div> </div>
)} )}
<label className="flex-1 cursor-pointer"> <label className="flex-1 cursor-pointer">
<div className={`px-3 py-2 border rounded-lg text-sm text-center font-medium hover:bg-gray-50 transition-colors ${isUploading.logo ? 'opacity-50' : ''}`}> <div className={`px-3 py-2 border rounded-lg text-sm text-center font-medium hover:bg-gray-50 transition-colors ${isUploading.logo ? 'opacity-50' : ''}`}>
{isUploading.logo ? 'Wird hochgeladen...' : 'Logo ändern'} {isUploading.logo ? 'Wird hochgeladen...' : 'Logo ändern'}
</div> </div>
<input type="file" onChange={(e) => handleUpload(e, 'logo')} accept="image/*" className="hidden" disabled={isUploading.logo} /> <input type="file" onChange={(e) => handleUpload(e, 'logo')} accept="image/*" className="hidden" disabled={isUploading.logo} />
</label> </label>
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Primärfarbe</label> <label className="block text-sm font-medium text-gray-700 mb-1">Primärfarbe</label>
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
type="color" type="color"
name="primaryColor" name="primaryColor"
value={themeColor} value={themeColor}
onChange={(e) => setThemeColor(e.target.value)} onChange={(e) => setThemeColor(e.target.value)}
className="h-9 w-12 p-1 border rounded cursor-pointer" className="h-9 w-12 p-1 border rounded cursor-pointer"
/> />
<input <input
type="text" type="text"
value={themeColor} value={themeColor}
onChange={(e) => setThemeColor(e.target.value)} onChange={(e) => setThemeColor(e.target.value)}
className="flex-1 px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500 font-mono text-sm" className="flex-1 px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500 font-mono text-sm"
pattern="^#([A-Fa-f0-9]{6})$" pattern="^#([A-Fa-f0-9]{6})$"
/> />
</div> </div>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Sekundärfarbe</label> <label className="block text-sm font-medium text-gray-700 mb-1">Sekundärfarbe</label>
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
type="color" type="color"
name="secondaryColor" name="secondaryColor"
value={secondaryColor} value={secondaryColor}
onChange={(e) => setSecondaryColor(e.target.value)} onChange={(e) => setSecondaryColor(e.target.value)}
className="h-9 w-12 p-1 border rounded cursor-pointer" className="h-9 w-12 p-1 border rounded cursor-pointer"
/> />
<input <input
type="text" type="text"
value={secondaryColor} value={secondaryColor}
onChange={(e) => setSecondaryColor(e.target.value)} onChange={(e) => setSecondaryColor(e.target.value)}
className="flex-1 px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500 font-mono text-sm" className="flex-1 px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500 font-mono text-sm"
pattern="^#([A-Fa-f0-9]{6})$" pattern="^#([A-Fa-f0-9]{6})$"
/> />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{/* LANDING PAGE */} {/* LANDING PAGE */}
<div className="space-y-4 pt-4"> <div className="space-y-4 pt-4">
<h3 className="text-sm font-semibold text-gray-900 border-b pb-2">Landing Page</h3> <h3 className="text-sm font-semibold text-gray-900 border-b pb-2">Landing Page</h3>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Hero Titel</label> <label className="block text-sm font-medium text-gray-700 mb-1">Hero Titel</label>
<input <input
type="text" type="text"
name="landingPageTitle" name="landingPageTitle"
defaultValue={org.landingPageTitle ?? ''} defaultValue={org.landingPageTitle ?? ''}
placeholder="Zukunft des Handwerks gestalten" placeholder="Zukunft des Handwerks gestalten"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500" className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500"
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Hero Untertitel / Text</label> <label className="block text-sm font-medium text-gray-700 mb-1">Hero Untertitel / Text</label>
<textarea <textarea
name="landingPageText" name="landingPageText"
defaultValue={org.landingPageText ?? ''} defaultValue={org.landingPageText ?? ''}
rows={3} rows={3}
placeholder="Gemeinsam stark für unsere Region." placeholder="Gemeinsam stark für unsere Region."
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500" className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500"
/> />
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Aufmacher Überschrift (Buttons)</label> <label className="block text-sm font-medium text-gray-700 mb-1">Aufmacher Überschrift (Buttons)</label>
<input <input
type="text" type="text"
name="landingPageSectionTitle" name="landingPageSectionTitle"
defaultValue={org.landingPageSectionTitle ?? ''} defaultValue={org.landingPageSectionTitle ?? ''}
placeholder={`${org.name || 'Ihre Innung'} Gemeinsam stark fürs Handwerk`} placeholder={`${org.name || 'Ihre Innung'} Gemeinsam stark fürs Handwerk`}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500" className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500"
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Button Text (CTA)</label> <label className="block text-sm font-medium text-gray-700 mb-1">Button Text (CTA)</label>
<input <input
type="text" type="text"
name="landingPageButtonText" name="landingPageButtonText"
defaultValue={org.landingPageButtonText ?? ''} defaultValue={org.landingPageButtonText ?? ''}
placeholder="Jetzt Mitglied werden" placeholder="Jetzt Mitglied werden"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500" className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500"
/> />
</div> </div>
</div> </div>
<input type="hidden" name="landingPageHeroImage" value={heroImageUrl} /> <input type="hidden" name="landingPageHeroImage" value={heroImageUrl} />
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Hero Hintergrundbild</label> <label className="block text-sm font-medium text-gray-700 mb-1">Hero Hintergrundbild</label>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<label className="flex-1 cursor-pointer"> <label className="flex-1 cursor-pointer">
<div className={`px-3 py-2 border rounded-lg text-sm text-center font-medium hover:bg-gray-50 transition-colors ${isUploading.hero ? 'opacity-50' : ''}`}> <div className={`px-3 py-2 border rounded-lg text-sm text-center font-medium hover:bg-gray-50 transition-colors ${isUploading.hero ? 'opacity-50' : ''}`}>
{isUploading.hero ? 'Wird hochgeladen...' : 'Bild auswählen'} {isUploading.hero ? 'Wird hochgeladen...' : 'Bild auswählen'}
</div> </div>
<input type="file" onChange={(e) => handleUpload(e, 'hero')} accept="image/*" className="hidden" disabled={isUploading.hero} /> <input type="file" onChange={(e) => handleUpload(e, 'hero')} accept="image/*" className="hidden" disabled={isUploading.hero} />
</label> </label>
{heroImageUrl && ( {heroImageUrl && (
<button type="button" onClick={() => setHeroImageUrl('')} className="text-red-500 hover:text-red-600 text-sm"> <button type="button" onClick={() => setHeroImageUrl('')} className="text-red-500 hover:text-red-600 text-sm">
Entfernen Entfernen
</button> </button>
)} )}
</div> </div>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1 flex justify-between"> <label className="block text-sm font-medium text-gray-700 mb-1 flex justify-between">
<span>Overlay Deckkraft</span> <span>Overlay Deckkraft</span>
<span className="text-gray-500">{org.landingPageHeroOverlayOpacity ?? 50}%</span> <span className="text-gray-500">{org.landingPageHeroOverlayOpacity ?? 50}%</span>
</label> </label>
<input <input
type="range" type="range"
name="landingPageHeroOverlayOpacity" name="landingPageHeroOverlayOpacity"
min="0" min="0"
max="100" max="100"
defaultValue={org.landingPageHeroOverlayOpacity ?? 50} defaultValue={org.landingPageHeroOverlayOpacity ?? 50}
className="w-full" className="w-full"
/> />
<p className="text-xs text-gray-500 mt-1">Legt fest, wie dunkel der Schleier über dem Hintergrundbild ist, damit der Text gut lesbar bleibt.</p> <p className="text-xs text-gray-500 mt-1">Legt fest, wie dunkel der Schleier über dem Hintergrundbild ist, damit der Text gut lesbar bleibt.</p>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Vorteile / Features</label> <label className="block text-sm font-medium text-gray-700 mb-1">Vorteile / Features</label>
<textarea <textarea
name="landingPageFeatures" name="landingPageFeatures"
defaultValue={initialFeatures} defaultValue={initialFeatures}
rows={5} rows={5}
placeholder="Ein Benefit pro Zeile..." placeholder="Ein Benefit pro Zeile..."
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500" className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500"
/> />
<p className="text-xs text-gray-500 mt-1">Bitte geben Sie pro Zeile einen Vorteil ein. Diese werden als Checkliste auf der Landingpage angezeigt.</p> <p className="text-xs text-gray-500 mt-1">Bitte geben Sie pro Zeile einen Vorteil ein. Diese werden als Checkliste auf der Landingpage angezeigt.</p>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">App Store URL</label> <label className="block text-sm font-medium text-gray-700 mb-1">App Store URL</label>
<input <input
type="url" type="url"
name="appStoreUrl" name="appStoreUrl"
defaultValue={org.appStoreUrl ?? ''} defaultValue={org.appStoreUrl ?? ''}
placeholder="https://apps.apple.com/..." placeholder="https://apps.apple.com/..."
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500" className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500"
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Google Play URL</label> <label className="block text-sm font-medium text-gray-700 mb-1">Google Play URL</label>
<input <input
type="url" type="url"
name="playStoreUrl" name="playStoreUrl"
defaultValue={org.playStoreUrl ?? ''} defaultValue={org.playStoreUrl ?? ''}
placeholder="https://play.google.com/..." placeholder="https://play.google.com/..."
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500" className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500"
/> />
</div> </div>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Footer Text</label> <label className="block text-sm font-medium text-gray-700 mb-1">Footer Text</label>
<textarea <textarea
name="landingPageFooter" name="landingPageFooter"
defaultValue={initialFooter} defaultValue={initialFooter}
rows={2} rows={2}
placeholder="© 2024 Innung. Alle Rechte vorbehalten." placeholder="© 2024 Innung. Alle Rechte vorbehalten."
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500" className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500"
/> />
</div> </div>
</div> </div>
<div className="pt-2"> <div className="pt-2">
<button <button
type="submit" type="submit"
disabled={isPending} disabled={isPending}
className="w-full bg-brand-500 text-white font-medium py-2 px-4 rounded-lg hover:bg-brand-600 transition-colors disabled:opacity-50" className="w-full bg-brand-500 text-white font-medium py-2 px-4 rounded-lg hover:bg-brand-600 transition-colors disabled:opacity-50"
> >
{isPending ? 'Wird gespeichert…' : 'Speichern'} {isPending ? 'Wird gespeichert…' : 'Speichern'}
</button> </button>
</div> </div>
</form> </form>
</div> </div>
) )
} }

View File

@ -1,25 +1,25 @@
'use client' 'use client'
import { removeMember } from '../../actions' import { removeMember } from '../../actions'
import { useState } from 'react' import { useState } from 'react'
export function MemberActions({ member, orgId }: { member: { id: string, name: string }, orgId: string }) { export function MemberActions({ member, orgId }: { member: { id: string, name: string }, orgId: string }) {
const [isPending, setIsPending] = useState(false) const [isPending, setIsPending] = useState(false)
const handleRemove = async () => { const handleRemove = async () => {
if (!confirm(`Möchten Sie das Mitglied ${member.name} wirklich entfernen?`)) return if (!confirm(`Möchten Sie das Mitglied ${member.name} wirklich entfernen?`)) return
setIsPending(true) setIsPending(true)
await removeMember(member.id, orgId) await removeMember(member.id, orgId)
setIsPending(false) setIsPending(false)
} }
return ( return (
<button <button
onClick={handleRemove} onClick={handleRemove}
disabled={isPending} disabled={isPending}
className="text-xs text-red-600 hover:text-red-700 font-medium transition-colors" className="text-xs text-red-600 hover:text-red-700 font-medium transition-colors"
> >
Entfernen Entfernen
</button> </button>
) )
} }

View File

@ -1,42 +1,42 @@
'use client' 'use client'
import { removeUserRole, updateUserRole } from '../../actions' import { removeUserRole, updateUserRole } from '../../actions'
import { useState } from 'react' import { useState } from 'react'
export function UserRoleActions({ ur, orgId }: { ur: { id: string, role: string, user: { email: string } }, orgId: string }) { export function UserRoleActions({ ur, orgId }: { ur: { id: string, role: string, user: { email: string } }, orgId: string }) {
const [isPending, setIsPending] = useState(false) const [isPending, setIsPending] = useState(false)
const handleRemove = async () => { const handleRemove = async () => {
if (!confirm(`Möchten Sie den Zugriff für ${ur.user.email} wirklich entfernen?`)) return if (!confirm(`Möchten Sie den Zugriff für ${ur.user.email} wirklich entfernen?`)) return
setIsPending(true) setIsPending(true)
await removeUserRole(ur.id, orgId) await removeUserRole(ur.id, orgId)
setIsPending(false) setIsPending(false)
} }
const handleToggleRole = async () => { const handleToggleRole = async () => {
const newRole = ur.role === 'admin' ? 'member' : 'admin' const newRole = ur.role === 'admin' ? 'member' : 'admin'
setIsPending(true) setIsPending(true)
await updateUserRole(ur.id, orgId, newRole) await updateUserRole(ur.id, orgId, newRole)
setIsPending(false) setIsPending(false)
} }
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={handleToggleRole} onClick={handleToggleRole}
disabled={isPending} disabled={isPending}
className="text-xs text-gray-600 hover:text-brand-600 font-medium transition-colors" className="text-xs text-gray-600 hover:text-brand-600 font-medium transition-colors"
title={ur.role === 'admin' ? 'Zum Mitglied machen' : 'Zum Admin machen'} title={ur.role === 'admin' ? 'Zum Mitglied machen' : 'Zum Admin machen'}
> >
{ur.role === 'admin' ? 'Rolle: Admin' : 'Rolle: Mitglied'} {ur.role === 'admin' ? 'Rolle: Admin' : 'Rolle: Mitglied'}
</button> </button>
<button <button
onClick={handleRemove} onClick={handleRemove}
disabled={isPending} disabled={isPending}
className="text-xs text-red-600 hover:text-red-700 font-medium transition-colors" className="text-xs text-red-600 hover:text-red-700 font-medium transition-colors"
> >
Entfernen Entfernen
</button> </button>
</div> </div>
) )
} }

View File

@ -1,236 +1,236 @@
import { prisma } from '@innungsapp/shared' import { prisma } from '@innungsapp/shared'
import { format } from 'date-fns' import { format } from 'date-fns'
import { de } from 'date-fns/locale' import { de } from 'date-fns/locale'
import Link from 'next/link' import Link from 'next/link'
import { toggleAiFeature } from './actions' import { toggleAiFeature } from './actions'
const PLAN_LABELS: Record<string, string> = { const PLAN_LABELS: Record<string, string> = {
pilot: 'Pilot', pilot: 'Pilot',
standard: 'Standard', standard: 'Standard',
pro: 'Pro', pro: 'Pro',
verband: 'Verband', verband: 'Verband',
} }
const PLAN_COLORS: Record<string, string> = { const PLAN_COLORS: Record<string, string> = {
pilot: 'bg-gray-100 text-gray-700', pilot: 'bg-gray-100 text-gray-700',
standard: 'bg-blue-100 text-blue-800', standard: 'bg-blue-100 text-blue-800',
pro: 'bg-purple-100 text-purple-800', pro: 'bg-purple-100 text-purple-800',
verband: 'bg-amber-100 text-amber-800', verband: 'bg-amber-100 text-amber-800',
} }
const PAGE_SIZE = 20 const PAGE_SIZE = 20
export default async function SuperAdminPage({ export default async function SuperAdminPage({
searchParams, searchParams,
}: { }: {
searchParams: Promise<{ q?: string; page?: string }> searchParams: Promise<{ q?: string; page?: string }>
}) { }) {
const { q = '', page = '1' } = await searchParams const { q = '', page = '1' } = await searchParams
const currentPage = Math.max(1, parseInt(page, 10)) const currentPage = Math.max(1, parseInt(page, 10))
const skip = (currentPage - 1) * PAGE_SIZE const skip = (currentPage - 1) * PAGE_SIZE
const where = q const where = q
? { ? {
OR: [ OR: [
{ name: { contains: q } }, { name: { contains: q } },
{ slug: { contains: q } }, { slug: { contains: q } },
{ contactEmail: { contains: q } }, { contactEmail: { contains: q } },
], ],
} }
: {} : {}
const [organizations, total] = await Promise.all([ const [organizations, total] = await Promise.all([
prisma.organization.findMany({ prisma.organization.findMany({
where, where,
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
skip, skip,
take: PAGE_SIZE, take: PAGE_SIZE,
include: { _count: { select: { members: true, userRoles: true } } }, include: { _count: { select: { members: true, userRoles: true } } },
}), }),
prisma.organization.count({ where }), prisma.organization.count({ where }),
]) ])
const totalPages = Math.ceil(total / PAGE_SIZE) const totalPages = Math.ceil(total / PAGE_SIZE)
return ( return (
<div className="max-w-[1400px] mx-auto space-y-12 py-4"> <div className="max-w-[1400px] mx-auto space-y-12 py-4">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div className="text-left space-y-2"> <div className="text-left space-y-2">
<h1 className="text-3xl font-black text-gray-900 tracking-tight font-outfit"> <h1 className="text-3xl font-black text-gray-900 tracking-tight font-outfit">
Innungs-Verwaltung <span className="text-[#E63946]">PRO</span> Innungs-Verwaltung <span className="text-[#E63946]">PRO</span>
</h1> </h1>
<p className="text-gray-400 font-medium">Hierüber werden alle Mandanten der Lösung verwaltet.</p> <p className="text-gray-400 font-medium">Hierüber werden alle Mandanten der Lösung verwaltet.</p>
</div> </div>
<Link <Link
href="/superadmin/create" href="/superadmin/create"
className="bg-[#E63946] text-white font-bold py-3 px-6 rounded-xl hover:bg-[#D62839] transition-all shadow-md shadow-red-100 flex items-center gap-2" className="bg-[#E63946] text-white font-bold py-3 px-6 rounded-xl hover:bg-[#D62839] transition-all shadow-md shadow-red-100 flex items-center gap-2"
> >
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2.5} stroke="currentColor" className="w-5 h-5"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2.5} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> <path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg> </svg>
Neue Innung anlegen Neue Innung anlegen
</Link> </Link>
</div> </div>
<div className="grid grid-cols-1 gap-12 items-start"> <div className="grid grid-cols-1 gap-12 items-start">
{/* List */} {/* List */}
<div className="space-y-6"> <div className="space-y-6">
{/* Search & Filter */} {/* Search & Filter */}
<div className="bg-white p-2 rounded-2xl border shadow-sm flex items-center"> <div className="bg-white p-2 rounded-2xl border shadow-sm flex items-center">
<form method="GET" className="flex-1 flex gap-2"> <form method="GET" className="flex-1 flex gap-2">
<div className="relative flex-1 group"> <div className="relative flex-1 group">
<div className="absolute inset-y-0 left-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-[#E63946] transition-colors"> <div className="absolute inset-y-0 left-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-[#E63946] transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-4 h-4"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-4 h-4">
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /> <path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg> </svg>
</div> </div>
<input <input
type="search" type="search"
name="q" name="q"
defaultValue={q} defaultValue={q}
placeholder="Innung suchen..." placeholder="Innung suchen..."
className="w-full pl-9 pr-4 py-3 bg-transparent text-sm outline-none placeholder:text-gray-300" className="w-full pl-9 pr-4 py-3 bg-transparent text-sm outline-none placeholder:text-gray-300"
/> />
</div> </div>
<button <button
type="submit" type="submit"
className="px-6 py-2.5 bg-gray-900 text-white rounded-xl text-sm font-bold hover:bg-black transition-all active:scale-[0.98]" className="px-6 py-2.5 bg-gray-900 text-white rounded-xl text-sm font-bold hover:bg-black transition-all active:scale-[0.98]"
> >
Suchen Suchen
</button> </button>
{q && ( {q && (
<Link <Link
href="/superadmin" href="/superadmin"
className="p-2.5 bg-gray-50 text-gray-400 rounded-xl hover:bg-gray-100 transition-colors" className="p-2.5 bg-gray-50 text-gray-400 rounded-xl hover:bg-gray-100 transition-colors"
> >
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-5 h-5"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg> </svg>
</Link> </Link>
)} )}
</form> </form>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between px-2"> <div className="flex items-center justify-between px-2">
<h2 className="text-sm font-bold text-gray-400 uppercase tracking-widest"> <h2 className="text-sm font-bold text-gray-400 uppercase tracking-widest">
Registrierte Innungen ({total}) Registrierte Innungen ({total})
</h2> </h2>
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{organizations.length === 0 ? ( {organizations.length === 0 ? (
<div className="bg-white p-12 text-center rounded-2xl border border-dashed border-gray-200"> <div className="bg-white p-12 text-center rounded-2xl border border-dashed border-gray-200">
<div className="text-gray-300 mb-2"> <div className="text-gray-300 mb-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1} stroke="currentColor" className="w-12 h-12 mx-auto"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1} stroke="currentColor" className="w-12 h-12 mx-auto">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 21v-7.5a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 .75.75V21m-1.5 0H21m-8.47-17.69-6 6a.75.75 0 0 0-.215.53V21m1.5 0H1.875a.375.375 0 0 1-.375-.375V11.25c0-4.46 3.07-8.189 7.5-9.088a9 9 0 0 1 1.585-.152Z" /> <path strokeLinecap="round" strokeLinejoin="round" d="M13.5 21v-7.5a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 .75.75V21m-1.5 0H21m-8.47-17.69-6 6a.75.75 0 0 0-.215.53V21m1.5 0H1.875a.375.375 0 0 1-.375-.375V11.25c0-4.46 3.07-8.189 7.5-9.088a9 9 0 0 1 1.585-.152Z" />
</svg> </svg>
</div> </div>
<p className="text-gray-500 font-medium"> <p className="text-gray-500 font-medium">
{q ? 'Keine Treffer für Ihre Suche.' : 'Bisher keine Innungen angelegt.'} {q ? 'Keine Treffer für Ihre Suche.' : 'Bisher keine Innungen angelegt.'}
</p> </p>
</div> </div>
) : ( ) : (
organizations.map((org) => ( organizations.map((org) => (
<div key={org.id} className="group bg-white p-6 rounded-2xl border hover:border-[#E63946] hover:shadow-xl hover:shadow-red-500/5 transition-all duration-300 relative overflow-hidden"> <div key={org.id} className="group bg-white p-6 rounded-2xl border hover:border-[#E63946] hover:shadow-xl hover:shadow-red-500/5 transition-all duration-300 relative overflow-hidden">
<div className="flex justify-between items-start gap-6 relative z-10"> <div className="flex justify-between items-start gap-6 relative z-10">
<Link href={`/superadmin/organizations/${org.id}`} className="flex-1 min-w-0"> <Link href={`/superadmin/organizations/${org.id}`} className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<h3 className="font-bold text-lg text-gray-900 group-hover:text-[#E63946] transition-colors">{org.name}</h3> <h3 className="font-bold text-lg text-gray-900 group-hover:text-[#E63946] transition-colors">{org.name}</h3>
<span className={`text-[10px] font-black uppercase tracking-tighter px-2 py-0.5 rounded-full border ${PLAN_COLORS[org.plan] ?? 'bg-gray-100 text-gray-700'}`}> <span className={`text-[10px] font-black uppercase tracking-tighter px-2 py-0.5 rounded-full border ${PLAN_COLORS[org.plan] ?? 'bg-gray-100 text-gray-700'}`}>
{org.plan} {org.plan}
</span> </span>
</div> </div>
<div className="flex items-center gap-4 text-xs text-gray-400 font-medium"> <div className="flex items-center gap-4 text-xs text-gray-400 font-medium">
<div className="flex items-center gap-1.5 font-mono"> <div className="flex items-center gap-1.5 font-mono">
<span className="text-[#E63946]">@</span> <span className="text-[#E63946]">@</span>
<span>{org.slug}</span> <span>{org.slug}</span>
</div> </div>
<span className="w-1 h-1 rounded-full bg-gray-200" /> <span className="w-1 h-1 rounded-full bg-gray-200" />
<span>{org.contactEmail || 'Keine Kontaktmail'}</span> <span>{org.contactEmail || 'Keine Kontaktmail'}</span>
</div> </div>
</Link> </Link>
<div className="flex items-center gap-2 lg:opacity-0 group-hover:opacity-100 transition-all duration-300 transform translate-x-2 group-hover:translate-x-0"> <div className="flex items-center gap-2 lg:opacity-0 group-hover:opacity-100 transition-all duration-300 transform translate-x-2 group-hover:translate-x-0">
<form action={async () => { <form action={async () => {
'use server' 'use server'
await toggleAiFeature(org.id, !org.aiEnabled) await toggleAiFeature(org.id, !org.aiEnabled)
}}> }}>
<button <button
type="submit" type="submit"
className={`p-2 rounded-xl border transition-all ${org.aiEnabled className={`p-2 rounded-xl border transition-all ${org.aiEnabled
? 'bg-green-50 text-green-600 border-green-100 hover:bg-red-50 hover:text-red-600' ? 'bg-green-50 text-green-600 border-green-100 hover:bg-red-50 hover:text-red-600'
: 'bg-gray-50 text-gray-400 border-gray-100 hover:bg-green-50 hover:text-green-600'}`} : 'bg-gray-50 text-gray-400 border-gray-100 hover:bg-green-50 hover:text-green-600'}`}
title={org.aiEnabled ? 'KI Deaktivieren' : 'KI Aktivieren'} title={org.aiEnabled ? 'KI Deaktivieren' : 'KI Aktivieren'}
> >
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-5 h-5"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.59 14.37a6 6 0 0 1-5.84 7.38v-4.8m5.84-2.58a14.98 14.98 0 0 0 6.16-12.12A14.98 14.98 0 0 0 9.59 8.31m5.84 6.06a6.01 6.01 0 0 1-5.84-1.29m0 0a6.01 6.01 0 0 1 0-8.5l.08.08a6.01 6.01 0 0 1 0 8.42Z" /> <path strokeLinecap="round" strokeLinejoin="round" d="M15.59 14.37a6 6 0 0 1-5.84 7.38v-4.8m5.84-2.58a14.98 14.98 0 0 0 6.16-12.12A14.98 14.98 0 0 0 9.59 8.31m5.84 6.06a6.01 6.01 0 0 1-5.84-1.29m0 0a6.01 6.01 0 0 1 0-8.5l.08.08a6.01 6.01 0 0 1 0 8.42Z" />
</svg> </svg>
</button> </button>
</form> </form>
<Link <Link
href={`/superadmin/organizations/${org.id}`} href={`/superadmin/organizations/${org.id}`}
className="p-2 bg-gray-900 text-white rounded-xl hover:bg-black transition-all" className="p-2 bg-gray-900 text-white rounded-xl hover:bg-black transition-all"
> >
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-5 h-5"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" /> <path strokeLinecap="round" strokeLinejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
</svg> </svg>
</Link> </Link>
</div> </div>
</div> </div>
<div className="mt-6 flex items-center gap-6"> <div className="mt-6 flex items-center gap-6">
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-[10px] text-gray-400 font-bold uppercase tracking-wider">Mitglieder</span> <span className="text-[10px] text-gray-400 font-bold uppercase tracking-wider">Mitglieder</span>
<span className="font-bold text-gray-900">{org._count.members}</span> <span className="font-bold text-gray-900">{org._count.members}</span>
</div> </div>
<div className="w-px h-6 bg-gray-100" /> <div className="w-px h-6 bg-gray-100" />
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-[10px] text-gray-400 font-bold uppercase tracking-wider">Admins</span> <span className="text-[10px] text-gray-400 font-bold uppercase tracking-wider">Admins</span>
<span className="font-bold text-gray-900">{org._count.userRoles}</span> <span className="font-bold text-gray-900">{org._count.userRoles}</span>
</div> </div>
<div className="w-px h-6 bg-gray-100 ml-auto" /> <div className="w-px h-6 bg-gray-100 ml-auto" />
<div className="flex flex-col items-end"> <div className="flex flex-col items-end">
<span className="text-[10px] text-gray-400 font-bold uppercase tracking-wider">Erstellt am</span> <span className="text-[10px] text-gray-400 font-bold uppercase tracking-wider">Erstellt am</span>
<span className="text-xs font-semibold text-gray-600">{format(org.createdAt, 'dd.MM.yyyy', { locale: de })}</span> <span className="text-xs font-semibold text-gray-600">{format(org.createdAt, 'dd.MM.yyyy', { locale: de })}</span>
</div> </div>
</div> </div>
</div> </div>
)) ))
)} )}
</div> </div>
{/* Pagination */} {/* Pagination */}
{totalPages > 1 && ( {totalPages > 1 && (
<div className="pt-8 flex items-center justify-between border-t border-gray-100"> <div className="pt-8 flex items-center justify-between border-t border-gray-100">
<span className="text-xs font-bold text-gray-400 uppercase tracking-widest"> <span className="text-xs font-bold text-gray-400 uppercase tracking-widest">
Seite {currentPage} / {totalPages} Seite {currentPage} / {totalPages}
</span> </span>
<div className="flex gap-2"> <div className="flex gap-2">
{currentPage > 1 && ( {currentPage > 1 && (
<Link <Link
href={`/superadmin?${new URLSearchParams({ q, page: String(currentPage - 1) })}`} href={`/superadmin?${new URLSearchParams({ q, page: String(currentPage - 1) })}`}
className="px-4 py-2 bg-white border border-gray-200 rounded-xl text-xs font-bold text-gray-600 hover:bg-gray-50 transition-all active:scale-[0.98]" className="px-4 py-2 bg-white border border-gray-200 rounded-xl text-xs font-bold text-gray-600 hover:bg-gray-50 transition-all active:scale-[0.98]"
> >
Zurück Zurück
</Link> </Link>
)} )}
{currentPage < totalPages && ( {currentPage < totalPages && (
<Link <Link
href={`/superadmin?${new URLSearchParams({ q, page: String(currentPage + 1) })}`} href={`/superadmin?${new URLSearchParams({ q, page: String(currentPage + 1) })}`}
className="px-4 py-2 bg-white border border-gray-200 rounded-xl text-xs font-bold text-gray-600 hover:bg-gray-50 transition-all active:scale-[0.98]" className="px-4 py-2 bg-white border border-gray-200 rounded-xl text-xs font-bold text-gray-600 hover:bg-gray-50 transition-all active:scale-[0.98]"
> >
Weiter Weiter
</Link> </Link>
)} )}
</div> </div>
</div> </div>
)} )}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
) )
} }

View File

@ -1,181 +1,181 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Sparkles, Copy, Check } from 'lucide-react' import { Sparkles, Copy, Check } from 'lucide-react'
import { trpc } from '@/lib/trpc-client' import { trpc } from '@/lib/trpc-client'
interface AIGeneratorProps { interface AIGeneratorProps {
type: 'news' | 'stelle' type: 'news' | 'stelle'
onApply?: (text: string) => void onApply?: (text: string) => void
} }
const THINKING_STEPS = [ const THINKING_STEPS = [
'KI denkt nach…', 'KI denkt nach…',
'Thema wird analysiert…', 'Thema wird analysiert…',
'Recherchiere Inhalte…', 'Recherchiere Inhalte…',
'Struktur wird geplant…', 'Struktur wird geplant…',
'Einleitung wird formuliert…', 'Einleitung wird formuliert…',
'Hauptteil wird ausgearbeitet…', 'Hauptteil wird ausgearbeitet…',
'Formulierungen werden verfeinert…', 'Formulierungen werden verfeinert…',
'Fachbegriffe werden geprüft…', 'Fachbegriffe werden geprüft…',
'Absätze werden aufgeteilt…', 'Absätze werden aufgeteilt…',
'Zwischenüberschriften werden gesetzt…', 'Zwischenüberschriften werden gesetzt…',
'Stil wird angepasst…', 'Stil wird angepasst…',
'Rechtschreibung wird kontrolliert…', 'Rechtschreibung wird kontrolliert…',
'Markdown wird formatiert…', 'Markdown wird formatiert…',
'Überschrift wird optimiert…', 'Überschrift wird optimiert…',
'Fazit wird formuliert…', 'Fazit wird formuliert…',
'Länge wird angepasst…', 'Länge wird angepasst…',
'Ton wird auf Zielgruppe abgestimmt…', 'Ton wird auf Zielgruppe abgestimmt…',
'Aufzählungen werden erstellt…', 'Aufzählungen werden erstellt…',
'Fettungen werden gesetzt…', 'Fettungen werden gesetzt…',
'Satzfluss wird geprüft…', 'Satzfluss wird geprüft…',
'Grammatik wird überprüft…', 'Grammatik wird überprüft…',
'Keywords werden eingebaut…', 'Keywords werden eingebaut…',
'Einleitung wird überarbeitet…', 'Einleitung wird überarbeitet…',
'Abschnitte werden umstrukturiert…', 'Abschnitte werden umstrukturiert…',
'Wiederholungen werden entfernt…', 'Wiederholungen werden entfernt…',
'Zeichensetzung wird geprüft…', 'Zeichensetzung wird geprüft…',
'Leerzeilen werden optimiert…', 'Leerzeilen werden optimiert…',
'Fachlich wird validiert…', 'Fachlich wird validiert…',
'Lesbarkeit wird verbessert…', 'Lesbarkeit wird verbessert…',
'Zusammenfassung wird erstellt…', 'Zusammenfassung wird erstellt…',
'Text wird poliert…', 'Text wird poliert…',
'Letzte Korrekturen…', 'Letzte Korrekturen…',
'Fast fertig…', 'Fast fertig…',
] ]
export function AIGenerator({ type, onApply }: AIGeneratorProps) { export function AIGenerator({ type, onApply }: AIGeneratorProps) {
const { data: org } = trpc.organizations.me.useQuery() const { data: org } = trpc.organizations.me.useQuery()
const [prompt, setPrompt] = useState('') const [prompt, setPrompt] = useState('')
const [format, setFormat] = useState('markdown') const [format, setFormat] = useState('markdown')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [generatedText, setGeneratedText] = useState('') const [generatedText, setGeneratedText] = useState('')
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const [stepIndex, setStepIndex] = useState(0) const [stepIndex, setStepIndex] = useState(0)
useEffect(() => { useEffect(() => {
if (!loading) { setStepIndex(0); return } if (!loading) { setStepIndex(0); return }
const interval = setInterval(() => { const interval = setInterval(() => {
setStepIndex((i) => (i + 1) % THINKING_STEPS.length) setStepIndex((i) => (i + 1) % THINKING_STEPS.length)
}, 5000) }, 5000)
return () => clearInterval(interval) return () => clearInterval(interval)
}, [loading]) }, [loading])
async function handleGenerate() { async function handleGenerate() {
if (!prompt.trim()) return if (!prompt.trim()) return
setLoading(true) setLoading(true)
setGeneratedText('') setGeneratedText('')
try { try {
const res = await fetch('/api/ai/generate', { const res = await fetch('/api/ai/generate', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, type, format }), body: JSON.stringify({ prompt, type, format }),
}) })
if (!res.ok) { if (!res.ok) {
throw new Error('Fehler bei der Generierung') throw new Error('Fehler bei der Generierung')
} }
const data = await res.json() const data = await res.json()
setGeneratedText(data.text) setGeneratedText(data.text)
} catch (err) { } catch (err) {
alert((err as Error).message) alert((err as Error).message)
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }
function handleCopy() { function handleCopy() {
navigator.clipboard.writeText(generatedText) navigator.clipboard.writeText(generatedText)
setCopied(true) setCopied(true)
setTimeout(() => setCopied(false), 2000) setTimeout(() => setCopied(false), 2000)
} }
if (org && !org.aiEnabled) return null if (org && !org.aiEnabled) return null
return ( return (
<div className="bg-white rounded-xl border border-brand-100 shadow-sm p-6 space-y-4 flex flex-col h-full bg-gradient-to-br from-white to-brand-50/20"> <div className="bg-white rounded-xl border border-brand-100 shadow-sm p-6 space-y-4 flex flex-col h-full bg-gradient-to-br from-white to-brand-50/20">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<Sparkles className="w-5 h-5 text-brand-500" /> <Sparkles className="w-5 h-5 text-brand-500" />
<h2 className="text-lg font-bold text-gray-900">KI-Assistent</h2> <h2 className="text-lg font-bold text-gray-900">KI-Assistent</h2>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
{type === 'news' ? 'Worum geht es in dem News-Beitrag?' : 'Beschreiben Sie die Stelle für die Lehrlingsbörse'} {type === 'news' ? 'Worum geht es in dem News-Beitrag?' : 'Beschreiben Sie die Stelle für die Lehrlingsbörse'}
</label> </label>
<textarea <textarea
rows={3} rows={3}
value={prompt} value={prompt}
onChange={(e) => setPrompt(e.target.value)} onChange={(e) => setPrompt(e.target.value)}
placeholder={type === 'news' ? "Schreibe einen Artikel über..." : "Eine kurze Zusammenfassung der Aufgaben..."} placeholder={type === 'news' ? "Schreibe einen Artikel über..." : "Eine kurze Zusammenfassung der Aufgaben..."}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
/> />
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<select <select
value={format} value={format}
onChange={(e) => setFormat(e.target.value)} onChange={(e) => setFormat(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 bg-white" className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 bg-white"
> >
<option value="markdown">Markdown Format</option> <option value="markdown">Markdown Format</option>
<option value="text">Einfacher Text</option> <option value="text">Einfacher Text</option>
</select> </select>
<button <button
type="button" type="button"
onClick={handleGenerate} onClick={handleGenerate}
disabled={loading || !prompt.trim()} disabled={loading || !prompt.trim()}
className="flex items-center gap-2 bg-brand-500 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 disabled:opacity-60 transition-colors" className="flex items-center gap-2 bg-brand-500 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 disabled:opacity-60 transition-colors"
> >
{loading ? 'Generiere...' : 'Generieren'} {loading ? 'Generiere...' : 'Generieren'}
<Sparkles className="w-4 h-4" /> <Sparkles className="w-4 h-4" />
</button> </button>
</div> </div>
{loading && ( {loading && (
<div className="flex items-center gap-3 px-4 py-3 bg-brand-50 border border-brand-100 rounded-lg"> <div className="flex items-center gap-3 px-4 py-3 bg-brand-50 border border-brand-100 rounded-lg">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-brand-400 animate-bounce [animation-delay:0ms]" /> <span className="w-2 h-2 rounded-full bg-brand-400 animate-bounce [animation-delay:0ms]" />
<span className="w-2 h-2 rounded-full bg-brand-400 animate-bounce [animation-delay:150ms]" /> <span className="w-2 h-2 rounded-full bg-brand-400 animate-bounce [animation-delay:150ms]" />
<span className="w-2 h-2 rounded-full bg-brand-400 animate-bounce [animation-delay:300ms]" /> <span className="w-2 h-2 rounded-full bg-brand-400 animate-bounce [animation-delay:300ms]" />
</div> </div>
<span className="text-sm text-brand-700 font-medium transition-all">{THINKING_STEPS[stepIndex]}</span> <span className="text-sm text-brand-700 font-medium transition-all">{THINKING_STEPS[stepIndex]}</span>
</div> </div>
)} )}
{generatedText && ( {generatedText && (
<div className="mt-4 flex-1 flex flex-col min-h-[300px] space-y-2"> <div className="mt-4 flex-1 flex flex-col min-h-[300px] space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">Ergebnis:</span> <span className="text-sm font-medium text-gray-700">Ergebnis:</span>
<div className="flex gap-4"> <div className="flex gap-4">
<button <button
type="button" type="button"
onClick={handleCopy} onClick={handleCopy}
className="flex items-center gap-1 text-xs text-brand-600 hover:text-brand-700 font-medium transition-colors" className="flex items-center gap-1 text-xs text-brand-600 hover:text-brand-700 font-medium transition-colors"
> >
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />} {copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
{copied ? 'Kopiert!' : 'Kopieren'} {copied ? 'Kopiert!' : 'Kopieren'}
</button> </button>
{onApply && ( {onApply && (
<button <button
type="button" type="button"
onClick={() => onApply(generatedText)} onClick={() => onApply(generatedText)}
className="flex items-center gap-1 text-xs text-brand-600 hover:text-brand-700 font-medium transition-colors" className="flex items-center gap-1 text-xs text-brand-600 hover:text-brand-700 font-medium transition-colors"
> >
<Check className="w-4 h-4" /> <Check className="w-4 h-4" />
Übernehmen Übernehmen
</button> </button>
)} )}
</div> </div>
</div> </div>
<textarea <textarea
readOnly readOnly
value={generatedText} value={generatedText}
className="w-full flex-1 p-3 border border-gray-200 rounded-lg text-sm bg-gray-50 focus:outline-none focus:ring-2 focus:ring-brand-500/50" className="w-full flex-1 p-3 border border-gray-200 rounded-lg text-sm bg-gray-50 focus:outline-none focus:ring-2 focus:ring-brand-500/50"
/> />
</div> </div>
)} )}
</div> </div>
) )
} }

View File

@ -1,136 +1,136 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { createAuthClient } from 'better-auth/react' import { createAuthClient } from 'better-auth/react'
const authClient = createAuthClient({ const authClient = createAuthClient({
// Keep auth requests on the current origin (important for tenant subdomains). // Keep auth requests on the current origin (important for tenant subdomains).
baseURL: typeof window !== 'undefined' baseURL: typeof window !== 'undefined'
? window.location.origin ? window.location.origin
: (process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3010'), : (process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3010'),
}) })
interface LoginFormProps { interface LoginFormProps {
primaryColor?: string primaryColor?: string
} }
export function LoginForm({ primaryColor = '#C99738' }: LoginFormProps) { export function LoginForm({ primaryColor = '#C99738' }: LoginFormProps) {
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [successMessage, setSuccessMessage] = useState('') const [successMessage, setSuccessMessage] = useState('')
useEffect(() => { useEffect(() => {
const params = new URLSearchParams(window.location.search) const params = new URLSearchParams(window.location.search)
const emailParam = params.get('email') const emailParam = params.get('email')
if (emailParam) setEmail(emailParam) if (emailParam) setEmail(emailParam)
const messageParam = params.get('message') const messageParam = params.get('message')
if (messageParam === 'password_changed') { if (messageParam === 'password_changed') {
setSuccessMessage('Passwort erfolgreich geändert. Bitte melden Sie sich mit Ihrem neuen Passwort an.') setSuccessMessage('Passwort erfolgreich geändert. Bitte melden Sie sich mit Ihrem neuen Passwort an.')
} }
}, []) }, [])
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
setLoading(true) setLoading(true)
setError('') setError('')
const result = await authClient.signIn.email({ const result = await authClient.signIn.email({
email, email,
password, password,
callbackURL: '/dashboard', callbackURL: '/dashboard',
}) })
setLoading(false) setLoading(false)
if (result.error) { if (result.error) {
setError(result.error.message ?? 'E-Mail oder Passwort falsch.') setError(result.error.message ?? 'E-Mail oder Passwort falsch.')
return return
} }
// Use callbackUrl if present, otherwise go to dashboard // Use callbackUrl if present, otherwise go to dashboard
// mustChangePassword is handled by the dashboard ForcePasswordChange component // mustChangePassword is handled by the dashboard ForcePasswordChange component
const params = new URLSearchParams(window.location.search) const params = new URLSearchParams(window.location.search)
const callbackUrl = params.get('callbackUrl') const callbackUrl = params.get('callbackUrl')
let target = '/dashboard' let target = '/dashboard'
if (callbackUrl?.startsWith('/')) { if (callbackUrl?.startsWith('/')) {
target = callbackUrl target = callbackUrl
// Normalize stale tenant-prefixed callback URLs like /test/dashboard // Normalize stale tenant-prefixed callback URLs like /test/dashboard
// when already on the tenant subdomain test.localhost. // when already on the tenant subdomain test.localhost.
const hostname = window.location.hostname const hostname = window.location.hostname
const parts = hostname.split('.') const parts = hostname.split('.')
const isTenantSubdomain = const isTenantSubdomain =
parts.length > 2 || (parts.length === 2 && parts[1] === 'localhost') parts.length > 2 || (parts.length === 2 && parts[1] === 'localhost')
const tenantSlug = isTenantSubdomain ? parts[0] : null const tenantSlug = isTenantSubdomain ? parts[0] : null
if (tenantSlug && target.startsWith(`/${tenantSlug}/`)) { if (tenantSlug && target.startsWith(`/${tenantSlug}/`)) {
target = target.slice(tenantSlug.length + 1) || '/dashboard' target = target.slice(tenantSlug.length + 1) || '/dashboard'
} }
} }
window.location.href = target window.location.href = target
} }
return ( return (
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
{successMessage && ( {successMessage && (
<p className="text-sm text-green-700 bg-green-50 border border-green-200 px-3 py-2 rounded-lg"> <p className="text-sm text-green-700 bg-green-50 border border-green-200 px-3 py-2 rounded-lg">
{successMessage} {successMessage}
</p> </p>
)} )}
<div> <div>
<label <label
htmlFor="email" htmlFor="email"
className="block text-xs font-medium text-gray-600 mb-1 uppercase tracking-wide" className="block text-xs font-medium text-gray-600 mb-1 uppercase tracking-wide"
> >
E-Mail-Adresse E-Mail-Adresse
</label> </label>
<input <input
id="email" id="email"
type="email" type="email"
required required
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
placeholder="admin@ihre-innung.de" placeholder="admin@ihre-innung.de"
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:border-transparent" className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:border-transparent"
style={{ '--tw-ring-color': primaryColor } as any} style={{ '--tw-ring-color': primaryColor } as any}
/> />
</div> </div>
<div> <div>
<label <label
htmlFor="password" htmlFor="password"
className="block text-xs font-medium text-gray-600 mb-1 uppercase tracking-wide" className="block text-xs font-medium text-gray-600 mb-1 uppercase tracking-wide"
> >
Passwort Passwort
</label> </label>
<input <input
id="password" id="password"
type="password" type="password"
required required
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="********" placeholder="********"
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:border-transparent" className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:border-transparent"
style={{ '--tw-ring-color': primaryColor } as any} style={{ '--tw-ring-color': primaryColor } as any}
/> />
</div> </div>
{error && ( {error && (
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg">{error}</p> <p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg">{error}</p>
)} )}
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
className="w-full text-white py-2.5 px-4 rounded-lg text-sm font-medium disabled:opacity-60 disabled:cursor-not-allowed transition-colors" className="w-full text-white py-2.5 px-4 rounded-lg text-sm font-medium disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
style={{ backgroundColor: primaryColor }} style={{ backgroundColor: primaryColor }}
> >
{loading ? 'Bitte warten...' : 'Anmelden'} {loading ? 'Bitte warten...' : 'Anmelden'}
</button> </button>
</form> </form>
) )
} }

View File

@ -1,55 +1,55 @@
'use client' 'use client'
import { createAuthClient } from 'better-auth/react' import { createAuthClient } from 'better-auth/react'
import { useRouter, usePathname } from 'next/navigation' import { useRouter, usePathname } from 'next/navigation'
import { LogOut } from 'lucide-react' import { LogOut } from 'lucide-react'
const authClient = createAuthClient({ const authClient = createAuthClient({
// Keep auth requests on the current origin (important for tenant subdomains). // Keep auth requests on the current origin (important for tenant subdomains).
baseURL: typeof window !== 'undefined' baseURL: typeof window !== 'undefined'
? window.location.origin ? window.location.origin
: (process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3010'), : (process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3010'),
}) })
const PAGE_TITLES: Record<string, string> = { const PAGE_TITLES: Record<string, string> = {
'/dashboard': 'Übersicht', '/dashboard': 'Übersicht',
'/dashboard/mitglieder': 'Mitglieder', '/dashboard/mitglieder': 'Mitglieder',
'/dashboard/news': 'News', '/dashboard/news': 'News',
'/dashboard/termine': 'Termine', '/dashboard/termine': 'Termine',
'/dashboard/stellen': 'Lehrlingsbörse', '/dashboard/stellen': 'Lehrlingsbörse',
'/dashboard/einstellungen': 'Einstellungen', '/dashboard/einstellungen': 'Einstellungen',
} }
export function Header() { export function Header() {
const router = useRouter() const router = useRouter()
const pathname = usePathname() const pathname = usePathname()
const title = Object.entries(PAGE_TITLES) const title = Object.entries(PAGE_TITLES)
.sort((a, b) => b[0].length - a[0].length) .sort((a, b) => b[0].length - a[0].length)
.find(([path]) => pathname === path || pathname.startsWith(path + '/'))?.[1] ?? 'Dashboard' .find(([path]) => pathname === path || pathname.startsWith(path + '/'))?.[1] ?? 'Dashboard'
async function handleSignOut() { async function handleSignOut() {
await authClient.signOut() await authClient.signOut()
router.push('/login') router.push('/login')
} }
return ( return (
<header className="h-14 bg-white border-b flex items-center justify-between px-6 flex-shrink-0"> <header className="h-14 bg-white border-b flex items-center justify-between px-6 flex-shrink-0">
<h2 <h2
className="text-sm font-semibold text-gray-700 tracking-tight" className="text-sm font-semibold text-gray-700 tracking-tight"
style={{ fontFamily: "'Syne', system-ui, sans-serif" }} style={{ fontFamily: "'Syne', system-ui, sans-serif" }}
> >
{title} {title}
</h2> </h2>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button
onClick={handleSignOut} onClick={handleSignOut}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 transition-colors" className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 transition-colors"
> >
<LogOut size={14} /> <LogOut size={14} />
Abmelden Abmelden
</button> </button>
</div> </div>
</header> </header>
) )
} }

View File

@ -1,56 +1,56 @@
#!/bin/sh #!/bin/sh
set -e set -e
# Keep DATABASE_URL consistent for every Prisma command # Keep DATABASE_URL consistent for every Prisma command
export DATABASE_URL="${DATABASE_URL:-postgresql://innungsapp:innungsapp@postgres:5432/innungsapp?schema=public}" export DATABASE_URL="${DATABASE_URL:-postgresql://innungsapp:innungsapp@postgres:5432/innungsapp?schema=public}"
MIGRATIONS_DIR="./packages/shared/prisma/migrations" MIGRATIONS_DIR="./packages/shared/prisma/migrations"
# Debug: Check environment variables # Debug: Check environment variables
echo "========================================" echo "========================================"
echo "Environment Variables Check:" echo "Environment Variables Check:"
echo "========================================" echo "========================================"
echo "DATABASE_URL: $DATABASE_URL" echo "DATABASE_URL: $DATABASE_URL"
echo "BETTER_AUTH_URL: ${BETTER_AUTH_URL:-[not set]}" echo "BETTER_AUTH_URL: ${BETTER_AUTH_URL:-[not set]}"
echo "BETTER_AUTH_BASE_URL: ${BETTER_AUTH_BASE_URL:-[not set]}" echo "BETTER_AUTH_BASE_URL: ${BETTER_AUTH_BASE_URL:-[not set]}"
if [ -n "$BETTER_AUTH_SECRET" ]; then if [ -n "$BETTER_AUTH_SECRET" ]; then
echo "BETTER_AUTH_SECRET: [set]" echo "BETTER_AUTH_SECRET: [set]"
else else
echo "BETTER_AUTH_SECRET: [not set]" echo "BETTER_AUTH_SECRET: [not set]"
fi fi
echo "NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-[not set]}" echo "NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-[not set]}"
echo "NODE_ENV: ${NODE_ENV:-[not set]}" echo "NODE_ENV: ${NODE_ENV:-[not set]}"
echo "========================================" echo "========================================"
echo "" echo ""
run_with_retries() { run_with_retries() {
attempt=1 attempt=1
max_attempts=20 max_attempts=20
while [ "$attempt" -le "$max_attempts" ]; do while [ "$attempt" -le "$max_attempts" ]; do
if "$@"; then if "$@"; then
return 0 return 0
fi fi
if [ "$attempt" -eq "$max_attempts" ]; then if [ "$attempt" -eq "$max_attempts" ]; then
echo "Command failed after ${max_attempts} attempts." echo "Command failed after ${max_attempts} attempts."
return 1 return 1
fi fi
echo "Database not ready yet. Retry ${attempt}/${max_attempts} in 3s..." echo "Database not ready yet. Retry ${attempt}/${max_attempts} in 3s..."
attempt=$((attempt + 1)) attempt=$((attempt + 1))
sleep 3 sleep 3
done done
} }
# Prefer migration-based deploys. Fall back to db push when no migrations exist yet. # Prefer migration-based deploys. Fall back to db push when no migrations exist yet.
set -- "$MIGRATIONS_DIR"/*/migration.sql set -- "$MIGRATIONS_DIR"/*/migration.sql
if [ -f "$1" ]; then if [ -f "$1" ]; then
echo "Applying Prisma migrations..." echo "Applying Prisma migrations..."
run_with_retries npx prisma migrate deploy --schema=./packages/shared/prisma/schema.prisma run_with_retries npx prisma migrate deploy --schema=./packages/shared/prisma/schema.prisma
else else
echo "No Prisma migrations found. Syncing schema with db push..." echo "No Prisma migrations found. Syncing schema with db push..."
run_with_retries npx prisma db push --skip-generate --schema=./packages/shared/prisma/schema.prisma run_with_retries npx prisma db push --skip-generate --schema=./packages/shared/prisma/schema.prisma
fi fi
echo "Starting Next.js server..." echo "Starting Next.js server..."
exec node apps/admin/server.js exec node apps/admin/server.js

View File

@ -1,72 +1,72 @@
import { betterAuth } from 'better-auth' import { betterAuth } from 'better-auth'
import { prismaAdapter } from 'better-auth/adapters/prisma' import { prismaAdapter } from 'better-auth/adapters/prisma'
import { magicLink } from 'better-auth/plugins' import { magicLink } from 'better-auth/plugins'
import { admin as adminPlugin } from 'better-auth/plugins' import { admin as adminPlugin } from 'better-auth/plugins'
import { prisma } from '@innungsapp/shared' import { prisma } from '@innungsapp/shared'
import { sendMagicLinkEmail } from './email' import { sendMagicLinkEmail } from './email'
import { headers } from 'next/headers' import { headers } from 'next/headers'
export const auth = betterAuth({ export const auth = betterAuth({
database: prismaAdapter(prisma, { database: prismaAdapter(prisma, {
provider: 'postgresql', provider: 'postgresql',
}), }),
emailAndPassword: { emailAndPassword: {
enabled: true, enabled: true,
}, },
secret: process.env.BETTER_AUTH_SECRET!, secret: process.env.BETTER_AUTH_SECRET!,
baseURL: process.env.BETTER_AUTH_URL!, baseURL: process.env.BETTER_AUTH_URL!,
trustedOrigins: [ trustedOrigins: [
process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3010', process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3010',
process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3010', process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3010',
'http://localhost:3000', 'http://localhost:3000',
'http://localhost:3001', 'http://localhost:3001',
'http://localhost:3010', 'http://localhost:3010',
'http://localhost:8081', 'http://localhost:8081',
'http://*.localhost:3010', 'http://*.localhost:3010',
'http://*.localhost:3000', 'http://*.localhost:3000',
'https://*.innungsapp.de', 'https://*.innungsapp.de',
'https://*.innungsapp.com', 'https://*.innungsapp.com',
// Additional origins from env (comma-separated) // Additional origins from env (comma-separated)
...(process.env.BETTER_AUTH_TRUSTED_ORIGINS ?? '').split(',').map((o) => o.trim()).filter(Boolean), ...(process.env.BETTER_AUTH_TRUSTED_ORIGINS ?? '').split(',').map((o) => o.trim()).filter(Boolean),
], ],
user: { user: {
additionalFields: { additionalFields: {
mustChangePassword: { mustChangePassword: {
type: 'boolean', type: 'boolean',
defaultValue: false, defaultValue: false,
}, },
}, },
}, },
plugins: [ plugins: [
magicLink({ magicLink({
sendMagicLink: async ({ email, url }) => { sendMagicLink: async ({ email, url }) => {
await sendMagicLinkEmail({ to: email, magicUrl: url }) await sendMagicLinkEmail({ to: email, magicUrl: url })
}, },
}), }),
adminPlugin(), adminPlugin(),
], ],
session: { session: {
cookieCache: { cookieCache: {
enabled: false, enabled: false,
}, },
}, },
}) })
export type Auth = typeof auth export type Auth = typeof auth
export async function getSanitizedHeaders(sourceHeaders?: HeadersInit) { export async function getSanitizedHeaders(sourceHeaders?: HeadersInit) {
const baseHeaders = sourceHeaders ? new Headers(sourceHeaders) : new Headers(await headers()) const baseHeaders = sourceHeaders ? new Headers(sourceHeaders) : new Headers(await headers())
const sanitizedHeaders = new Headers(baseHeaders) const sanitizedHeaders = new Headers(baseHeaders)
// Avoid ENOTFOUND by forcing host to localhost for internal better-auth fetches // Avoid ENOTFOUND by forcing host to localhost for internal better-auth fetches
// We use the host defined in BETTER_AUTH_URL // We use the host defined in BETTER_AUTH_URL
try { try {
const betterAuthUrl = new URL(process.env.BETTER_AUTH_URL || 'http://localhost:3010') const betterAuthUrl = new URL(process.env.BETTER_AUTH_URL || 'http://localhost:3010')
sanitizedHeaders.set('host', betterAuthUrl.host) sanitizedHeaders.set('host', betterAuthUrl.host)
} catch (e) { } catch (e) {
sanitizedHeaders.set('host', 'localhost:3010') sanitizedHeaders.set('host', 'localhost:3010')
} }
return sanitizedHeaders return sanitizedHeaders
} }

View File

@ -1,151 +1,151 @@
import nodemailer from 'nodemailer' import nodemailer from 'nodemailer'
const SMTP_HOST = (process.env.SMTP_HOST ?? '').trim() const SMTP_HOST = (process.env.SMTP_HOST ?? '').trim()
const SMTP_HOST_IS_PLACEHOLDER = SMTP_HOST === '' || SMTP_HOST.toLowerCase() === 'smtp.example.com' const SMTP_HOST_IS_PLACEHOLDER = SMTP_HOST === '' || SMTP_HOST.toLowerCase() === 'smtp.example.com'
const transporter = nodemailer.createTransport({ const transporter = nodemailer.createTransport({
host: SMTP_HOST, host: SMTP_HOST,
port: Number(process.env.SMTP_PORT) || 587, port: Number(process.env.SMTP_PORT) || 587,
secure: process.env.SMTP_SECURE === 'true', secure: process.env.SMTP_SECURE === 'true',
auth: auth:
process.env.SMTP_USER process.env.SMTP_USER
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS } ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }
: undefined, : undefined,
}) })
async function sendMailOrSkip(mailOptions: any, emailType: string) { async function sendMailOrSkip(mailOptions: any, emailType: string) {
if (SMTP_HOST_IS_PLACEHOLDER) { if (SMTP_HOST_IS_PLACEHOLDER) {
const target = typeof mailOptions?.to === 'string' ? mailOptions.to : 'unknown-recipient' const target = typeof mailOptions?.to === 'string' ? mailOptions.to : 'unknown-recipient'
console.warn(`[email] SMTP not configured. Skipping ${emailType} email to ${target}.`) console.warn(`[email] SMTP not configured. Skipping ${emailType} email to ${target}.`)
return return
} }
await transporter.sendMail(mailOptions) await transporter.sendMail(mailOptions)
} }
export async function sendMagicLinkEmail({ export async function sendMagicLinkEmail({
to, to,
magicUrl, magicUrl,
}: { }: {
to: string to: string
magicUrl: string magicUrl: string
}) { }) {
await sendMailOrSkip({ await sendMailOrSkip({
from: process.env.EMAIL_FROM ?? 'noreply@innungsapp.de', from: process.env.EMAIL_FROM ?? 'noreply@innungsapp.de',
to, to,
subject: 'Ihr Login-Link für InnungsApp', subject: 'Ihr Login-Link für InnungsApp',
html: ` html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;"> <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: #E63946; padding: 24px; border-radius: 8px 8px 0 0;"> <div style="background: #E63946; padding: 24px; border-radius: 8px 8px 0 0;">
<h1 style="color: white; margin: 0; font-size: 24px;">InnungsApp</h1> <h1 style="color: white; margin: 0; font-size: 24px;">InnungsApp</h1>
</div> </div>
<div style="background: #fff; padding: 32px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 8px 8px;"> <div style="background: #fff; padding: 32px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 8px 8px;">
<h2 style="color: #111827; margin-top: 0;">Ihr persönlicher Login-Link</h2> <h2 style="color: #111827; margin-top: 0;">Ihr persönlicher Login-Link</h2>
<p style="color: #4b5563;">Klicken Sie auf den folgenden Button, um sich einzuloggen. Der Link ist 24 Stunden gültig.</p> <p style="color: #4b5563;">Klicken Sie auf den folgenden Button, um sich einzuloggen. Der Link ist 24 Stunden gültig.</p>
<a href="${magicUrl}" <a href="${magicUrl}"
style="display: inline-block; background: #E63946; color: white; padding: 12px 24px; style="display: inline-block; background: #E63946; color: white; padding: 12px 24px;
border-radius: 6px; text-decoration: none; font-weight: bold; margin: 16px 0;"> border-radius: 6px; text-decoration: none; font-weight: bold; margin: 16px 0;">
Jetzt einloggen Jetzt einloggen
</a> </a>
<p style="color: #9ca3af; font-size: 14px;"> <p style="color: #9ca3af; font-size: 14px;">
Wenn Sie diesen Link nicht angefordert haben, können Sie diese E-Mail ignorieren. Wenn Sie diesen Link nicht angefordert haben, können Sie diese E-Mail ignorieren.
</p> </p>
<hr style="border-color: #e5e7eb; margin: 24px 0;" /> <hr style="border-color: #e5e7eb; margin: 24px 0;" />
<p style="color: #9ca3af; font-size: 12px; margin: 0;"> <p style="color: #9ca3af; font-size: 12px; margin: 0;">
InnungsApp · Die digitale Plattform für Innungen InnungsApp · Die digitale Plattform für Innungen
</p> </p>
</div> </div>
</div> </div>
`, `,
}, 'magic link') }, 'magic link')
} }
export async function sendInviteEmail({ export async function sendInviteEmail({
to, to,
memberName, memberName,
orgName, orgName,
apiUrl, apiUrl,
}: { }: {
to: string to: string
memberName: string memberName: string
orgName: string orgName: string
apiUrl: string apiUrl: string
}) { }) {
// Generate magic link for the invite // Generate magic link for the invite
const signInUrl = `${apiUrl}/login?email=${encodeURIComponent(to)}&invited=true` const signInUrl = `${apiUrl}/login?email=${encodeURIComponent(to)}&invited=true`
await sendMailOrSkip({ await sendMailOrSkip({
from: process.env.EMAIL_FROM ?? 'noreply@innungsapp.de', from: process.env.EMAIL_FROM ?? 'noreply@innungsapp.de',
to, to,
subject: `Einladung zur InnungsApp — ${orgName}`, subject: `Einladung zur InnungsApp — ${orgName}`,
html: ` html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;"> <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: #E63946; padding: 24px; border-radius: 8px 8px 0 0;"> <div style="background: #E63946; padding: 24px; border-radius: 8px 8px 0 0;">
<h1 style="color: white; margin: 0; font-size: 24px;">InnungsApp</h1> <h1 style="color: white; margin: 0; font-size: 24px;">InnungsApp</h1>
</div> </div>
<div style="background: #fff; padding: 32px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 8px 8px;"> <div style="background: #fff; padding: 32px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 8px 8px;">
<h2 style="color: #111827; margin-top: 0;">Hallo ${memberName},</h2> <h2 style="color: #111827; margin-top: 0;">Hallo ${memberName},</h2>
<p style="color: #4b5563;"> <p style="color: #4b5563;">
Sie wurden von der <strong>${orgName}</strong> zur InnungsApp eingeladen. Sie wurden von der <strong>${orgName}</strong> zur InnungsApp eingeladen.
InnungsApp ist die digitale Plattform Ihrer Innung für News, Termine und das Mitgliederverzeichnis. InnungsApp ist die digitale Plattform Ihrer Innung für News, Termine und das Mitgliederverzeichnis.
</p> </p>
<p style="color: #4b5563;">Klicken Sie auf den Button, um Ihren Account zu aktivieren:</p> <p style="color: #4b5563;">Klicken Sie auf den Button, um Ihren Account zu aktivieren:</p>
<a href="${signInUrl}" <a href="${signInUrl}"
style="display: inline-block; background: #E63946; color: white; padding: 12px 24px; style="display: inline-block; background: #E63946; color: white; padding: 12px 24px;
border-radius: 6px; text-decoration: none; font-weight: bold; margin: 16px 0;"> border-radius: 6px; text-decoration: none; font-weight: bold; margin: 16px 0;">
Jetzt Zugang aktivieren Jetzt Zugang aktivieren
</a> </a>
<p style="color: #9ca3af; font-size: 14px;">Kein Passwort nötig Sie erhalten einen sicheren Login-Link per E-Mail.</p> <p style="color: #9ca3af; font-size: 14px;">Kein Passwort nötig Sie erhalten einen sicheren Login-Link per E-Mail.</p>
</div> </div>
</div> </div>
`, `,
}, 'invite') }, 'invite')
} }
export async function sendAdminCredentialsEmail({ export async function sendAdminCredentialsEmail({
to, to,
adminName, adminName,
orgName, orgName,
password, password,
loginUrl, loginUrl,
}: { }: {
to: string to: string
adminName: string adminName: string
orgName: string orgName: string
password: string password: string
loginUrl: string loginUrl: string
}) { }) {
await sendMailOrSkip({ await sendMailOrSkip({
from: process.env.EMAIL_FROM ?? 'noreply@innungsapp.de', from: process.env.EMAIL_FROM ?? 'noreply@innungsapp.de',
to, to,
subject: `Admin-Zugang für — ${orgName}`, subject: `Admin-Zugang für — ${orgName}`,
html: ` html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;"> <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: #111827; padding: 24px; border-radius: 8px 8px 0 0;"> <div style="background: #111827; padding: 24px; border-radius: 8px 8px 0 0;">
<h1 style="color: white; margin: 0; font-size: 24px;">InnungsApp Admin</h1> <h1 style="color: white; margin: 0; font-size: 24px;">InnungsApp Admin</h1>
</div> </div>
<div style="background: #fff; padding: 32px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 8px 8px;"> <div style="background: #fff; padding: 32px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 8px 8px;">
<h2 style="color: #111827; margin-top: 0;">Hallo ${adminName},</h2> <h2 style="color: #111827; margin-top: 0;">Hallo ${adminName},</h2>
<p style="color: #4b5563;"> <p style="color: #4b5563;">
Sie wurden als Administrator für die <strong>${orgName}</strong> in der InnungsApp freigeschaltet. Sie wurden als Administrator für die <strong>${orgName}</strong> in der InnungsApp freigeschaltet.
</p> </p>
<div style="background: #f9fafb; padding: 20px; border-radius: 8px; margin: 24px 0;"> <div style="background: #f9fafb; padding: 20px; border-radius: 8px; margin: 24px 0;">
<p style="margin-top: 0; font-size: 14px; color: #6b7280; text-transform: uppercase; letter-spacing: 0.05em;">Ihre Zugangsdaten</p> <p style="margin-top: 0; font-size: 14px; color: #6b7280; text-transform: uppercase; letter-spacing: 0.05em;">Ihre Zugangsdaten</p>
<p style="margin: 8px 0; font-size: 16px; color: #111827;"><strong>E-Mail:</strong> ${to}</p> <p style="margin: 8px 0; font-size: 16px; color: #111827;"><strong>E-Mail:</strong> ${to}</p>
<p style="margin: 8px 0; font-size: 16px; color: #111827;"><strong>Passwort:</strong> <code style="background: #eee; padding: 2px 4px; rounded: 4px;">${password}</code></p> <p style="margin: 8px 0; font-size: 16px; color: #111827;"><strong>Passwort:</strong> <code style="background: #eee; padding: 2px 4px; rounded: 4px;">${password}</code></p>
</div> </div>
<p style="color: #4b5563;">Klicken Sie auf den Button, um sich im Verwaltungsportal anzumelden. Sie werden aufgefordert, Ihr Passwort nach dem ersten Login zu ändern.</p> <p style="color: #4b5563;">Klicken Sie auf den Button, um sich im Verwaltungsportal anzumelden. Sie werden aufgefordert, Ihr Passwort nach dem ersten Login zu ändern.</p>
<a href="${loginUrl}/login?email=${encodeURIComponent(to)}" <a href="${loginUrl}/login?email=${encodeURIComponent(to)}"
style="display: inline-block; background: #111827; color: white; padding: 12px 24px; style="display: inline-block; background: #111827; color: white; padding: 12px 24px;
border-radius: 6px; text-decoration: none; font-weight: bold; margin: 16px 0;"> border-radius: 6px; text-decoration: none; font-weight: bold; margin: 16px 0;">
Zum Admin-Portal Zum Admin-Portal
</a> </a>
<hr style="border-color: #e5e7eb; margin: 24px 0;" /> <hr style="border-color: #e5e7eb; margin: 24px 0;" />
<p style="color: #9ca3af; font-size: 12px; margin: 0;"> <p style="color: #9ca3af; font-size: 12px; margin: 0;">
InnungsApp · Administrative Portal InnungsApp · Administrative Portal
</p> </p>
</div> </div>
</div> </div>
`, `,
}, 'admin credentials') }, 'admin credentials')
} }

View File

@ -1,20 +1,20 @@
import { headers } from 'next/headers' import { headers } from 'next/headers'
const RESERVED_SUBDOMAINS = ['www', 'app', 'admin', 'localhost', 'superadmin', 'api'] const RESERVED_SUBDOMAINS = ['www', 'app', 'admin', 'localhost', 'superadmin', 'api']
export async function getTenantSlug() { export async function getTenantSlug() {
const host = (await headers()).get('host') || '' const host = (await headers()).get('host') || ''
const domainParts = host.split(':')[0].split('.') const domainParts = host.split(':')[0].split('.')
if ( if (
domainParts.length > 2 || domainParts.length > 2 ||
(domainParts.length === 2 && domainParts[1] === 'localhost') (domainParts.length === 2 && domainParts[1] === 'localhost')
) { ) {
const slug = domainParts[0] const slug = domainParts[0]
if (!RESERVED_SUBDOMAINS.includes(slug)) { if (!RESERVED_SUBDOMAINS.includes(slug)) {
return slug return slug
} }
} }
return null return null
} }

View File

@ -1,123 +1,124 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server' import type { NextRequest } from 'next/server'
const PUBLIC_PREFIXES = [ const PUBLIC_PREFIXES = [
'/login', '/login',
'/api/auth', '/api/auth',
'/api/health', '/api/health',
'/api/trpc/stellen.listPublic', '/api/trpc/stellen.listPublic',
'/api/setup', '/api/setup',
'/registrierung', '/registrierung',
'/impressum', '/impressum',
'/datenschutz', '/datenschutz',
] ]
const PUBLIC_EXACT_PATHS = ['/'] const PUBLIC_EXACT_PATHS = ['/']
const TENANT_SHARED_PATHS = ['/login', '/api', '/superadmin', '/registrierung', '/impressum', '/datenschutz', '/passwort-aendern'] const TENANT_SHARED_PATHS = ['/login', '/api', '/superadmin', '/registrierung', '/impressum', '/datenschutz', '/passwort-aendern']
// Reserved subdomains that shouldn't be treated as tenant slugs // Reserved subdomains that shouldn't be treated as tenant slugs
const RESERVED_SUBDOMAINS = [ const RESERVED_SUBDOMAINS = [
'www', 'app', 'admin', 'localhost', 'superadmin', 'api', 'www', 'app', 'admin', 'localhost', 'superadmin', 'api',
'logo.png', 'favicon.ico', 'robots.txt', 'sitemap.xml', 'logo.png', 'favicon.ico', 'robots.txt', 'sitemap.xml',
'apple-touch-icon', 'android-chrome', 'manifest' 'apple-touch-icon', 'android-chrome', 'manifest'
] ]
export function middleware(request: NextRequest) { export function middleware(request: NextRequest) {
const url = request.nextUrl const url = request.nextUrl
const pathname = url.pathname const pathname = url.pathname
// 1. Subdomain Extraction // 1. Subdomain Extraction
const hostname = request.headers.get('host') || '' const hostname = request.headers.get('host') || ''
const domainParts = hostname.split(':')[0].split('.') const domainParts = hostname.split(':')[0].split('.')
let slug = null let slug = null
// For localhost: tischler.localhost -> parts: ['tischler', 'localhost'] // For localhost: tischler.localhost -> parts: ['tischler', 'localhost']
// For production: tischler.innungsapp.de -> parts: ['tischler', 'innungsapp', 'de'] // For production: tischler.innungsapp.de -> parts: ['tischler', 'innungsapp', 'de']
if ( if (
domainParts.length > 2 || domainParts.length > 2 ||
(domainParts.length === 2 && domainParts[1] === 'localhost') (domainParts.length === 2 && domainParts[1] === 'localhost')
) { ) {
const potentialSlug = domainParts[0] const potentialSlug = domainParts[0]
if (!RESERVED_SUBDOMAINS.includes(potentialSlug)) { if (!RESERVED_SUBDOMAINS.includes(potentialSlug)) {
slug = potentialSlug slug = potentialSlug
} }
} }
// Normalize stale tenant-prefixed shared paths like /test/login to /login // Normalize stale tenant-prefixed shared paths like /test/login to /login
// before auth checks, otherwise callbackUrl can get stuck on /test/login. // before auth checks, otherwise callbackUrl can get stuck on /test/login.
if (slug) { if (slug) {
for (const sharedPath of TENANT_SHARED_PATHS) { for (const sharedPath of TENANT_SHARED_PATHS) {
const prefixedPath = `/${slug}${sharedPath}` const prefixedPath = `/${slug}${sharedPath}`
if (pathname === prefixedPath || pathname.startsWith(`${prefixedPath}/`)) { if (pathname === prefixedPath || pathname.startsWith(`${prefixedPath}/`)) {
const canonicalUrl = request.nextUrl.clone() const canonicalUrl = request.nextUrl.clone()
canonicalUrl.pathname = pathname.replace(prefixedPath, sharedPath) canonicalUrl.pathname = pathname.replace(prefixedPath, sharedPath)
return NextResponse.redirect(canonicalUrl) return NextResponse.redirect(canonicalUrl)
} }
} }
} }
// Allow static files from /public // Allow static files from /public
const isStaticFile = pathname.includes('.') && !pathname.startsWith('/api') const isStaticFile = pathname.includes('.') && !pathname.startsWith('/api')
const isPublic = const isPublic =
isStaticFile || isStaticFile ||
PUBLIC_EXACT_PATHS.includes(pathname) || PUBLIC_EXACT_PATHS.includes(pathname) ||
PUBLIC_PREFIXES.some((p) => pathname.startsWith(p)) PUBLIC_PREFIXES.some((p) => pathname.startsWith(p))
// 2. Auth Check // 2. Auth Check
const sessionToken = const sessionToken =
request.cookies.get('better-auth.session_token') ?? request.cookies.get('better-auth.session_token') ??
request.cookies.get('__Secure-better-auth.session_token') request.cookies.get('__Secure-better-auth.session_token')
if (!isPublic && !sessionToken) { if (!isPublic && !sessionToken) {
const loginUrl = new URL('/login', request.url) const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('callbackUrl', pathname) loginUrl.searchParams.set('callbackUrl', pathname)
return NextResponse.redirect(loginUrl) return NextResponse.redirect(loginUrl)
} }
// 3. Subdomain Redirection / Rewrite // 3. Subdomain Redirection / Rewrite
if (slug) { if (slug) {
// Paths that should not be rewritten into the slug folder // Paths that should not be rewritten into the slug folder
// because they are shared across the entire app // because they are shared across the entire app
const isSharedPath = TENANT_SHARED_PATHS.some((p) => pathname.startsWith(p)) || const isSharedPath = TENANT_SHARED_PATHS.some((p) => pathname.startsWith(p)) ||
pathname.startsWith('/_next') || pathname.startsWith('/_next') ||
/\.(png|jpg|jpeg|gif|svg|webp|ico|txt|xml)$/i.test(pathname) /\.(png|jpg|jpeg|gif|svg|webp|ico|txt|xml)$/i.test(pathname)
if (!isSharedPath && !pathname.startsWith(`/${slug}`)) { if (!isSharedPath && !pathname.startsWith(`/${slug}`)) {
const rewriteUrl = request.nextUrl.clone() const rewriteUrl = request.nextUrl.clone()
rewriteUrl.pathname = `/${slug}${pathname === '/' ? '' : pathname}` rewriteUrl.pathname = `/${slug}${pathname === '/' ? '' : pathname}`
return NextResponse.rewrite(rewriteUrl) return NextResponse.rewrite(rewriteUrl)
} }
} else { } else {
// Check if the user is trying to access a path that starts with a potential slug // Check if the user is trying to access a path that starts with a potential slug
// but they are on the root domain. // but they are on the root domain.
// Example: localhost/tischler/... should redirect to tischler.localhost/... // Example: localhost/tischler/... should redirect to tischler.localhost/...
const pathParts = pathname.split('/') const pathParts = pathname.split('/')
if (pathParts.length > 1) { if (pathParts.length > 1) {
const potentialSlug = pathParts[1] const potentialSlug = pathParts[1]
// Check if it's a known non-reserved path but could be an organization slug // Check if it's a known non-reserved path but could be an organization slug
// We don't want to redirect /login, /api, etc. // We don't want to redirect /login, /api, etc.
const SHARED_PATHS = ['login', 'api', 'superadmin', 'dashboard', 'registrierung', 'impressum', 'datenschutz', '_next', 'uploads', 'favicon.ico', 'passwort-aendern'] const SHARED_PATHS = ['login', 'api', 'superadmin', 'dashboard', 'registrierung', 'impressum', 'datenschutz', '_next', 'uploads', 'favicon.ico', 'passwort-aendern']
const isStaticAsset = /\.(png|jpg|jpeg|gif|svg|webp|ico|txt|xml)$/i.test(potentialSlug) const isStaticAsset = /\.(png|jpg|jpeg|gif|svg|webp|ico|txt|xml)$/i.test(potentialSlug)
if (potentialSlug && !SHARED_PATHS.includes(potentialSlug) && !isStaticAsset) { const isValidSlug = /^[a-z0-9][a-z0-9-]*$/.test(potentialSlug)
// This looks like a tenant path being accessed from the root domain. if (potentialSlug && !SHARED_PATHS.includes(potentialSlug) && !isStaticAsset && isValidSlug) {
// Redirect to subdomain. // This looks like a tenant path being accessed from the root domain.
const baseHost = hostname.split('.').slice(-2).join('.') // Simplistic, assumes domain.tld or localhost // Redirect to subdomain.
// For localhost it's special const baseHost = hostname.split('.').slice(-2).join('.') // Simplistic, assumes domain.tld or localhost
const isLocalhost = hostname.includes('localhost') // For localhost it's special
const newHost = isLocalhost const isLocalhost = hostname.includes('localhost')
? `${potentialSlug}.localhost${hostname.includes(':') ? `:${hostname.split(':')[1]}` : ''}` const newHost = isLocalhost
: `${potentialSlug}.${baseHost}` ? `${potentialSlug}.localhost${hostname.includes(':') ? `:${hostname.split(':')[1]}` : ''}`
: `${potentialSlug}.${baseHost}`
const remainingPath = '/' + pathParts.slice(2).join('/')
return NextResponse.redirect(new URL(remainingPath, `${url.protocol}//${newHost}`)) const remainingPath = '/' + pathParts.slice(2).join('/')
} return NextResponse.redirect(new URL(remainingPath, `${url.protocol}//${newHost}`))
} }
} }
}
return NextResponse.next()
} return NextResponse.next()
}
export const config = {
matcher: [ export const config = {
'/((?!_next/static|_next/image|favicon.ico|uploads).*)', matcher: [
], '/((?!_next/static|_next/image|favicon.ico|uploads).*)',
} ],
}

View File

@ -1,5 +1,5 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@ -1,32 +1,32 @@
import type { NextConfig } from 'next' import type { NextConfig } from 'next'
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
transpilePackages: ['@innungsapp/shared'], transpilePackages: ['@innungsapp/shared'],
output: process.env.DOCKER_BUILD ? 'standalone' : undefined, output: process.env.DOCKER_BUILD ? 'standalone' : undefined,
experimental: {}, experimental: {},
// Include Prisma binaries in standalone build // Include Prisma binaries in standalone build
outputFileTracingIncludes: { outputFileTracingIncludes: {
'/': [ '/': [
'./node_modules/.pnpm/@prisma+client@5.22.0_prisma@5.22.0/node_modules/.prisma/**/*', './node_modules/.pnpm/@prisma+client@5.22.0_prisma@5.22.0/node_modules/.prisma/**/*',
'./node_modules/.pnpm/@prisma+client@5.22.0_prisma@5.22.0/node_modules/@prisma/client/**/*', './node_modules/.pnpm/@prisma+client@5.22.0_prisma@5.22.0/node_modules/@prisma/client/**/*',
], ],
}, },
webpack: (config, { dev }) => { webpack: (config, { dev }) => {
if (dev) { if (dev) {
// Avoid filesystem cache writes on very low-disk dev machines (ENOSPC). // Avoid filesystem cache writes on very low-disk dev machines (ENOSPC).
config.cache = false config.cache = false
} }
return config return config
}, },
// Serve uploaded files // Serve uploaded files
async rewrites() { async rewrites() {
return [ return [
{ {
source: '/uploads/:path*', source: '/uploads/:path*',
destination: '/api/uploads/:path*', destination: '/api/uploads/:path*',
}, },
] ]
}, },
} }
export default nextConfig export default nextConfig

View File

@ -17,4 +17,4 @@ $map = $json | ConvertFrom-Json
if (-not $map.sourcesContent -or $map.sourcesContent.Count -lt 1) { throw 'No sourcesContent in map payload' } if (-not $map.sourcesContent -or $map.sourcesContent.Count -lt 1) { throw 'No sourcesContent in map payload' }
$source = $map.sourcesContent[0] $source = $map.sourcesContent[0]
[System.IO.File]::WriteAllText($outPath, $source, [System.Text.UTF8Encoding]::new($false)) [System.IO.File]::WriteAllText($outPath, $source, [System.Text.UTF8Encoding]::new($false))
Write-Host 'Recovered actions.ts from source map payload' Write-Host 'Recovered actions.ts from source map payload'

View File

@ -1,14 +1,14 @@
import { type FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch' import { type FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch'
import { auth, getSanitizedHeaders } from '@/lib/auth' import { auth, getSanitizedHeaders } from '@/lib/auth'
import { prisma } from '@innungsapp/shared' import { prisma } from '@innungsapp/shared'
export async function createContext({ req }: FetchCreateContextFnOptions) { export async function createContext({ req }: FetchCreateContextFnOptions) {
const session = await auth.api.getSession({ headers: await getSanitizedHeaders(req.headers) }) const session = await auth.api.getSession({ headers: await getSanitizedHeaders(req.headers) })
return { return {
req, req,
session, session,
prisma, prisma,
} }
} }
export type Context = Awaited<ReturnType<typeof createContext>> export type Context = Awaited<ReturnType<typeof createContext>>

File diff suppressed because it is too large Load Diff

View File

@ -1,22 +1,22 @@
app/[slug]/dashboard/news/[id]/page.tsx(46,24): error TS2345: Argument of type '{ name: string; id: string; createdAt: Date; newsId: string; storagePath: string; sizeBytes: number | null; mimeType: string | null; }[]' is not assignable to parameter of type 'SetStateAction<{ name: string; storagePath: string; sizeBytes: number; mimeType?: string | null | undefined; }[]>'. app/[slug]/dashboard/news/[id]/page.tsx(46,24): error TS2345: Argument of type '{ name: string; id: string; createdAt: Date; newsId: string; storagePath: string; sizeBytes: number | null; mimeType: string | null; }[]' is not assignable to parameter of type 'SetStateAction<{ name: string; storagePath: string; sizeBytes: number; mimeType?: string | null | undefined; }[]>'.
Type '{ name: string; id: string; createdAt: Date; newsId: string; storagePath: string; sizeBytes: number | null; mimeType: string | null; }[]' is not assignable to type '{ name: string; storagePath: string; sizeBytes: number; mimeType?: string | null | undefined; }[]'. Type '{ name: string; id: string; createdAt: Date; newsId: string; storagePath: string; sizeBytes: number | null; mimeType: string | null; }[]' is not assignable to type '{ name: string; storagePath: string; sizeBytes: number; mimeType?: string | null | undefined; }[]'.
Type '{ name: string; id: string; createdAt: Date; newsId: string; storagePath: string; sizeBytes: number | null; mimeType: string | null; }' is not assignable to type '{ name: string; storagePath: string; sizeBytes: number; mimeType?: string | null | undefined; }'. Type '{ name: string; id: string; createdAt: Date; newsId: string; storagePath: string; sizeBytes: number | null; mimeType: string | null; }' is not assignable to type '{ name: string; storagePath: string; sizeBytes: number; mimeType?: string | null | undefined; }'.
Types of property 'sizeBytes' are incompatible. Types of property 'sizeBytes' are incompatible.
Type 'number | null' is not assignable to type 'number'. Type 'number | null' is not assignable to type 'number'.
Type 'null' is not assignable to type 'number'. Type 'null' is not assignable to type 'number'.
app/api/export/termin/[id]/route.ts(38,28): error TS2339: Property 'createdAt' does not exist on type '{ member: { name: string; email: string; id: string; createdAt: Date; updatedAt: Date; userId: string | null; status: string; orgId: string; ort: string; betrieb: string; sparte: string; ... 4 more ...; pushToken: string | null; }; } & { ...; }'. app/api/export/termin/[id]/route.ts(38,28): error TS2339: Property 'createdAt' does not exist on type '{ member: { name: string; email: string; id: string; createdAt: Date; updatedAt: Date; userId: string | null; status: string; orgId: string; ort: string; betrieb: string; sparte: string; ... 4 more ...; pushToken: string | null; }; } & { ...; }'.
app/api/registrierung/[slug]/route.ts(48,7): error TS2353: Object literal may only specify known properties, and 'role' does not exist in type 'Without<MemberCreateInput, MemberUncheckedCreateInput> & MemberUncheckedCreateInput'. app/api/registrierung/[slug]/route.ts(48,7): error TS2353: Object literal may only specify known properties, and 'role' does not exist in type 'Without<MemberCreateInput, MemberUncheckedCreateInput> & MemberUncheckedCreateInput'.
app/superadmin/organizations/[id]/page.tsx(113,35): error TS2322: Type '() => Promise<{ success: boolean; error: string; }>' is not assignable to type 'string | ((formData: FormData) => void | Promise<void>) | undefined'. app/superadmin/organizations/[id]/page.tsx(113,35): error TS2322: Type '() => Promise<{ success: boolean; error: string; }>' is not assignable to type 'string | ((formData: FormData) => void | Promise<void>) | undefined'.
Type '() => Promise<{ success: boolean; error: string; }>' is not assignable to type '(formData: FormData) => void | Promise<void>'. Type '() => Promise<{ success: boolean; error: string; }>' is not assignable to type '(formData: FormData) => void | Promise<void>'.
Type 'Promise<{ success: boolean; error: string; }>' is not assignable to type 'void | Promise<void>'. Type 'Promise<{ success: boolean; error: string; }>' is not assignable to type 'void | Promise<void>'.
Type 'Promise<{ success: boolean; error: string; }>' is not assignable to type 'Promise<void>'. Type 'Promise<{ success: boolean; error: string; }>' is not assignable to type 'Promise<void>'.
Type '{ success: boolean; error: string; }' is not assignable to type 'void'. Type '{ success: boolean; error: string; }' is not assignable to type 'void'.
app/superadmin/page.tsx(131,55): error TS2322: Type '() => Promise<{ success: boolean; error: string; }>' is not assignable to type 'string | ((formData: FormData) => void | Promise<void>) | undefined'. app/superadmin/page.tsx(131,55): error TS2322: Type '() => Promise<{ success: boolean; error: string; }>' is not assignable to type 'string | ((formData: FormData) => void | Promise<void>) | undefined'.
Type '() => Promise<{ success: boolean; error: string; }>' is not assignable to type '(formData: FormData) => void | Promise<void>'. Type '() => Promise<{ success: boolean; error: string; }>' is not assignable to type '(formData: FormData) => void | Promise<void>'.
Type 'Promise<{ success: boolean; error: string; }>' is not assignable to type 'void | Promise<void>'. Type 'Promise<{ success: boolean; error: string; }>' is not assignable to type 'void | Promise<void>'.
Type 'Promise<{ success: boolean; error: string; }>' is not assignable to type 'Promise<void>'. Type 'Promise<{ success: boolean; error: string; }>' is not assignable to type 'Promise<void>'.
Type '{ success: boolean; error: string; }' is not assignable to type 'void'. Type '{ success: boolean; error: string; }' is not assignable to type 'void'.
scripts/test-attachments.ts(1,10): error TS2459: Module '"@innungsapp/shared/prisma"' declares 'PrismaClient' locally, but it is not exported. scripts/test-attachments.ts(1,10): error TS2459: Module '"@innungsapp/shared/prisma"' declares 'PrismaClient' locally, but it is not exported.
seed-auth.ts(2,30): error TS2307: Cannot find module '@prisma/client' or its corresponding type declarations. seed-auth.ts(2,30): error TS2307: Cannot find module '@prisma/client' or its corresponding type declarations.
test-trpc.ts(1,27): error TS2307: Cannot find module './server/routers/_app' or its corresponding type declarations. test-trpc.ts(1,27): error TS2307: Cannot find module './server/routers/_app' or its corresponding type declarations.
test-trpc.ts(2,30): error TS2307: Cannot find module '@innungsapp/shared/prisma/client' or its corresponding type declarations. test-trpc.ts(2,30): error TS2307: Cannot find module '@innungsapp/shared/prisma/client' or its corresponding type declarations.

View File

@ -1,140 +1,140 @@
import React, { useEffect, useRef } from 'react' import React, { useEffect, useRef } from 'react'
import { import {
View, View,
Text, Text,
StyleSheet, StyleSheet,
ActivityIndicator, ActivityIndicator,
Animated, Animated,
ImageBackground, ImageBackground,
Dimensions, Dimensions,
} from 'react-native' } from 'react-native'
const { width, height } = Dimensions.get('window') const { width, height } = Dimensions.get('window')
export function LoadingScreen() { export function LoadingScreen() {
const fadeAnim = useRef(new Animated.Value(0)).current const fadeAnim = useRef(new Animated.Value(0)).current
const scaleAnim = useRef(new Animated.Value(0.95)).current const scaleAnim = useRef(new Animated.Value(0.95)).current
useEffect(() => { useEffect(() => {
Animated.parallel([ Animated.parallel([
Animated.timing(fadeAnim, { Animated.timing(fadeAnim, {
toValue: 1, toValue: 1,
duration: 800, duration: 800,
useNativeDriver: true, useNativeDriver: true,
}), }),
Animated.spring(scaleAnim, { Animated.spring(scaleAnim, {
toValue: 1, toValue: 1,
friction: 8, friction: 8,
tension: 40, tension: 40,
useNativeDriver: true, useNativeDriver: true,
}), }),
]).start() ]).start()
}, []) }, [])
return ( return (
<View style={styles.container}> <View style={styles.container}>
<ImageBackground <ImageBackground
source={require('../../assets/loading_bg.png')} source={require('../../assets/loading_bg.png')}
style={styles.backgroundImage} style={styles.backgroundImage}
resizeMode="cover" resizeMode="cover"
> >
<Animated.View <Animated.View
style={[ style={[
styles.content, styles.content,
{ {
opacity: fadeAnim, opacity: fadeAnim,
transform: [{ scale: scaleAnim }], transform: [{ scale: scaleAnim }],
}, },
]} ]}
> >
{/* Card with rounded corners and semi-transparent background */} {/* Card with rounded corners and semi-transparent background */}
<View style={styles.glassCard}> <View style={styles.glassCard}>
<View style={styles.logoContainer}> <View style={styles.logoContainer}>
<View style={styles.logoBox}> <View style={styles.logoBox}>
<Text style={styles.logoLetter}>I</Text> <Text style={styles.logoLetter}>I</Text>
</View> </View>
<Text style={styles.appName}>InnungsApp</Text> <Text style={styles.appName}>InnungsApp</Text>
</View> </View>
<View style={styles.loaderContainer}> <View style={styles.loaderContainer}>
<ActivityIndicator size="large" color="#003B7E" /> <ActivityIndicator size="large" color="#003B7E" />
<Text style={styles.loadingText}>Wird geladen...</Text> <Text style={styles.loadingText}>Wird geladen...</Text>
</View> </View>
</View> </View>
</Animated.View> </Animated.View>
</ImageBackground> </ImageBackground>
</View> </View>
) )
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: '#003B7E', backgroundColor: '#003B7E',
}, },
backgroundImage: { backgroundImage: {
flex: 1, flex: 1,
width: width, width: width,
height: height, height: height,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
}, },
content: { content: {
width: '80%', width: '80%',
maxWidth: 320, maxWidth: 320,
alignItems: 'center', alignItems: 'center',
}, },
glassCard: { glassCard: {
width: '100%', width: '100%',
padding: 32, padding: 32,
borderRadius: 32, // Rounded corners as requested borderRadius: 32, // Rounded corners as requested
borderWidth: 1, borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.4)', borderColor: 'rgba(255, 255, 255, 0.4)',
backgroundColor: 'rgba(255, 255, 255, 0.95)', // Solid-ish background for clean look without blur backgroundColor: 'rgba(255, 255, 255, 0.95)', // Solid-ish background for clean look without blur
alignItems: 'center', alignItems: 'center',
shadowColor: '#000', shadowColor: '#000',
shadowOffset: { width: 0, height: 10 }, shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.1, shadowOpacity: 0.1,
shadowRadius: 20, shadowRadius: 20,
elevation: 10, elevation: 10,
}, },
logoContainer: { logoContainer: {
alignItems: 'center', alignItems: 'center',
marginBottom: 24, marginBottom: 24,
}, },
logoBox: { logoBox: {
width: 64, width: 64,
height: 64, height: 64,
backgroundColor: '#003B7E', backgroundColor: '#003B7E',
borderRadius: 20, borderRadius: 20,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
marginBottom: 12, marginBottom: 12,
shadowColor: '#000', shadowColor: '#000',
shadowOffset: { width: 0, height: 4 }, shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2, shadowOpacity: 0.2,
shadowRadius: 8, shadowRadius: 8,
elevation: 5, elevation: 5,
}, },
logoLetter: { logoLetter: {
color: '#FFFFFF', color: '#FFFFFF',
fontSize: 32, fontSize: 32,
fontWeight: '900', fontWeight: '900',
}, },
appName: { appName: {
fontSize: 24, fontSize: 24,
fontWeight: '800', fontWeight: '800',
color: '#0F172A', color: '#0F172A',
letterSpacing: -0.5, letterSpacing: -0.5,
}, },
loaderContainer: { loaderContainer: {
alignItems: 'center', alignItems: 'center',
gap: 12, gap: 12,
}, },
loadingText: { loadingText: {
fontSize: 14, fontSize: 14,
color: '#64748B', color: '#64748B',
fontWeight: '600', fontWeight: '600',
marginTop: 8, marginTop: 8,
}, },
}) })

View File

@ -1,84 +1,84 @@
services: services:
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
container_name: innungsapp-postgres container_name: innungsapp-postgres
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_DB: "${POSTGRES_DB:-innungsapp}" POSTGRES_DB: "${POSTGRES_DB:-innungsapp}"
POSTGRES_USER: "${POSTGRES_USER:-innungsapp}" POSTGRES_USER: "${POSTGRES_USER:-innungsapp}"
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-innungsapp}" POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-innungsapp}"
ports: ports:
- "5432:5432" - "5432:5432"
volumes: volumes:
- pg_data:/var/lib/postgresql/data - pg_data:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-innungsapp} -d ${POSTGRES_DB:-innungsapp}"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-innungsapp} -d ${POSTGRES_DB:-innungsapp}"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
start_period: 20s start_period: 20s
admin: admin:
build: build:
context: . context: .
dockerfile: apps/admin/Dockerfile dockerfile: apps/admin/Dockerfile
args: args:
BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET}" BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET}"
BETTER_AUTH_URL: "${BETTER_AUTH_URL:-https://innungsapp.com}" BETTER_AUTH_URL: "${BETTER_AUTH_URL:-https://innungsapp.com}"
BETTER_AUTH_BASE_URL: "${BETTER_AUTH_URL:-https://innungsapp.com}" BETTER_AUTH_BASE_URL: "${BETTER_AUTH_URL:-https://innungsapp.com}"
NEXT_PUBLIC_APP_URL: "${NEXT_PUBLIC_APP_URL:-https://innungsapp.com}" NEXT_PUBLIC_APP_URL: "${NEXT_PUBLIC_APP_URL:-https://innungsapp.com}"
container_name: innungsapp-admin container_name: innungsapp-admin
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
ports: ports:
- "3010:3000" - "3010:3000"
environment: environment:
# Database — PostgreSQL # Database — PostgreSQL
DATABASE_URL: "${DATABASE_URL:-postgresql://innungsapp:innungsapp@postgres:5432/innungsapp?schema=public}" DATABASE_URL: "${DATABASE_URL:-postgresql://innungsapp:innungsapp@postgres:5432/innungsapp?schema=public}"
# Auth — CHANGE THESE in production! # Auth — CHANGE THESE in production!
BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET}" BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET}"
BETTER_AUTH_URL: "${BETTER_AUTH_URL:-https://yourdomain.com}" BETTER_AUTH_URL: "${BETTER_AUTH_URL:-https://yourdomain.com}"
BETTER_AUTH_BASE_URL: "${BETTER_AUTH_URL:-https://yourdomain.com}" BETTER_AUTH_BASE_URL: "${BETTER_AUTH_URL:-https://yourdomain.com}"
# Email (SMTP) # Email (SMTP)
EMAIL_FROM: "${EMAIL_FROM:-noreply@innungsapp.de}" EMAIL_FROM: "${EMAIL_FROM:-noreply@innungsapp.de}"
SMTP_HOST: "${SMTP_HOST}" SMTP_HOST: "${SMTP_HOST}"
SMTP_PORT: "${SMTP_PORT:-587}" SMTP_PORT: "${SMTP_PORT:-587}"
SMTP_SECURE: "${SMTP_SECURE:-false}" SMTP_SECURE: "${SMTP_SECURE:-false}"
SMTP_USER: "${SMTP_USER}" SMTP_USER: "${SMTP_USER}"
SMTP_PASS: "${SMTP_PASS}" SMTP_PASS: "${SMTP_PASS}"
# Superadmin seed defaults (override in .env) # Superadmin seed defaults (override in .env)
SUPERADMIN_EMAIL: "${SUPERADMIN_EMAIL:-superadmin@innungsapp.de}" SUPERADMIN_EMAIL: "${SUPERADMIN_EMAIL:-superadmin@innungsapp.de}"
SUPERADMIN_PASSWORD: "${SUPERADMIN_PASSWORD:-}" SUPERADMIN_PASSWORD: "${SUPERADMIN_PASSWORD:-}"
# Public URLs # Public URLs
NEXT_PUBLIC_APP_URL: "${NEXT_PUBLIC_APP_URL:-https://yourdomain.com}" NEXT_PUBLIC_APP_URL: "${NEXT_PUBLIC_APP_URL:-https://yourdomain.com}"
NEXT_PUBLIC_POSTHOG_KEY: "${NEXT_PUBLIC_POSTHOG_KEY:-}" NEXT_PUBLIC_POSTHOG_KEY: "${NEXT_PUBLIC_POSTHOG_KEY:-}"
NEXT_PUBLIC_POSTHOG_HOST: "${NEXT_PUBLIC_POSTHOG_HOST:-https://us.i.posthog.com}" NEXT_PUBLIC_POSTHOG_HOST: "${NEXT_PUBLIC_POSTHOG_HOST:-https://us.i.posthog.com}"
# File uploads # File uploads
UPLOAD_DIR: "/app/uploads" UPLOAD_DIR: "/app/uploads"
UPLOAD_MAX_SIZE_MB: "${UPLOAD_MAX_SIZE_MB:-10}" UPLOAD_MAX_SIZE_MB: "${UPLOAD_MAX_SIZE_MB:-10}"
# Node # Node
NODE_ENV: "production" NODE_ENV: "production"
pids_limit: 512 pids_limit: 512
ulimits: ulimits:
nproc: 65535 nproc: 65535
volumes: volumes:
# Uploaded files — persists across restarts # Uploaded files — persists across restarts
- uploads_data:/app/uploads - uploads_data:/app/uploads
healthcheck: healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/api/health | grep -q '\"status\":\"ok\"'"] test: ["CMD-SHELL", "wget -qO- http://localhost:3000/api/health | grep -q '\"status\":\"ok\"'"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
start_period: 40s start_period: 40s
volumes: volumes:
pg_data: pg_data:
uploads_data: uploads_data:

View File

@ -1,30 +1,30 @@
{ {
"name": "innungsapp", "name": "innungsapp",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "turbo dev", "dev": "turbo dev",
"build": "turbo build", "build": "turbo build",
"lint": "turbo lint", "lint": "turbo lint",
"type-check": "turbo type-check", "type-check": "turbo type-check",
"db:generate": "pnpm --filter @innungsapp/shared prisma:generate", "db:generate": "pnpm --filter @innungsapp/shared prisma:generate",
"db:migrate": "pnpm --filter @innungsapp/shared prisma:migrate", "db:migrate": "pnpm --filter @innungsapp/shared prisma:migrate",
"db:push": "pnpm --filter @innungsapp/shared prisma:push", "db:push": "pnpm --filter @innungsapp/shared prisma:push",
"db:studio": "pnpm --filter @innungsapp/shared prisma:studio", "db:studio": "pnpm --filter @innungsapp/shared prisma:studio",
"db:seed": "pnpm --filter @innungsapp/shared prisma:seed", "db:seed": "pnpm --filter @innungsapp/shared prisma:seed",
"db:seed-superadmin": "pnpm --filter @innungsapp/shared prisma:seed-superadmin", "db:seed-superadmin": "pnpm --filter @innungsapp/shared prisma:seed-superadmin",
"db:reset": "pnpm --filter @innungsapp/shared prisma:migrate -- --reset" "db:reset": "pnpm --filter @innungsapp/shared prisma:migrate -- --reset"
}, },
"devDependencies": { "devDependencies": {
"turbo": "^2.3.0", "turbo": "^2.3.0",
"typescript": "^5.6.0", "typescript": "^5.6.0",
"undici": "6.23.0" "undici": "6.23.0"
}, },
"packageManager": "pnpm@9.12.0", "packageManager": "pnpm@9.12.0",
"engines": { "engines": {
"node": ">=20.0.0", "node": ">=20.0.0",
"pnpm": ">=9.0.0" "pnpm": ">=9.0.0"
}, },
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3" "bcryptjs": "^3.0.3"
} }
} }

View File

@ -1,30 +1,30 @@
{ {
"name": "@innungsapp/shared", "name": "@innungsapp/shared",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./prisma": "./src/lib/prisma.ts", "./prisma": "./src/lib/prisma.ts",
"./types": "./src/types/index.ts" "./types": "./src/types/index.ts"
}, },
"scripts": { "scripts": {
"type-check": "tsc --noEmit", "type-check": "tsc --noEmit",
"prisma:generate": "prisma generate", "prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev", "prisma:migrate": "prisma migrate dev",
"prisma:push": "prisma db push", "prisma:push": "prisma db push",
"prisma:studio": "prisma studio", "prisma:studio": "prisma studio",
"prisma:seed": "tsx prisma/seed.ts", "prisma:seed": "tsx prisma/seed.ts",
"prisma:seed-superadmin": "tsx prisma/seed-superadmin.ts", "prisma:seed-superadmin": "tsx prisma/seed-superadmin.ts",
"prisma:seed-admin": "tsx prisma/seed-admin-password.ts", "prisma:seed-admin": "tsx prisma/seed-admin-password.ts",
"prisma:seed-demo-members": "tsx prisma/seed-demo-members.ts" "prisma:seed-demo-members": "tsx prisma/seed-demo-members.ts"
}, },
"dependencies": { "dependencies": {
"@prisma/client": "^5.20.0" "@prisma/client": "^5.20.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"prisma": "^5.20.0", "prisma": "^5.20.0",
"tsx": "^4.19.0", "tsx": "^4.19.0",
"typescript": "^5.6.0" "typescript": "^5.6.0"
} }
} }

View File

@ -1,350 +1,350 @@
-- CreateTable -- CreateTable
CREATE TABLE "user" ( CREATE TABLE "user" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"name" TEXT NOT NULL, "name" TEXT NOT NULL,
"email" TEXT NOT NULL, "email" TEXT NOT NULL,
"email_verified" BOOLEAN NOT NULL DEFAULT false, "email_verified" BOOLEAN NOT NULL DEFAULT false,
"image" TEXT, "image" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL, "updated_at" TIMESTAMP(3) NOT NULL,
"role" TEXT, "role" TEXT,
"banned" BOOLEAN DEFAULT false, "banned" BOOLEAN DEFAULT false,
"ban_reason" TEXT, "ban_reason" TEXT,
"ban_expires" TIMESTAMP(3), "ban_expires" TIMESTAMP(3),
"must_change_password" BOOLEAN DEFAULT false, "must_change_password" BOOLEAN DEFAULT false,
CONSTRAINT "user_pkey" PRIMARY KEY ("id") CONSTRAINT "user_pkey" PRIMARY KEY ("id")
); );
-- CreateTable -- CreateTable
CREATE TABLE "session" ( CREATE TABLE "session" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL, "expires_at" TIMESTAMP(3) NOT NULL,
"token" TEXT NOT NULL, "token" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL, "updated_at" TIMESTAMP(3) NOT NULL,
"ip_address" TEXT, "ip_address" TEXT,
"user_agent" TEXT, "user_agent" TEXT,
"user_id" TEXT NOT NULL, "user_id" TEXT NOT NULL,
CONSTRAINT "session_pkey" PRIMARY KEY ("id") CONSTRAINT "session_pkey" PRIMARY KEY ("id")
); );
-- CreateTable -- CreateTable
CREATE TABLE "account" ( CREATE TABLE "account" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"account_id" TEXT NOT NULL, "account_id" TEXT NOT NULL,
"provider_id" TEXT NOT NULL, "provider_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL, "user_id" TEXT NOT NULL,
"access_token" TEXT, "access_token" TEXT,
"refresh_token" TEXT, "refresh_token" TEXT,
"id_token" TEXT, "id_token" TEXT,
"access_token_expires_at" TIMESTAMP(3), "access_token_expires_at" TIMESTAMP(3),
"refresh_token_expires_at" TIMESTAMP(3), "refresh_token_expires_at" TIMESTAMP(3),
"scope" TEXT, "scope" TEXT,
"password" TEXT, "password" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL, "updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "account_pkey" PRIMARY KEY ("id") CONSTRAINT "account_pkey" PRIMARY KEY ("id")
); );
-- CreateTable -- CreateTable
CREATE TABLE "verification" ( CREATE TABLE "verification" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"identifier" TEXT NOT NULL, "identifier" TEXT NOT NULL,
"value" TEXT NOT NULL, "value" TEXT NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL, "expires_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP, "created_at" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3), "updated_at" TIMESTAMP(3),
CONSTRAINT "verification_pkey" PRIMARY KEY ("id") CONSTRAINT "verification_pkey" PRIMARY KEY ("id")
); );
-- CreateTable -- CreateTable
CREATE TABLE "organizations" ( CREATE TABLE "organizations" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"name" TEXT NOT NULL, "name" TEXT NOT NULL,
"slug" TEXT NOT NULL, "slug" TEXT NOT NULL,
"plan" TEXT NOT NULL DEFAULT 'pilot', "plan" TEXT NOT NULL DEFAULT 'pilot',
"logo_url" TEXT, "logo_url" TEXT,
"primary_color" TEXT NOT NULL DEFAULT '#E63946', "primary_color" TEXT NOT NULL DEFAULT '#E63946',
"secondary_color" TEXT, "secondary_color" TEXT,
"contact_email" TEXT, "contact_email" TEXT,
"avv_accepted" BOOLEAN NOT NULL DEFAULT false, "avv_accepted" BOOLEAN NOT NULL DEFAULT false,
"avv_accepted_at" TIMESTAMP(3), "avv_accepted_at" TIMESTAMP(3),
"landing_page_title" TEXT, "landing_page_title" TEXT,
"landing_page_text" TEXT, "landing_page_text" TEXT,
"landing_page_section_title" TEXT, "landing_page_section_title" TEXT,
"landing_page_button_text" TEXT, "landing_page_button_text" TEXT,
"landing_page_hero_image" TEXT, "landing_page_hero_image" TEXT,
"landing_page_hero_overlay_opacity" INTEGER DEFAULT 50, "landing_page_hero_overlay_opacity" INTEGER DEFAULT 50,
"landing_page_features" JSONB, "landing_page_features" JSONB,
"landing_page_footer" JSONB, "landing_page_footer" JSONB,
"app_store_url" TEXT, "app_store_url" TEXT,
"play_store_url" TEXT, "play_store_url" TEXT,
"ai_enabled" BOOLEAN NOT NULL DEFAULT false, "ai_enabled" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "organizations_pkey" PRIMARY KEY ("id") CONSTRAINT "organizations_pkey" PRIMARY KEY ("id")
); );
-- CreateTable -- CreateTable
CREATE TABLE "members" ( CREATE TABLE "members" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"org_id" TEXT NOT NULL, "org_id" TEXT NOT NULL,
"user_id" TEXT, "user_id" TEXT,
"name" TEXT NOT NULL, "name" TEXT NOT NULL,
"betrieb" TEXT NOT NULL, "betrieb" TEXT NOT NULL,
"sparte" TEXT NOT NULL, "sparte" TEXT NOT NULL,
"ort" TEXT NOT NULL, "ort" TEXT NOT NULL,
"telefon" TEXT, "telefon" TEXT,
"email" TEXT NOT NULL, "email" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'aktiv', "status" TEXT NOT NULL DEFAULT 'aktiv',
"ist_ausbildungsbetrieb" BOOLEAN NOT NULL DEFAULT false, "ist_ausbildungsbetrieb" BOOLEAN NOT NULL DEFAULT false,
"seit" INTEGER, "seit" INTEGER,
"avatar_url" TEXT, "avatar_url" TEXT,
"push_token" TEXT, "push_token" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL, "updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "members_pkey" PRIMARY KEY ("id") CONSTRAINT "members_pkey" PRIMARY KEY ("id")
); );
-- CreateTable -- CreateTable
CREATE TABLE "user_roles" ( CREATE TABLE "user_roles" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"org_id" TEXT NOT NULL, "org_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL, "user_id" TEXT NOT NULL,
"role" TEXT NOT NULL, "role" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "user_roles_pkey" PRIMARY KEY ("id") CONSTRAINT "user_roles_pkey" PRIMARY KEY ("id")
); );
-- CreateTable -- CreateTable
CREATE TABLE "news" ( CREATE TABLE "news" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"org_id" TEXT NOT NULL, "org_id" TEXT NOT NULL,
"author_id" TEXT, "author_id" TEXT,
"title" TEXT NOT NULL, "title" TEXT NOT NULL,
"body" TEXT NOT NULL, "body" TEXT NOT NULL,
"kategorie" TEXT NOT NULL, "kategorie" TEXT NOT NULL,
"published_at" TIMESTAMP(3), "published_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "news_pkey" PRIMARY KEY ("id") CONSTRAINT "news_pkey" PRIMARY KEY ("id")
); );
-- CreateTable -- CreateTable
CREATE TABLE "news_reads" ( CREATE TABLE "news_reads" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"news_id" TEXT NOT NULL, "news_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL, "user_id" TEXT NOT NULL,
"read_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "read_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "news_reads_pkey" PRIMARY KEY ("id") CONSTRAINT "news_reads_pkey" PRIMARY KEY ("id")
); );
-- CreateTable -- CreateTable
CREATE TABLE "news_attachments" ( CREATE TABLE "news_attachments" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"news_id" TEXT NOT NULL, "news_id" TEXT NOT NULL,
"name" TEXT NOT NULL, "name" TEXT NOT NULL,
"storage_path" TEXT NOT NULL, "storage_path" TEXT NOT NULL,
"mime_type" TEXT, "mime_type" TEXT,
"size_bytes" INTEGER, "size_bytes" INTEGER,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "news_attachments_pkey" PRIMARY KEY ("id") CONSTRAINT "news_attachments_pkey" PRIMARY KEY ("id")
); );
-- CreateTable -- CreateTable
CREATE TABLE "stellen" ( CREATE TABLE "stellen" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"org_id" TEXT NOT NULL, "org_id" TEXT NOT NULL,
"member_id" TEXT NOT NULL, "member_id" TEXT NOT NULL,
"sparte" TEXT NOT NULL, "sparte" TEXT NOT NULL,
"stellen_anz" INTEGER NOT NULL DEFAULT 1, "stellen_anz" INTEGER NOT NULL DEFAULT 1,
"verguetung" TEXT, "verguetung" TEXT,
"lehrjahr" TEXT, "lehrjahr" TEXT,
"beschreibung" TEXT, "beschreibung" TEXT,
"kontakt_email" TEXT NOT NULL, "kontakt_email" TEXT NOT NULL,
"kontakt_name" TEXT, "kontakt_name" TEXT,
"aktiv" BOOLEAN NOT NULL DEFAULT true, "aktiv" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL, "updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "stellen_pkey" PRIMARY KEY ("id") CONSTRAINT "stellen_pkey" PRIMARY KEY ("id")
); );
-- CreateTable -- CreateTable
CREATE TABLE "termine" ( CREATE TABLE "termine" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"org_id" TEXT NOT NULL, "org_id" TEXT NOT NULL,
"titel" TEXT NOT NULL, "titel" TEXT NOT NULL,
"datum" TIMESTAMP(3) NOT NULL, "datum" TIMESTAMP(3) NOT NULL,
"uhrzeit" TEXT, "uhrzeit" TEXT,
"ende_datum" TIMESTAMP(3), "ende_datum" TIMESTAMP(3),
"ende_uhrzeit" TEXT, "ende_uhrzeit" TEXT,
"ort" TEXT, "ort" TEXT,
"adresse" TEXT, "adresse" TEXT,
"typ" TEXT NOT NULL, "typ" TEXT NOT NULL,
"beschreibung" TEXT, "beschreibung" TEXT,
"max_teilnehmer" INTEGER, "max_teilnehmer" INTEGER,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "termine_pkey" PRIMARY KEY ("id") CONSTRAINT "termine_pkey" PRIMARY KEY ("id")
); );
-- CreateTable -- CreateTable
CREATE TABLE "termin_anmeldungen" ( CREATE TABLE "termin_anmeldungen" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"termin_id" TEXT NOT NULL, "termin_id" TEXT NOT NULL,
"member_id" TEXT NOT NULL, "member_id" TEXT NOT NULL,
"angemeldet_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "angemeldet_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "termin_anmeldungen_pkey" PRIMARY KEY ("id") CONSTRAINT "termin_anmeldungen_pkey" PRIMARY KEY ("id")
); );
-- CreateTable -- CreateTable
CREATE TABLE "conversations" ( CREATE TABLE "conversations" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"org_id" TEXT NOT NULL, "org_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL, "updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "conversations_pkey" PRIMARY KEY ("id") CONSTRAINT "conversations_pkey" PRIMARY KEY ("id")
); );
-- CreateTable -- CreateTable
CREATE TABLE "conversation_members" ( CREATE TABLE "conversation_members" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"conversation_id" TEXT NOT NULL, "conversation_id" TEXT NOT NULL,
"member_id" TEXT NOT NULL, "member_id" TEXT NOT NULL,
"last_read_at" TIMESTAMP(3), "last_read_at" TIMESTAMP(3),
CONSTRAINT "conversation_members_pkey" PRIMARY KEY ("id") CONSTRAINT "conversation_members_pkey" PRIMARY KEY ("id")
); );
-- CreateTable -- CreateTable
CREATE TABLE "messages" ( CREATE TABLE "messages" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"conversation_id" TEXT NOT NULL, "conversation_id" TEXT NOT NULL,
"sender_id" TEXT NOT NULL, "sender_id" TEXT NOT NULL,
"body" TEXT NOT NULL, "body" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "messages_pkey" PRIMARY KEY ("id") CONSTRAINT "messages_pkey" PRIMARY KEY ("id")
); );
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "user_email_key" ON "user"("email"); CREATE UNIQUE INDEX "user_email_key" ON "user"("email");
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "session_token_key" ON "session"("token"); CREATE UNIQUE INDEX "session_token_key" ON "session"("token");
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "organizations_slug_key" ON "organizations"("slug"); CREATE UNIQUE INDEX "organizations_slug_key" ON "organizations"("slug");
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "members_user_id_key" ON "members"("user_id"); CREATE UNIQUE INDEX "members_user_id_key" ON "members"("user_id");
-- CreateIndex -- CreateIndex
CREATE INDEX "members_org_id_idx" ON "members"("org_id"); CREATE INDEX "members_org_id_idx" ON "members"("org_id");
-- CreateIndex -- CreateIndex
CREATE INDEX "members_status_idx" ON "members"("status"); CREATE INDEX "members_status_idx" ON "members"("status");
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "user_roles_org_id_user_id_key" ON "user_roles"("org_id", "user_id"); CREATE UNIQUE INDEX "user_roles_org_id_user_id_key" ON "user_roles"("org_id", "user_id");
-- CreateIndex -- CreateIndex
CREATE INDEX "news_org_id_idx" ON "news"("org_id"); CREATE INDEX "news_org_id_idx" ON "news"("org_id");
-- CreateIndex -- CreateIndex
CREATE INDEX "news_published_at_idx" ON "news"("published_at"); CREATE INDEX "news_published_at_idx" ON "news"("published_at");
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "news_reads_news_id_user_id_key" ON "news_reads"("news_id", "user_id"); CREATE UNIQUE INDEX "news_reads_news_id_user_id_key" ON "news_reads"("news_id", "user_id");
-- CreateIndex -- CreateIndex
CREATE INDEX "stellen_org_id_idx" ON "stellen"("org_id"); CREATE INDEX "stellen_org_id_idx" ON "stellen"("org_id");
-- CreateIndex -- CreateIndex
CREATE INDEX "stellen_aktiv_idx" ON "stellen"("aktiv"); CREATE INDEX "stellen_aktiv_idx" ON "stellen"("aktiv");
-- CreateIndex -- CreateIndex
CREATE INDEX "termine_org_id_idx" ON "termine"("org_id"); CREATE INDEX "termine_org_id_idx" ON "termine"("org_id");
-- CreateIndex -- CreateIndex
CREATE INDEX "termine_datum_idx" ON "termine"("datum"); CREATE INDEX "termine_datum_idx" ON "termine"("datum");
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "termin_anmeldungen_termin_id_member_id_key" ON "termin_anmeldungen"("termin_id", "member_id"); CREATE UNIQUE INDEX "termin_anmeldungen_termin_id_member_id_key" ON "termin_anmeldungen"("termin_id", "member_id");
-- CreateIndex -- CreateIndex
CREATE INDEX "conversations_org_id_idx" ON "conversations"("org_id"); CREATE INDEX "conversations_org_id_idx" ON "conversations"("org_id");
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "conversation_members_conversation_id_member_id_key" ON "conversation_members"("conversation_id", "member_id"); CREATE UNIQUE INDEX "conversation_members_conversation_id_member_id_key" ON "conversation_members"("conversation_id", "member_id");
-- CreateIndex -- CreateIndex
CREATE INDEX "messages_conversation_id_idx" ON "messages"("conversation_id"); CREATE INDEX "messages_conversation_id_idx" ON "messages"("conversation_id");
-- AddForeignKey -- AddForeignKey
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE "session" ADD CONSTRAINT "session_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey -- AddForeignKey
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE "account" ADD CONSTRAINT "account_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey -- AddForeignKey
ALTER TABLE "members" ADD CONSTRAINT "members_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE "members" ADD CONSTRAINT "members_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey -- AddForeignKey
ALTER TABLE "members" ADD CONSTRAINT "members_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE; ALTER TABLE "members" ADD CONSTRAINT "members_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey -- AddForeignKey
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey -- AddForeignKey
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey -- AddForeignKey
ALTER TABLE "news" ADD CONSTRAINT "news_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE "news" ADD CONSTRAINT "news_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey -- AddForeignKey
ALTER TABLE "news" ADD CONSTRAINT "news_author_id_fkey" FOREIGN KEY ("author_id") REFERENCES "members"("id") ON DELETE SET NULL ON UPDATE CASCADE; ALTER TABLE "news" ADD CONSTRAINT "news_author_id_fkey" FOREIGN KEY ("author_id") REFERENCES "members"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey -- AddForeignKey
ALTER TABLE "news_reads" ADD CONSTRAINT "news_reads_news_id_fkey" FOREIGN KEY ("news_id") REFERENCES "news"("id") ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE "news_reads" ADD CONSTRAINT "news_reads_news_id_fkey" FOREIGN KEY ("news_id") REFERENCES "news"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey -- AddForeignKey
ALTER TABLE "news_attachments" ADD CONSTRAINT "news_attachments_news_id_fkey" FOREIGN KEY ("news_id") REFERENCES "news"("id") ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE "news_attachments" ADD CONSTRAINT "news_attachments_news_id_fkey" FOREIGN KEY ("news_id") REFERENCES "news"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey -- AddForeignKey
ALTER TABLE "stellen" ADD CONSTRAINT "stellen_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE "stellen" ADD CONSTRAINT "stellen_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey -- AddForeignKey
ALTER TABLE "stellen" ADD CONSTRAINT "stellen_member_id_fkey" FOREIGN KEY ("member_id") REFERENCES "members"("id") ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE "stellen" ADD CONSTRAINT "stellen_member_id_fkey" FOREIGN KEY ("member_id") REFERENCES "members"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey -- AddForeignKey
ALTER TABLE "termine" ADD CONSTRAINT "termine_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE "termine" ADD CONSTRAINT "termine_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey -- AddForeignKey
ALTER TABLE "termin_anmeldungen" ADD CONSTRAINT "termin_anmeldungen_termin_id_fkey" FOREIGN KEY ("termin_id") REFERENCES "termine"("id") ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE "termin_anmeldungen" ADD CONSTRAINT "termin_anmeldungen_termin_id_fkey" FOREIGN KEY ("termin_id") REFERENCES "termine"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey -- AddForeignKey
ALTER TABLE "termin_anmeldungen" ADD CONSTRAINT "termin_anmeldungen_member_id_fkey" FOREIGN KEY ("member_id") REFERENCES "members"("id") ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE "termin_anmeldungen" ADD CONSTRAINT "termin_anmeldungen_member_id_fkey" FOREIGN KEY ("member_id") REFERENCES "members"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey -- AddForeignKey
ALTER TABLE "conversation_members" ADD CONSTRAINT "conversation_members_conversation_id_fkey" FOREIGN KEY ("conversation_id") REFERENCES "conversations"("id") ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE "conversation_members" ADD CONSTRAINT "conversation_members_conversation_id_fkey" FOREIGN KEY ("conversation_id") REFERENCES "conversations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey -- AddForeignKey
ALTER TABLE "conversation_members" ADD CONSTRAINT "conversation_members_member_id_fkey" FOREIGN KEY ("member_id") REFERENCES "members"("id") ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE "conversation_members" ADD CONSTRAINT "conversation_members_member_id_fkey" FOREIGN KEY ("member_id") REFERENCES "members"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey -- AddForeignKey
ALTER TABLE "messages" ADD CONSTRAINT "messages_conversation_id_fkey" FOREIGN KEY ("conversation_id") REFERENCES "conversations"("id") ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE "messages" ADD CONSTRAINT "messages_conversation_id_fkey" FOREIGN KEY ("conversation_id") REFERENCES "conversations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey -- AddForeignKey
ALTER TABLE "messages" ADD CONSTRAINT "messages_sender_id_fkey" FOREIGN KEY ("sender_id") REFERENCES "members"("id") ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE "messages" ADD CONSTRAINT "messages_sender_id_fkey" FOREIGN KEY ("sender_id") REFERENCES "members"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -1,3 +1,3 @@
# Please do not edit this file manually # Please do not edit this file manually
# It should be added in your version-control system (i.e. Git) # It should be added in your version-control system (i.e. Git)
provider = "postgresql" provider = "postgresql"

View File

@ -1,337 +1,337 @@
// InnungsApp — Prisma Schema // InnungsApp — Prisma Schema
// Stack: PostgreSQL + Prisma ORM + better-auth // Stack: PostgreSQL + Prisma ORM + better-auth
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
} }
datasource db { datasource db {
provider = "postgresql" provider = "postgresql"
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
// ============================================= // =============================================
// BETTER-AUTH TABLES // BETTER-AUTH TABLES
// ============================================= // =============================================
model User { model User {
id String @id id String @id
name String name String
email String @unique email String @unique
emailVerified Boolean @default(false) @map("email_verified") emailVerified Boolean @default(false) @map("email_verified")
image String? image String?
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
// better-auth admin plugin fields // better-auth admin plugin fields
role String? role String?
banned Boolean? @default(false) banned Boolean? @default(false)
banReason String? @map("ban_reason") banReason String? @map("ban_reason")
banExpires DateTime? @map("ban_expires") banExpires DateTime? @map("ban_expires")
// Password management // Password management
mustChangePassword Boolean? @default(false) @map("must_change_password") mustChangePassword Boolean? @default(false) @map("must_change_password")
// App relations // App relations
sessions Session[] sessions Session[]
accounts Account[] accounts Account[]
member Member? member Member?
userRoles UserRole[] userRoles UserRole[]
@@map("user") @@map("user")
} }
model Session { model Session {
id String @id id String @id
expiresAt DateTime @map("expires_at") expiresAt DateTime @map("expires_at")
token String @unique token String @unique
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
ipAddress String? @map("ip_address") ipAddress String? @map("ip_address")
userAgent String? @map("user_agent") userAgent String? @map("user_agent")
userId String @map("user_id") userId String @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("session") @@map("session")
} }
model Account { model Account {
id String @id id String @id
accountId String @map("account_id") accountId String @map("account_id")
providerId String @map("provider_id") providerId String @map("provider_id")
userId String @map("user_id") userId String @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
accessToken String? @map("access_token") accessToken String? @map("access_token")
refreshToken String? @map("refresh_token") refreshToken String? @map("refresh_token")
idToken String? @map("id_token") idToken String? @map("id_token")
accessTokenExpiresAt DateTime? @map("access_token_expires_at") accessTokenExpiresAt DateTime? @map("access_token_expires_at")
refreshTokenExpiresAt DateTime? @map("refresh_token_expires_at") refreshTokenExpiresAt DateTime? @map("refresh_token_expires_at")
scope String? scope String?
password String? password String?
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
@@map("account") @@map("account")
} }
model Verification { model Verification {
id String @id id String @id
identifier String identifier String
value String value String
expiresAt DateTime @map("expires_at") expiresAt DateTime @map("expires_at")
createdAt DateTime? @default(now()) @map("created_at") createdAt DateTime? @default(now()) @map("created_at")
updatedAt DateTime? @updatedAt @map("updated_at") updatedAt DateTime? @updatedAt @map("updated_at")
@@map("verification") @@map("verification")
} }
// ============================================= // =============================================
// ORGANIZATIONS // ORGANIZATIONS
// ============================================= // =============================================
model Organization { model Organization {
id String @id @default(uuid()) id String @id @default(uuid())
name String name String
slug String @unique slug String @unique
plan String @default("pilot") // pilot | standard | pro | verband plan String @default("pilot") // pilot | standard | pro | verband
logoUrl String? @map("logo_url") logoUrl String? @map("logo_url")
primaryColor String @default("#E63946") @map("primary_color") primaryColor String @default("#E63946") @map("primary_color")
secondaryColor String? @map("secondary_color") secondaryColor String? @map("secondary_color")
contactEmail String? @map("contact_email") contactEmail String? @map("contact_email")
avvAccepted Boolean @default(false) @map("avv_accepted") avvAccepted Boolean @default(false) @map("avv_accepted")
avvAcceptedAt DateTime? @map("avv_accepted_at") avvAcceptedAt DateTime? @map("avv_accepted_at")
landingPageTitle String? @map("landing_page_title") landingPageTitle String? @map("landing_page_title")
landingPageText String? @map("landing_page_text") landingPageText String? @map("landing_page_text")
landingPageSectionTitle String? @map("landing_page_section_title") landingPageSectionTitle String? @map("landing_page_section_title")
landingPageButtonText String? @map("landing_page_button_text") landingPageButtonText String? @map("landing_page_button_text")
landingPageHeroImage String? @map("landing_page_hero_image") landingPageHeroImage String? @map("landing_page_hero_image")
landingPageHeroOverlayOpacity Int? @default(50) @map("landing_page_hero_overlay_opacity") landingPageHeroOverlayOpacity Int? @default(50) @map("landing_page_hero_overlay_opacity")
landingPageFeatures Json? @map("landing_page_features") @db.JsonB landingPageFeatures Json? @map("landing_page_features") @db.JsonB
landingPageFooter Json? @map("landing_page_footer") @db.JsonB landingPageFooter Json? @map("landing_page_footer") @db.JsonB
appStoreUrl String? @map("app_store_url") appStoreUrl String? @map("app_store_url")
playStoreUrl String? @map("play_store_url") playStoreUrl String? @map("play_store_url")
aiEnabled Boolean @default(false) @map("ai_enabled") aiEnabled Boolean @default(false) @map("ai_enabled")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
members Member[] members Member[]
userRoles UserRole[] userRoles UserRole[]
news News[] news News[]
stellen Stelle[] stellen Stelle[]
termine Termin[] termine Termin[]
@@map("organizations") @@map("organizations")
} }
// ============================================= // =============================================
// MEMBERS // MEMBERS
// ============================================= // =============================================
model Member { model Member {
id String @id @default(uuid()) id String @id @default(uuid())
orgId String @map("org_id") orgId String @map("org_id")
userId String? @unique @map("user_id") // NULL until magic-link clicked userId String? @unique @map("user_id") // NULL until magic-link clicked
name String name String
betrieb String betrieb String
sparte String sparte String
ort String ort String
telefon String? telefon String?
email String email String
status String @default("aktiv") // aktiv | ruhend | ausgetreten status String @default("aktiv") // aktiv | ruhend | ausgetreten
istAusbildungsbetrieb Boolean @default(false) @map("ist_ausbildungsbetrieb") istAusbildungsbetrieb Boolean @default(false) @map("ist_ausbildungsbetrieb")
seit Int? seit Int?
avatarUrl String? @map("avatar_url") avatarUrl String? @map("avatar_url")
pushToken String? @map("push_token") pushToken String? @map("push_token")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull) user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
newsAuthored News[] @relation("NewsAuthor") newsAuthored News[] @relation("NewsAuthor")
stellen Stelle[] stellen Stelle[]
terminAnmeldungen TerminAnmeldung[] terminAnmeldungen TerminAnmeldung[]
sentMessages Message[] sentMessages Message[]
conversationMembers ConversationMember[] conversationMembers ConversationMember[]
@@index([orgId]) @@index([orgId])
@@index([status]) @@index([status])
@@map("members") @@map("members")
} }
// ============================================= // =============================================
// USER ROLES (multi-tenancy) // USER ROLES (multi-tenancy)
// ============================================= // =============================================
model UserRole { model UserRole {
id String @id @default(uuid()) id String @id @default(uuid())
orgId String @map("org_id") orgId String @map("org_id")
userId String @map("user_id") userId String @map("user_id")
role String // admin | member role String // admin | member
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([orgId, userId]) @@unique([orgId, userId])
@@map("user_roles") @@map("user_roles")
} }
// ============================================= // =============================================
// NEWS // NEWS
// ============================================= // =============================================
model News { model News {
id String @id @default(uuid()) id String @id @default(uuid())
orgId String @map("org_id") orgId String @map("org_id")
authorId String? @map("author_id") authorId String? @map("author_id")
title String title String
body String // Markdown body String // Markdown
kategorie String // Wichtig | Pruefung | Foerderung | Veranstaltung | Allgemein kategorie String // Wichtig | Pruefung | Foerderung | Veranstaltung | Allgemein
publishedAt DateTime? @map("published_at") // NULL = Entwurf publishedAt DateTime? @map("published_at") // NULL = Entwurf
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
author Member? @relation("NewsAuthor", fields: [authorId], references: [id], onDelete: SetNull) author Member? @relation("NewsAuthor", fields: [authorId], references: [id], onDelete: SetNull)
reads NewsRead[] reads NewsRead[]
attachments NewsAttachment[] attachments NewsAttachment[]
@@index([orgId]) @@index([orgId])
@@index([publishedAt]) @@index([publishedAt])
@@map("news") @@map("news")
} }
model NewsRead { model NewsRead {
id String @id @default(uuid()) id String @id @default(uuid())
newsId String @map("news_id") newsId String @map("news_id")
userId String @map("user_id") userId String @map("user_id")
readAt DateTime @default(now()) @map("read_at") readAt DateTime @default(now()) @map("read_at")
news News @relation(fields: [newsId], references: [id], onDelete: Cascade) news News @relation(fields: [newsId], references: [id], onDelete: Cascade)
@@unique([newsId, userId]) @@unique([newsId, userId])
@@map("news_reads") @@map("news_reads")
} }
model NewsAttachment { model NewsAttachment {
id String @id @default(uuid()) id String @id @default(uuid())
newsId String @map("news_id") newsId String @map("news_id")
name String name String
storagePath String @map("storage_path") storagePath String @map("storage_path")
mimeType String? @map("mime_type") mimeType String? @map("mime_type")
sizeBytes Int? @map("size_bytes") sizeBytes Int? @map("size_bytes")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
news News @relation(fields: [newsId], references: [id], onDelete: Cascade) news News @relation(fields: [newsId], references: [id], onDelete: Cascade)
@@map("news_attachments") @@map("news_attachments")
} }
// ============================================= // =============================================
// STELLENANGEBOTE (Lehrlingsbörse) // STELLENANGEBOTE (Lehrlingsbörse)
// ============================================= // =============================================
model Stelle { model Stelle {
id String @id @default(uuid()) id String @id @default(uuid())
orgId String @map("org_id") orgId String @map("org_id")
memberId String @map("member_id") memberId String @map("member_id")
sparte String sparte String
stellenAnz Int @default(1) @map("stellen_anz") stellenAnz Int @default(1) @map("stellen_anz")
verguetung String? // "600-800 € / Monat" verguetung String? // "600-800 € / Monat"
lehrjahr String? // "1. Lehrjahr" | "beliebig" lehrjahr String? // "1. Lehrjahr" | "beliebig"
beschreibung String? beschreibung String?
kontaktEmail String @map("kontakt_email") kontaktEmail String @map("kontakt_email")
kontaktName String? @map("kontakt_name") kontaktName String? @map("kontakt_name")
aktiv Boolean @default(true) aktiv Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
member Member @relation(fields: [memberId], references: [id], onDelete: Cascade) member Member @relation(fields: [memberId], references: [id], onDelete: Cascade)
@@index([orgId]) @@index([orgId])
@@index([aktiv]) @@index([aktiv])
@@map("stellen") @@map("stellen")
} }
// ============================================= // =============================================
// TERMINE // TERMINE
// ============================================= // =============================================
model Termin { model Termin {
id String @id @default(uuid()) id String @id @default(uuid())
orgId String @map("org_id") orgId String @map("org_id")
titel String titel String
datum DateTime datum DateTime
uhrzeit String? // stored as "HH:MM" uhrzeit String? // stored as "HH:MM"
endeDatum DateTime? @map("ende_datum") endeDatum DateTime? @map("ende_datum")
endeUhrzeit String? @map("ende_uhrzeit") endeUhrzeit String? @map("ende_uhrzeit")
ort String? ort String?
adresse String? adresse String?
typ String // Pruefung | Versammlung | Kurs | Event | Sonstiges typ String // Pruefung | Versammlung | Kurs | Event | Sonstiges
beschreibung String? beschreibung String?
maxTeilnehmer Int? @map("max_teilnehmer") // NULL = unbegrenzt maxTeilnehmer Int? @map("max_teilnehmer") // NULL = unbegrenzt
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
anmeldungen TerminAnmeldung[] anmeldungen TerminAnmeldung[]
@@index([orgId]) @@index([orgId])
@@index([datum]) @@index([datum])
@@map("termine") @@map("termine")
} }
model TerminAnmeldung { model TerminAnmeldung {
id String @id @default(uuid()) id String @id @default(uuid())
terminId String @map("termin_id") terminId String @map("termin_id")
memberId String @map("member_id") memberId String @map("member_id")
angemeldetAt DateTime @default(now()) @map("angemeldet_at") angemeldetAt DateTime @default(now()) @map("angemeldet_at")
termin Termin @relation(fields: [terminId], references: [id], onDelete: Cascade) termin Termin @relation(fields: [terminId], references: [id], onDelete: Cascade)
member Member @relation(fields: [memberId], references: [id], onDelete: Cascade) member Member @relation(fields: [memberId], references: [id], onDelete: Cascade)
@@unique([terminId, memberId]) @@unique([terminId, memberId])
@@map("termin_anmeldungen") @@map("termin_anmeldungen")
} }
// ============================================= // =============================================
// DIREKTNACHRICHTEN (Chat) // DIREKTNACHRICHTEN (Chat)
// ============================================= // =============================================
model Conversation { model Conversation {
id String @id @default(uuid()) id String @id @default(uuid())
orgId String @map("org_id") orgId String @map("org_id")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
members ConversationMember[] members ConversationMember[]
messages Message[] messages Message[]
@@index([orgId]) @@index([orgId])
@@map("conversations") @@map("conversations")
} }
model ConversationMember { model ConversationMember {
id String @id @default(uuid()) id String @id @default(uuid())
conversationId String @map("conversation_id") conversationId String @map("conversation_id")
memberId String @map("member_id") memberId String @map("member_id")
lastReadAt DateTime? @map("last_read_at") lastReadAt DateTime? @map("last_read_at")
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
member Member @relation(fields: [memberId], references: [id], onDelete: Cascade) member Member @relation(fields: [memberId], references: [id], onDelete: Cascade)
@@unique([conversationId, memberId]) @@unique([conversationId, memberId])
@@map("conversation_members") @@map("conversation_members")
} }
model Message { model Message {
id String @id @default(uuid()) id String @id @default(uuid())
conversationId String @map("conversation_id") conversationId String @map("conversation_id")
senderId String @map("sender_id") senderId String @map("sender_id")
body String body String
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
sender Member @relation(fields: [senderId], references: [id], onDelete: Cascade) sender Member @relation(fields: [senderId], references: [id], onDelete: Cascade)
@@index([conversationId]) @@index([conversationId])
@@map("messages") @@map("messages")
} }

View File

@ -1,85 +1,85 @@
const { PrismaClient } = require('@prisma/client') const { PrismaClient } = require('@prisma/client')
const { randomBytes, scrypt } = require('crypto') const { randomBytes, scrypt } = require('crypto')
const { promisify } = require('util') const { promisify } = require('util')
const prisma = new PrismaClient() const prisma = new PrismaClient()
const scryptAsync = promisify(scrypt) const scryptAsync = promisify(scrypt)
async function hashPassword(password) { async function hashPassword(password) {
const salt = randomBytes(16).toString('hex') const salt = randomBytes(16).toString('hex')
const key = await scryptAsync(password.normalize('NFKC'), salt, 64, { const key = await scryptAsync(password.normalize('NFKC'), salt, 64, {
N: 16384, N: 16384,
r: 16, r: 16,
p: 1, p: 1,
maxmem: 128 * 16384 * 16 * 2, maxmem: 128 * 16384 * 16 * 2,
}) })
return `${salt}:${key.toString('hex')}` return `${salt}:${key.toString('hex')}`
} }
function getEnv(name) { function getEnv(name) {
return (process.env[name] || '').trim() return (process.env[name] || '').trim()
} }
async function main() { async function main() {
const email = (getEnv('SUPERADMIN_EMAIL') || 'superadmin@innungsapp.de').toLowerCase() const email = (getEnv('SUPERADMIN_EMAIL') || 'superadmin@innungsapp.de').toLowerCase()
const name = getEnv('SUPERADMIN_NAME') || 'Super Admin' const name = getEnv('SUPERADMIN_NAME') || 'Super Admin'
const userId = getEnv('SUPERADMIN_USER_ID') || 'superadmin-user-id' const userId = getEnv('SUPERADMIN_USER_ID') || 'superadmin-user-id'
const accountId = getEnv('SUPERADMIN_ACCOUNT_ID') || 'superadmin-account-id' const accountId = getEnv('SUPERADMIN_ACCOUNT_ID') || 'superadmin-account-id'
let password = getEnv('SUPERADMIN_PASSWORD') let password = getEnv('SUPERADMIN_PASSWORD')
if (!password) { if (!password) {
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
throw new Error('SUPERADMIN_PASSWORD must be set in production.') throw new Error('SUPERADMIN_PASSWORD must be set in production.')
} }
password = 'demo1234' password = 'demo1234'
console.warn('SUPERADMIN_PASSWORD not set. Using development fallback password.') console.warn('SUPERADMIN_PASSWORD not set. Using development fallback password.')
} }
console.log(`Seeding superadmin user for ${email}...`) console.log(`Seeding superadmin user for ${email}...`)
const hash = await hashPassword(password) const hash = await hashPassword(password)
const user = await prisma.user.upsert({ const user = await prisma.user.upsert({
where: { email }, where: { email },
update: { update: {
name, name,
emailVerified: true, emailVerified: true,
role: 'admin', role: 'admin',
}, },
create: { create: {
id: userId, id: userId,
name, name,
email, email,
emailVerified: true, emailVerified: true,
role: 'admin', role: 'admin',
}, },
}) })
await prisma.account.upsert({ await prisma.account.upsert({
where: { id: accountId }, where: { id: accountId },
update: { update: {
accountId: user.id, accountId: user.id,
providerId: 'credential', providerId: 'credential',
userId: user.id, userId: user.id,
password: hash, password: hash,
}, },
create: { create: {
id: accountId, id: accountId,
accountId: user.id, accountId: user.id,
providerId: 'credential', providerId: 'credential',
userId: user.id, userId: user.id,
password: hash, password: hash,
}, },
}) })
console.log(`Done. Login: ${email} / ${password}`) console.log(`Done. Login: ${email} / ${password}`)
} }
main() main()
.catch((error) => { .catch((error) => {
console.error(error) console.error(error)
process.exit(1) process.exit(1)
}) })
.finally(async () => { .finally(async () => {
await prisma.$disconnect() await prisma.$disconnect()
}) })

View File

@ -1,82 +1,82 @@
import { PrismaClient } from '@prisma/client' import { PrismaClient } from '@prisma/client'
import { scrypt, randomBytes } from 'crypto' import { scrypt, randomBytes } from 'crypto'
import { promisify } from 'util' import { promisify } from 'util'
const scryptAsync = promisify(scrypt) const scryptAsync = promisify(scrypt)
const prisma = new PrismaClient() const prisma = new PrismaClient()
async function hashPassword(password: string): Promise<string> { async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16).toString('hex') const salt = randomBytes(16).toString('hex')
const key = await scryptAsync(password.normalize('NFKC'), salt, 64, { const key = await scryptAsync(password.normalize('NFKC'), salt, 64, {
N: 16384, r: 16, p: 1, maxmem: 128 * 16384 * 16 * 2, N: 16384, r: 16, p: 1, maxmem: 128 * 16384 * 16 * 2,
}) as Buffer }) as Buffer
return `${salt}:${key.toString('hex')}` return `${salt}:${key.toString('hex')}`
} }
function getEnv(name: string): string { function getEnv(name: string): string {
return (process.env[name] ?? '').trim() return (process.env[name] ?? '').trim()
} }
async function main() { async function main() {
const email = getEnv('SUPERADMIN_EMAIL').toLowerCase() || 'superadmin@innungsapp.de' const email = getEnv('SUPERADMIN_EMAIL').toLowerCase() || 'superadmin@innungsapp.de'
const name = getEnv('SUPERADMIN_NAME') || 'Super Admin' const name = getEnv('SUPERADMIN_NAME') || 'Super Admin'
const userId = getEnv('SUPERADMIN_USER_ID') || 'superadmin-user-id' const userId = getEnv('SUPERADMIN_USER_ID') || 'superadmin-user-id'
const accountId = getEnv('SUPERADMIN_ACCOUNT_ID') || 'superadmin-account-id' const accountId = getEnv('SUPERADMIN_ACCOUNT_ID') || 'superadmin-account-id'
let password = getEnv('SUPERADMIN_PASSWORD') let password = getEnv('SUPERADMIN_PASSWORD')
if (!password) { if (!password) {
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
throw new Error('SUPERADMIN_PASSWORD must be set in production.') throw new Error('SUPERADMIN_PASSWORD must be set in production.')
} }
password = 'demo1234' password = 'demo1234'
console.warn('SUPERADMIN_PASSWORD not set. Using development fallback password.') console.warn('SUPERADMIN_PASSWORD not set. Using development fallback password.')
} }
console.log(`Seeding superadmin user for ${email}...`) console.log(`Seeding superadmin user for ${email}...`)
const hash = await hashPassword(password) const hash = await hashPassword(password)
const superAdminUser = await prisma.user.upsert({ const superAdminUser = await prisma.user.upsert({
where: { email }, where: { email },
update: { update: {
name, name,
emailVerified: true, emailVerified: true,
role: 'admin', role: 'admin',
}, },
create: { create: {
id: userId, id: userId,
name, name,
email, email,
emailVerified: true, emailVerified: true,
role: 'admin', role: 'admin',
}, },
}) })
await prisma.account.upsert({ await prisma.account.upsert({
where: { id: accountId }, where: { id: accountId },
update: { update: {
accountId: superAdminUser.id, accountId: superAdminUser.id,
userId: superAdminUser.id, userId: superAdminUser.id,
providerId: 'credential', providerId: 'credential',
password: hash, password: hash,
}, },
create: { create: {
id: accountId, id: accountId,
accountId: superAdminUser.id, accountId: superAdminUser.id,
providerId: 'credential', providerId: 'credential',
userId: superAdminUser.id, userId: superAdminUser.id,
password: hash, password: hash,
}, },
}) })
console.log(`Done. Login: ${email} / ${password}`) console.log(`Done. Login: ${email} / ${password}`)
} }
main() main()
.catch((e) => { .catch((e) => {
console.error(e) console.error(e)
process.exit(1) process.exit(1)
}) })
.finally(async () => { .finally(async () => {
await prisma.$disconnect() await prisma.$disconnect()
}) })

View File

@ -1,3 +1,3 @@
export { prisma } from './lib/prisma' export { prisma } from './lib/prisma'
export { Prisma } from '@prisma/client' export { Prisma } from '@prisma/client'
export * from './types/index' export * from './types/index'