log
This commit is contained in:
parent
d93f43bf01
commit
0084c5f05b
|
|
@ -1,47 +1,47 @@
|
|||
# =============================================
|
||||
# DATABASE (PostgreSQL)
|
||||
# =============================================
|
||||
POSTGRES_DB="innungsapp"
|
||||
POSTGRES_USER="innungsapp"
|
||||
POSTGRES_PASSWORD="innungsapp"
|
||||
DATABASE_URL="postgresql://innungsapp:innungsapp@localhost:5432/innungsapp?schema=public"
|
||||
|
||||
# =============================================
|
||||
# BETTER-AUTH
|
||||
# =============================================
|
||||
BETTER_AUTH_SECRET="change-me-to-a-random-32-char-string"
|
||||
BETTER_AUTH_URL="http://localhost:3000"
|
||||
|
||||
# =============================================
|
||||
# EMAIL (SMTP for magic links & invitations)
|
||||
# =============================================
|
||||
EMAIL_FROM="noreply@innungsapp.de"
|
||||
SMTP_HOST="smtp.example.com"
|
||||
SMTP_PORT="587"
|
||||
SMTP_SECURE="false"
|
||||
SMTP_USER=""
|
||||
SMTP_PASS=""
|
||||
|
||||
# =============================================
|
||||
# ADMIN APP (Next.js)
|
||||
# =============================================
|
||||
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||
NEXT_PUBLIC_POSTHOG_KEY=""
|
||||
NEXT_PUBLIC_POSTHOG_HOST="https://us.i.posthog.com"
|
||||
|
||||
# =============================================
|
||||
# SUPERADMIN SEED
|
||||
# =============================================
|
||||
SUPERADMIN_EMAIL="superadmin@innungsapp.de"
|
||||
SUPERADMIN_PASSWORD="change-me-strong-password"
|
||||
|
||||
# =============================================
|
||||
# MOBILE APP (Expo)
|
||||
# =============================================
|
||||
EXPO_PUBLIC_API_URL="http://localhost:3000"
|
||||
|
||||
# =============================================
|
||||
# FILE UPLOADS
|
||||
# =============================================
|
||||
UPLOAD_DIR="./uploads"
|
||||
UPLOAD_MAX_SIZE_MB="10"
|
||||
# =============================================
|
||||
# DATABASE (PostgreSQL)
|
||||
# =============================================
|
||||
POSTGRES_DB="innungsapp"
|
||||
POSTGRES_USER="innungsapp"
|
||||
POSTGRES_PASSWORD="innungsapp"
|
||||
DATABASE_URL="postgresql://innungsapp:innungsapp@localhost:5432/innungsapp?schema=public"
|
||||
|
||||
# =============================================
|
||||
# BETTER-AUTH
|
||||
# =============================================
|
||||
BETTER_AUTH_SECRET="change-me-to-a-random-32-char-string"
|
||||
BETTER_AUTH_URL="http://localhost:3000"
|
||||
|
||||
# =============================================
|
||||
# EMAIL (SMTP for magic links & invitations)
|
||||
# =============================================
|
||||
EMAIL_FROM="noreply@innungsapp.de"
|
||||
SMTP_HOST="smtp.example.com"
|
||||
SMTP_PORT="587"
|
||||
SMTP_SECURE="false"
|
||||
SMTP_USER=""
|
||||
SMTP_PASS=""
|
||||
|
||||
# =============================================
|
||||
# ADMIN APP (Next.js)
|
||||
# =============================================
|
||||
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||
NEXT_PUBLIC_POSTHOG_KEY=""
|
||||
NEXT_PUBLIC_POSTHOG_HOST="https://us.i.posthog.com"
|
||||
|
||||
# =============================================
|
||||
# SUPERADMIN SEED
|
||||
# =============================================
|
||||
SUPERADMIN_EMAIL="superadmin@innungsapp.de"
|
||||
SUPERADMIN_PASSWORD="change-me-strong-password"
|
||||
|
||||
# =============================================
|
||||
# MOBILE APP (Expo)
|
||||
# =============================================
|
||||
EXPO_PUBLIC_API_URL="http://localhost:3000"
|
||||
|
||||
# =============================================
|
||||
# FILE UPLOADS
|
||||
# =============================================
|
||||
UPLOAD_DIR="./uploads"
|
||||
UPLOAD_MAX_SIZE_MB="10"
|
||||
|
|
|
|||
|
|
@ -1,34 +1,34 @@
|
|||
# =============================================
|
||||
# Produktion — .env Vorlage
|
||||
# Kopieren als: innungsapp/.env
|
||||
# =============================================
|
||||
|
||||
# Database (PostgreSQL)
|
||||
POSTGRES_DB="innungsapp"
|
||||
POSTGRES_USER="innungsapp"
|
||||
POSTGRES_PASSWORD="change-this-db-password"
|
||||
DATABASE_URL="postgresql://innungsapp:change-this-db-password@postgres:5432/innungsapp?schema=public"
|
||||
|
||||
# Auth — UNBEDINGT ändern!
|
||||
BETTER_AUTH_SECRET="min-32-zeichen-langer-zufalls-string"
|
||||
BETTER_AUTH_URL="https://yourdomain.com"
|
||||
|
||||
# Email (SMTP)
|
||||
EMAIL_FROM="noreply@yourdomain.com"
|
||||
SMTP_HOST="smtp.example.com"
|
||||
SMTP_PORT="587"
|
||||
SMTP_SECURE="false"
|
||||
SMTP_USER="user@example.com"
|
||||
SMTP_PASS="your-smtp-password"
|
||||
|
||||
# Öffentliche URLs
|
||||
NEXT_PUBLIC_APP_URL="https://yourdomain.com"
|
||||
NEXT_PUBLIC_POSTHOG_KEY=""
|
||||
NEXT_PUBLIC_POSTHOG_HOST="https://us.i.posthog.com"
|
||||
|
||||
# Superadmin Seed
|
||||
SUPERADMIN_EMAIL="superadmin@yourdomain.com"
|
||||
SUPERADMIN_PASSWORD="change-this-superadmin-password"
|
||||
|
||||
# Uploads
|
||||
UPLOAD_MAX_SIZE_MB="10"
|
||||
# =============================================
|
||||
# Produktion — .env Vorlage
|
||||
# Kopieren als: innungsapp/.env
|
||||
# =============================================
|
||||
|
||||
# Database (PostgreSQL)
|
||||
POSTGRES_DB="innungsapp"
|
||||
POSTGRES_USER="innungsapp"
|
||||
POSTGRES_PASSWORD="change-this-db-password"
|
||||
DATABASE_URL="postgresql://innungsapp:change-this-db-password@postgres:5432/innungsapp?schema=public"
|
||||
|
||||
# Auth — UNBEDINGT ändern!
|
||||
BETTER_AUTH_SECRET="min-32-zeichen-langer-zufalls-string"
|
||||
BETTER_AUTH_URL="https://yourdomain.com"
|
||||
|
||||
# Email (SMTP)
|
||||
EMAIL_FROM="noreply@yourdomain.com"
|
||||
SMTP_HOST="smtp.example.com"
|
||||
SMTP_PORT="587"
|
||||
SMTP_SECURE="false"
|
||||
SMTP_USER="user@example.com"
|
||||
SMTP_PASS="your-smtp-password"
|
||||
|
||||
# Öffentliche URLs
|
||||
NEXT_PUBLIC_APP_URL="https://yourdomain.com"
|
||||
NEXT_PUBLIC_POSTHOG_KEY=""
|
||||
NEXT_PUBLIC_POSTHOG_HOST="https://us.i.posthog.com"
|
||||
|
||||
# Superadmin Seed
|
||||
SUPERADMIN_EMAIL="superadmin@yourdomain.com"
|
||||
SUPERADMIN_PASSWORD="change-this-superadmin-password"
|
||||
|
||||
# Uploads
|
||||
UPLOAD_MAX_SIZE_MB="10"
|
||||
|
|
|
|||
|
|
@ -1,36 +1,36 @@
|
|||
# Dependencies
|
||||
node_modules
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# Build outputs
|
||||
.next
|
||||
dist
|
||||
build
|
||||
out
|
||||
|
||||
# Turbo
|
||||
.turbo
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
.env.staging
|
||||
|
||||
# Uploads (local file storage)
|
||||
apps/admin/uploads/
|
||||
|
||||
# Expo
|
||||
apps/mobile/.expo
|
||||
apps/mobile/android
|
||||
apps/mobile/ios
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editor
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
# Dependencies
|
||||
node_modules
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# Build outputs
|
||||
.next
|
||||
dist
|
||||
build
|
||||
out
|
||||
|
||||
# Turbo
|
||||
.turbo
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
.env.staging
|
||||
|
||||
# Uploads (local file storage)
|
||||
apps/admin/uploads/
|
||||
|
||||
# Expo
|
||||
apps/mobile/.expo
|
||||
apps/mobile/android
|
||||
apps/mobile/ios
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editor
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
|
|
|
|||
|
|
@ -1,327 +1,327 @@
|
|||
# InnungsApp
|
||||
|
||||
Digitale Plattform fuer Innungen mit Admin-Dashboard (Next.js) und Mobile App (Expo).
|
||||
|
||||
## Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| Monorepo | pnpm Workspaces + Turborepo |
|
||||
| Admin Dashboard | Next.js 15 (App Router) |
|
||||
| Mobile App | Expo + React Native |
|
||||
| API | tRPC v11 |
|
||||
| Auth | better-auth (magic links + credential login) |
|
||||
| Database | PostgreSQL + Prisma ORM (`jsonb` fuer Landing-Page-Felder) |
|
||||
| Styling | Tailwind CSS (admin), NativeWind (mobile) |
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
```text
|
||||
innungsapp/
|
||||
|-- apps/
|
||||
| |-- admin/
|
||||
| `-- mobile/
|
||||
|-- packages/
|
||||
| `-- shared/
|
||||
| `-- prisma/
|
||||
|-- docker-compose.yml
|
||||
`-- README.md
|
||||
```
|
||||
|
||||
## Local Setup
|
||||
|
||||
Port-Hinweis:
|
||||
|
||||
- Ohne Docker (lokales `pnpm dev`): App typischerweise auf `http://localhost:3000`
|
||||
- Mit Docker Compose: App auf `http://localhost:3010` (Container-intern weiter `3000`)
|
||||
|
||||
### Voraussetzungen
|
||||
|
||||
- Node.js >= 20
|
||||
- pnpm >= 9
|
||||
- SMTP-Zugang (fuer Einladungen und Magic Links)
|
||||
|
||||
### 1. Abhaengigkeiten installieren
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### 2. Umgebungsvariablen setzen (Admin lokal)
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Danach `.env` anpassen (mindestens `DATABASE_URL`, `BETTER_AUTH_SECRET`, SMTP-Werte).
|
||||
|
||||
### 3. DB vorbereiten (lokal)
|
||||
|
||||
Lokale PostgreSQL-DB starten (nur falls noch nicht aktiv):
|
||||
|
||||
```bash
|
||||
docker compose up -d postgres
|
||||
```
|
||||
|
||||
Prisma vorbereiten:
|
||||
|
||||
```bash
|
||||
pnpm db:generate
|
||||
pnpm db:push
|
||||
```
|
||||
|
||||
Optional Demo-Daten:
|
||||
|
||||
```bash
|
||||
pnpm db:seed
|
||||
pnpm db:seed-superadmin
|
||||
```
|
||||
|
||||
### 4. Entwicklung starten
|
||||
|
||||
```bash
|
||||
pnpm --filter @innungsapp/admin dev
|
||||
pnpm --filter @innungsapp/mobile dev
|
||||
```
|
||||
|
||||
Oder parallel:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## Production Deployment (Docker, Admin)
|
||||
|
||||
Dieser Abschnitt ist der verbindliche Weg fuer den Productive-Server.
|
||||
|
||||
### Voraussetzungen
|
||||
|
||||
- Linux Server mit Docker + Docker Compose
|
||||
- DNS-Eintrag auf den Server
|
||||
- SMTP-Zugangsdaten
|
||||
- Reverse Proxy (z. B. Nginx) fuer HTTPS
|
||||
|
||||
### 1. Repository klonen
|
||||
|
||||
```bash
|
||||
git clone <repo-url>
|
||||
cd innungsapp
|
||||
```
|
||||
|
||||
### 2. Production-Env anlegen
|
||||
|
||||
```bash
|
||||
cp .env.production.example .env
|
||||
```
|
||||
|
||||
Pflichtwerte in `.env`:
|
||||
|
||||
- `DATABASE_URL` (PostgreSQL DSN, z. B. `postgresql://innungsapp:...@postgres:5432/innungsapp?schema=public`)
|
||||
- `POSTGRES_DB`
|
||||
- `POSTGRES_USER`
|
||||
- `POSTGRES_PASSWORD`
|
||||
- `BETTER_AUTH_SECRET` (mindestens 32 Zeichen)
|
||||
- `BETTER_AUTH_URL` (z. B. `https://app.deine-innung.de`)
|
||||
- `NEXT_PUBLIC_APP_URL` (gleich wie `BETTER_AUTH_URL`)
|
||||
- `EMAIL_FROM`
|
||||
- `SMTP_HOST`
|
||||
- `SMTP_PORT`
|
||||
- `SMTP_SECURE`
|
||||
- `SMTP_USER`
|
||||
- `SMTP_PASS`
|
||||
- `SUPERADMIN_EMAIL`
|
||||
- `SUPERADMIN_PASSWORD`
|
||||
|
||||
### 3. Container bauen und starten
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Hinweis zum DB-Start:
|
||||
|
||||
- Wenn Prisma-Migrationen vorhanden sind, wird `prisma migrate deploy` ausgefuehrt.
|
||||
- Wenn keine Migrationen vorhanden sind, wird einmalig `prisma db push` ausgefuehrt.
|
||||
|
||||
### 4. Healthcheck und Logs pruefen
|
||||
|
||||
```bash
|
||||
docker compose logs -f admin
|
||||
curl -fsS http://localhost:3010/api/health
|
||||
```
|
||||
|
||||
Erwartet: JSON mit `"status":"ok"`, z. B.
|
||||
|
||||
```json
|
||||
{"status":"ok","timestamp":"2026-03-04T12:34:56.789Z"}
|
||||
```
|
||||
|
||||
### 5. Superadmin anlegen (nur beim ersten Start)
|
||||
|
||||
```bash
|
||||
docker compose exec -w /app admin node packages/shared/prisma/seed-superadmin.js
|
||||
```
|
||||
|
||||
Login-Daten kommen aus `.env`:
|
||||
|
||||
- E-Mail: `SUPERADMIN_EMAIL`
|
||||
- Passwort: `SUPERADMIN_PASSWORD`
|
||||
|
||||
Hinweis:
|
||||
|
||||
- In `NODE_ENV=production` bricht der Seed ab, wenn `SUPERADMIN_PASSWORD` fehlt.
|
||||
- In Entwicklung wird ohne `SUPERADMIN_PASSWORD` als Fallback `demo1234` genutzt.
|
||||
- Der Seed ist idempotent (`upsert`) und kann bei Bedarf erneut ausgefuehrt werden.
|
||||
|
||||
### 6. HTTPS (Reverse Proxy)
|
||||
|
||||
Nginx sollte auf `localhost:3010` weiterleiten und TLS terminieren.
|
||||
Beispiel:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name app.deine-innung.de;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name app.deine-innung.de;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/app.deine-innung.de/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/app.deine-innung.de/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3010;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Updates einspielen
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose up -d --build
|
||||
docker compose logs -f admin
|
||||
```
|
||||
|
||||
### 8. Backup und Restore (Docker Volumes)
|
||||
|
||||
Vorher die exakten Volumenamen pruefen:
|
||||
|
||||
```bash
|
||||
docker volume ls | grep pg_data
|
||||
docker volume ls | grep uploads_data
|
||||
```
|
||||
|
||||
Backup:
|
||||
|
||||
```bash
|
||||
mkdir -p backups
|
||||
docker run --rm \
|
||||
-v innungsapp_pg_data:/volume \
|
||||
-v "$(pwd)/backups:/backup" \
|
||||
alpine sh -c "tar czf /backup/pg_data_$(date +%F_%H%M).tar.gz -C /volume ."
|
||||
```
|
||||
|
||||
Restore (nur bei gestoppter App):
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker run --rm \
|
||||
-v innungsapp_pg_data:/volume \
|
||||
-v "$(pwd)/backups:/backup" \
|
||||
alpine sh -c "rm -rf /volume/* && tar xzf /backup/<backup-file>.tar.gz -C /volume"
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 9. Verifizierte Kommandos (Stand 4. Maerz 2026)
|
||||
|
||||
Die folgenden Befehle wurden in dieser Umgebung erfolgreich ausgefuehrt:
|
||||
|
||||
```bash
|
||||
# 1) Postgres starten (falls noch nicht aktiv)
|
||||
docker compose up -d postgres
|
||||
|
||||
# 2) Prisma Client generieren
|
||||
(cd packages/shared && npx prisma generate)
|
||||
|
||||
# 3) Initiale PostgreSQL-Migration erstellen (einmalig)
|
||||
(cd packages/shared && \
|
||||
DATABASE_URL="postgresql://innungsapp:innungsapp@localhost:5432/innungsapp?schema=public" \
|
||||
npx prisma migrate dev --name init_postgres --schema=prisma/schema.prisma --create-only)
|
||||
|
||||
# 4) Migration anwenden
|
||||
(cd packages/shared && \
|
||||
DATABASE_URL="postgresql://innungsapp:innungsapp@localhost:5432/innungsapp?schema=public" \
|
||||
npx prisma migrate deploy --schema=prisma/schema.prisma)
|
||||
|
||||
# 5) Gesamtes Setup bauen und starten
|
||||
docker compose up -d --build
|
||||
|
||||
# 6) Superadmin seeden (mit ENV-Werten)
|
||||
docker compose exec -e SUPERADMIN_EMAIL=superadmin@innungsapp.de \
|
||||
-e SUPERADMIN_PASSWORD='demo1234' \
|
||||
-w /app admin node packages/shared/prisma/seed-superadmin.js
|
||||
|
||||
# 7) Laufzeitstatus pruefen
|
||||
docker compose ps
|
||||
docker compose logs --tail 80 admin
|
||||
curl -fsS http://localhost:3010/api/health
|
||||
```
|
||||
|
||||
Optionale SQL-Verifikation (wurde ebenfalls erfolgreich getestet):
|
||||
|
||||
```bash
|
||||
# JSONB-Spalten pruefen
|
||||
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;"
|
||||
|
||||
# Seeded Superadmin pruefen
|
||||
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';"
|
||||
```
|
||||
|
||||
## Mobile Release (EAS)
|
||||
|
||||
```bash
|
||||
cd apps/mobile
|
||||
eas build --platform all --profile production
|
||||
eas submit --platform all
|
||||
```
|
||||
|
||||
Wichtig:
|
||||
|
||||
- In `apps/mobile/eas.json` sind Submit-Placeholders vorhanden und muessen ersetzt werden.
|
||||
- Fuer Production darf keine API-URL auf `localhost` zeigen.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `migrate deploy` oder `db push` fehlschlaegt
|
||||
|
||||
- `DATABASE_URL` pruefen
|
||||
- `postgres` Container Healthcheck pruefen (`docker compose ps`)
|
||||
- Logs: `docker compose logs -f admin`
|
||||
|
||||
### Healthcheck liefert Fehler
|
||||
|
||||
- Containerstatus: `docker compose ps`
|
||||
- App-Logs lesen
|
||||
- Reverse Proxy testweise umgehen und direkt `http://localhost:3010/api/health` pruefen
|
||||
|
||||
### Login funktioniert nicht nach Seed
|
||||
|
||||
- Seed-Command erneut ausfuehren
|
||||
- In DB pruefen, ob `user` und `account` Eintraege fuer `superadmin@innungsapp.de` existieren
|
||||
|
||||
## Weiterfuehrende Doku
|
||||
|
||||
- Produkt-Roadmap: `../ROADMAP.md`
|
||||
- Architektur: `../ARCHITECTURE.md`
|
||||
- API Design: `../API_DESIGN.md`
|
||||
# InnungsApp
|
||||
|
||||
Digitale Plattform fuer Innungen mit Admin-Dashboard (Next.js) und Mobile App (Expo).
|
||||
|
||||
## Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| Monorepo | pnpm Workspaces + Turborepo |
|
||||
| Admin Dashboard | Next.js 15 (App Router) |
|
||||
| Mobile App | Expo + React Native |
|
||||
| API | tRPC v11 |
|
||||
| Auth | better-auth (magic links + credential login) |
|
||||
| Database | PostgreSQL + Prisma ORM (`jsonb` fuer Landing-Page-Felder) |
|
||||
| Styling | Tailwind CSS (admin), NativeWind (mobile) |
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
```text
|
||||
innungsapp/
|
||||
|-- apps/
|
||||
| |-- admin/
|
||||
| `-- mobile/
|
||||
|-- packages/
|
||||
| `-- shared/
|
||||
| `-- prisma/
|
||||
|-- docker-compose.yml
|
||||
`-- README.md
|
||||
```
|
||||
|
||||
## Local Setup
|
||||
|
||||
Port-Hinweis:
|
||||
|
||||
- Ohne Docker (lokales `pnpm dev`): App typischerweise auf `http://localhost:3000`
|
||||
- Mit Docker Compose: App auf `http://localhost:3010` (Container-intern weiter `3000`)
|
||||
|
||||
### Voraussetzungen
|
||||
|
||||
- Node.js >= 20
|
||||
- pnpm >= 9
|
||||
- SMTP-Zugang (fuer Einladungen und Magic Links)
|
||||
|
||||
### 1. Abhaengigkeiten installieren
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### 2. Umgebungsvariablen setzen (Admin lokal)
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Danach `.env` anpassen (mindestens `DATABASE_URL`, `BETTER_AUTH_SECRET`, SMTP-Werte).
|
||||
|
||||
### 3. DB vorbereiten (lokal)
|
||||
|
||||
Lokale PostgreSQL-DB starten (nur falls noch nicht aktiv):
|
||||
|
||||
```bash
|
||||
docker compose up -d postgres
|
||||
```
|
||||
|
||||
Prisma vorbereiten:
|
||||
|
||||
```bash
|
||||
pnpm db:generate
|
||||
pnpm db:push
|
||||
```
|
||||
|
||||
Optional Demo-Daten:
|
||||
|
||||
```bash
|
||||
pnpm db:seed
|
||||
pnpm db:seed-superadmin
|
||||
```
|
||||
|
||||
### 4. Entwicklung starten
|
||||
|
||||
```bash
|
||||
pnpm --filter @innungsapp/admin dev
|
||||
pnpm --filter @innungsapp/mobile dev
|
||||
```
|
||||
|
||||
Oder parallel:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## Production Deployment (Docker, Admin)
|
||||
|
||||
Dieser Abschnitt ist der verbindliche Weg fuer den Productive-Server.
|
||||
|
||||
### Voraussetzungen
|
||||
|
||||
- Linux Server mit Docker + Docker Compose
|
||||
- DNS-Eintrag auf den Server
|
||||
- SMTP-Zugangsdaten
|
||||
- Reverse Proxy (z. B. Nginx) fuer HTTPS
|
||||
|
||||
### 1. Repository klonen
|
||||
|
||||
```bash
|
||||
git clone <repo-url>
|
||||
cd innungsapp
|
||||
```
|
||||
|
||||
### 2. Production-Env anlegen
|
||||
|
||||
```bash
|
||||
cp .env.production.example .env
|
||||
```
|
||||
|
||||
Pflichtwerte in `.env`:
|
||||
|
||||
- `DATABASE_URL` (PostgreSQL DSN, z. B. `postgresql://innungsapp:...@postgres:5432/innungsapp?schema=public`)
|
||||
- `POSTGRES_DB`
|
||||
- `POSTGRES_USER`
|
||||
- `POSTGRES_PASSWORD`
|
||||
- `BETTER_AUTH_SECRET` (mindestens 32 Zeichen)
|
||||
- `BETTER_AUTH_URL` (z. B. `https://app.deine-innung.de`)
|
||||
- `NEXT_PUBLIC_APP_URL` (gleich wie `BETTER_AUTH_URL`)
|
||||
- `EMAIL_FROM`
|
||||
- `SMTP_HOST`
|
||||
- `SMTP_PORT`
|
||||
- `SMTP_SECURE`
|
||||
- `SMTP_USER`
|
||||
- `SMTP_PASS`
|
||||
- `SUPERADMIN_EMAIL`
|
||||
- `SUPERADMIN_PASSWORD`
|
||||
|
||||
### 3. Container bauen und starten
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Hinweis zum DB-Start:
|
||||
|
||||
- Wenn Prisma-Migrationen vorhanden sind, wird `prisma migrate deploy` ausgefuehrt.
|
||||
- Wenn keine Migrationen vorhanden sind, wird einmalig `prisma db push` ausgefuehrt.
|
||||
|
||||
### 4. Healthcheck und Logs pruefen
|
||||
|
||||
```bash
|
||||
docker compose logs -f admin
|
||||
curl -fsS http://localhost:3010/api/health
|
||||
```
|
||||
|
||||
Erwartet: JSON mit `"status":"ok"`, z. B.
|
||||
|
||||
```json
|
||||
{"status":"ok","timestamp":"2026-03-04T12:34:56.789Z"}
|
||||
```
|
||||
|
||||
### 5. Superadmin anlegen (nur beim ersten Start)
|
||||
|
||||
```bash
|
||||
docker compose exec -w /app admin node packages/shared/prisma/seed-superadmin.js
|
||||
```
|
||||
|
||||
Login-Daten kommen aus `.env`:
|
||||
|
||||
- E-Mail: `SUPERADMIN_EMAIL`
|
||||
- Passwort: `SUPERADMIN_PASSWORD`
|
||||
|
||||
Hinweis:
|
||||
|
||||
- In `NODE_ENV=production` bricht der Seed ab, wenn `SUPERADMIN_PASSWORD` fehlt.
|
||||
- In Entwicklung wird ohne `SUPERADMIN_PASSWORD` als Fallback `demo1234` genutzt.
|
||||
- Der Seed ist idempotent (`upsert`) und kann bei Bedarf erneut ausgefuehrt werden.
|
||||
|
||||
### 6. HTTPS (Reverse Proxy)
|
||||
|
||||
Nginx sollte auf `localhost:3010` weiterleiten und TLS terminieren.
|
||||
Beispiel:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name app.deine-innung.de;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name app.deine-innung.de;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/app.deine-innung.de/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/app.deine-innung.de/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3010;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Updates einspielen
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose up -d --build
|
||||
docker compose logs -f admin
|
||||
```
|
||||
|
||||
### 8. Backup und Restore (Docker Volumes)
|
||||
|
||||
Vorher die exakten Volumenamen pruefen:
|
||||
|
||||
```bash
|
||||
docker volume ls | grep pg_data
|
||||
docker volume ls | grep uploads_data
|
||||
```
|
||||
|
||||
Backup:
|
||||
|
||||
```bash
|
||||
mkdir -p backups
|
||||
docker run --rm \
|
||||
-v innungsapp_pg_data:/volume \
|
||||
-v "$(pwd)/backups:/backup" \
|
||||
alpine sh -c "tar czf /backup/pg_data_$(date +%F_%H%M).tar.gz -C /volume ."
|
||||
```
|
||||
|
||||
Restore (nur bei gestoppter App):
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker run --rm \
|
||||
-v innungsapp_pg_data:/volume \
|
||||
-v "$(pwd)/backups:/backup" \
|
||||
alpine sh -c "rm -rf /volume/* && tar xzf /backup/<backup-file>.tar.gz -C /volume"
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 9. Verifizierte Kommandos (Stand 4. Maerz 2026)
|
||||
|
||||
Die folgenden Befehle wurden in dieser Umgebung erfolgreich ausgefuehrt:
|
||||
|
||||
```bash
|
||||
# 1) Postgres starten (falls noch nicht aktiv)
|
||||
docker compose up -d postgres
|
||||
|
||||
# 2) Prisma Client generieren
|
||||
(cd packages/shared && npx prisma generate)
|
||||
|
||||
# 3) Initiale PostgreSQL-Migration erstellen (einmalig)
|
||||
(cd packages/shared && \
|
||||
DATABASE_URL="postgresql://innungsapp:innungsapp@localhost:5432/innungsapp?schema=public" \
|
||||
npx prisma migrate dev --name init_postgres --schema=prisma/schema.prisma --create-only)
|
||||
|
||||
# 4) Migration anwenden
|
||||
(cd packages/shared && \
|
||||
DATABASE_URL="postgresql://innungsapp:innungsapp@localhost:5432/innungsapp?schema=public" \
|
||||
npx prisma migrate deploy --schema=prisma/schema.prisma)
|
||||
|
||||
# 5) Gesamtes Setup bauen und starten
|
||||
docker compose up -d --build
|
||||
|
||||
# 6) Superadmin seeden (mit ENV-Werten)
|
||||
docker compose exec -e SUPERADMIN_EMAIL=superadmin@innungsapp.de \
|
||||
-e SUPERADMIN_PASSWORD='demo1234' \
|
||||
-w /app admin node packages/shared/prisma/seed-superadmin.js
|
||||
|
||||
# 7) Laufzeitstatus pruefen
|
||||
docker compose ps
|
||||
docker compose logs --tail 80 admin
|
||||
curl -fsS http://localhost:3010/api/health
|
||||
```
|
||||
|
||||
Optionale SQL-Verifikation (wurde ebenfalls erfolgreich getestet):
|
||||
|
||||
```bash
|
||||
# JSONB-Spalten pruefen
|
||||
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;"
|
||||
|
||||
# Seeded Superadmin pruefen
|
||||
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';"
|
||||
```
|
||||
|
||||
## Mobile Release (EAS)
|
||||
|
||||
```bash
|
||||
cd apps/mobile
|
||||
eas build --platform all --profile production
|
||||
eas submit --platform all
|
||||
```
|
||||
|
||||
Wichtig:
|
||||
|
||||
- In `apps/mobile/eas.json` sind Submit-Placeholders vorhanden und muessen ersetzt werden.
|
||||
- Fuer Production darf keine API-URL auf `localhost` zeigen.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `migrate deploy` oder `db push` fehlschlaegt
|
||||
|
||||
- `DATABASE_URL` pruefen
|
||||
- `postgres` Container Healthcheck pruefen (`docker compose ps`)
|
||||
- Logs: `docker compose logs -f admin`
|
||||
|
||||
### Healthcheck liefert Fehler
|
||||
|
||||
- Containerstatus: `docker compose ps`
|
||||
- App-Logs lesen
|
||||
- Reverse Proxy testweise umgehen und direkt `http://localhost:3010/api/health` pruefen
|
||||
|
||||
### Login funktioniert nicht nach Seed
|
||||
|
||||
- Seed-Command erneut ausfuehren
|
||||
- In DB pruefen, ob `user` und `account` Eintraege fuer `superadmin@innungsapp.de` existieren
|
||||
|
||||
## Weiterfuehrende Doku
|
||||
|
||||
- Produkt-Roadmap: `../ROADMAP.md`
|
||||
- Architektur: `../ARCHITECTURE.md`
|
||||
- API Design: `../API_DESIGN.md`
|
||||
|
|
|
|||
|
|
@ -1,107 +1,110 @@
|
|||
# =============================================
|
||||
# Stage 1: Dependencies
|
||||
# =============================================
|
||||
FROM node:20-slim AS deps
|
||||
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
|
||||
|
||||
# Install OpenSSL for Prisma
|
||||
RUN apt-get update && apt-get install -y openssl ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy workspace config files
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
|
||||
COPY apps/admin/package.json ./apps/admin/
|
||||
COPY packages/shared/package.json ./packages/shared/
|
||||
|
||||
# Install all dependencies
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# =============================================
|
||||
# Stage 2: Build
|
||||
# =============================================
|
||||
FROM node:20-slim AS builder
|
||||
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
|
||||
|
||||
# Install OpenSSL for Prisma
|
||||
RUN apt-get update && apt-get install -y openssl ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=deps /app/node_modules ./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 . .
|
||||
|
||||
# Generate Prisma client for Alpine Linux
|
||||
RUN pnpm --filter @innungsapp/shared prisma:generate
|
||||
|
||||
# Accept build arguments for environment variables
|
||||
ARG BETTER_AUTH_SECRET
|
||||
ARG BETTER_AUTH_URL
|
||||
ARG BETTER_AUTH_BASE_URL
|
||||
ARG NEXT_PUBLIC_APP_URL
|
||||
|
||||
# Build the admin app
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV DOCKER_BUILD=1
|
||||
# Set environment variables from build args for Next.js build
|
||||
ENV BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET
|
||||
ENV BETTER_AUTH_URL=$BETTER_AUTH_URL
|
||||
ENV BETTER_AUTH_BASE_URL=$BETTER_AUTH_BASE_URL
|
||||
ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL
|
||||
RUN pnpm --filter @innungsapp/admin build
|
||||
|
||||
# =============================================
|
||||
# Stage 3: Production Runner
|
||||
# =============================================
|
||||
FROM node:20-slim AS runner
|
||||
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
|
||||
|
||||
# Install OpenSSL for Prisma
|
||||
RUN apt-get update && apt-get install -y openssl ca-certificates wget && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy built output (standalone includes all necessary node_modules)
|
||||
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/public ./apps/admin/public
|
||||
|
||||
# Copy Prisma schema + migrations for runtime migrations
|
||||
COPY --from=builder /app/packages/shared/prisma ./packages/shared/prisma
|
||||
|
||||
# Copy Prisma Client package for runtime seed scripts.
|
||||
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 ./node_modules/.prisma
|
||||
|
||||
# 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/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/client/schema.prisma /app/apps/admin/.next/server/
|
||||
|
||||
# Install Prisma CLI globally for runtime migrations
|
||||
RUN npm install -g prisma@5.22.0
|
||||
|
||||
# Create uploads directory
|
||||
RUN mkdir -p /app/uploads && chown nextjs:nodejs /app/uploads
|
||||
|
||||
# Copy entrypoint
|
||||
COPY --from=builder /app/apps/admin/docker-entrypoint.sh ./docker-entrypoint.sh
|
||||
RUN chmod +x ./docker-entrypoint.sh
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||
# =============================================
|
||||
# Stage 1: Dependencies
|
||||
# =============================================
|
||||
FROM node:20-slim AS deps
|
||||
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
|
||||
|
||||
# Install OpenSSL for Prisma
|
||||
RUN apt-get update && apt-get install -y openssl ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy workspace config files
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
|
||||
COPY apps/admin/package.json ./apps/admin/
|
||||
COPY packages/shared/package.json ./packages/shared/
|
||||
|
||||
# Install all dependencies
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# =============================================
|
||||
# Stage 2: Build
|
||||
# =============================================
|
||||
FROM node:20-slim AS builder
|
||||
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
|
||||
|
||||
# Install OpenSSL for Prisma
|
||||
RUN apt-get update && apt-get install -y openssl ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=deps /app/node_modules ./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 . .
|
||||
|
||||
# Generate Prisma client for Alpine Linux
|
||||
RUN pnpm --filter @innungsapp/shared prisma:generate
|
||||
|
||||
# Accept build arguments for environment variables
|
||||
ARG BETTER_AUTH_SECRET
|
||||
ARG BETTER_AUTH_URL
|
||||
ARG BETTER_AUTH_BASE_URL
|
||||
ARG NEXT_PUBLIC_APP_URL
|
||||
|
||||
# Build the admin app
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV DOCKER_BUILD=1
|
||||
# Set environment variables from build args for Next.js build
|
||||
ENV BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET
|
||||
ENV BETTER_AUTH_URL=$BETTER_AUTH_URL
|
||||
ENV BETTER_AUTH_BASE_URL=$BETTER_AUTH_BASE_URL
|
||||
ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL
|
||||
RUN pnpm --filter @innungsapp/admin build
|
||||
|
||||
# =============================================
|
||||
# Stage 3: Production Runner
|
||||
# =============================================
|
||||
FROM node:20-slim AS runner
|
||||
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
|
||||
|
||||
# Install OpenSSL for Prisma
|
||||
RUN apt-get update && apt-get install -y openssl ca-certificates wget && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy built output (standalone includes all necessary node_modules)
|
||||
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/public ./apps/admin/public
|
||||
|
||||
# Fix permissions so nextjs user can write to .next/cache at runtime
|
||||
RUN chown -R nextjs:nodejs /app/apps/admin/.next
|
||||
|
||||
# Copy Prisma schema + migrations for runtime migrations
|
||||
COPY --from=builder /app/packages/shared/prisma ./packages/shared/prisma
|
||||
|
||||
# Copy Prisma Client package for runtime seed scripts.
|
||||
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 ./node_modules/.prisma
|
||||
|
||||
# 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/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/client/schema.prisma /app/apps/admin/.next/server/
|
||||
|
||||
# Install Prisma CLI globally for runtime migrations
|
||||
RUN npm install -g prisma@5.22.0
|
||||
|
||||
# Create uploads directory
|
||||
RUN mkdir -p /app/uploads && chown nextjs:nodejs /app/uploads
|
||||
|
||||
# Copy entrypoint
|
||||
COPY --from=builder /app/apps/admin/docker-entrypoint.sh ./docker-entrypoint.sh
|
||||
RUN chmod +x ./docker-entrypoint.sh
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||
|
|
|
|||
|
|
@ -1,70 +1,70 @@
|
|||
'use server'
|
||||
|
||||
import { auth, getSanitizedHeaders } from '@/lib/auth'
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
// @ts-ignore
|
||||
import { hashPassword } from 'better-auth/crypto'
|
||||
|
||||
export async function changePasswordAndDisableMustChange(prevState: any, formData: FormData) {
|
||||
const newPassword = formData.get('newPassword') as string
|
||||
const confirmPassword = formData.get('confirmPassword') as string
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
return { success: false, error: 'Passwörter stimmen nicht überein.' }
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
return { success: false, error: 'Das Passwort muss mindestens 8 Zeichen lang sein.' }
|
||||
}
|
||||
|
||||
const sanitizedHeaders = await getSanitizedHeaders()
|
||||
const session = await auth.api.getSession({ headers: sanitizedHeaders })
|
||||
if (!session?.user) {
|
||||
return { success: false, error: 'Nicht authentifiziert.' }
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
// Hash and save new password directly — user is already authenticated so no old password needed
|
||||
const newHash = await hashPassword(newPassword)
|
||||
|
||||
const credAccount = await prisma.account.findFirst({
|
||||
where: { userId, providerId: 'credential' },
|
||||
})
|
||||
|
||||
if (credAccount) {
|
||||
await prisma.account.update({
|
||||
where: { id: credAccount.id },
|
||||
data: { password: newHash },
|
||||
})
|
||||
} else {
|
||||
await prisma.account.create({
|
||||
data: {
|
||||
id: crypto.randomUUID(),
|
||||
accountId: userId,
|
||||
providerId: 'credential',
|
||||
userId,
|
||||
password: newHash,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Clear mustChangePassword
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { mustChangePassword: false },
|
||||
})
|
||||
|
||||
// Sign out so the user logs in fresh with the new password
|
||||
try {
|
||||
await auth.api.signOut({ headers: sanitizedHeaders })
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
error: '',
|
||||
redirectTo: `/login?message=password_changed&callbackUrl=/dashboard`,
|
||||
}
|
||||
}
|
||||
'use server'
|
||||
|
||||
import { auth, getSanitizedHeaders } from '@/lib/auth'
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
// @ts-ignore
|
||||
import { hashPassword } from 'better-auth/crypto'
|
||||
|
||||
export async function changePasswordAndDisableMustChange(prevState: any, formData: FormData) {
|
||||
const newPassword = formData.get('newPassword') as string
|
||||
const confirmPassword = formData.get('confirmPassword') as string
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
return { success: false, error: 'Passwörter stimmen nicht überein.' }
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
return { success: false, error: 'Das Passwort muss mindestens 8 Zeichen lang sein.' }
|
||||
}
|
||||
|
||||
const sanitizedHeaders = await getSanitizedHeaders()
|
||||
const session = await auth.api.getSession({ headers: sanitizedHeaders })
|
||||
if (!session?.user) {
|
||||
return { success: false, error: 'Nicht authentifiziert.' }
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
// Hash and save new password directly — user is already authenticated so no old password needed
|
||||
const newHash = await hashPassword(newPassword)
|
||||
|
||||
const credAccount = await prisma.account.findFirst({
|
||||
where: { userId, providerId: 'credential' },
|
||||
})
|
||||
|
||||
if (credAccount) {
|
||||
await prisma.account.update({
|
||||
where: { id: credAccount.id },
|
||||
data: { password: newHash },
|
||||
})
|
||||
} else {
|
||||
await prisma.account.create({
|
||||
data: {
|
||||
id: crypto.randomUUID(),
|
||||
accountId: userId,
|
||||
providerId: 'credential',
|
||||
userId,
|
||||
password: newHash,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Clear mustChangePassword
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { mustChangePassword: false },
|
||||
})
|
||||
|
||||
// Sign out so the user logs in fresh with the new password
|
||||
try {
|
||||
await auth.api.signOut({ headers: sanitizedHeaders })
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
error: '',
|
||||
redirectTo: `/login?message=password_changed&callbackUrl=/dashboard`,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,66 +1,66 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useActionState } from 'react'
|
||||
import { changePasswordAndDisableMustChange } from '../actions'
|
||||
|
||||
export function ForcePasswordChange({ slug }: { slug: string }) {
|
||||
const [state, action, isPending] = useActionState(changePasswordAndDisableMustChange, { success: false, error: '', redirectTo: '' })
|
||||
|
||||
useEffect(() => {
|
||||
if (state?.success && state?.redirectTo) {
|
||||
window.location.href = state.redirectTo
|
||||
}
|
||||
}, [state?.success, state?.redirectTo])
|
||||
|
||||
return (
|
||||
<div className="bg-white border rounded-xl p-8 max-w-md w-full shadow-sm">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl font-bold text-gray-900 mb-2">Passwort festlegen</h1>
|
||||
<p className="text-gray-500 text-sm">
|
||||
Bitte vergeben Sie jetzt ein persönliches Passwort für Ihren Account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form action={action} className="space-y-4">
|
||||
<input type="hidden" name="slug" value={slug} />
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">Neues Passwort</label>
|
||||
<input
|
||||
name="newPassword"
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">Passwort wiederholen</label>
|
||||
<input
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
placeholder="••••••••"
|
||||
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{state?.error && (
|
||||
<p className="text-xs text-red-600 bg-red-50 p-2 rounded">{state?.error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
{isPending ? 'Speichern...' : 'Passwort festlegen'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useActionState } from 'react'
|
||||
import { changePasswordAndDisableMustChange } from '../actions'
|
||||
|
||||
export function ForcePasswordChange({ slug }: { slug: string }) {
|
||||
const [state, action, isPending] = useActionState(changePasswordAndDisableMustChange, { success: false, error: '', redirectTo: '' })
|
||||
|
||||
useEffect(() => {
|
||||
if (state?.success && state?.redirectTo) {
|
||||
window.location.href = state.redirectTo
|
||||
}
|
||||
}, [state?.success, state?.redirectTo])
|
||||
|
||||
return (
|
||||
<div className="bg-white border rounded-xl p-8 max-w-md w-full shadow-sm">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl font-bold text-gray-900 mb-2">Passwort festlegen</h1>
|
||||
<p className="text-gray-500 text-sm">
|
||||
Bitte vergeben Sie jetzt ein persönliches Passwort für Ihren Account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form action={action} className="space-y-4">
|
||||
<input type="hidden" name="slug" value={slug} />
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">Neues Passwort</label>
|
||||
<input
|
||||
name="newPassword"
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">Passwort wiederholen</label>
|
||||
<input
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
placeholder="••••••••"
|
||||
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{state?.error && (
|
||||
<p className="text-xs text-red-600 bg-red-50 p-2 rounded">{state?.error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
{isPending ? 'Speichern...' : 'Passwort festlegen'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,213 +1,213 @@
|
|||
import { prisma } from '@innungsapp/shared'
|
||||
import { auth, getSanitizedHeaders } from '@/lib/auth'
|
||||
import { headers } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { MEMBER_STATUS_LABELS } from '@innungsapp/shared'
|
||||
import { format } from 'date-fns'
|
||||
import { de } from 'date-fns/locale'
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
aktiv: 'bg-green-100 text-green-700',
|
||||
ruhend: 'bg-yellow-100 text-yellow-700',
|
||||
ausgetreten: 'bg-red-100 text-red-700',
|
||||
}
|
||||
|
||||
export default async function MitgliederPage(props: {
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
|
||||
}) {
|
||||
const searchParams = await props.searchParams
|
||||
const search = typeof searchParams.q === 'string' ? searchParams.q : ''
|
||||
const statusFilter = typeof searchParams.status === 'string' ? searchParams.status : undefined
|
||||
|
||||
const sanitizedHeaders = await getSanitizedHeaders()
|
||||
const session = await auth.api.getSession({ headers: sanitizedHeaders })
|
||||
if (!session?.user) redirect('/login')
|
||||
|
||||
const userRole = await prisma.userRole.findFirst({
|
||||
where: { userId: session.user.id },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
if (!userRole || userRole.role !== 'admin') redirect('/dashboard')
|
||||
|
||||
const members = await prisma.member.findMany({
|
||||
where: {
|
||||
orgId: userRole.orgId,
|
||||
...(statusFilter && { status: statusFilter as never }),
|
||||
...(search && {
|
||||
OR: [
|
||||
{ name: { contains: search } },
|
||||
{ betrieb: { contains: search } },
|
||||
{ ort: { contains: search } },
|
||||
],
|
||||
}),
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
})
|
||||
|
||||
// Also fetch admins to display them in the list if no status filter or status matches "aktiv"
|
||||
const admins = await prisma.userRole.findMany({
|
||||
where: {
|
||||
orgId: userRole.orgId,
|
||||
role: 'admin',
|
||||
...(search && {
|
||||
user: {
|
||||
OR: [
|
||||
{ name: { contains: search } },
|
||||
{ email: { contains: search } },
|
||||
]
|
||||
}
|
||||
})
|
||||
},
|
||||
include: {
|
||||
user: true
|
||||
}
|
||||
})
|
||||
|
||||
const adminUserIds = new Set(admins.map((a: typeof admins[number]) => a.userId))
|
||||
// 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 combinedList = [
|
||||
// Include admins only if there's no status filter, or if filtering for 'aktiv'
|
||||
...(!statusFilter || statusFilter === 'aktiv' ? admins.map((a: typeof admins[number]) => {
|
||||
const m = memberByUserId.get(a.user.id)
|
||||
return {
|
||||
id: m ? m.id : `admin-${a.user.id}`,
|
||||
name: m?.name ?? a.user.name,
|
||||
betrieb: m?.betrieb ?? a.user.email,
|
||||
sparte: m?.sparte ?? 'Sonderfunktion',
|
||||
ort: m?.ort ?? '—',
|
||||
seit: m?.seit ?? null as number | null,
|
||||
status: m?.status ?? 'aktiv',
|
||||
userId: a.user.id,
|
||||
isAdmin: true,
|
||||
realId: m ? m.id : a.user.id,
|
||||
role: 'Administrator',
|
||||
}
|
||||
}) : []),
|
||||
...members.filter((m: typeof members[number]) => !adminUserIds.has(m.userId ?? '')).map((m: typeof members[number]) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
betrieb: m.betrieb,
|
||||
sparte: m.sparte,
|
||||
ort: m.ort,
|
||||
seit: m.seit,
|
||||
status: m.status,
|
||||
userId: m.userId,
|
||||
isAdmin: false,
|
||||
realId: m.id,
|
||||
role: 'Mitglied',
|
||||
}))
|
||||
]
|
||||
|
||||
combinedList.sort((a: typeof combinedList[number], b: typeof combinedList[number]) => a.name.localeCompare(b.name))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Mitglieder</h1>
|
||||
<p className="text-gray-500 mt-1">{combinedList.length} Einträge</p>
|
||||
</div>
|
||||
<Link
|
||||
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"
|
||||
>
|
||||
+ Mitglied anlegen
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-lg border p-4 flex gap-4">
|
||||
<form className="flex gap-4 w-full">
|
||||
<input
|
||||
name="q"
|
||||
defaultValue={search}
|
||||
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"
|
||||
/>
|
||||
<select
|
||||
name="status"
|
||||
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"
|
||||
>
|
||||
<option value="">Alle Status</option>
|
||||
<option value="aktiv">Aktiv</option>
|
||||
<option value="ruhend">Ruhend</option>
|
||||
<option value="ausgetreten">Ausgetreten</option>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg text-sm hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Suchen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white rounded-lg border overflow-hidden">
|
||||
<table className="w-full data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name / Betrieb</th>
|
||||
<th>Rolle</th>
|
||||
<th>Ort</th>
|
||||
<th>Mitglied seit</th>
|
||||
<th>Status</th>
|
||||
<th>Eingeladen</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{combinedList.map((m) => (
|
||||
<tr key={m.id}>
|
||||
<td>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{m.name}</p>
|
||||
<p className="text-xs text-gray-500">{m.betrieb}</p>
|
||||
</div>
|
||||
</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'}`}>
|
||||
{m.role}
|
||||
</span>
|
||||
</td>
|
||||
<td>{m.ort}</td>
|
||||
<td>{m.seit ?? '—'}</td>
|
||||
<td>
|
||||
<span
|
||||
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'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{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] text-gray-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<Link
|
||||
href={`/dashboard/mitglieder/${m.realId}`}
|
||||
className="text-sm text-brand-600 hover:underline"
|
||||
>
|
||||
Bearbeiten
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{combinedList.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
Keine Mitglieder gefunden
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
import { auth, getSanitizedHeaders } from '@/lib/auth'
|
||||
import { headers } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { MEMBER_STATUS_LABELS } from '@innungsapp/shared'
|
||||
import { format } from 'date-fns'
|
||||
import { de } from 'date-fns/locale'
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
aktiv: 'bg-green-100 text-green-700',
|
||||
ruhend: 'bg-yellow-100 text-yellow-700',
|
||||
ausgetreten: 'bg-red-100 text-red-700',
|
||||
}
|
||||
|
||||
export default async function MitgliederPage(props: {
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
|
||||
}) {
|
||||
const searchParams = await props.searchParams
|
||||
const search = typeof searchParams.q === 'string' ? searchParams.q : ''
|
||||
const statusFilter = typeof searchParams.status === 'string' ? searchParams.status : undefined
|
||||
|
||||
const sanitizedHeaders = await getSanitizedHeaders()
|
||||
const session = await auth.api.getSession({ headers: sanitizedHeaders })
|
||||
if (!session?.user) redirect('/login')
|
||||
|
||||
const userRole = await prisma.userRole.findFirst({
|
||||
where: { userId: session.user.id },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
if (!userRole || userRole.role !== 'admin') redirect('/dashboard')
|
||||
|
||||
const members = await prisma.member.findMany({
|
||||
where: {
|
||||
orgId: userRole.orgId,
|
||||
...(statusFilter && { status: statusFilter as never }),
|
||||
...(search && {
|
||||
OR: [
|
||||
{ name: { contains: search } },
|
||||
{ betrieb: { contains: search } },
|
||||
{ ort: { contains: search } },
|
||||
],
|
||||
}),
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
})
|
||||
|
||||
// Also fetch admins to display them in the list if no status filter or status matches "aktiv"
|
||||
const admins = await prisma.userRole.findMany({
|
||||
where: {
|
||||
orgId: userRole.orgId,
|
||||
role: 'admin',
|
||||
...(search && {
|
||||
user: {
|
||||
OR: [
|
||||
{ name: { contains: search } },
|
||||
{ email: { contains: search } },
|
||||
]
|
||||
}
|
||||
})
|
||||
},
|
||||
include: {
|
||||
user: true
|
||||
}
|
||||
})
|
||||
|
||||
const adminUserIds = new Set(admins.map((a: typeof admins[number]) => a.userId))
|
||||
// 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 combinedList = [
|
||||
// Include admins only if there's no status filter, or if filtering for 'aktiv'
|
||||
...(!statusFilter || statusFilter === 'aktiv' ? admins.map((a: typeof admins[number]) => {
|
||||
const m = memberByUserId.get(a.user.id)
|
||||
return {
|
||||
id: m ? m.id : `admin-${a.user.id}`,
|
||||
name: m?.name ?? a.user.name,
|
||||
betrieb: m?.betrieb ?? a.user.email,
|
||||
sparte: m?.sparte ?? 'Sonderfunktion',
|
||||
ort: m?.ort ?? '—',
|
||||
seit: m?.seit ?? null as number | null,
|
||||
status: m?.status ?? 'aktiv',
|
||||
userId: a.user.id,
|
||||
isAdmin: true,
|
||||
realId: m ? m.id : a.user.id,
|
||||
role: 'Administrator',
|
||||
}
|
||||
}) : []),
|
||||
...members.filter((m: typeof members[number]) => !adminUserIds.has(m.userId ?? '')).map((m: typeof members[number]) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
betrieb: m.betrieb,
|
||||
sparte: m.sparte,
|
||||
ort: m.ort,
|
||||
seit: m.seit,
|
||||
status: m.status,
|
||||
userId: m.userId,
|
||||
isAdmin: false,
|
||||
realId: m.id,
|
||||
role: 'Mitglied',
|
||||
}))
|
||||
]
|
||||
|
||||
combinedList.sort((a: typeof combinedList[number], b: typeof combinedList[number]) => a.name.localeCompare(b.name))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Mitglieder</h1>
|
||||
<p className="text-gray-500 mt-1">{combinedList.length} Einträge</p>
|
||||
</div>
|
||||
<Link
|
||||
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"
|
||||
>
|
||||
+ Mitglied anlegen
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-lg border p-4 flex gap-4">
|
||||
<form className="flex gap-4 w-full">
|
||||
<input
|
||||
name="q"
|
||||
defaultValue={search}
|
||||
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"
|
||||
/>
|
||||
<select
|
||||
name="status"
|
||||
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"
|
||||
>
|
||||
<option value="">Alle Status</option>
|
||||
<option value="aktiv">Aktiv</option>
|
||||
<option value="ruhend">Ruhend</option>
|
||||
<option value="ausgetreten">Ausgetreten</option>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg text-sm hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Suchen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white rounded-lg border overflow-hidden">
|
||||
<table className="w-full data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name / Betrieb</th>
|
||||
<th>Rolle</th>
|
||||
<th>Ort</th>
|
||||
<th>Mitglied seit</th>
|
||||
<th>Status</th>
|
||||
<th>Eingeladen</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{combinedList.map((m) => (
|
||||
<tr key={m.id}>
|
||||
<td>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{m.name}</p>
|
||||
<p className="text-xs text-gray-500">{m.betrieb}</p>
|
||||
</div>
|
||||
</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'}`}>
|
||||
{m.role}
|
||||
</span>
|
||||
</td>
|
||||
<td>{m.ort}</td>
|
||||
<td>{m.seit ?? '—'}</td>
|
||||
<td>
|
||||
<span
|
||||
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'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{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] text-gray-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<Link
|
||||
href={`/dashboard/mitglieder/${m.realId}`}
|
||||
className="text-sm text-brand-600 hover:underline"
|
||||
>
|
||||
Bearbeiten
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{combinedList.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
Keine Mitglieder gefunden
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,235 +1,235 @@
|
|||
'use client'
|
||||
|
||||
import { use, useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { getTrpcErrorMessage } from '@/lib/trpc-error'
|
||||
import Link from 'next/link'
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { ssr: false })
|
||||
|
||||
const KATEGORIEN = [
|
||||
{ value: 'Wichtig', label: 'Wichtig' },
|
||||
{ value: 'Pruefung', label: 'Prüfung' },
|
||||
{ value: 'Foerderung', label: 'Förderung' },
|
||||
{ value: 'Veranstaltung', label: 'Veranstaltung' },
|
||||
{ value: 'Allgemein', label: 'Allgemein' },
|
||||
]
|
||||
|
||||
export default function NewsEditPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params)
|
||||
const router = useRouter()
|
||||
|
||||
const { data: news, isLoading } = trpc.news.byId.useQuery({ id })
|
||||
const updateMutation = trpc.news.update.useMutation({
|
||||
onSuccess: () => router.push('/dashboard/news'),
|
||||
})
|
||||
const deleteMutation = trpc.news.delete.useMutation({
|
||||
onSuccess: () => router.push('/dashboard/news'),
|
||||
})
|
||||
|
||||
const [title, setTitle] = useState('')
|
||||
const [body, setBody] = useState('')
|
||||
const [kategorie, setKategorie] = useState('Allgemein')
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [attachments, setAttachments] = useState<
|
||||
Array<{ name: string; storagePath: string; sizeBytes: number; mimeType?: string | null }>
|
||||
>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (news) {
|
||||
setTitle(news.title)
|
||||
setBody(news.body)
|
||||
setKategorie(news.kategorie)
|
||||
if (news.attachments) {
|
||||
setAttachments(news.attachments.map((a: typeof news.attachments[number]) => ({ ...a, sizeBytes: a.sizeBytes ?? 0 })))
|
||||
}
|
||||
}
|
||||
}, [news])
|
||||
|
||||
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>
|
||||
|
||||
async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setUploading(true)
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
try {
|
||||
const res = await fetch('/api/upload', { method: 'POST', body: formData })
|
||||
const data = await res.json()
|
||||
setAttachments((prev) => [...prev, data])
|
||||
} catch {
|
||||
alert('Upload fehlgeschlagen')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSave(publishNow: boolean) {
|
||||
if (!title.trim() || !body.trim()) return
|
||||
updateMutation.mutate({
|
||||
id,
|
||||
data: {
|
||||
title,
|
||||
body,
|
||||
kategorie: kategorie as never,
|
||||
publishedAt: publishNow ? new Date().toISOString() : undefined,
|
||||
attachments: attachments.map((a) => ({
|
||||
name: a.name,
|
||||
storagePath: a.storagePath,
|
||||
sizeBytes: a.sizeBytes,
|
||||
mimeType: a.mimeType || 'application/pdf',
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function handleUnpublish() {
|
||||
updateMutation.mutate({ id, data: { publishedAt: null } })
|
||||
}
|
||||
|
||||
const isPublished = !!news.publishedAt
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/news" className="text-xs text-gray-400 hover:text-gray-600 uppercase tracking-wide">
|
||||
← Zurück
|
||||
</Link>
|
||||
<span className="text-gray-200">/</span>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Beitrag bearbeiten</h1>
|
||||
{isPublished && (
|
||||
<span className="text-[11px] font-medium bg-green-100 text-green-700 px-2 py-0.5 rounded-full">
|
||||
Publiziert
|
||||
</span>
|
||||
)}
|
||||
{!isPublished && (
|
||||
<span className="text-[11px] font-medium bg-gray-100 text-gray-500 px-2 py-0.5 rounded-full">
|
||||
Entwurf
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border p-6 space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Titel</label>
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Kategorie</label>
|
||||
<select
|
||||
value={kategorie}
|
||||
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"
|
||||
>
|
||||
{KATEGORIEN.map((k) => (
|
||||
<option key={k.value} value={k.value}>{k.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Inhalt</label>
|
||||
<div data-color-mode="light">
|
||||
<MDEditor
|
||||
value={body}
|
||||
onChange={(v) => setBody(v ?? '')}
|
||||
height={400}
|
||||
preview="live"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Attachments */}
|
||||
<div>
|
||||
<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">
|
||||
{uploading ? '⏳ Hochladen...' : '📎 Datei anhängen'}
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf,image/*"
|
||||
onChange={handleFileUpload}
|
||||
disabled={uploading}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
{attachments.length > 0 && (
|
||||
<ul className="mt-2 space-y-1">
|
||||
{attachments.map((a, i) => (
|
||||
<li key={i} className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<span>📄</span>
|
||||
<span>{a.name}</span>
|
||||
{a.sizeBytes != null && (
|
||||
<span className="text-gray-400">({Math.round(a.sizeBytes / 1024)} KB)</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setAttachments(prev => prev.filter((_, idx) => idx !== i))}
|
||||
className="text-red-500 hover:text-red-700 ml-2"
|
||||
title="Entfernen"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{updateMutation.error && (
|
||||
<p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg">
|
||||
{getTrpcErrorMessage(updateMutation.error)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t">
|
||||
<div className="flex gap-3">
|
||||
{!isPublished && (
|
||||
<button
|
||||
onClick={() => handleSave(true)}
|
||||
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"
|
||||
>
|
||||
Publizieren
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleSave(false)}
|
||||
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"
|
||||
>
|
||||
{updateMutation.isPending ? 'Wird gespeichert...' : 'Speichern'}
|
||||
</button>
|
||||
{isPublished && (
|
||||
<button
|
||||
onClick={handleUnpublish}
|
||||
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"
|
||||
>
|
||||
Depublizieren
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm('Beitrag wirklich löschen?')) deleteMutation.mutate({ id })
|
||||
}}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="text-sm text-red-500 hover:text-red-700 transition-colors"
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { use, useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { getTrpcErrorMessage } from '@/lib/trpc-error'
|
||||
import Link from 'next/link'
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { ssr: false })
|
||||
|
||||
const KATEGORIEN = [
|
||||
{ value: 'Wichtig', label: 'Wichtig' },
|
||||
{ value: 'Pruefung', label: 'Prüfung' },
|
||||
{ value: 'Foerderung', label: 'Förderung' },
|
||||
{ value: 'Veranstaltung', label: 'Veranstaltung' },
|
||||
{ value: 'Allgemein', label: 'Allgemein' },
|
||||
]
|
||||
|
||||
export default function NewsEditPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params)
|
||||
const router = useRouter()
|
||||
|
||||
const { data: news, isLoading } = trpc.news.byId.useQuery({ id })
|
||||
const updateMutation = trpc.news.update.useMutation({
|
||||
onSuccess: () => router.push('/dashboard/news'),
|
||||
})
|
||||
const deleteMutation = trpc.news.delete.useMutation({
|
||||
onSuccess: () => router.push('/dashboard/news'),
|
||||
})
|
||||
|
||||
const [title, setTitle] = useState('')
|
||||
const [body, setBody] = useState('')
|
||||
const [kategorie, setKategorie] = useState('Allgemein')
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [attachments, setAttachments] = useState<
|
||||
Array<{ name: string; storagePath: string; sizeBytes: number; mimeType?: string | null }>
|
||||
>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (news) {
|
||||
setTitle(news.title)
|
||||
setBody(news.body)
|
||||
setKategorie(news.kategorie)
|
||||
if (news.attachments) {
|
||||
setAttachments(news.attachments.map((a: typeof news.attachments[number]) => ({ ...a, sizeBytes: a.sizeBytes ?? 0 })))
|
||||
}
|
||||
}
|
||||
}, [news])
|
||||
|
||||
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>
|
||||
|
||||
async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setUploading(true)
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
try {
|
||||
const res = await fetch('/api/upload', { method: 'POST', body: formData })
|
||||
const data = await res.json()
|
||||
setAttachments((prev) => [...prev, data])
|
||||
} catch {
|
||||
alert('Upload fehlgeschlagen')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSave(publishNow: boolean) {
|
||||
if (!title.trim() || !body.trim()) return
|
||||
updateMutation.mutate({
|
||||
id,
|
||||
data: {
|
||||
title,
|
||||
body,
|
||||
kategorie: kategorie as never,
|
||||
publishedAt: publishNow ? new Date().toISOString() : undefined,
|
||||
attachments: attachments.map((a) => ({
|
||||
name: a.name,
|
||||
storagePath: a.storagePath,
|
||||
sizeBytes: a.sizeBytes,
|
||||
mimeType: a.mimeType || 'application/pdf',
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function handleUnpublish() {
|
||||
updateMutation.mutate({ id, data: { publishedAt: null } })
|
||||
}
|
||||
|
||||
const isPublished = !!news.publishedAt
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/news" className="text-xs text-gray-400 hover:text-gray-600 uppercase tracking-wide">
|
||||
← Zurück
|
||||
</Link>
|
||||
<span className="text-gray-200">/</span>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Beitrag bearbeiten</h1>
|
||||
{isPublished && (
|
||||
<span className="text-[11px] font-medium bg-green-100 text-green-700 px-2 py-0.5 rounded-full">
|
||||
Publiziert
|
||||
</span>
|
||||
)}
|
||||
{!isPublished && (
|
||||
<span className="text-[11px] font-medium bg-gray-100 text-gray-500 px-2 py-0.5 rounded-full">
|
||||
Entwurf
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border p-6 space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Titel</label>
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Kategorie</label>
|
||||
<select
|
||||
value={kategorie}
|
||||
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"
|
||||
>
|
||||
{KATEGORIEN.map((k) => (
|
||||
<option key={k.value} value={k.value}>{k.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Inhalt</label>
|
||||
<div data-color-mode="light">
|
||||
<MDEditor
|
||||
value={body}
|
||||
onChange={(v) => setBody(v ?? '')}
|
||||
height={400}
|
||||
preview="live"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Attachments */}
|
||||
<div>
|
||||
<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">
|
||||
{uploading ? '⏳ Hochladen...' : '📎 Datei anhängen'}
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf,image/*"
|
||||
onChange={handleFileUpload}
|
||||
disabled={uploading}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
{attachments.length > 0 && (
|
||||
<ul className="mt-2 space-y-1">
|
||||
{attachments.map((a, i) => (
|
||||
<li key={i} className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<span>📄</span>
|
||||
<span>{a.name}</span>
|
||||
{a.sizeBytes != null && (
|
||||
<span className="text-gray-400">({Math.round(a.sizeBytes / 1024)} KB)</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setAttachments(prev => prev.filter((_, idx) => idx !== i))}
|
||||
className="text-red-500 hover:text-red-700 ml-2"
|
||||
title="Entfernen"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{updateMutation.error && (
|
||||
<p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg">
|
||||
{getTrpcErrorMessage(updateMutation.error)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t">
|
||||
<div className="flex gap-3">
|
||||
{!isPublished && (
|
||||
<button
|
||||
onClick={() => handleSave(true)}
|
||||
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"
|
||||
>
|
||||
Publizieren
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleSave(false)}
|
||||
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"
|
||||
>
|
||||
{updateMutation.isPending ? 'Wird gespeichert...' : 'Speichern'}
|
||||
</button>
|
||||
{isPublished && (
|
||||
<button
|
||||
onClick={handleUnpublish}
|
||||
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"
|
||||
>
|
||||
Depublizieren
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm('Beitrag wirklich löschen?')) deleteMutation.mutate({ id })
|
||||
}}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="text-sm text-red-500 hover:text-red-700 transition-colors"
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,124 +1,124 @@
|
|||
import { prisma } from '@innungsapp/shared'
|
||||
import { auth, getSanitizedHeaders } from '@/lib/auth'
|
||||
import { headers } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { NEWS_KATEGORIE_LABELS } from '@innungsapp/shared'
|
||||
import { format } from 'date-fns'
|
||||
import { de } from 'date-fns/locale'
|
||||
|
||||
const KATEGORIE_COLORS: Record<string, string> = {
|
||||
Wichtig: 'bg-red-100 text-red-700',
|
||||
Pruefung: 'bg-blue-100 text-blue-700',
|
||||
Foerderung: 'bg-green-100 text-green-700',
|
||||
Veranstaltung: 'bg-purple-100 text-purple-700',
|
||||
Allgemein: 'bg-gray-100 text-gray-700',
|
||||
}
|
||||
|
||||
export default async function NewsPage() {
|
||||
const sanitizedHeaders = await getSanitizedHeaders()
|
||||
const session = await auth.api.getSession({ headers: sanitizedHeaders })
|
||||
if (!session?.user) redirect('/login')
|
||||
const userRole = await prisma.userRole.findFirst({
|
||||
where: { userId: session.user.id, role: 'admin' },
|
||||
})
|
||||
if (!userRole) redirect('/dashboard')
|
||||
|
||||
const news = await prisma.news.findMany({
|
||||
where: { orgId: userRole.orgId },
|
||||
include: { author: { select: { name: true } } },
|
||||
orderBy: [{ publishedAt: 'desc' }, { createdAt: 'desc' }],
|
||||
})
|
||||
|
||||
const published = news.filter((n: typeof news[number]) => n.publishedAt)
|
||||
const drafts = news.filter((n: typeof news[number]) => !n.publishedAt)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
<Link
|
||||
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"
|
||||
>
|
||||
+ Beitrag erstellen
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{drafts.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||
Entwürfe
|
||||
</h2>
|
||||
<div className="bg-white rounded-lg border overflow-hidden">
|
||||
<table className="w-full data-table">
|
||||
<tbody>
|
||||
{drafts.map((n: typeof drafts[number]) => (
|
||||
<tr key={n.id}>
|
||||
<td className="w-full">
|
||||
<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>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${KATEGORIE_COLORS[n.kategorie]}`}>
|
||||
{NEWS_KATEGORIE_LABELS[n.kategorie]}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<Link href={`/dashboard/news/${n.id}`} className="text-sm text-brand-600 hover:underline">
|
||||
Bearbeiten
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||
Publiziert
|
||||
</h2>
|
||||
<div className="bg-white rounded-lg border overflow-hidden">
|
||||
<table className="w-full data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Titel</th>
|
||||
<th>Kategorie</th>
|
||||
<th>Autor</th>
|
||||
<th>Datum</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{published.map((n: typeof published[number]) => (
|
||||
<tr key={n.id}>
|
||||
<td className="font-medium text-gray-900">{n.title}</td>
|
||||
<td>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${KATEGORIE_COLORS[n.kategorie]}`}>
|
||||
{NEWS_KATEGORIE_LABELS[n.kategorie]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-gray-500">{n.author?.name ?? '—'}</td>
|
||||
<td className="text-gray-500">
|
||||
{n.publishedAt ? format(n.publishedAt, 'dd.MM.yyyy', { locale: de }) : '—'}
|
||||
</td>
|
||||
<td>
|
||||
<Link href={`/dashboard/news/${n.id}`} className="text-sm text-brand-600 hover:underline">
|
||||
Bearbeiten
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
import { auth, getSanitizedHeaders } from '@/lib/auth'
|
||||
import { headers } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { NEWS_KATEGORIE_LABELS } from '@innungsapp/shared'
|
||||
import { format } from 'date-fns'
|
||||
import { de } from 'date-fns/locale'
|
||||
|
||||
const KATEGORIE_COLORS: Record<string, string> = {
|
||||
Wichtig: 'bg-red-100 text-red-700',
|
||||
Pruefung: 'bg-blue-100 text-blue-700',
|
||||
Foerderung: 'bg-green-100 text-green-700',
|
||||
Veranstaltung: 'bg-purple-100 text-purple-700',
|
||||
Allgemein: 'bg-gray-100 text-gray-700',
|
||||
}
|
||||
|
||||
export default async function NewsPage() {
|
||||
const sanitizedHeaders = await getSanitizedHeaders()
|
||||
const session = await auth.api.getSession({ headers: sanitizedHeaders })
|
||||
if (!session?.user) redirect('/login')
|
||||
const userRole = await prisma.userRole.findFirst({
|
||||
where: { userId: session.user.id, role: 'admin' },
|
||||
})
|
||||
if (!userRole) redirect('/dashboard')
|
||||
|
||||
const news = await prisma.news.findMany({
|
||||
where: { orgId: userRole.orgId },
|
||||
include: { author: { select: { name: true } } },
|
||||
orderBy: [{ publishedAt: 'desc' }, { createdAt: 'desc' }],
|
||||
})
|
||||
|
||||
const published = news.filter((n: typeof news[number]) => n.publishedAt)
|
||||
const drafts = news.filter((n: typeof news[number]) => !n.publishedAt)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
<Link
|
||||
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"
|
||||
>
|
||||
+ Beitrag erstellen
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{drafts.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||
Entwürfe
|
||||
</h2>
|
||||
<div className="bg-white rounded-lg border overflow-hidden">
|
||||
<table className="w-full data-table">
|
||||
<tbody>
|
||||
{drafts.map((n: typeof drafts[number]) => (
|
||||
<tr key={n.id}>
|
||||
<td className="w-full">
|
||||
<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>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${KATEGORIE_COLORS[n.kategorie]}`}>
|
||||
{NEWS_KATEGORIE_LABELS[n.kategorie]}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<Link href={`/dashboard/news/${n.id}`} className="text-sm text-brand-600 hover:underline">
|
||||
Bearbeiten
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||
Publiziert
|
||||
</h2>
|
||||
<div className="bg-white rounded-lg border overflow-hidden">
|
||||
<table className="w-full data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Titel</th>
|
||||
<th>Kategorie</th>
|
||||
<th>Autor</th>
|
||||
<th>Datum</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{published.map((n: typeof published[number]) => (
|
||||
<tr key={n.id}>
|
||||
<td className="font-medium text-gray-900">{n.title}</td>
|
||||
<td>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${KATEGORIE_COLORS[n.kategorie]}`}>
|
||||
{NEWS_KATEGORIE_LABELS[n.kategorie]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-gray-500">{n.author?.name ?? '—'}</td>
|
||||
<td className="text-gray-500">
|
||||
{n.publishedAt ? format(n.publishedAt, 'dd.MM.yyyy', { locale: de }) : '—'}
|
||||
</td>
|
||||
<td>
|
||||
<Link href={`/dashboard/news/${n.id}`} className="text-sm text-brand-600 hover:underline">
|
||||
Bearbeiten
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,126 +1,126 @@
|
|||
import { prisma } from '@innungsapp/shared'
|
||||
import { auth, getSanitizedHeaders } from '@/lib/auth'
|
||||
import { headers } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { StatsCards } from '@/components/stats/StatsCards'
|
||||
import Link from 'next/link'
|
||||
import { format } from 'date-fns'
|
||||
import { de } from 'date-fns/locale'
|
||||
import { NEWS_KATEGORIE_LABELS, TERMIN_TYP_LABELS } from '@innungsapp/shared'
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const sanitizedHeaders = await getSanitizedHeaders()
|
||||
const session = await auth.api.getSession({ headers: sanitizedHeaders })
|
||||
if (!session?.user) redirect('/login')
|
||||
|
||||
const userRole = await prisma.userRole.findFirst({
|
||||
where: { userId: session.user.id },
|
||||
include: { org: true },
|
||||
})
|
||||
if (!userRole) redirect('/login')
|
||||
|
||||
const orgId = userRole.orgId
|
||||
const now = new Date()
|
||||
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||||
|
||||
const [activeMembers, newsThisWeek, upcomingTermine, activeStellen, recentNews, nextTermine] =
|
||||
await Promise.all([
|
||||
prisma.member.count({ where: { orgId, status: 'aktiv' } }),
|
||||
prisma.news.count({ where: { orgId, publishedAt: { gte: weekAgo, not: null } } }),
|
||||
prisma.termin.count({ where: { orgId, datum: { gte: now } } }),
|
||||
prisma.stelle.count({ where: { orgId, aktiv: true } }),
|
||||
prisma.news.findMany({
|
||||
where: { orgId, publishedAt: { not: null } },
|
||||
orderBy: { publishedAt: 'desc' },
|
||||
take: 5,
|
||||
include: { author: { select: { name: true } } },
|
||||
}),
|
||||
prisma.termin.findMany({
|
||||
where: { orgId, datum: { gte: now } },
|
||||
orderBy: { datum: 'asc' },
|
||||
take: 3,
|
||||
}),
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Übersicht</h1>
|
||||
<p className="text-gray-500 mt-1">{userRole.org.name}</p>
|
||||
</div>
|
||||
|
||||
<StatsCards
|
||||
stats={[
|
||||
{ label: 'Aktive Mitglieder', value: activeMembers, icon: '👥' },
|
||||
{ label: 'News diese Woche', value: newsThisWeek, icon: '📰' },
|
||||
{ label: 'Bevorstehende Termine', value: upcomingTermine, icon: '📅' },
|
||||
{ label: 'Aktive Stellen', value: activeStellen, icon: '🎓' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Recent News */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-semibold text-gray-900">Neueste Beiträge</h2>
|
||||
<Link href="/dashboard/news" className="text-sm text-brand-600 hover:underline">
|
||||
Alle anzeigen
|
||||
</Link>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{recentNews.map((n: typeof recentNews[number]) => (
|
||||
<div key={n.id} className="flex items-start gap-3 py-2 border-b last:border-0">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm text-gray-900 truncate">{n.title}</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
{n.publishedAt
|
||||
? format(n.publishedAt, 'dd. MMM yyyy', { locale: de })
|
||||
: 'Entwurf'}{' '}
|
||||
· {n.author?.name ?? 'Unbekannt'}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full whitespace-nowrap">
|
||||
{NEWS_KATEGORIE_LABELS[n.kategorie]}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upcoming Termine */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-semibold text-gray-900">Nächste Termine</h2>
|
||||
<Link href="/dashboard/termine" className="text-sm text-brand-600 hover:underline">
|
||||
Alle anzeigen
|
||||
</Link>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{nextTermine.length === 0 && (
|
||||
<p className="text-sm text-gray-500">Keine bevorstehenden Termine</p>
|
||||
)}
|
||||
{nextTermine.map((t: typeof nextTermine[number]) => (
|
||||
<div key={t.id} className="flex items-start gap-3 py-2 border-b last:border-0">
|
||||
<div className="text-center min-w-[40px]">
|
||||
<p className="text-lg font-bold text-brand-500 leading-none">
|
||||
{format(t.datum, 'dd', { locale: de })}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 uppercase">
|
||||
{format(t.datum, 'MMM', { locale: de })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<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>
|
||||
</div>
|
||||
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full whitespace-nowrap">
|
||||
{TERMIN_TYP_LABELS[t.typ]}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
import { auth, getSanitizedHeaders } from '@/lib/auth'
|
||||
import { headers } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { StatsCards } from '@/components/stats/StatsCards'
|
||||
import Link from 'next/link'
|
||||
import { format } from 'date-fns'
|
||||
import { de } from 'date-fns/locale'
|
||||
import { NEWS_KATEGORIE_LABELS, TERMIN_TYP_LABELS } from '@innungsapp/shared'
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const sanitizedHeaders = await getSanitizedHeaders()
|
||||
const session = await auth.api.getSession({ headers: sanitizedHeaders })
|
||||
if (!session?.user) redirect('/login')
|
||||
|
||||
const userRole = await prisma.userRole.findFirst({
|
||||
where: { userId: session.user.id },
|
||||
include: { org: true },
|
||||
})
|
||||
if (!userRole) redirect('/login')
|
||||
|
||||
const orgId = userRole.orgId
|
||||
const now = new Date()
|
||||
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||||
|
||||
const [activeMembers, newsThisWeek, upcomingTermine, activeStellen, recentNews, nextTermine] =
|
||||
await Promise.all([
|
||||
prisma.member.count({ where: { orgId, status: 'aktiv' } }),
|
||||
prisma.news.count({ where: { orgId, publishedAt: { gte: weekAgo, not: null } } }),
|
||||
prisma.termin.count({ where: { orgId, datum: { gte: now } } }),
|
||||
prisma.stelle.count({ where: { orgId, aktiv: true } }),
|
||||
prisma.news.findMany({
|
||||
where: { orgId, publishedAt: { not: null } },
|
||||
orderBy: { publishedAt: 'desc' },
|
||||
take: 5,
|
||||
include: { author: { select: { name: true } } },
|
||||
}),
|
||||
prisma.termin.findMany({
|
||||
where: { orgId, datum: { gte: now } },
|
||||
orderBy: { datum: 'asc' },
|
||||
take: 3,
|
||||
}),
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Übersicht</h1>
|
||||
<p className="text-gray-500 mt-1">{userRole.org.name}</p>
|
||||
</div>
|
||||
|
||||
<StatsCards
|
||||
stats={[
|
||||
{ label: 'Aktive Mitglieder', value: activeMembers, icon: '👥' },
|
||||
{ label: 'News diese Woche', value: newsThisWeek, icon: '📰' },
|
||||
{ label: 'Bevorstehende Termine', value: upcomingTermine, icon: '📅' },
|
||||
{ label: 'Aktive Stellen', value: activeStellen, icon: '🎓' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Recent News */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-semibold text-gray-900">Neueste Beiträge</h2>
|
||||
<Link href="/dashboard/news" className="text-sm text-brand-600 hover:underline">
|
||||
Alle anzeigen
|
||||
</Link>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{recentNews.map((n: typeof recentNews[number]) => (
|
||||
<div key={n.id} className="flex items-start gap-3 py-2 border-b last:border-0">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm text-gray-900 truncate">{n.title}</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
{n.publishedAt
|
||||
? format(n.publishedAt, 'dd. MMM yyyy', { locale: de })
|
||||
: 'Entwurf'}{' '}
|
||||
· {n.author?.name ?? 'Unbekannt'}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full whitespace-nowrap">
|
||||
{NEWS_KATEGORIE_LABELS[n.kategorie]}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upcoming Termine */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-semibold text-gray-900">Nächste Termine</h2>
|
||||
<Link href="/dashboard/termine" className="text-sm text-brand-600 hover:underline">
|
||||
Alle anzeigen
|
||||
</Link>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{nextTermine.length === 0 && (
|
||||
<p className="text-sm text-gray-500">Keine bevorstehenden Termine</p>
|
||||
)}
|
||||
{nextTermine.map((t: typeof nextTermine[number]) => (
|
||||
<div key={t.id} className="flex items-start gap-3 py-2 border-b last:border-0">
|
||||
<div className="text-center min-w-[40px]">
|
||||
<p className="text-lg font-bold text-brand-500 leading-none">
|
||||
{format(t.datum, 'dd', { locale: de })}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 uppercase">
|
||||
{format(t.datum, 'MMM', { locale: de })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<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>
|
||||
</div>
|
||||
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full whitespace-nowrap">
|
||||
{TERMIN_TYP_LABELS[t.typ]}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,191 +1,191 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { getTrpcErrorMessage } from '@/lib/trpc-error'
|
||||
import Link from 'next/link'
|
||||
import { AIGenerator } from '@/components/ai-generator'
|
||||
|
||||
export default function StelleNeuPage() {
|
||||
const router = useRouter()
|
||||
|
||||
const { data: members } = trpc.members.list.useQuery({})
|
||||
const createMutation = trpc.stellen.createForMember.useMutation({
|
||||
onSuccess: () => router.push('/dashboard/stellen'),
|
||||
})
|
||||
|
||||
const [form, setForm] = useState({
|
||||
memberId: '',
|
||||
sparte: '',
|
||||
stellenAnz: 1,
|
||||
verguetung: '',
|
||||
lehrjahr: '',
|
||||
beschreibung: '',
|
||||
kontaktEmail: '',
|
||||
kontaktName: '',
|
||||
})
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!form.memberId) return
|
||||
createMutation.mutate({
|
||||
...form,
|
||||
stellenAnz: Number(form.stellenAnz),
|
||||
verguetung: form.verguetung || undefined,
|
||||
lehrjahr: form.lehrjahr || undefined,
|
||||
beschreibung: form.beschreibung || undefined,
|
||||
kontaktName: form.kontaktName || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
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'
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/stellen" className="text-xs text-gray-400 hover:text-gray-600 uppercase tracking-wide">
|
||||
← Zurück
|
||||
</Link>
|
||||
<span className="text-gray-200">/</span>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Stelle anlegen</h1>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-start">
|
||||
<div className="lg:col-span-2">
|
||||
<form onSubmit={handleSubmit} className="bg-white rounded-lg border p-6 space-y-6">
|
||||
{/* Betrieb */}
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Betrieb</p>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Mitglied / Betrieb *</label>
|
||||
<select
|
||||
required
|
||||
value={form.memberId}
|
||||
onChange={(e) => {
|
||||
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 })
|
||||
}}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="">Mitglied auswählen...</option>
|
||||
{members?.map((m: NonNullable<typeof members>[number]) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.betrieb} – {m.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stellendetails */}
|
||||
<div className="border-t pt-5">
|
||||
<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>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Sparte *</label>
|
||||
<input
|
||||
required
|
||||
value={form.sparte}
|
||||
onChange={(e) => setForm({ ...form, sparte: e.target.value })}
|
||||
placeholder="z.B. Elektrotechnik"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Anzahl Stellen</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.stellenAnz}
|
||||
onChange={(e) => setForm({ ...form, stellenAnz: Number(e.target.value) })}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Lehrjahr</label>
|
||||
<input
|
||||
value={form.lehrjahr}
|
||||
onChange={(e) => setForm({ ...form, lehrjahr: e.target.value })}
|
||||
placeholder="z.B. 1. Lehrjahr"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Vergütung</label>
|
||||
<input
|
||||
value={form.verguetung}
|
||||
onChange={(e) => setForm({ ...form, verguetung: e.target.value })}
|
||||
placeholder="z.B. 650 € / Monat"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={form.beschreibung}
|
||||
onChange={(e) => setForm({ ...form, beschreibung: e.target.value })}
|
||||
placeholder="Aufgaben, Anforderungen, ..."
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kontakt */}
|
||||
<div className="border-t pt-5">
|
||||
<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>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kontakt-E-Mail *</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={form.kontaktEmail}
|
||||
onChange={(e) => setForm({ ...form, kontaktEmail: e.target.value })}
|
||||
placeholder="bewerbung@betrieb.de"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Ansprechpartner</label>
|
||||
<input
|
||||
value={form.kontaktName}
|
||||
onChange={(e) => setForm({ ...form, kontaktName: e.target.value })}
|
||||
placeholder="Max Mustermann"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{createMutation.error && (
|
||||
<p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg">
|
||||
{getTrpcErrorMessage(createMutation.error)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2 border-t">
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
{createMutation.isPending ? 'Wird gespeichert...' : 'Stelle anlegen'}
|
||||
</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">
|
||||
Abbrechen
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<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 })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { getTrpcErrorMessage } from '@/lib/trpc-error'
|
||||
import Link from 'next/link'
|
||||
import { AIGenerator } from '@/components/ai-generator'
|
||||
|
||||
export default function StelleNeuPage() {
|
||||
const router = useRouter()
|
||||
|
||||
const { data: members } = trpc.members.list.useQuery({})
|
||||
const createMutation = trpc.stellen.createForMember.useMutation({
|
||||
onSuccess: () => router.push('/dashboard/stellen'),
|
||||
})
|
||||
|
||||
const [form, setForm] = useState({
|
||||
memberId: '',
|
||||
sparte: '',
|
||||
stellenAnz: 1,
|
||||
verguetung: '',
|
||||
lehrjahr: '',
|
||||
beschreibung: '',
|
||||
kontaktEmail: '',
|
||||
kontaktName: '',
|
||||
})
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!form.memberId) return
|
||||
createMutation.mutate({
|
||||
...form,
|
||||
stellenAnz: Number(form.stellenAnz),
|
||||
verguetung: form.verguetung || undefined,
|
||||
lehrjahr: form.lehrjahr || undefined,
|
||||
beschreibung: form.beschreibung || undefined,
|
||||
kontaktName: form.kontaktName || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
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'
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/stellen" className="text-xs text-gray-400 hover:text-gray-600 uppercase tracking-wide">
|
||||
← Zurück
|
||||
</Link>
|
||||
<span className="text-gray-200">/</span>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Stelle anlegen</h1>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-start">
|
||||
<div className="lg:col-span-2">
|
||||
<form onSubmit={handleSubmit} className="bg-white rounded-lg border p-6 space-y-6">
|
||||
{/* Betrieb */}
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Betrieb</p>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Mitglied / Betrieb *</label>
|
||||
<select
|
||||
required
|
||||
value={form.memberId}
|
||||
onChange={(e) => {
|
||||
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 })
|
||||
}}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="">Mitglied auswählen...</option>
|
||||
{members?.map((m: NonNullable<typeof members>[number]) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.betrieb} – {m.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stellendetails */}
|
||||
<div className="border-t pt-5">
|
||||
<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>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Sparte *</label>
|
||||
<input
|
||||
required
|
||||
value={form.sparte}
|
||||
onChange={(e) => setForm({ ...form, sparte: e.target.value })}
|
||||
placeholder="z.B. Elektrotechnik"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Anzahl Stellen</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.stellenAnz}
|
||||
onChange={(e) => setForm({ ...form, stellenAnz: Number(e.target.value) })}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Lehrjahr</label>
|
||||
<input
|
||||
value={form.lehrjahr}
|
||||
onChange={(e) => setForm({ ...form, lehrjahr: e.target.value })}
|
||||
placeholder="z.B. 1. Lehrjahr"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Vergütung</label>
|
||||
<input
|
||||
value={form.verguetung}
|
||||
onChange={(e) => setForm({ ...form, verguetung: e.target.value })}
|
||||
placeholder="z.B. 650 € / Monat"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={form.beschreibung}
|
||||
onChange={(e) => setForm({ ...form, beschreibung: e.target.value })}
|
||||
placeholder="Aufgaben, Anforderungen, ..."
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kontakt */}
|
||||
<div className="border-t pt-5">
|
||||
<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>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kontakt-E-Mail *</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={form.kontaktEmail}
|
||||
onChange={(e) => setForm({ ...form, kontaktEmail: e.target.value })}
|
||||
placeholder="bewerbung@betrieb.de"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Ansprechpartner</label>
|
||||
<input
|
||||
value={form.kontaktName}
|
||||
onChange={(e) => setForm({ ...form, kontaktName: e.target.value })}
|
||||
placeholder="Max Mustermann"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{createMutation.error && (
|
||||
<p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg">
|
||||
{getTrpcErrorMessage(createMutation.error)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2 border-t">
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
{createMutation.isPending ? 'Wird gespeichert...' : 'Stelle anlegen'}
|
||||
</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">
|
||||
Abbrechen
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<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 })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,397 +1,397 @@
|
|||
import { prisma } from '@innungsapp/shared'
|
||||
import { notFound } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
|
||||
function jsonToText(value: unknown): string {
|
||||
if (value == null) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((item) => (typeof item === 'string' ? item : JSON.stringify(item)))
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
export default async function TenantLandingPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>
|
||||
}) {
|
||||
const { slug } = await params
|
||||
|
||||
// Exclude dashboard routes
|
||||
if (slug === 'dashboard' || slug === 'login' || slug === 'superadmin') {
|
||||
return notFound()
|
||||
}
|
||||
|
||||
const org = await prisma.organization.findUnique({
|
||||
where: { slug }
|
||||
})
|
||||
|
||||
if (!org) {
|
||||
return notFound()
|
||||
}
|
||||
|
||||
const primaryColor = org.primaryColor || '#E63946'
|
||||
const secondaryColor = org.secondaryColor || undefined
|
||||
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 features = jsonToText(org.landingPageFeatures) || '✅ Exzellente Ausbildung\n✅ Starke Gemeinschaft\n✅ Politische Interessenvertretung'
|
||||
const footer = jsonToText(org.landingPageFooter) || `© ${new Date().getFullYear()} ${org.name}`
|
||||
const sectionTitle = org.landingPageSectionTitle || `${org.name || 'Ihre Innung'} – Gemeinsam stark fürs Handwerk`
|
||||
const buttonText = org.landingPageButtonText || 'Jetzt App laden'
|
||||
|
||||
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}>
|
||||
{/* Header */}
|
||||
<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%)`
|
||||
}}>
|
||||
<div className="flex items-center gap-4">
|
||||
{org.logoUrl ? (
|
||||
<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>
|
||||
)}
|
||||
<span className="font-bold text-lg text-gray-800">{org.name || 'Innungs-Logo'}</span>
|
||||
</div>
|
||||
<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="#leistungen" className="hover:text-black">Leistungen</a>
|
||||
<a href="#app" className="hover:text-black">App</a>
|
||||
</nav>
|
||||
<Link
|
||||
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"
|
||||
style={{ color: primaryColor }}
|
||||
>
|
||||
Mitglieder verwalten
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
{/* 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]">
|
||||
{/* Background Image / Pattern */}
|
||||
{org.landingPageHeroImage ? (
|
||||
<div className="absolute inset-0 z-0">
|
||||
<img src={org.landingPageHeroImage} alt="Hero Background" className="w-full h-full object-cover" />
|
||||
<div
|
||||
className="absolute inset-0 bg-white"
|
||||
// If you have a specific overlay opacity field you could use it here. Defaulting to 0.5.
|
||||
style={{ opacity: 0.5 }}
|
||||
></div>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-white/30 via-transparent to-white/90"></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="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 }}>
|
||||
{org.name || 'Ihre Innung'}
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-black text-gray-900 tracking-tight leading-[1.1]">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600 leading-relaxed max-w-2xl mx-auto font-medium">
|
||||
{text}
|
||||
</p>
|
||||
<div className="pt-6 flex gap-4 justify-center">
|
||||
<a
|
||||
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"
|
||||
style={{ backgroundColor: primaryColor }}
|
||||
>
|
||||
{buttonText}
|
||||
</a>
|
||||
<a
|
||||
href="#leistungen"
|
||||
className="px-8 py-3.5 rounded-full font-semibold border shadow-sm transition-all cursor-pointer block hover:opacity-80"
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
borderColor: secondaryColor || '#e5e7eb',
|
||||
color: secondaryColor || '#374151'
|
||||
}}
|
||||
>
|
||||
Mehr erfahren
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features / Benefits */}
|
||||
<section id="leistungen" className="px-8 py-16" style={{ backgroundColor: secondaryColor ? `${secondaryColor}08` : '#f9fafb' }}>
|
||||
<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>
|
||||
<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) => (
|
||||
<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 }}>
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-800">{feature.replace(/^[-\*\✅\ ]+/, '')}</h3>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* App Features Grid */}
|
||||
<section id="app" className="px-8 py-20 bg-white">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<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 }}>
|
||||
<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
|
||||
</div>
|
||||
<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">
|
||||
Verpassen Sie keine wichtigen Branchen-Updates mehr. Vernetzen Sie sich mit anderen Betrieben und verwalten Sie Termine bequem auf dem Smartphone.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{/* 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="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>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3">Aktuelles</h3>
|
||||
<p className="text-gray-500 leading-relaxed">
|
||||
Lesen Sie die wichtigsten Branchen-News und bleiben Sie immer auf dem aktuellsten Stand.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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="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>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3">Termine</h3>
|
||||
<p className="text-gray-500 leading-relaxed">
|
||||
Verwalten Sie Veranstaltungen, Fortbildungen und Innungsversammlungen direkt in Ihrem Kalender.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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="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>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3">Stellenbörse</h3>
|
||||
<p className="text-gray-500 leading-relaxed">
|
||||
Finden Sie neue Fachkräfte oder Auszubildende. Veröffentlichen Sie Ihre offenen Stellenangebote branchenintern.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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="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>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3">Nachrichten</h3>
|
||||
<p className="text-gray-500 leading-relaxed">
|
||||
Tauschen Sie sich mit anderen Betrieben aus. Schnelle Kontaktaufnahme über direkte Einzel- und Gruppenchats.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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="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>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3">Profil & Ausweis</h3>
|
||||
<p className="text-gray-500 leading-relaxed">
|
||||
Ihr digitaler Mitgliedsausweis immer griffbereit. Verwalten Sie das Profil Ihres Betriebs komfortabel in der App.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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="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>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3">Netzwerk</h3>
|
||||
<p className="text-gray-500 leading-relaxed">
|
||||
Profitieren Sie von starken Kooperationen und Angeboten ausgewählter Partnerbetriebe in der Region.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Application Mock */}
|
||||
<section id="apps" className="px-8 py-32 relative overflow-hidden" style={{
|
||||
background: `linear-gradient(to bottom, #ffffff 0%, ${primaryColor} 40%, #111827 100%)`
|
||||
}}>
|
||||
|
||||
{/* 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 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="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="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>
|
||||
Jetzt verfügbar
|
||||
</div>
|
||||
<h2 className="text-4xl md:text-5xl font-black leading-tight">
|
||||
Laden Sie unsere App herunter
|
||||
</h2>
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-4 pt-4">
|
||||
{(!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">
|
||||
<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 className="text-xs text-white/70">Download on the</div>
|
||||
<div className="text-lg font-semibold leading-none">App Store</div>
|
||||
</div>
|
||||
</a>
|
||||
) : null}
|
||||
{(!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">
|
||||
<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 className="text-xs text-white/70">GET IT ON</div>
|
||||
<div className="text-lg font-semibold leading-none">Google Play</div>
|
||||
</div>
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{/* 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>
|
||||
|
||||
{/* App Screenshot Mockup */}
|
||||
<div className="w-full h-full bg-gray-50 flex flex-col pt-6">
|
||||
{/* App Header */}
|
||||
<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">
|
||||
{org.logoUrl ? (
|
||||
<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 }}>
|
||||
{org.name ? org.name.charAt(0).toUpperCase() : 'I'}
|
||||
</div>
|
||||
)}
|
||||
<div className="font-bold text-sm text-gray-800 truncate w-28">{org.name || 'Ihre Innung'}</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
{/* App Content */}
|
||||
<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="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="relative z-10 text-white font-bold text-lg leading-tight">Willkommen,<br />Max Mustermann</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
<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="w-12 h-12 rounded-lg flex-shrink-0" style={{ backgroundColor: `${primaryColor}15` }}></div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<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>
|
||||
</div>
|
||||
<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="flex-1 space-y-2">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 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="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>
|
||||
<span className="text-[9px] font-semibold" style={{ color: primaryColor }}>Start</span>
|
||||
</div>
|
||||
<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>
|
||||
<span className="text-[9px] font-medium">Aktuelles</span>
|
||||
</div>
|
||||
<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>
|
||||
<span className="text-[9px] font-medium">Termine</span>
|
||||
</div>
|
||||
<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>
|
||||
<span className="text-[9px] font-medium">Stellen</span>
|
||||
</div>
|
||||
<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>
|
||||
<span className="text-[9px] font-medium">Nachricht..</span>
|
||||
</div>
|
||||
<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>
|
||||
<span className="text-[9px] font-medium">Profil</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<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">
|
||||
<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">
|
||||
Profitieren Sie von unserem starken Netzwerk, exklusiven Brancheninformationen und unserer digitalen Innungs-App.
|
||||
</p>
|
||||
<a
|
||||
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"
|
||||
style={{ backgroundColor: primaryColor }}
|
||||
>
|
||||
Jetzt Mitglied werden
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<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="text-gray-300 font-bold text-lg mb-6">{org.name || 'Innungs-Logo'}</div>
|
||||
<div className="whitespace-pre-wrap">{footer}</div>
|
||||
<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="/datenschutz" className="hover:text-white transition-colors">Datenschutz</Link>
|
||||
<Link href="/kontakt" className="hover:text-white transition-colors">Kontakt</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
import { notFound } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
|
||||
function jsonToText(value: unknown): string {
|
||||
if (value == null) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((item) => (typeof item === 'string' ? item : JSON.stringify(item)))
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
export default async function TenantLandingPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>
|
||||
}) {
|
||||
const { slug } = await params
|
||||
|
||||
// Exclude dashboard routes
|
||||
if (slug === 'dashboard' || slug === 'login' || slug === 'superadmin') {
|
||||
return notFound()
|
||||
}
|
||||
|
||||
const org = await prisma.organization.findUnique({
|
||||
where: { slug }
|
||||
})
|
||||
|
||||
if (!org) {
|
||||
return notFound()
|
||||
}
|
||||
|
||||
const primaryColor = org.primaryColor || '#E63946'
|
||||
const secondaryColor = org.secondaryColor || undefined
|
||||
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 features = jsonToText(org.landingPageFeatures) || '✅ Exzellente Ausbildung\n✅ Starke Gemeinschaft\n✅ Politische Interessenvertretung'
|
||||
const footer = jsonToText(org.landingPageFooter) || `© ${new Date().getFullYear()} ${org.name}`
|
||||
const sectionTitle = org.landingPageSectionTitle || `${org.name || 'Ihre Innung'} – Gemeinsam stark fürs Handwerk`
|
||||
const buttonText = org.landingPageButtonText || 'Jetzt App laden'
|
||||
|
||||
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}>
|
||||
{/* Header */}
|
||||
<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%)`
|
||||
}}>
|
||||
<div className="flex items-center gap-4">
|
||||
{org.logoUrl ? (
|
||||
<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>
|
||||
)}
|
||||
<span className="font-bold text-lg text-gray-800">{org.name || 'Innungs-Logo'}</span>
|
||||
</div>
|
||||
<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="#leistungen" className="hover:text-black">Leistungen</a>
|
||||
<a href="#app" className="hover:text-black">App</a>
|
||||
</nav>
|
||||
<Link
|
||||
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"
|
||||
style={{ color: primaryColor }}
|
||||
>
|
||||
Mitglieder verwalten
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
{/* 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]">
|
||||
{/* Background Image / Pattern */}
|
||||
{org.landingPageHeroImage ? (
|
||||
<div className="absolute inset-0 z-0">
|
||||
<img src={org.landingPageHeroImage} alt="Hero Background" className="w-full h-full object-cover" />
|
||||
<div
|
||||
className="absolute inset-0 bg-white"
|
||||
// If you have a specific overlay opacity field you could use it here. Defaulting to 0.5.
|
||||
style={{ opacity: 0.5 }}
|
||||
></div>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-white/30 via-transparent to-white/90"></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="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 }}>
|
||||
{org.name || 'Ihre Innung'}
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-black text-gray-900 tracking-tight leading-[1.1]">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600 leading-relaxed max-w-2xl mx-auto font-medium">
|
||||
{text}
|
||||
</p>
|
||||
<div className="pt-6 flex gap-4 justify-center">
|
||||
<a
|
||||
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"
|
||||
style={{ backgroundColor: primaryColor }}
|
||||
>
|
||||
{buttonText}
|
||||
</a>
|
||||
<a
|
||||
href="#leistungen"
|
||||
className="px-8 py-3.5 rounded-full font-semibold border shadow-sm transition-all cursor-pointer block hover:opacity-80"
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
borderColor: secondaryColor || '#e5e7eb',
|
||||
color: secondaryColor || '#374151'
|
||||
}}
|
||||
>
|
||||
Mehr erfahren
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features / Benefits */}
|
||||
<section id="leistungen" className="px-8 py-16" style={{ backgroundColor: secondaryColor ? `${secondaryColor}08` : '#f9fafb' }}>
|
||||
<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>
|
||||
<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) => (
|
||||
<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 }}>
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-800">{feature.replace(/^[-\*\✅\ ]+/, '')}</h3>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* App Features Grid */}
|
||||
<section id="app" className="px-8 py-20 bg-white">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<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 }}>
|
||||
<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
|
||||
</div>
|
||||
<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">
|
||||
Verpassen Sie keine wichtigen Branchen-Updates mehr. Vernetzen Sie sich mit anderen Betrieben und verwalten Sie Termine bequem auf dem Smartphone.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{/* 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="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>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3">Aktuelles</h3>
|
||||
<p className="text-gray-500 leading-relaxed">
|
||||
Lesen Sie die wichtigsten Branchen-News und bleiben Sie immer auf dem aktuellsten Stand.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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="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>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3">Termine</h3>
|
||||
<p className="text-gray-500 leading-relaxed">
|
||||
Verwalten Sie Veranstaltungen, Fortbildungen und Innungsversammlungen direkt in Ihrem Kalender.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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="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>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3">Stellenbörse</h3>
|
||||
<p className="text-gray-500 leading-relaxed">
|
||||
Finden Sie neue Fachkräfte oder Auszubildende. Veröffentlichen Sie Ihre offenen Stellenangebote branchenintern.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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="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>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3">Nachrichten</h3>
|
||||
<p className="text-gray-500 leading-relaxed">
|
||||
Tauschen Sie sich mit anderen Betrieben aus. Schnelle Kontaktaufnahme über direkte Einzel- und Gruppenchats.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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="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>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3">Profil & Ausweis</h3>
|
||||
<p className="text-gray-500 leading-relaxed">
|
||||
Ihr digitaler Mitgliedsausweis immer griffbereit. Verwalten Sie das Profil Ihres Betriebs komfortabel in der App.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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="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>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3">Netzwerk</h3>
|
||||
<p className="text-gray-500 leading-relaxed">
|
||||
Profitieren Sie von starken Kooperationen und Angeboten ausgewählter Partnerbetriebe in der Region.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Application Mock */}
|
||||
<section id="apps" className="px-8 py-32 relative overflow-hidden" style={{
|
||||
background: `linear-gradient(to bottom, #ffffff 0%, ${primaryColor} 40%, #111827 100%)`
|
||||
}}>
|
||||
|
||||
{/* 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 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="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="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>
|
||||
Jetzt verfügbar
|
||||
</div>
|
||||
<h2 className="text-4xl md:text-5xl font-black leading-tight">
|
||||
Laden Sie unsere App herunter
|
||||
</h2>
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-4 pt-4">
|
||||
{(!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">
|
||||
<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 className="text-xs text-white/70">Download on the</div>
|
||||
<div className="text-lg font-semibold leading-none">App Store</div>
|
||||
</div>
|
||||
</a>
|
||||
) : null}
|
||||
{(!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">
|
||||
<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 className="text-xs text-white/70">GET IT ON</div>
|
||||
<div className="text-lg font-semibold leading-none">Google Play</div>
|
||||
</div>
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{/* 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>
|
||||
|
||||
{/* App Screenshot Mockup */}
|
||||
<div className="w-full h-full bg-gray-50 flex flex-col pt-6">
|
||||
{/* App Header */}
|
||||
<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">
|
||||
{org.logoUrl ? (
|
||||
<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 }}>
|
||||
{org.name ? org.name.charAt(0).toUpperCase() : 'I'}
|
||||
</div>
|
||||
)}
|
||||
<div className="font-bold text-sm text-gray-800 truncate w-28">{org.name || 'Ihre Innung'}</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
{/* App Content */}
|
||||
<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="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="relative z-10 text-white font-bold text-lg leading-tight">Willkommen,<br />Max Mustermann</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
<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="w-12 h-12 rounded-lg flex-shrink-0" style={{ backgroundColor: `${primaryColor}15` }}></div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<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>
|
||||
</div>
|
||||
<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="flex-1 space-y-2">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 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="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>
|
||||
<span className="text-[9px] font-semibold" style={{ color: primaryColor }}>Start</span>
|
||||
</div>
|
||||
<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>
|
||||
<span className="text-[9px] font-medium">Aktuelles</span>
|
||||
</div>
|
||||
<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>
|
||||
<span className="text-[9px] font-medium">Termine</span>
|
||||
</div>
|
||||
<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>
|
||||
<span className="text-[9px] font-medium">Stellen</span>
|
||||
</div>
|
||||
<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>
|
||||
<span className="text-[9px] font-medium">Nachricht..</span>
|
||||
</div>
|
||||
<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>
|
||||
<span className="text-[9px] font-medium">Profil</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<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">
|
||||
<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">
|
||||
Profitieren Sie von unserem starken Netzwerk, exklusiven Brancheninformationen und unserer digitalen Innungs-App.
|
||||
</p>
|
||||
<a
|
||||
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"
|
||||
style={{ backgroundColor: primaryColor }}
|
||||
>
|
||||
Jetzt Mitglied werden
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<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="text-gray-300 font-bold text-lg mb-6">{org.name || 'Innungs-Logo'}</div>
|
||||
<div className="whitespace-pre-wrap">{footer}</div>
|
||||
<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="/datenschutz" className="hover:text-white transition-colors">Datenschutz</Link>
|
||||
<Link href="/kontakt" className="hover:text-white transition-colors">Kontakt</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,126 +1,126 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import OpenAI from 'openai'
|
||||
|
||||
type LlmProvider = 'openai' | 'openrouter'
|
||||
|
||||
function getProvider(): LlmProvider {
|
||||
const configured = (process.env.LLM_PROVIDER ?? '').toLowerCase()
|
||||
if (configured === 'openrouter') return 'openrouter'
|
||||
if (configured === 'openai') return 'openai'
|
||||
return process.env.OPENROUTER_API_KEY ? 'openrouter' : 'openai'
|
||||
}
|
||||
|
||||
function createClient(provider: LlmProvider) {
|
||||
if (provider === 'openrouter') {
|
||||
const apiKey = process.env.OPENROUTER_API_KEY || ''
|
||||
return new OpenAI({
|
||||
apiKey,
|
||||
baseURL: process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1',
|
||||
defaultHeaders: {
|
||||
...(process.env.OPENROUTER_SITE_URL
|
||||
? { 'HTTP-Referer': process.env.OPENROUTER_SITE_URL }
|
||||
: {}),
|
||||
...(process.env.OPENROUTER_APP_NAME
|
||||
? { 'X-Title': process.env.OPENROUTER_APP_NAME }
|
||||
: {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return new OpenAI({
|
||||
apiKey: process.env.OPENAI_API_KEY || '',
|
||||
})
|
||||
}
|
||||
|
||||
function getModel(provider: LlmProvider): string {
|
||||
if (provider === 'openrouter') {
|
||||
return process.env.OPENROUTER_MODEL || 'minimax/minimax-m2.5'
|
||||
}
|
||||
return process.env.OPENAI_MODEL || 'gpt-4o-mini'
|
||||
}
|
||||
|
||||
function hasApiKey(provider: LlmProvider): boolean {
|
||||
if (provider === 'openrouter') return !!process.env.OPENROUTER_API_KEY
|
||||
return !!process.env.OPENAI_API_KEY
|
||||
}
|
||||
|
||||
function buildFallbackLandingContent(orgName: string, context: string) {
|
||||
const cleanOrg = orgName.trim()
|
||||
const cleanContext = context.trim().replace(/\s+/g, ' ')
|
||||
const shortContext = cleanContext.slice(0, 180)
|
||||
const detailSentence = shortContext
|
||||
? `Dabei stehen insbesondere ${shortContext}.`
|
||||
: 'Dabei stehen regionale Vernetzung, starke Ausbildung und praxisnahe Unterstützung im Mittelpunkt.'
|
||||
|
||||
return {
|
||||
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}`,
|
||||
fallbackUsed: true,
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
let parsedBody: any = null
|
||||
|
||||
try {
|
||||
const body = await req.json()
|
||||
parsedBody = body
|
||||
const { orgName, context } = body
|
||||
|
||||
if (!orgName || !context) {
|
||||
return NextResponse.json({ error: 'orgName and context are required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const provider = getProvider()
|
||||
const model = getModel(provider)
|
||||
|
||||
if (!hasApiKey(provider)) {
|
||||
return NextResponse.json(buildFallbackLandingContent(orgName, context))
|
||||
}
|
||||
|
||||
const client = createClient(provider)
|
||||
|
||||
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.
|
||||
|
||||
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)",
|
||||
"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 completion = await client.chat.completions.create({
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: systemMessage },
|
||||
{ role: 'user', content: userMessage },
|
||||
],
|
||||
// some openrouter models ignore response_format, so doing it purely by prompt
|
||||
temperature: 0.7
|
||||
})
|
||||
|
||||
let textResponse = completion.choices[0]?.message?.content || ''
|
||||
|
||||
// safely remove potential markdown blocks just in case
|
||||
textResponse = textResponse.trim()
|
||||
if (textResponse.startsWith('```json')) {
|
||||
textResponse = textResponse.replace(/^```json\n?/, '').replace(/\n?```$/, '').trim()
|
||||
} else if (textResponse.startsWith('```')) {
|
||||
textResponse = textResponse.replace(/^```\n?/, '').replace(/\n?```$/, '').trim()
|
||||
}
|
||||
|
||||
const result = JSON.parse(textResponse)
|
||||
|
||||
return NextResponse.json(result)
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Error generating AI landing page content:', error)
|
||||
if (parsedBody?.orgName && parsedBody?.context) {
|
||||
return NextResponse.json(buildFallbackLandingContent(parsedBody.orgName, parsedBody.context))
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: error?.message || 'Failed to generate content' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
import { NextResponse } from 'next/server'
|
||||
import OpenAI from 'openai'
|
||||
|
||||
type LlmProvider = 'openai' | 'openrouter'
|
||||
|
||||
function getProvider(): LlmProvider {
|
||||
const configured = (process.env.LLM_PROVIDER ?? '').toLowerCase()
|
||||
if (configured === 'openrouter') return 'openrouter'
|
||||
if (configured === 'openai') return 'openai'
|
||||
return process.env.OPENROUTER_API_KEY ? 'openrouter' : 'openai'
|
||||
}
|
||||
|
||||
function createClient(provider: LlmProvider) {
|
||||
if (provider === 'openrouter') {
|
||||
const apiKey = process.env.OPENROUTER_API_KEY || ''
|
||||
return new OpenAI({
|
||||
apiKey,
|
||||
baseURL: process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1',
|
||||
defaultHeaders: {
|
||||
...(process.env.OPENROUTER_SITE_URL
|
||||
? { 'HTTP-Referer': process.env.OPENROUTER_SITE_URL }
|
||||
: {}),
|
||||
...(process.env.OPENROUTER_APP_NAME
|
||||
? { 'X-Title': process.env.OPENROUTER_APP_NAME }
|
||||
: {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return new OpenAI({
|
||||
apiKey: process.env.OPENAI_API_KEY || '',
|
||||
})
|
||||
}
|
||||
|
||||
function getModel(provider: LlmProvider): string {
|
||||
if (provider === 'openrouter') {
|
||||
return process.env.OPENROUTER_MODEL || 'minimax/minimax-m2.5'
|
||||
}
|
||||
return process.env.OPENAI_MODEL || 'gpt-4o-mini'
|
||||
}
|
||||
|
||||
function hasApiKey(provider: LlmProvider): boolean {
|
||||
if (provider === 'openrouter') return !!process.env.OPENROUTER_API_KEY
|
||||
return !!process.env.OPENAI_API_KEY
|
||||
}
|
||||
|
||||
function buildFallbackLandingContent(orgName: string, context: string) {
|
||||
const cleanOrg = orgName.trim()
|
||||
const cleanContext = context.trim().replace(/\s+/g, ' ')
|
||||
const shortContext = cleanContext.slice(0, 180)
|
||||
const detailSentence = shortContext
|
||||
? `Dabei stehen insbesondere ${shortContext}.`
|
||||
: 'Dabei stehen regionale Vernetzung, starke Ausbildung und praxisnahe Unterstützung im Mittelpunkt.'
|
||||
|
||||
return {
|
||||
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}`,
|
||||
fallbackUsed: true,
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
let parsedBody: any = null
|
||||
|
||||
try {
|
||||
const body = await req.json()
|
||||
parsedBody = body
|
||||
const { orgName, context } = body
|
||||
|
||||
if (!orgName || !context) {
|
||||
return NextResponse.json({ error: 'orgName and context are required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const provider = getProvider()
|
||||
const model = getModel(provider)
|
||||
|
||||
if (!hasApiKey(provider)) {
|
||||
return NextResponse.json(buildFallbackLandingContent(orgName, context))
|
||||
}
|
||||
|
||||
const client = createClient(provider)
|
||||
|
||||
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.
|
||||
|
||||
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)",
|
||||
"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 completion = await client.chat.completions.create({
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: systemMessage },
|
||||
{ role: 'user', content: userMessage },
|
||||
],
|
||||
// some openrouter models ignore response_format, so doing it purely by prompt
|
||||
temperature: 0.7
|
||||
})
|
||||
|
||||
let textResponse = completion.choices[0]?.message?.content || ''
|
||||
|
||||
// safely remove potential markdown blocks just in case
|
||||
textResponse = textResponse.trim()
|
||||
if (textResponse.startsWith('```json')) {
|
||||
textResponse = textResponse.replace(/^```json\n?/, '').replace(/\n?```$/, '').trim()
|
||||
} else if (textResponse.startsWith('```')) {
|
||||
textResponse = textResponse.replace(/^```\n?/, '').replace(/\n?```$/, '').trim()
|
||||
}
|
||||
|
||||
const result = JSON.parse(textResponse)
|
||||
|
||||
return NextResponse.json(result)
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Error generating AI landing page content:', error)
|
||||
if (parsedBody?.orgName && parsedBody?.context) {
|
||||
return NextResponse.json(buildFallbackLandingContent(parsedBody.orgName, parsedBody.context))
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: error?.message || 'Failed to generate content' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,17 +78,17 @@ export async function POST(req: Request) {
|
|||
}
|
||||
|
||||
let systemMessage = ''
|
||||
|
||||
if (type === 'news') {
|
||||
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.
|
||||
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'}.`
|
||||
} else if (type === 'stelle') {
|
||||
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.
|
||||
Die Stellenanzeige soll Begeisterung wecken und klar die Aufgaben sowie Anforderungen kommunizieren.
|
||||
Das gewünschte Ausgabeformat ist: ${format === 'markdown' ? 'Markdown' : 'Einfacher unformatierter Text'}.`
|
||||
|
||||
if (type === 'news') {
|
||||
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.
|
||||
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'}.`
|
||||
} else if (type === 'stelle') {
|
||||
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.
|
||||
Die Stellenanzeige soll Begeisterung wecken und klar die Aufgaben sowie Anforderungen kommunizieren.
|
||||
Das gewünschte Ausgabeformat ist: ${format === 'markdown' ? 'Markdown' : 'Einfacher unformatierter Text'}.`
|
||||
} else {
|
||||
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) {
|
||||
console.error('AI Generate Error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error?.message || 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
{ error: error?.message || 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { auth, getSanitizedHeaders } from '@/lib/auth'
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
|
||||
export async function POST() {
|
||||
const session = await auth.api.getSession({ headers: await getSanitizedHeaders() })
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Nicht eingeloggt' }, { status: 401 })
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: { mustChangePassword: false },
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
import { NextResponse } from 'next/server'
|
||||
import { auth, getSanitizedHeaders } from '@/lib/auth'
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
|
||||
export async function POST() {
|
||||
const session = await auth.api.getSession({ headers: await getSanitizedHeaders() })
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Nicht eingeloggt' }, { status: 401 })
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: { mustChangePassword: false },
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,37 +1,37 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth, getSanitizedHeaders } from '@/lib/auth'
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
// @ts-ignore
|
||||
import { hashPassword } from 'better-auth/crypto'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: await getSanitizedHeaders() })
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Nicht eingeloggt' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { newPassword } = await req.json()
|
||||
if (!newPassword || newPassword.length < 8) {
|
||||
return NextResponse.json({ error: 'Passwort muss mindestens 8 Zeichen haben.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
const newHash = await hashPassword(newPassword)
|
||||
|
||||
const credAccount = await prisma.account.findFirst({
|
||||
where: { userId, providerId: 'credential' },
|
||||
})
|
||||
|
||||
if (credAccount) {
|
||||
await prisma.account.update({ where: { id: credAccount.id }, data: { password: newHash } })
|
||||
} else {
|
||||
const { randomUUID } = await import('node:crypto')
|
||||
await prisma.account.create({
|
||||
data: { id: randomUUID(), accountId: userId, providerId: 'credential', userId, password: newHash },
|
||||
})
|
||||
}
|
||||
|
||||
await prisma.user.update({ where: { id: userId }, data: { mustChangePassword: false } })
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth, getSanitizedHeaders } from '@/lib/auth'
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
// @ts-ignore
|
||||
import { hashPassword } from 'better-auth/crypto'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: await getSanitizedHeaders() })
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Nicht eingeloggt' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { newPassword } = await req.json()
|
||||
if (!newPassword || newPassword.length < 8) {
|
||||
return NextResponse.json({ error: 'Passwort muss mindestens 8 Zeichen haben.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
const newHash = await hashPassword(newPassword)
|
||||
|
||||
const credAccount = await prisma.account.findFirst({
|
||||
where: { userId, providerId: 'credential' },
|
||||
})
|
||||
|
||||
if (credAccount) {
|
||||
await prisma.account.update({ where: { id: credAccount.id }, data: { password: newHash } })
|
||||
} else {
|
||||
const { randomUUID } = await import('node:crypto')
|
||||
await prisma.account.create({
|
||||
data: { id: randomUUID(), accountId: userId, providerId: 'credential', userId, password: newHash },
|
||||
})
|
||||
}
|
||||
|
||||
await prisma.user.update({ where: { id: userId }, data: { mustChangePassword: false } })
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,50 +1,50 @@
|
|||
import { NextRequest } from 'next/server'
|
||||
import { auth, getSanitizedHeaders } from '@/lib/auth'
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await auth.api.getSession({ headers: await getSanitizedHeaders(req.headers) })
|
||||
if (!session?.user) {
|
||||
return new Response('Unauthorized', { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
// Verify admin role via UserRole table
|
||||
const userRole = await prisma.userRole.findFirst({
|
||||
where: { userId: session.user.id, role: 'admin' },
|
||||
})
|
||||
if (!userRole) {
|
||||
return new Response('Forbidden', { status: 403 })
|
||||
}
|
||||
|
||||
const termin = await prisma.termin.findUnique({
|
||||
where: { id, orgId: userRole.orgId },
|
||||
include: { anmeldungen: { include: { member: true } } },
|
||||
})
|
||||
|
||||
if (!termin) {
|
||||
return new Response('Not found', { status: 404 })
|
||||
}
|
||||
|
||||
if (termin.anmeldungen.length === 0) {
|
||||
return new Response('Keine Anmeldungen vorhanden', { status: 404 })
|
||||
}
|
||||
|
||||
const rows = termin.anmeldungen.map((a) => ({
|
||||
Name: a.member.name,
|
||||
Email: a.member.email,
|
||||
Betrieb: a.member.betrieb ?? '',
|
||||
Angemeldet: new Date(a.angemeldetAt).toLocaleDateString('de-DE'),
|
||||
}))
|
||||
|
||||
const header = Object.keys(rows[0]).join(';')
|
||||
const csv = [header, ...rows.map((r) => Object.values(r).join(';'))].join('\n')
|
||||
|
||||
return new Response('\uFEFF' + csv, {
|
||||
headers: {
|
||||
'Content-Type': 'text/csv; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="teilnehmer-${id}.csv"`,
|
||||
},
|
||||
})
|
||||
}
|
||||
import { NextRequest } from 'next/server'
|
||||
import { auth, getSanitizedHeaders } from '@/lib/auth'
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await auth.api.getSession({ headers: await getSanitizedHeaders(req.headers) })
|
||||
if (!session?.user) {
|
||||
return new Response('Unauthorized', { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
// Verify admin role via UserRole table
|
||||
const userRole = await prisma.userRole.findFirst({
|
||||
where: { userId: session.user.id, role: 'admin' },
|
||||
})
|
||||
if (!userRole) {
|
||||
return new Response('Forbidden', { status: 403 })
|
||||
}
|
||||
|
||||
const termin = await prisma.termin.findUnique({
|
||||
where: { id, orgId: userRole.orgId },
|
||||
include: { anmeldungen: { include: { member: true } } },
|
||||
})
|
||||
|
||||
if (!termin) {
|
||||
return new Response('Not found', { status: 404 })
|
||||
}
|
||||
|
||||
if (termin.anmeldungen.length === 0) {
|
||||
return new Response('Keine Anmeldungen vorhanden', { status: 404 })
|
||||
}
|
||||
|
||||
const rows = termin.anmeldungen.map((a) => ({
|
||||
Name: a.member.name,
|
||||
Email: a.member.email,
|
||||
Betrieb: a.member.betrieb ?? '',
|
||||
Angemeldet: new Date(a.angemeldetAt).toLocaleDateString('de-DE'),
|
||||
}))
|
||||
|
||||
const header = Object.keys(rows[0]).join(';')
|
||||
const csv = [header, ...rows.map((r) => Object.values(r).join(';'))].join('\n')
|
||||
|
||||
return new Response('\uFEFF' + csv, {
|
||||
headers: {
|
||||
'Content-Type': 'text/csv; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="teilnehmer-${id}.csv"`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,23 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth, getSanitizedHeaders } from '@/lib/auth'
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: await getSanitizedHeaders(req.headers) })
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { token } = await req.json()
|
||||
if (!token || typeof token !== 'string') {
|
||||
return NextResponse.json({ error: 'Invalid token' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Store push token on the member record
|
||||
await prisma.member.updateMany({
|
||||
where: { userId: session.user.id },
|
||||
data: { pushToken: token },
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth, getSanitizedHeaders } from '@/lib/auth'
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: await getSanitizedHeaders(req.headers) })
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { token } = await req.json()
|
||||
if (!token || typeof token !== 'string') {
|
||||
return NextResponse.json({ error: 'Invalid token' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Store push token on the member record
|
||||
await prisma.member.updateMany({
|
||||
where: { userId: session.user.id },
|
||||
data: { pushToken: token },
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,60 +1,60 @@
|
|||
/**
|
||||
* DEV-ONLY: Sets a password for the demo admin user via better-auth.
|
||||
* Call once after seeding: GET http://localhost:3010/api/setup
|
||||
* Remove this file before going to production.
|
||||
*/
|
||||
import { NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
|
||||
export async function GET() {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
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
|
||||
await prisma.account.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.user.deleteMany({ where: { id: 'demo-admin-user-id' } })
|
||||
|
||||
// Re-create via better-auth so the password is properly hashed
|
||||
const result = await auth.api.signUpEmail({
|
||||
body: { email: 'admin@demo.de', password: 'demo1234', name: 'Demo Admin' },
|
||||
})
|
||||
|
||||
if (!result?.user) {
|
||||
return NextResponse.json({ error: 'signUp failed', result }, { status: 500 })
|
||||
}
|
||||
|
||||
const newUserId = result.user.id
|
||||
|
||||
// Restore org membership for the new user ID
|
||||
const org = await prisma.organization.findFirst({ where: { slug: 'innung-elektro-stuttgart' } })
|
||||
if (org) {
|
||||
await prisma.userRole.upsert({
|
||||
where: { orgId_userId: { orgId: org.id, userId: newUserId } },
|
||||
update: {},
|
||||
create: { orgId: org.id, userId: newUserId, role: 'admin' },
|
||||
})
|
||||
await prisma.member.upsert({
|
||||
where: { userId: newUserId },
|
||||
update: {},
|
||||
create: {
|
||||
orgId: org.id,
|
||||
userId: newUserId,
|
||||
name: 'Demo Admin',
|
||||
betrieb: 'Innungsgeschäftsstelle',
|
||||
sparte: 'Elektrotechnik',
|
||||
ort: 'Stuttgart',
|
||||
email: 'admin@demo.de',
|
||||
status: 'aktiv',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
message: 'Setup complete. Login: admin@demo.de / demo1234',
|
||||
})
|
||||
}
|
||||
/**
|
||||
* DEV-ONLY: Sets a password for the demo admin user via better-auth.
|
||||
* Call once after seeding: GET http://localhost:3010/api/setup
|
||||
* Remove this file before going to production.
|
||||
*/
|
||||
import { NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
|
||||
export async function GET() {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
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
|
||||
await prisma.account.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.user.deleteMany({ where: { id: 'demo-admin-user-id' } })
|
||||
|
||||
// Re-create via better-auth so the password is properly hashed
|
||||
const result = await auth.api.signUpEmail({
|
||||
body: { email: 'admin@demo.de', password: 'demo1234', name: 'Demo Admin' },
|
||||
})
|
||||
|
||||
if (!result?.user) {
|
||||
return NextResponse.json({ error: 'signUp failed', result }, { status: 500 })
|
||||
}
|
||||
|
||||
const newUserId = result.user.id
|
||||
|
||||
// Restore org membership for the new user ID
|
||||
const org = await prisma.organization.findFirst({ where: { slug: 'innung-elektro-stuttgart' } })
|
||||
if (org) {
|
||||
await prisma.userRole.upsert({
|
||||
where: { orgId_userId: { orgId: org.id, userId: newUserId } },
|
||||
update: {},
|
||||
create: { orgId: org.id, userId: newUserId, role: 'admin' },
|
||||
})
|
||||
await prisma.member.upsert({
|
||||
where: { userId: newUserId },
|
||||
update: {},
|
||||
create: {
|
||||
orgId: org.id,
|
||||
userId: newUserId,
|
||||
name: 'Demo Admin',
|
||||
betrieb: 'Innungsgeschäftsstelle',
|
||||
sparte: 'Elektrotechnik',
|
||||
ort: 'Stuttgart',
|
||||
email: 'admin@demo.de',
|
||||
status: 'aktiv',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
message: 'Setup complete. Login: admin@demo.de / demo1234',
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,61 +1,61 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { writeFile, mkdir } from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { auth, getSanitizedHeaders } from '@/lib/auth'
|
||||
|
||||
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
|
||||
|
||||
function getUploadRoot() {
|
||||
if (path.isAbsolute(UPLOAD_DIR)) {
|
||||
return UPLOAD_DIR
|
||||
}
|
||||
return path.resolve(process.cwd(), UPLOAD_DIR)
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
// Auth check
|
||||
const session = await auth.api.getSession({ headers: await getSanitizedHeaders(req.headers) })
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const formData = await req.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (file.size > MAX_SIZE_BYTES) {
|
||||
return NextResponse.json({ error: 'File too large' }, { status: 413 })
|
||||
}
|
||||
|
||||
// Only allow safe file types
|
||||
const allowedTypes = [
|
||||
'application/pdf',
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
]
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
return NextResponse.json({ error: 'File type not allowed' }, { status: 415 })
|
||||
}
|
||||
|
||||
const ext = path.extname(file.name)
|
||||
const fileName = `${randomUUID()}${ext}`
|
||||
const uploadPath = getUploadRoot()
|
||||
|
||||
await mkdir(uploadPath, { recursive: true })
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
await writeFile(path.join(uploadPath, fileName), buffer)
|
||||
|
||||
return NextResponse.json({
|
||||
storagePath: fileName,
|
||||
name: file.name,
|
||||
sizeBytes: file.size,
|
||||
url: `/uploads/${fileName}`,
|
||||
})
|
||||
}
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { writeFile, mkdir } from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { auth, getSanitizedHeaders } from '@/lib/auth'
|
||||
|
||||
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
|
||||
|
||||
function getUploadRoot() {
|
||||
if (path.isAbsolute(UPLOAD_DIR)) {
|
||||
return UPLOAD_DIR
|
||||
}
|
||||
return path.resolve(process.cwd(), UPLOAD_DIR)
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
// Auth check
|
||||
const session = await auth.api.getSession({ headers: await getSanitizedHeaders(req.headers) })
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const formData = await req.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (file.size > MAX_SIZE_BYTES) {
|
||||
return NextResponse.json({ error: 'File too large' }, { status: 413 })
|
||||
}
|
||||
|
||||
// Only allow safe file types
|
||||
const allowedTypes = [
|
||||
'application/pdf',
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
]
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
return NextResponse.json({ error: 'File type not allowed' }, { status: 415 })
|
||||
}
|
||||
|
||||
const ext = path.extname(file.name)
|
||||
const fileName = `${randomUUID()}${ext}`
|
||||
const uploadPath = getUploadRoot()
|
||||
|
||||
await mkdir(uploadPath, { recursive: true })
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
await writeFile(path.join(uploadPath, fileName), buffer)
|
||||
|
||||
return NextResponse.json({
|
||||
storagePath: fileName,
|
||||
name: file.name,
|
||||
sizeBytes: file.size,
|
||||
url: `/uploads/${fileName}`,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,50 +1,50 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { readFile } from 'fs/promises'
|
||||
import path from 'path'
|
||||
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR ?? (process.env.NODE_ENV === 'production' ? '/app/uploads' : './uploads')
|
||||
|
||||
function getUploadRoot() {
|
||||
if (path.isAbsolute(UPLOAD_DIR)) {
|
||||
return UPLOAD_DIR
|
||||
}
|
||||
return path.resolve(process.cwd(), UPLOAD_DIR)
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
try {
|
||||
const { path: filePathParams } = await params
|
||||
const uploadRoot = getUploadRoot()
|
||||
const filePath = path.join(uploadRoot, ...filePathParams)
|
||||
|
||||
// Security: prevent path traversal
|
||||
const resolved = path.resolve(filePath)
|
||||
const uploadDir = path.resolve(uploadRoot)
|
||||
if (!resolved.startsWith(uploadDir + path.sep) && resolved !== uploadDir) {
|
||||
return new NextResponse('Forbidden', { status: 403 })
|
||||
}
|
||||
|
||||
const file = await readFile(resolved)
|
||||
const ext = path.extname(resolved).toLowerCase()
|
||||
const mimeTypes: Record<string, string> = {
|
||||
'.pdf': 'application/pdf',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
}
|
||||
|
||||
return new NextResponse(file, {
|
||||
headers: {
|
||||
'Content-Type': mimeTypes[ext] ?? 'application/octet-stream',
|
||||
'Cache-Control': 'public, max-age=86400',
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
return new NextResponse('Not Found', { status: 404 })
|
||||
}
|
||||
}
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { readFile } from 'fs/promises'
|
||||
import path from 'path'
|
||||
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR ?? (process.env.NODE_ENV === 'production' ? '/app/uploads' : './uploads')
|
||||
|
||||
function getUploadRoot() {
|
||||
if (path.isAbsolute(UPLOAD_DIR)) {
|
||||
return UPLOAD_DIR
|
||||
}
|
||||
return path.resolve(process.cwd(), UPLOAD_DIR)
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
try {
|
||||
const { path: filePathParams } = await params
|
||||
const uploadRoot = getUploadRoot()
|
||||
const filePath = path.join(uploadRoot, ...filePathParams)
|
||||
|
||||
// Security: prevent path traversal
|
||||
const resolved = path.resolve(filePath)
|
||||
const uploadDir = path.resolve(uploadRoot)
|
||||
if (!resolved.startsWith(uploadDir + path.sep) && resolved !== uploadDir) {
|
||||
return new NextResponse('Forbidden', { status: 403 })
|
||||
}
|
||||
|
||||
const file = await readFile(resolved)
|
||||
const ext = path.extname(resolved).toLowerCase()
|
||||
const mimeTypes: Record<string, string> = {
|
||||
'.pdf': 'application/pdf',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
}
|
||||
|
||||
return new NextResponse(file, {
|
||||
headers: {
|
||||
'Content-Type': mimeTypes[ext] ?? 'application/octet-stream',
|
||||
'Cache-Control': 'public, max-age=86400',
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
return new NextResponse('Not Found', { status: 404 })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,107 +1,107 @@
|
|||
import { auth, getSanitizedHeaders } from '@/lib/auth'
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
import { headers } from 'next/headers'
|
||||
import Link from 'next/link'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default async function GlobalDashboardRedirect() {
|
||||
const headerList = await headers()
|
||||
const host = headerList.get('host') || ''
|
||||
const session = await auth.api.getSession({ headers: await getSanitizedHeaders(headerList) })
|
||||
|
||||
if (!session?.user) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
// Superadmin logic
|
||||
const superAdminEmail = process.env.SUPERADMIN_EMAIL || 'superadmin@innungsapp.de'
|
||||
const isSuperAdmin = session.user.email === superAdminEmail || session.user.role === 'admin'
|
||||
|
||||
if (isSuperAdmin) {
|
||||
redirect('/superadmin')
|
||||
}
|
||||
|
||||
const userRoles = await prisma.userRole.findMany({
|
||||
where: { userId: session.user.id, role: 'admin' },
|
||||
include: {
|
||||
org: {
|
||||
select: { id: true, name: true, slug: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
if (userRoles.length === 1) {
|
||||
const slug = userRoles[0].org.slug
|
||||
const protocol = host.includes('localhost') ? 'http' : 'https'
|
||||
|
||||
// Construct the subdomain URL
|
||||
let newHost = host
|
||||
if (host.includes('localhost')) {
|
||||
const port = host.includes(':') ? `:${host.split(':')[1]}` : ''
|
||||
newHost = `${slug}.localhost${port}`
|
||||
} else {
|
||||
// Assumes domain.tld
|
||||
const parts = host.split('.')
|
||||
if (parts.length === 2) {
|
||||
newHost = `${slug}.${host}`
|
||||
} else if (parts.length > 2) {
|
||||
newHost = `${slug}.${parts.slice(-2).join('.')}`
|
||||
}
|
||||
}
|
||||
|
||||
redirect(`${protocol}://${newHost}/dashboard`)
|
||||
}
|
||||
|
||||
const getOrgUrl = (slug: string, currentHost: string) => {
|
||||
const protocol = currentHost.includes('localhost') ? 'http' : 'https'
|
||||
let newHost = currentHost
|
||||
if (currentHost.includes('localhost')) {
|
||||
const port = currentHost.includes(':') ? `:${currentHost.split(':')[1]}` : ''
|
||||
newHost = `${slug}.localhost${port}`
|
||||
} else {
|
||||
const parts = currentHost.split('.')
|
||||
newHost = parts.length >= 2 ? `${slug}.${parts.slice(-2).join('.')}` : `${slug}.${currentHost}`
|
||||
}
|
||||
return `${protocol}://${newHost}/dashboard`
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
<h1 className="text-xl font-bold text-gray-900 mb-2">
|
||||
{userRoles.length > 1 ? 'Bitte Innung auswählen' : 'Kein Mandant zugeordnet'}
|
||||
</h1>
|
||||
|
||||
{userRoles.length > 1 ? (
|
||||
<div className="space-y-2 mb-6">
|
||||
{userRoles.map((userRole) => (
|
||||
<Link
|
||||
key={userRole.org.id}
|
||||
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"
|
||||
>
|
||||
{userRole.org.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 mb-6 text-sm">
|
||||
Ihr Konto hat aktuell keinen Admin-Zugriff auf eine Innung.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form action={async () => {
|
||||
'use server'
|
||||
const { auth, getSanitizedHeaders } = await import('@/lib/auth')
|
||||
await auth.api.signOut({ headers: await getSanitizedHeaders() })
|
||||
redirect('/login')
|
||||
}}>
|
||||
<button type="submit" className="text-sm font-medium text-brand-600 hover:text-brand-700">
|
||||
Abmelden
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { auth, getSanitizedHeaders } from '@/lib/auth'
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
import { headers } from 'next/headers'
|
||||
import Link from 'next/link'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default async function GlobalDashboardRedirect() {
|
||||
const headerList = await headers()
|
||||
const host = headerList.get('host') || ''
|
||||
const session = await auth.api.getSession({ headers: await getSanitizedHeaders(headerList) })
|
||||
|
||||
if (!session?.user) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
// Superadmin logic
|
||||
const superAdminEmail = process.env.SUPERADMIN_EMAIL || 'superadmin@innungsapp.de'
|
||||
const isSuperAdmin = session.user.email === superAdminEmail || session.user.role === 'admin'
|
||||
|
||||
if (isSuperAdmin) {
|
||||
redirect('/superadmin')
|
||||
}
|
||||
|
||||
const userRoles = await prisma.userRole.findMany({
|
||||
where: { userId: session.user.id, role: 'admin' },
|
||||
include: {
|
||||
org: {
|
||||
select: { id: true, name: true, slug: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
if (userRoles.length === 1) {
|
||||
const slug = userRoles[0].org.slug
|
||||
const protocol = host.includes('localhost') ? 'http' : 'https'
|
||||
|
||||
// Construct the subdomain URL
|
||||
let newHost = host
|
||||
if (host.includes('localhost')) {
|
||||
const port = host.includes(':') ? `:${host.split(':')[1]}` : ''
|
||||
newHost = `${slug}.localhost${port}`
|
||||
} else {
|
||||
// Assumes domain.tld
|
||||
const parts = host.split('.')
|
||||
if (parts.length === 2) {
|
||||
newHost = `${slug}.${host}`
|
||||
} else if (parts.length > 2) {
|
||||
newHost = `${slug}.${parts.slice(-2).join('.')}`
|
||||
}
|
||||
}
|
||||
|
||||
redirect(`${protocol}://${newHost}/dashboard`)
|
||||
}
|
||||
|
||||
const getOrgUrl = (slug: string, currentHost: string) => {
|
||||
const protocol = currentHost.includes('localhost') ? 'http' : 'https'
|
||||
let newHost = currentHost
|
||||
if (currentHost.includes('localhost')) {
|
||||
const port = currentHost.includes(':') ? `:${currentHost.split(':')[1]}` : ''
|
||||
newHost = `${slug}.localhost${port}`
|
||||
} else {
|
||||
const parts = currentHost.split('.')
|
||||
newHost = parts.length >= 2 ? `${slug}.${parts.slice(-2).join('.')}` : `${slug}.${currentHost}`
|
||||
}
|
||||
return `${protocol}://${newHost}/dashboard`
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
<h1 className="text-xl font-bold text-gray-900 mb-2">
|
||||
{userRoles.length > 1 ? 'Bitte Innung auswählen' : 'Kein Mandant zugeordnet'}
|
||||
</h1>
|
||||
|
||||
{userRoles.length > 1 ? (
|
||||
<div className="space-y-2 mb-6">
|
||||
{userRoles.map((userRole) => (
|
||||
<Link
|
||||
key={userRole.org.id}
|
||||
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"
|
||||
>
|
||||
{userRole.org.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 mb-6 text-sm">
|
||||
Ihr Konto hat aktuell keinen Admin-Zugriff auf eine Innung.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form action={async () => {
|
||||
'use server'
|
||||
const { auth, getSanitizedHeaders } = await import('@/lib/auth')
|
||||
await auth.api.signOut({ headers: await getSanitizedHeaders() })
|
||||
redirect('/login')
|
||||
}}>
|
||||
<button type="submit" className="text-sm font-medium text-brand-600 hover:text-brand-700">
|
||||
Abmelden
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,133 +1,133 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { createAuthClient } from 'better-auth/react'
|
||||
|
||||
const authClient = createAuthClient({
|
||||
baseURL: typeof window !== 'undefined'
|
||||
? window.location.origin
|
||||
: (process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3010'),
|
||||
})
|
||||
|
||||
export default function PasswortAendernPage() {
|
||||
const [oldPassword, setOldPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('Die neuen Passwörter stimmen nicht überein.')
|
||||
return
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
setError('Das neue Passwort muss mindestens 8 Zeichen haben.')
|
||||
return
|
||||
}
|
||||
if (newPassword === oldPassword) {
|
||||
setError('Das neue Passwort muss sich vom alten unterscheiden.')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
const result = await authClient.changePassword({
|
||||
currentPassword: oldPassword,
|
||||
newPassword,
|
||||
revokeOtherSessions: false,
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
setLoading(false)
|
||||
setError(result.error.message ?? 'Das alte Passwort ist falsch.')
|
||||
return
|
||||
}
|
||||
|
||||
// Mark mustChangePassword as done
|
||||
await fetch('/api/auth/clear-must-change-password', { method: 'POST' })
|
||||
|
||||
window.location.href = '/dashboard'
|
||||
}
|
||||
|
||||
return (
|
||||
<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="bg-white rounded-lg border p-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl font-semibold text-gray-900">Passwort ändern</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Bitte legen Sie jetzt Ihr persönliches Passwort fest.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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.
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="oldPassword" className="block text-xs font-medium text-gray-600 mb-1 uppercase tracking-wide">
|
||||
Temporäres Passwort (aus der Einladung)
|
||||
</label>
|
||||
<input
|
||||
id="oldPassword"
|
||||
type="password"
|
||||
required
|
||||
value={oldPassword}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="newPassword" className="block text-xs font-medium text-gray-600 mb-1 uppercase tracking-wide">
|
||||
Neues Passwort
|
||||
</label>
|
||||
<input
|
||||
id="newPassword"
|
||||
type="password"
|
||||
required
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-xs font-medium text-gray-600 mb-1 uppercase tracking-wide">
|
||||
Neues Passwort bestätigen
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
{loading ? 'Bitte warten...' : 'Passwort festlegen'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { createAuthClient } from 'better-auth/react'
|
||||
|
||||
const authClient = createAuthClient({
|
||||
baseURL: typeof window !== 'undefined'
|
||||
? window.location.origin
|
||||
: (process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3010'),
|
||||
})
|
||||
|
||||
export default function PasswortAendernPage() {
|
||||
const [oldPassword, setOldPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('Die neuen Passwörter stimmen nicht überein.')
|
||||
return
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
setError('Das neue Passwort muss mindestens 8 Zeichen haben.')
|
||||
return
|
||||
}
|
||||
if (newPassword === oldPassword) {
|
||||
setError('Das neue Passwort muss sich vom alten unterscheiden.')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
const result = await authClient.changePassword({
|
||||
currentPassword: oldPassword,
|
||||
newPassword,
|
||||
revokeOtherSessions: false,
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
setLoading(false)
|
||||
setError(result.error.message ?? 'Das alte Passwort ist falsch.')
|
||||
return
|
||||
}
|
||||
|
||||
// Mark mustChangePassword as done
|
||||
await fetch('/api/auth/clear-must-change-password', { method: 'POST' })
|
||||
|
||||
window.location.href = '/dashboard'
|
||||
}
|
||||
|
||||
return (
|
||||
<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="bg-white rounded-lg border p-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl font-semibold text-gray-900">Passwort ändern</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Bitte legen Sie jetzt Ihr persönliches Passwort fest.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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.
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="oldPassword" className="block text-xs font-medium text-gray-600 mb-1 uppercase tracking-wide">
|
||||
Temporäres Passwort (aus der Einladung)
|
||||
</label>
|
||||
<input
|
||||
id="oldPassword"
|
||||
type="password"
|
||||
required
|
||||
value={oldPassword}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="newPassword" className="block text-xs font-medium text-gray-600 mb-1 uppercase tracking-wide">
|
||||
Neues Passwort
|
||||
</label>
|
||||
<input
|
||||
id="newPassword"
|
||||
type="password"
|
||||
required
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-xs font-medium text-gray-600 mb-1 uppercase tracking-wide">
|
||||
Neues Passwort bestätigen
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
{loading ? 'Bitte warten...' : 'Passwort festlegen'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,429 +1,429 @@
|
|||
'use client'
|
||||
|
||||
import { useActionState, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { createOrganization } from './actions'
|
||||
import { LandingPagePreview } from './LandingPagePreview'
|
||||
|
||||
const initialState = { success: false, error: '' }
|
||||
|
||||
export function CreateOrgForm() {
|
||||
const [state, formAction, isPending] = useActionState(createOrganization, initialState)
|
||||
const router = useRouter()
|
||||
const [step, setStep] = useState(1)
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
slug: '',
|
||||
contactEmail: '',
|
||||
adminEmail: '',
|
||||
adminPassword: '',
|
||||
logoUrl: '',
|
||||
plan: 'pilot',
|
||||
primaryColor: '#E63946',
|
||||
secondaryColor: '',
|
||||
landingPageTitle: '',
|
||||
landingPageText: '',
|
||||
landingPageHeroImage: '',
|
||||
landingPageHeroOverlayOpacity: 50,
|
||||
landingPageFeatures: '',
|
||||
landingPageFooter: '',
|
||||
appStoreUrl: '',
|
||||
playStoreUrl: ''
|
||||
})
|
||||
|
||||
const [aiContext, setAiContext] = useState('')
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
|
||||
const handleGenerateContent = async () => {
|
||||
if (!formData.name || !aiContext) return
|
||||
setIsGenerating(true)
|
||||
try {
|
||||
const res = await fetch('/api/ai/generate-landing-page', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ orgName: formData.name, context: aiContext })
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.title && data.text) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
landingPageTitle: data.title,
|
||||
landingPageText: data.text
|
||||
}))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('AI generation failed', err)
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
setIsUploading(true)
|
||||
const uploadFormData = new FormData()
|
||||
uploadFormData.append('file', file)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
body: uploadFormData
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.url) {
|
||||
setFormData(prev => ({ ...prev, logoUrl: data.url }))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Upload failed', err)
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const [isHeroUploading, setIsHeroUploading] = useState(false)
|
||||
const appBaseUrl = (typeof window !== 'undefined'
|
||||
? window.location.origin
|
||||
: (process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3010')).replace(/\/$/, '')
|
||||
|
||||
const handleHeroUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
setIsHeroUploading(true)
|
||||
const uploadFormData = new FormData()
|
||||
uploadFormData.append('file', file)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
body: uploadFormData
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.url) {
|
||||
setFormData(prev => ({ ...prev, landingPageHeroImage: data.url }))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Upload failed', err)
|
||||
} finally {
|
||||
setIsHeroUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
|
||||
setFormData(prev => ({ ...prev, [e.target.name]: e.target.value }))
|
||||
}
|
||||
|
||||
const nextStep = () => setStep(prev => prev + 1)
|
||||
const prevStep = () => setStep(prev => prev - 1)
|
||||
|
||||
// Reset wizard after success
|
||||
if (state.success && step !== 5) {
|
||||
setStep(5)
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
<LandingPagePreview formData={formData} />
|
||||
</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">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-6 font-outfit shrink-0">Neue Innung anlegen</h2>
|
||||
|
||||
{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">
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
{[1, 2, 3, 4, 5].map((s) => (
|
||||
<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'}`}>
|
||||
{s}
|
||||
</div>
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form action={formAction} className="flex-1 shrink-0 space-y-6">
|
||||
{step !== 1 && (
|
||||
<>
|
||||
<input type="hidden" name="name" value={formData.name} />
|
||||
<input type="hidden" name="slug" value={formData.slug} />
|
||||
</>
|
||||
)}
|
||||
<input type="hidden" name="contactEmail" value={formData.contactEmail} />
|
||||
<input type="hidden" name="adminEmail" value={formData.adminEmail} />
|
||||
<input type="hidden" name="adminPassword" value={formData.adminPassword} />
|
||||
<input type="hidden" name="logoUrl" value={formData.logoUrl} />
|
||||
<input type="hidden" name="plan" value={formData.plan} />
|
||||
<input type="hidden" name="primaryColor" value={formData.primaryColor} />
|
||||
<input type="hidden" name="secondaryColor" value={formData.secondaryColor} />
|
||||
<input type="hidden" name="landingPageTitle" value={formData.landingPageTitle} />
|
||||
<input type="hidden" name="landingPageText" value={formData.landingPageText} />
|
||||
<input type="hidden" name="landingPageHeroImage" value={formData.landingPageHeroImage} />
|
||||
<input type="hidden" name="landingPageFeatures" value={formData.landingPageFeatures} />
|
||||
<input type="hidden" name="landingPageFooter" value={formData.landingPageFooter} />
|
||||
<input type="hidden" name="appStoreUrl" value={formData.appStoreUrl} />
|
||||
<input type="hidden" name="playStoreUrl" value={formData.playStoreUrl} />
|
||||
|
||||
{step === 1 && (
|
||||
<div className="space-y-5 animate-in fade-in slide-in-from-right-4 duration-300">
|
||||
<div>
|
||||
<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" />
|
||||
</div>
|
||||
<div>
|
||||
<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" />
|
||||
<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>
|
||||
<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">
|
||||
<option value="pilot">Pilot</option>
|
||||
<option value="standard">Standard</option>
|
||||
<option value="pro">Pro</option>
|
||||
<option value="verband">Verband</option>
|
||||
</select>
|
||||
</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">
|
||||
Weiter zu Branding
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<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>
|
||||
<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" />
|
||||
</div>
|
||||
<div>
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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">
|
||||
{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">
|
||||
<img src={formData.logoUrl} alt="Logo" className="max-w-full max-h-full object-contain" />
|
||||
</div>
|
||||
) : (
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
<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' : ''}`}>
|
||||
{isUploading ? 'Wird hochgeladen...' : formData.logoUrl ? 'Logo ändern' : 'Bild auswählen'}
|
||||
</div>
|
||||
<input type="file" onChange={handleUpload} accept="image/*" className="hidden" disabled={isUploading} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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">
|
||||
<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">
|
||||
<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 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">
|
||||
Zurück
|
||||
</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">
|
||||
Weiter zur Landingpage
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<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">
|
||||
<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">
|
||||
Beschreiben Sie in wenigen Stichpunkten, worauf die Innung fokussiert ist (Region, Tradition, Ausbildung, etc.). Die KI generiert daraus eine moderne Landingpage.
|
||||
</p>
|
||||
<textarea
|
||||
value={aiContext}
|
||||
onChange={(e) => setAiContext(e.target.value)}
|
||||
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]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerateContent}
|
||||
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"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
Generieren...
|
||||
</>
|
||||
) : '✨ Content generieren'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
<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">
|
||||
Zurück
|
||||
</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]">
|
||||
Weiter zu Erweitert
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 4 && (
|
||||
<div className="space-y-5 animate-in fade-in slide-in-from-right-4 duration-300">
|
||||
<div>
|
||||
<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">
|
||||
{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">
|
||||
<img src={formData.landingPageHeroImage} alt="Hero" className="max-w-full max-h-full object-cover" />
|
||||
</div>
|
||||
) : (
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
<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' : ''}`}>
|
||||
{isHeroUploading ? 'Wird hochgeladen...' : formData.landingPageHeroImage ? 'Bild ändern' : 'Bild auswählen'}
|
||||
</div>
|
||||
<input type="file" onChange={handleHeroUpload} accept="image/*" className="hidden" disabled={isHeroUploading} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formData.landingPageHeroImage && (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-1">
|
||||
Hero-Deckkraft (Opacity: {formData.landingPageHeroOverlayOpacity}%)
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
name="landingPageHeroOverlayOpacity"
|
||||
min="0"
|
||||
max="100"
|
||||
value={formData.landingPageHeroOverlayOpacity}
|
||||
onChange={handleChange}
|
||||
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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<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">
|
||||
<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">
|
||||
<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>
|
||||
<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" />
|
||||
<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 className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<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" />
|
||||
</div>
|
||||
<div>
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
<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">
|
||||
Zurück
|
||||
</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">
|
||||
{isPending ? 'Wird erstellt...' : 'Innung anlegen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 5 && (
|
||||
<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">
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<a href={`${appBaseUrl}/${formData.slug}`} target="_blank" rel="noreferrer" className="text-[#E63946] font-bold text-lg hover:underline block break-all">
|
||||
{appBaseUrl}/{formData.slug}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<button type="button" onClick={() => {
|
||||
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]">
|
||||
Zurück zur Übersicht
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useActionState, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { createOrganization } from './actions'
|
||||
import { LandingPagePreview } from './LandingPagePreview'
|
||||
|
||||
const initialState = { success: false, error: '' }
|
||||
|
||||
export function CreateOrgForm() {
|
||||
const [state, formAction, isPending] = useActionState(createOrganization, initialState)
|
||||
const router = useRouter()
|
||||
const [step, setStep] = useState(1)
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
slug: '',
|
||||
contactEmail: '',
|
||||
adminEmail: '',
|
||||
adminPassword: '',
|
||||
logoUrl: '',
|
||||
plan: 'pilot',
|
||||
primaryColor: '#E63946',
|
||||
secondaryColor: '',
|
||||
landingPageTitle: '',
|
||||
landingPageText: '',
|
||||
landingPageHeroImage: '',
|
||||
landingPageHeroOverlayOpacity: 50,
|
||||
landingPageFeatures: '',
|
||||
landingPageFooter: '',
|
||||
appStoreUrl: '',
|
||||
playStoreUrl: ''
|
||||
})
|
||||
|
||||
const [aiContext, setAiContext] = useState('')
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
|
||||
const handleGenerateContent = async () => {
|
||||
if (!formData.name || !aiContext) return
|
||||
setIsGenerating(true)
|
||||
try {
|
||||
const res = await fetch('/api/ai/generate-landing-page', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ orgName: formData.name, context: aiContext })
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.title && data.text) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
landingPageTitle: data.title,
|
||||
landingPageText: data.text
|
||||
}))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('AI generation failed', err)
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
setIsUploading(true)
|
||||
const uploadFormData = new FormData()
|
||||
uploadFormData.append('file', file)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
body: uploadFormData
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.url) {
|
||||
setFormData(prev => ({ ...prev, logoUrl: data.url }))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Upload failed', err)
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const [isHeroUploading, setIsHeroUploading] = useState(false)
|
||||
const appBaseUrl = (typeof window !== 'undefined'
|
||||
? window.location.origin
|
||||
: (process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3010')).replace(/\/$/, '')
|
||||
|
||||
const handleHeroUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
setIsHeroUploading(true)
|
||||
const uploadFormData = new FormData()
|
||||
uploadFormData.append('file', file)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
body: uploadFormData
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.url) {
|
||||
setFormData(prev => ({ ...prev, landingPageHeroImage: data.url }))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Upload failed', err)
|
||||
} finally {
|
||||
setIsHeroUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
|
||||
setFormData(prev => ({ ...prev, [e.target.name]: e.target.value }))
|
||||
}
|
||||
|
||||
const nextStep = () => setStep(prev => prev + 1)
|
||||
const prevStep = () => setStep(prev => prev - 1)
|
||||
|
||||
// Reset wizard after success
|
||||
if (state.success && step !== 5) {
|
||||
setStep(5)
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
<LandingPagePreview formData={formData} />
|
||||
</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">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-6 font-outfit shrink-0">Neue Innung anlegen</h2>
|
||||
|
||||
{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">
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
{[1, 2, 3, 4, 5].map((s) => (
|
||||
<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'}`}>
|
||||
{s}
|
||||
</div>
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form action={formAction} className="flex-1 shrink-0 space-y-6">
|
||||
{step !== 1 && (
|
||||
<>
|
||||
<input type="hidden" name="name" value={formData.name} />
|
||||
<input type="hidden" name="slug" value={formData.slug} />
|
||||
</>
|
||||
)}
|
||||
<input type="hidden" name="contactEmail" value={formData.contactEmail} />
|
||||
<input type="hidden" name="adminEmail" value={formData.adminEmail} />
|
||||
<input type="hidden" name="adminPassword" value={formData.adminPassword} />
|
||||
<input type="hidden" name="logoUrl" value={formData.logoUrl} />
|
||||
<input type="hidden" name="plan" value={formData.plan} />
|
||||
<input type="hidden" name="primaryColor" value={formData.primaryColor} />
|
||||
<input type="hidden" name="secondaryColor" value={formData.secondaryColor} />
|
||||
<input type="hidden" name="landingPageTitle" value={formData.landingPageTitle} />
|
||||
<input type="hidden" name="landingPageText" value={formData.landingPageText} />
|
||||
<input type="hidden" name="landingPageHeroImage" value={formData.landingPageHeroImage} />
|
||||
<input type="hidden" name="landingPageFeatures" value={formData.landingPageFeatures} />
|
||||
<input type="hidden" name="landingPageFooter" value={formData.landingPageFooter} />
|
||||
<input type="hidden" name="appStoreUrl" value={formData.appStoreUrl} />
|
||||
<input type="hidden" name="playStoreUrl" value={formData.playStoreUrl} />
|
||||
|
||||
{step === 1 && (
|
||||
<div className="space-y-5 animate-in fade-in slide-in-from-right-4 duration-300">
|
||||
<div>
|
||||
<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" />
|
||||
</div>
|
||||
<div>
|
||||
<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" />
|
||||
<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>
|
||||
<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">
|
||||
<option value="pilot">Pilot</option>
|
||||
<option value="standard">Standard</option>
|
||||
<option value="pro">Pro</option>
|
||||
<option value="verband">Verband</option>
|
||||
</select>
|
||||
</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">
|
||||
Weiter zu Branding
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<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>
|
||||
<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" />
|
||||
</div>
|
||||
<div>
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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">
|
||||
{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">
|
||||
<img src={formData.logoUrl} alt="Logo" className="max-w-full max-h-full object-contain" />
|
||||
</div>
|
||||
) : (
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
<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' : ''}`}>
|
||||
{isUploading ? 'Wird hochgeladen...' : formData.logoUrl ? 'Logo ändern' : 'Bild auswählen'}
|
||||
</div>
|
||||
<input type="file" onChange={handleUpload} accept="image/*" className="hidden" disabled={isUploading} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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">
|
||||
<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">
|
||||
<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 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">
|
||||
Zurück
|
||||
</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">
|
||||
Weiter zur Landingpage
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<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">
|
||||
<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">
|
||||
Beschreiben Sie in wenigen Stichpunkten, worauf die Innung fokussiert ist (Region, Tradition, Ausbildung, etc.). Die KI generiert daraus eine moderne Landingpage.
|
||||
</p>
|
||||
<textarea
|
||||
value={aiContext}
|
||||
onChange={(e) => setAiContext(e.target.value)}
|
||||
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]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerateContent}
|
||||
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"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
Generieren...
|
||||
</>
|
||||
) : '✨ Content generieren'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
<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">
|
||||
Zurück
|
||||
</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]">
|
||||
Weiter zu Erweitert
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 4 && (
|
||||
<div className="space-y-5 animate-in fade-in slide-in-from-right-4 duration-300">
|
||||
<div>
|
||||
<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">
|
||||
{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">
|
||||
<img src={formData.landingPageHeroImage} alt="Hero" className="max-w-full max-h-full object-cover" />
|
||||
</div>
|
||||
) : (
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
<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' : ''}`}>
|
||||
{isHeroUploading ? 'Wird hochgeladen...' : formData.landingPageHeroImage ? 'Bild ändern' : 'Bild auswählen'}
|
||||
</div>
|
||||
<input type="file" onChange={handleHeroUpload} accept="image/*" className="hidden" disabled={isHeroUploading} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formData.landingPageHeroImage && (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-1">
|
||||
Hero-Deckkraft (Opacity: {formData.landingPageHeroOverlayOpacity}%)
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
name="landingPageHeroOverlayOpacity"
|
||||
min="0"
|
||||
max="100"
|
||||
value={formData.landingPageHeroOverlayOpacity}
|
||||
onChange={handleChange}
|
||||
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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<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">
|
||||
<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">
|
||||
<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>
|
||||
<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" />
|
||||
<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 className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<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" />
|
||||
</div>
|
||||
<div>
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
<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">
|
||||
Zurück
|
||||
</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">
|
||||
{isPending ? 'Wird erstellt...' : 'Innung anlegen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 5 && (
|
||||
<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">
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<a href={`${appBaseUrl}/${formData.slug}`} target="_blank" rel="noreferrer" className="text-[#E63946] font-bold text-lg hover:underline block break-all">
|
||||
{appBaseUrl}/{formData.slug}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<button type="button" onClick={() => {
|
||||
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]">
|
||||
Zurück zur Übersicht
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,355 +1,355 @@
|
|||
export function LandingPagePreview({ formData }: { formData: any }) {
|
||||
const primaryColor = formData.primaryColor || '#E63946'
|
||||
const secondaryColor = formData.secondaryColor || undefined
|
||||
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 features = formData.landingPageFeatures || '✅ Exzellente Ausbildung\n✅ Starke Gemeinschaft\n✅ Politische Interessenvertretung'
|
||||
const footer = formData.landingPageFooter || '© 2024 Innung'
|
||||
const sectionTitle = formData.landingPageSectionTitle || `${formData.name || 'Ihre Innung'} – Gemeinsam stark fürs Handwerk`
|
||||
const buttonText = formData.landingPageButtonText || 'Jetzt App laden'
|
||||
|
||||
return (
|
||||
<div className="w-full h-full bg-white overflow-y-auto font-sans flex flex-col relative">
|
||||
{/* Header */}
|
||||
<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%)`
|
||||
}}>
|
||||
<div className="flex items-center gap-4">
|
||||
{formData.logoUrl ? (
|
||||
<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>
|
||||
)}
|
||||
<span className="font-bold text-lg text-gray-800">{formData.name || 'Innungs-Logo'}</span>
|
||||
</div>
|
||||
<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="#leistungen" className="hover:text-black">Leistungen</a>
|
||||
<a href="#app" className="hover:text-black">App</a>
|
||||
</nav>
|
||||
<a
|
||||
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"
|
||||
style={{ color: primaryColor }}
|
||||
>
|
||||
Mitglieder verwalten
|
||||
</a>
|
||||
</header>
|
||||
|
||||
{/* 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]">
|
||||
{/* Background Image / Pattern */}
|
||||
{formData.landingPageHeroImage ? (
|
||||
<div className="absolute inset-0 z-0">
|
||||
<img src={formData.landingPageHeroImage} alt="Hero Background" className="w-full h-full object-cover" />
|
||||
<div
|
||||
className="absolute inset-0 bg-white"
|
||||
style={{ opacity: formData.landingPageHeroOverlayOpacity !== undefined ? formData.landingPageHeroOverlayOpacity / 100 : 0.5 }}
|
||||
></div>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-white/30 via-transparent to-white/90"></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="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 }}>
|
||||
{formData.name || 'Ihre Innung'}
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-black text-gray-900 tracking-tight leading-[1.1]">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600 leading-relaxed max-w-2xl mx-auto font-medium">
|
||||
{text}
|
||||
</p>
|
||||
<div className="pt-6 flex gap-4 justify-center">
|
||||
<a
|
||||
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"
|
||||
style={{ backgroundColor: primaryColor }}
|
||||
>
|
||||
{buttonText}
|
||||
</a>
|
||||
<a
|
||||
href="#leistungen"
|
||||
className="px-8 py-3.5 rounded-full font-semibold border shadow-sm transition-all cursor-pointer block hover:opacity-80"
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
borderColor: secondaryColor || '#e5e7eb',
|
||||
color: secondaryColor || '#374151'
|
||||
}}
|
||||
>
|
||||
Mehr erfahren
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features / Benefits */}
|
||||
<section id="leistungen" className="px-8 py-16" style={{ backgroundColor: secondaryColor ? `${secondaryColor}08` : '#f9fafb' }}>
|
||||
<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>
|
||||
<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) => (
|
||||
<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 }}>
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-800">{feature.replace(/^[-\*\✅\ ]+/, '')}</h3>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* App Features Grid */}
|
||||
<section id="app" className="px-8 py-20 bg-white">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<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 }}>
|
||||
<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
|
||||
</div>
|
||||
<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">
|
||||
Verpassen Sie keine wichtigen Branchen-Updates mehr. Vernetzen Sie sich mit anderen Betrieben und verwalten Sie Termine bequem auf dem Smartphone.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{/* 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="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>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3">Aktuelles</h3>
|
||||
<p className="text-gray-500 leading-relaxed">
|
||||
Lesen Sie die wichtigsten Branchen-News und bleiben Sie immer auf dem aktuellsten Stand.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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="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>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3">Termine</h3>
|
||||
<p className="text-gray-500 leading-relaxed">
|
||||
Verwalten Sie Veranstaltungen, Fortbildungen und Innungsversammlungen direkt in Ihrem Kalender.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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="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>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3">Stellenbörse</h3>
|
||||
<p className="text-gray-500 leading-relaxed">
|
||||
Finden Sie neue Fachkräfte oder Auszubildende. Veröffentlichen Sie Ihre offenen Stellenangebote branchenintern.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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="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>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3">Nachrichten</h3>
|
||||
<p className="text-gray-500 leading-relaxed">
|
||||
Tauschen Sie sich mit anderen Betrieben aus. Schnelle Kontaktaufnahme über direkte Einzel- und Gruppenchats.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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="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>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3">Profil & Ausweis</h3>
|
||||
<p className="text-gray-500 leading-relaxed">
|
||||
Ihr digitaler Mitgliedsausweis immer griffbereit. Verwalten Sie das Profil Ihres Betriebs komfortabel in der App.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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="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>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3">Netzwerk</h3>
|
||||
<p className="text-gray-500 leading-relaxed">
|
||||
Profitieren Sie von starken Kooperationen und Angeboten ausgewählter Partnerbetriebe in der Region.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Application Mock */}
|
||||
<section id="apps" className="px-8 py-32 relative overflow-hidden" style={{
|
||||
background: `linear-gradient(to bottom, #ffffff 0%, ${primaryColor} 40%, #111827 100%)`
|
||||
}}>
|
||||
|
||||
{/* 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 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="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="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>
|
||||
Jetzt verfügbar
|
||||
</div>
|
||||
<h2 className="text-4xl md:text-5xl font-black leading-tight">
|
||||
Laden Sie unsere App herunter
|
||||
</h2>
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-4 pt-4">
|
||||
{(!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">
|
||||
<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 className="text-xs text-white/70">Download on the</div>
|
||||
<div className="text-lg font-semibold leading-none">App Store</div>
|
||||
</div>
|
||||
</a>
|
||||
) : null}
|
||||
{(!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">
|
||||
<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 className="text-xs text-white/70">GET IT ON</div>
|
||||
<div className="text-lg font-semibold leading-none">Google Play</div>
|
||||
</div>
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{/* 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>
|
||||
|
||||
{/* App Screenshot Mockup */}
|
||||
<div className="w-full h-full bg-gray-50 flex flex-col pt-6">
|
||||
{/* App Header */}
|
||||
<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">
|
||||
{formData.logoUrl ? (
|
||||
<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 }}>
|
||||
{formData.name ? formData.name.charAt(0).toUpperCase() : 'I'}
|
||||
</div>
|
||||
)}
|
||||
<div className="font-bold text-sm text-gray-800 truncate w-28">{formData.name || 'Ihre Innung'}</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
{/* App Content */}
|
||||
<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="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="relative z-10 text-white font-bold text-lg leading-tight">Willkommen,<br />Max Mustermann</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
<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="w-12 h-12 rounded-lg flex-shrink-0" style={{ backgroundColor: `${primaryColor}15` }}></div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<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>
|
||||
</div>
|
||||
<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="flex-1 space-y-2">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 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="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>
|
||||
<span className="text-[9px] font-semibold" style={{ color: primaryColor }}>Start</span>
|
||||
</div>
|
||||
<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>
|
||||
<span className="text-[9px] font-medium">Aktuelles</span>
|
||||
</div>
|
||||
<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>
|
||||
<span className="text-[9px] font-medium">Termine</span>
|
||||
</div>
|
||||
<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>
|
||||
<span className="text-[9px] font-medium">Stellen</span>
|
||||
</div>
|
||||
<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>
|
||||
<span className="text-[9px] font-medium">Nachricht..</span>
|
||||
</div>
|
||||
<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>
|
||||
<span className="text-[9px] font-medium">Profil</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<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">
|
||||
<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">
|
||||
Profitieren Sie von unserem starken Netzwerk, exklusiven Brancheninformationen und unserer digitalen Innungs-App.
|
||||
</p>
|
||||
<a
|
||||
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"
|
||||
style={{ backgroundColor: primaryColor }}
|
||||
>
|
||||
Jetzt Mitglied werden
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<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="text-gray-300 font-bold text-lg mb-6">{formData.name || 'Innungs-Logo'}</div>
|
||||
<div className="whitespace-pre-wrap">{footer}</div>
|
||||
<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">Datenschutz</a>
|
||||
<a href="#" className="hover:text-white transition-colors">Kontakt</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export function LandingPagePreview({ formData }: { formData: any }) {
|
||||
const primaryColor = formData.primaryColor || '#E63946'
|
||||
const secondaryColor = formData.secondaryColor || undefined
|
||||
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 features = formData.landingPageFeatures || '✅ Exzellente Ausbildung\n✅ Starke Gemeinschaft\n✅ Politische Interessenvertretung'
|
||||
const footer = formData.landingPageFooter || '© 2024 Innung'
|
||||
const sectionTitle = formData.landingPageSectionTitle || `${formData.name || 'Ihre Innung'} – Gemeinsam stark fürs Handwerk`
|
||||
const buttonText = formData.landingPageButtonText || 'Jetzt App laden'
|
||||
|
||||
return (
|
||||
<div className="w-full h-full bg-white overflow-y-auto font-sans flex flex-col relative">
|
||||
{/* Header */}
|
||||
<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%)`
|
||||
}}>
|
||||
<div className="flex items-center gap-4">
|
||||
{formData.logoUrl ? (
|
||||
<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>
|
||||
)}
|
||||
<span className="font-bold text-lg text-gray-800">{formData.name || 'Innungs-Logo'}</span>
|
||||
</div>
|
||||
<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="#leistungen" className="hover:text-black">Leistungen</a>
|
||||
<a href="#app" className="hover:text-black">App</a>
|
||||
</nav>
|
||||
<a
|
||||
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"
|
||||
style={{ color: primaryColor }}
|
||||
>
|
||||
Mitglieder verwalten
|
||||
</a>
|
||||
</header>
|
||||
|
||||
{/* 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]">
|
||||
{/* Background Image / Pattern */}
|
||||
{formData.landingPageHeroImage ? (
|
||||
<div className="absolute inset-0 z-0">
|
||||
<img src={formData.landingPageHeroImage} alt="Hero Background" className="w-full h-full object-cover" />
|
||||
<div
|
||||
className="absolute inset-0 bg-white"
|
||||
style={{ opacity: formData.landingPageHeroOverlayOpacity !== undefined ? formData.landingPageHeroOverlayOpacity / 100 : 0.5 }}
|
||||
></div>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-white/30 via-transparent to-white/90"></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="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 }}>
|
||||
{formData.name || 'Ihre Innung'}
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-black text-gray-900 tracking-tight leading-[1.1]">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600 leading-relaxed max-w-2xl mx-auto font-medium">
|
||||
{text}
|
||||
</p>
|
||||
<div className="pt-6 flex gap-4 justify-center">
|
||||
<a
|
||||
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"
|
||||
style={{ backgroundColor: primaryColor }}
|
||||
>
|
||||
{buttonText}
|
||||
</a>
|
||||
<a
|
||||
href="#leistungen"
|
||||
className="px-8 py-3.5 rounded-full font-semibold border shadow-sm transition-all cursor-pointer block hover:opacity-80"
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
borderColor: secondaryColor || '#e5e7eb',
|
||||
color: secondaryColor || '#374151'
|
||||
}}
|
||||
>
|
||||
Mehr erfahren
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features / Benefits */}
|
||||
<section id="leistungen" className="px-8 py-16" style={{ backgroundColor: secondaryColor ? `${secondaryColor}08` : '#f9fafb' }}>
|
||||
<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>
|
||||
<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) => (
|
||||
<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 }}>
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-800">{feature.replace(/^[-\*\✅\ ]+/, '')}</h3>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* App Features Grid */}
|
||||
<section id="app" className="px-8 py-20 bg-white">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<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 }}>
|
||||
<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
|
||||
</div>
|
||||
<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">
|
||||
Verpassen Sie keine wichtigen Branchen-Updates mehr. Vernetzen Sie sich mit anderen Betrieben und verwalten Sie Termine bequem auf dem Smartphone.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{/* 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="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>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3">Aktuelles</h3>
|
||||
<p className="text-gray-500 leading-relaxed">
|
||||
Lesen Sie die wichtigsten Branchen-News und bleiben Sie immer auf dem aktuellsten Stand.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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="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>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3">Termine</h3>
|
||||
<p className="text-gray-500 leading-relaxed">
|
||||
Verwalten Sie Veranstaltungen, Fortbildungen und Innungsversammlungen direkt in Ihrem Kalender.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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="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>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3">Stellenbörse</h3>
|
||||
<p className="text-gray-500 leading-relaxed">
|
||||
Finden Sie neue Fachkräfte oder Auszubildende. Veröffentlichen Sie Ihre offenen Stellenangebote branchenintern.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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="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>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3">Nachrichten</h3>
|
||||
<p className="text-gray-500 leading-relaxed">
|
||||
Tauschen Sie sich mit anderen Betrieben aus. Schnelle Kontaktaufnahme über direkte Einzel- und Gruppenchats.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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="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>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3">Profil & Ausweis</h3>
|
||||
<p className="text-gray-500 leading-relaxed">
|
||||
Ihr digitaler Mitgliedsausweis immer griffbereit. Verwalten Sie das Profil Ihres Betriebs komfortabel in der App.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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="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>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3">Netzwerk</h3>
|
||||
<p className="text-gray-500 leading-relaxed">
|
||||
Profitieren Sie von starken Kooperationen und Angeboten ausgewählter Partnerbetriebe in der Region.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Application Mock */}
|
||||
<section id="apps" className="px-8 py-32 relative overflow-hidden" style={{
|
||||
background: `linear-gradient(to bottom, #ffffff 0%, ${primaryColor} 40%, #111827 100%)`
|
||||
}}>
|
||||
|
||||
{/* 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 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="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="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>
|
||||
Jetzt verfügbar
|
||||
</div>
|
||||
<h2 className="text-4xl md:text-5xl font-black leading-tight">
|
||||
Laden Sie unsere App herunter
|
||||
</h2>
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-4 pt-4">
|
||||
{(!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">
|
||||
<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 className="text-xs text-white/70">Download on the</div>
|
||||
<div className="text-lg font-semibold leading-none">App Store</div>
|
||||
</div>
|
||||
</a>
|
||||
) : null}
|
||||
{(!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">
|
||||
<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 className="text-xs text-white/70">GET IT ON</div>
|
||||
<div className="text-lg font-semibold leading-none">Google Play</div>
|
||||
</div>
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{/* 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>
|
||||
|
||||
{/* App Screenshot Mockup */}
|
||||
<div className="w-full h-full bg-gray-50 flex flex-col pt-6">
|
||||
{/* App Header */}
|
||||
<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">
|
||||
{formData.logoUrl ? (
|
||||
<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 }}>
|
||||
{formData.name ? formData.name.charAt(0).toUpperCase() : 'I'}
|
||||
</div>
|
||||
)}
|
||||
<div className="font-bold text-sm text-gray-800 truncate w-28">{formData.name || 'Ihre Innung'}</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
{/* App Content */}
|
||||
<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="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="relative z-10 text-white font-bold text-lg leading-tight">Willkommen,<br />Max Mustermann</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
<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="w-12 h-12 rounded-lg flex-shrink-0" style={{ backgroundColor: `${primaryColor}15` }}></div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<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>
|
||||
</div>
|
||||
<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="flex-1 space-y-2">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 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="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>
|
||||
<span className="text-[9px] font-semibold" style={{ color: primaryColor }}>Start</span>
|
||||
</div>
|
||||
<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>
|
||||
<span className="text-[9px] font-medium">Aktuelles</span>
|
||||
</div>
|
||||
<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>
|
||||
<span className="text-[9px] font-medium">Termine</span>
|
||||
</div>
|
||||
<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>
|
||||
<span className="text-[9px] font-medium">Stellen</span>
|
||||
</div>
|
||||
<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>
|
||||
<span className="text-[9px] font-medium">Nachricht..</span>
|
||||
</div>
|
||||
<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>
|
||||
<span className="text-[9px] font-medium">Profil</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<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">
|
||||
<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">
|
||||
Profitieren Sie von unserem starken Netzwerk, exklusiven Brancheninformationen und unserer digitalen Innungs-App.
|
||||
</p>
|
||||
<a
|
||||
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"
|
||||
style={{ backgroundColor: primaryColor }}
|
||||
>
|
||||
Jetzt Mitglied werden
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<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="text-gray-300 font-bold text-lg mb-6">{formData.name || 'Innungs-Logo'}</div>
|
||||
<div className="whitespace-pre-wrap">{footer}</div>
|
||||
<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">Datenschutz</a>
|
||||
<a href="#" className="hover:text-white transition-colors">Kontakt</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,477 +1,477 @@
|
|||
'use server'
|
||||
|
||||
import { prisma, Prisma } from '@innungsapp/shared'
|
||||
import { auth, getSanitizedHeaders } from '@/lib/auth'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { z } from 'zod'
|
||||
import { sendAdminCredentialsEmail } from '@/lib/email'
|
||||
// @ts-ignore
|
||||
import { hashPassword } from 'better-auth/crypto'
|
||||
|
||||
function normalizeEmail(email: string | null | undefined): string {
|
||||
return (email ?? '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
function toJsonbText(value: string | undefined): Prisma.InputJsonValue | Prisma.NullableJsonNullValueInput {
|
||||
if (!value) {
|
||||
return Prisma.DbNull
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a credential (email+password) account for a user.
|
||||
* Uses direct DB write with better-auth's hashPassword for compatibility.
|
||||
*/
|
||||
async function setCredentialPassword(userId: string, password: string) {
|
||||
const hashedPassword = await hashPassword(password)
|
||||
const updated = await prisma.account.updateMany({
|
||||
where: { userId, providerId: 'credential' },
|
||||
data: { password: hashedPassword, accountId: userId },
|
||||
})
|
||||
if (updated.count === 0) {
|
||||
await prisma.account.create({
|
||||
data: {
|
||||
id: crypto.randomUUID(),
|
||||
userId,
|
||||
accountId: userId,
|
||||
providerId: 'credential',
|
||||
password: hashedPassword,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function requireSuperAdmin() {
|
||||
const session = await auth.api.getSession({ headers: await getSanitizedHeaders() })
|
||||
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
|
||||
const isSuperAdmin = session?.user && (
|
||||
session.user.email === superAdminEmail ||
|
||||
(session.user as any).role === 'admin'
|
||||
)
|
||||
|
||||
if (!isSuperAdmin) {
|
||||
return null
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
const createOrgSchema = z.object({
|
||||
name: z.string().min(2, 'Name muss mindestens 2 Zeichen lang sein'),
|
||||
slug: z
|
||||
.string()
|
||||
.min(2, 'Slug muss mindestens 2 Zeichen lang sein')
|
||||
.regex(/^[a-z0-9-]+$/, 'Slug darf nur Kleinbuchstaben, Zahlen und Bindestriche enthalten'),
|
||||
contactEmail: z.string().email('Ungueltige E-Mail Adresse').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('')),
|
||||
logoUrl: z.string().optional().nullable(),
|
||||
plan: z.enum(['pilot', 'standard', 'pro', 'verband']).default('pilot'),
|
||||
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('')),
|
||||
landingPageTitle: z.string().optional(),
|
||||
landingPageText: z.string().optional(),
|
||||
landingPageHeroImage: z.string().optional().nullable(),
|
||||
landingPageHeroOverlayOpacity: z.number().min(0).max(100).optional().default(50),
|
||||
landingPageFeatures: z.string().optional(),
|
||||
landingPageFooter: z.string().optional(),
|
||||
landingPageSectionTitle: z.string().optional(),
|
||||
landingPageButtonText: z.string().optional(),
|
||||
appStoreUrl: z.string().url('Ungueltige URL').optional().or(z.literal('')),
|
||||
playStoreUrl: z.string().url('Ungueltige URL').optional().or(z.literal('')),
|
||||
})
|
||||
|
||||
const updateOrgSchema = z.object({
|
||||
name: z.string().min(2, 'Name muss mindestens 2 Zeichen lang sein'),
|
||||
plan: z.enum(['pilot', 'standard', 'pro', 'verband']),
|
||||
contactEmail: z.string().email('Ungueltige E-Mail Adresse').optional().or(z.literal('')),
|
||||
logoUrl: z.string().optional().nullable(),
|
||||
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('')),
|
||||
landingPageTitle: z.string().optional(),
|
||||
landingPageText: z.string().optional(),
|
||||
landingPageHeroImage: z.string().optional().nullable(),
|
||||
landingPageFeatures: z.string().optional(),
|
||||
landingPageFooter: z.string().optional(),
|
||||
landingPageSectionTitle: z.string().optional(),
|
||||
landingPageButtonText: z.string().optional(),
|
||||
appStoreUrl: z.string().url('Ungueltige URL').optional().or(z.literal('')),
|
||||
playStoreUrl: z.string().url('Ungueltige URL').optional().or(z.literal('')),
|
||||
})
|
||||
|
||||
const createAdminSchema = z.object({
|
||||
orgId: z.string(),
|
||||
name: z.string().min(2, 'Name ist zu kurz'),
|
||||
email: z.string().email('Ungueltige E-Mail Adresse'),
|
||||
password: z.string().min(8, 'Passwort muss mindestens 8 Zeichen lang sein'),
|
||||
})
|
||||
|
||||
const createMemberSchema = z.object({
|
||||
orgId: z.string(),
|
||||
name: z.string().min(2, 'Name ist zu kurz'),
|
||||
email: z.string().email('Ungueltige E-Mail Adresse'),
|
||||
betrieb: z.string().min(2, 'Betrieb ist zu kurz'),
|
||||
sparte: z.string().min(2, 'Sparte ist zu kurz'),
|
||||
ort: z.string().min(2, 'Ort ist zu kurz'),
|
||||
})
|
||||
|
||||
export async function createOrganization(prevState: any, formData: FormData) {
|
||||
const session = await requireSuperAdmin()
|
||||
if (!session) return { success: false, error: 'Nicht autorisiert.' }
|
||||
|
||||
try {
|
||||
const rawData = {
|
||||
name: (formData.get('name') as string).trim(),
|
||||
slug: (formData.get('slug') as string).trim().toLowerCase(),
|
||||
contactEmail: (formData.get('contactEmail') as string).trim(),
|
||||
adminEmail: normalizeEmail(formData.get('adminEmail') as string),
|
||||
adminPassword: formData.get('adminPassword') as string,
|
||||
logoUrl: formData.get('logoUrl') as string,
|
||||
plan: (formData.get('plan') as string) || 'pilot',
|
||||
primaryColor: formData.get('primaryColor') as string,
|
||||
secondaryColor: formData.get('secondaryColor') as string,
|
||||
landingPageTitle: (formData.get('landingPageTitle') as string).trim(),
|
||||
landingPageText: (formData.get('landingPageText') as string).trim(),
|
||||
landingPageHeroImage: formData.get('landingPageHeroImage') as string,
|
||||
landingPageHeroOverlayOpacity: Number(formData.get('landingPageHeroOverlayOpacity') || '50'),
|
||||
landingPageFeatures: (formData.get('landingPageFeatures') as string).trim(),
|
||||
landingPageFooter: (formData.get('landingPageFooter') as string).trim(),
|
||||
landingPageSectionTitle: (formData.get('landingPageSectionTitle') as string || '').trim(),
|
||||
landingPageButtonText: (formData.get('landingPageButtonText') as string || '').trim(),
|
||||
appStoreUrl: (formData.get('appStoreUrl') as string || '').trim(),
|
||||
playStoreUrl: (formData.get('playStoreUrl') as string || '').trim(),
|
||||
}
|
||||
|
||||
const validatedData = createOrgSchema.parse(rawData)
|
||||
|
||||
const existingOrg = await prisma.organization.findUnique({
|
||||
where: { slug: validatedData.slug },
|
||||
})
|
||||
|
||||
if (existingOrg) {
|
||||
return { success: false, error: 'Diese Kurzbezeichnung (Slug) existiert bereits.' }
|
||||
}
|
||||
|
||||
const org = await prisma.organization.create({
|
||||
data: {
|
||||
name: validatedData.name,
|
||||
slug: validatedData.slug,
|
||||
contactEmail: validatedData.contactEmail || validatedData.adminEmail || null,
|
||||
plan: validatedData.plan,
|
||||
primaryColor: validatedData.primaryColor || '#E63946',
|
||||
secondaryColor: validatedData.secondaryColor || null,
|
||||
logoUrl: validatedData.logoUrl || null,
|
||||
landingPageTitle: validatedData.landingPageTitle || null,
|
||||
landingPageText: validatedData.landingPageText || null,
|
||||
landingPageHeroImage: validatedData.landingPageHeroImage || null,
|
||||
// @ts-ignore
|
||||
landingPageHeroOverlayOpacity: validatedData.landingPageHeroOverlayOpacity,
|
||||
landingPageFeatures: toJsonbText(validatedData.landingPageFeatures),
|
||||
landingPageFooter: toJsonbText(validatedData.landingPageFooter),
|
||||
landingPageSectionTitle: validatedData.landingPageSectionTitle || null,
|
||||
landingPageButtonText: validatedData.landingPageButtonText || null,
|
||||
appStoreUrl: validatedData.appStoreUrl || null,
|
||||
playStoreUrl: validatedData.playStoreUrl || null,
|
||||
},
|
||||
})
|
||||
|
||||
if (validatedData.adminEmail) {
|
||||
let user = await prisma.user.findUnique({ where: { email: validatedData.adminEmail } })
|
||||
|
||||
if (!user) {
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
id: crypto.randomUUID(),
|
||||
name: validatedData.adminEmail.split('@')[0],
|
||||
email: validatedData.adminEmail,
|
||||
emailVerified: true,
|
||||
mustChangePassword: !!validatedData.adminPassword,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
// If user exists, we still want to make sure they are verified and maybe force password change
|
||||
user = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
emailVerified: true,
|
||||
...(validatedData.adminPassword ? { mustChangePassword: true } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await prisma.userRole.upsert({
|
||||
where: {
|
||||
orgId_userId: {
|
||||
orgId: org.id,
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
update: { role: 'admin' },
|
||||
create: {
|
||||
orgId: org.id,
|
||||
userId: user.id,
|
||||
role: 'admin',
|
||||
},
|
||||
})
|
||||
|
||||
if (validatedData.adminPassword) {
|
||||
await setCredentialPassword(user.id, validatedData.adminPassword)
|
||||
try {
|
||||
await sendAdminCredentialsEmail({
|
||||
to: validatedData.adminEmail,
|
||||
adminName: user.name || validatedData.adminEmail.split('@')[0],
|
||||
orgName: org.name,
|
||||
password: validatedData.adminPassword,
|
||||
loginUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3010',
|
||||
})
|
||||
} catch (emailError) {
|
||||
console.error('E-Mail konnte nicht gesendet werden:', emailError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath('/superadmin')
|
||||
return { success: true, error: '' }
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return { success: false, error: error.errors[0].message }
|
||||
}
|
||||
return { success: false, error: 'Ein unerwarteter Fehler ist aufgetreten.' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateOrganization(id: string, prevState: any, formData: FormData) {
|
||||
const session = await requireSuperAdmin()
|
||||
if (!session) return { success: false, error: 'Nicht autorisiert.' }
|
||||
|
||||
try {
|
||||
const rawData = {
|
||||
name: (formData.get('name') as string).trim(),
|
||||
plan: formData.get('plan') as string,
|
||||
contactEmail: (formData.get('contactEmail') as string).trim(),
|
||||
logoUrl: formData.get('logoUrl') as string,
|
||||
primaryColor: formData.get('primaryColor') as string,
|
||||
secondaryColor: formData.get('secondaryColor') as string,
|
||||
landingPageTitle: (formData.get('landingPageTitle') as string).trim(),
|
||||
landingPageText: (formData.get('landingPageText') as string).trim(),
|
||||
landingPageHeroImage: formData.get('landingPageHeroImage') as string,
|
||||
landingPageFeatures: (formData.get('landingPageFeatures') as string).trim(),
|
||||
landingPageFooter: (formData.get('landingPageFooter') as string).trim(),
|
||||
landingPageSectionTitle: (formData.get('landingPageSectionTitle') as string || '').trim(),
|
||||
landingPageButtonText: (formData.get('landingPageButtonText') as string || '').trim(),
|
||||
appStoreUrl: (formData.get('appStoreUrl') as string || '').trim(),
|
||||
playStoreUrl: (formData.get('playStoreUrl') as string || '').trim(),
|
||||
}
|
||||
|
||||
const validatedData = updateOrgSchema.parse(rawData)
|
||||
|
||||
await prisma.organization.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: validatedData.name,
|
||||
plan: validatedData.plan,
|
||||
contactEmail: validatedData.contactEmail || null,
|
||||
logoUrl: validatedData.logoUrl || null,
|
||||
primaryColor: validatedData.primaryColor || '#E63946',
|
||||
secondaryColor: validatedData.secondaryColor || null,
|
||||
landingPageTitle: validatedData.landingPageTitle || null,
|
||||
landingPageText: validatedData.landingPageText || null,
|
||||
landingPageHeroImage: validatedData.landingPageHeroImage || null,
|
||||
landingPageFeatures: toJsonbText(validatedData.landingPageFeatures),
|
||||
landingPageFooter: toJsonbText(validatedData.landingPageFooter),
|
||||
landingPageSectionTitle: validatedData.landingPageSectionTitle || null,
|
||||
landingPageButtonText: validatedData.landingPageButtonText || null,
|
||||
appStoreUrl: validatedData.appStoreUrl || null,
|
||||
playStoreUrl: validatedData.playStoreUrl || null,
|
||||
},
|
||||
})
|
||||
|
||||
revalidatePath('/superadmin')
|
||||
revalidatePath(`/superadmin/organizations/${id}`)
|
||||
return { success: true, error: '' }
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return { success: false, error: error.errors[0].message }
|
||||
}
|
||||
return { success: false, error: 'Ein unerwarteter Fehler ist aufgetreten.' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleAiFeature(id: string, enabled: boolean) {
|
||||
const session = await requireSuperAdmin()
|
||||
if (!session) return { success: false, error: 'Nicht autorisiert.' }
|
||||
|
||||
await prisma.organization.update({
|
||||
where: { id },
|
||||
data: { aiEnabled: enabled },
|
||||
})
|
||||
|
||||
revalidatePath('/superadmin')
|
||||
revalidatePath(`/superadmin/organizations/${id}`)
|
||||
return { success: true, error: '' }
|
||||
}
|
||||
|
||||
export async function deleteOrganization(id: string) {
|
||||
const session = await requireSuperAdmin()
|
||||
if (!session) return { success: false, error: 'Nicht autorisiert.' }
|
||||
|
||||
await prisma.organization.delete({ where: { id } })
|
||||
revalidatePath('/superadmin')
|
||||
redirect('/superadmin')
|
||||
}
|
||||
|
||||
export async function createAdmin(prevState: any, formData: FormData) {
|
||||
const session = await requireSuperAdmin()
|
||||
if (!session) return { success: false, error: 'Nicht autorisiert.' }
|
||||
|
||||
try {
|
||||
const rawData = {
|
||||
orgId: formData.get('orgId') as string,
|
||||
name: (formData.get('name') as string).trim(),
|
||||
email: normalizeEmail(formData.get('email') as string),
|
||||
password: formData.get('password') as string,
|
||||
}
|
||||
|
||||
const validatedData = createAdminSchema.parse(rawData)
|
||||
|
||||
let user = await prisma.user.findUnique({ where: { email: validatedData.email } })
|
||||
|
||||
if (!user) {
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
id: crypto.randomUUID(),
|
||||
name: validatedData.name,
|
||||
email: validatedData.email,
|
||||
emailVerified: true,
|
||||
mustChangePassword: true,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
user = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
emailVerified: true,
|
||||
mustChangePassword: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await setCredentialPassword(user.id, validatedData.password)
|
||||
|
||||
await prisma.userRole.upsert({
|
||||
where: {
|
||||
orgId_userId: {
|
||||
orgId: validatedData.orgId,
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
update: { role: 'admin' },
|
||||
create: {
|
||||
orgId: validatedData.orgId,
|
||||
userId: user.id,
|
||||
role: 'admin',
|
||||
},
|
||||
})
|
||||
|
||||
const org = await prisma.organization.findUnique({
|
||||
where: { id: validatedData.orgId },
|
||||
select: { name: true },
|
||||
})
|
||||
|
||||
try {
|
||||
await sendAdminCredentialsEmail({
|
||||
to: validatedData.email,
|
||||
adminName: validatedData.name,
|
||||
orgName: org?.name || 'Ihre Innung',
|
||||
password: validatedData.password,
|
||||
loginUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3010',
|
||||
})
|
||||
} catch (emailError) {
|
||||
console.error('E-Mail konnte nicht gesendet werden (Admin wurde trotzdem angelegt):', emailError)
|
||||
}
|
||||
|
||||
revalidatePath(`/superadmin/organizations/${validatedData.orgId}`)
|
||||
return { success: true, error: '' }
|
||||
} catch (error) {
|
||||
console.error('Failed to create admin:', error)
|
||||
if (error instanceof z.ZodError) {
|
||||
return { success: false, error: error.errors[0].message }
|
||||
}
|
||||
return { success: false, error: 'Ein Fehler ist aufgetreten.' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeUserRole(id: string, orgId: string) {
|
||||
const session = await requireSuperAdmin()
|
||||
if (!session) return { success: false, error: 'Nicht autorisiert.' }
|
||||
|
||||
await prisma.userRole.delete({ where: { id } })
|
||||
revalidatePath(`/superadmin/organizations/${orgId}`)
|
||||
return { success: true, error: '' }
|
||||
}
|
||||
|
||||
export async function updateUserRole(id: string, orgId: string, role: string) {
|
||||
const session = await requireSuperAdmin()
|
||||
if (!session) return { success: false, error: 'Nicht autorisiert.' }
|
||||
|
||||
await prisma.userRole.update({
|
||||
where: { id },
|
||||
data: { role },
|
||||
})
|
||||
revalidatePath(`/superadmin/organizations/${orgId}`)
|
||||
return { success: true, error: '' }
|
||||
}
|
||||
|
||||
export async function removeMember(id: string, orgId: string) {
|
||||
const session = await requireSuperAdmin()
|
||||
if (!session) return { success: false, error: 'Nicht autorisiert.' }
|
||||
|
||||
await prisma.member.delete({ where: { id } })
|
||||
revalidatePath(`/superadmin/organizations/${orgId}`)
|
||||
return { success: true, error: '' }
|
||||
}
|
||||
|
||||
export async function createMember(prevState: any, formData: FormData) {
|
||||
const session = await requireSuperAdmin()
|
||||
if (!session) return { success: false, error: 'Nicht autorisiert.' }
|
||||
|
||||
try {
|
||||
const rawData = {
|
||||
orgId: formData.get('orgId') as string,
|
||||
name: (formData.get('name') as string).trim(),
|
||||
email: normalizeEmail(formData.get('email') as string),
|
||||
betrieb: (formData.get('betrieb') as string).trim(),
|
||||
sparte: (formData.get('sparte') as string).trim(),
|
||||
ort: (formData.get('ort') as string).trim(),
|
||||
}
|
||||
|
||||
const validatedData = createMemberSchema.parse(rawData)
|
||||
|
||||
await prisma.member.create({
|
||||
data: {
|
||||
orgId: validatedData.orgId,
|
||||
name: validatedData.name,
|
||||
email: validatedData.email,
|
||||
betrieb: validatedData.betrieb,
|
||||
sparte: validatedData.sparte,
|
||||
ort: validatedData.ort,
|
||||
status: 'aktiv',
|
||||
},
|
||||
})
|
||||
|
||||
revalidatePath(`/superadmin/organizations/${validatedData.orgId}`)
|
||||
return { success: true, error: '' }
|
||||
} catch (error) {
|
||||
console.error('Failed to create member:', error)
|
||||
if (error instanceof z.ZodError) {
|
||||
return { success: false, error: error.errors[0].message }
|
||||
}
|
||||
return { success: false, error: 'Ein Fehler ist aufgetreten.' }
|
||||
}
|
||||
}
|
||||
'use server'
|
||||
|
||||
import { prisma, Prisma } from '@innungsapp/shared'
|
||||
import { auth, getSanitizedHeaders } from '@/lib/auth'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { z } from 'zod'
|
||||
import { sendAdminCredentialsEmail } from '@/lib/email'
|
||||
// @ts-ignore
|
||||
import { hashPassword } from 'better-auth/crypto'
|
||||
|
||||
function normalizeEmail(email: string | null | undefined): string {
|
||||
return (email ?? '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
function toJsonbText(value: string | undefined): Prisma.InputJsonValue | Prisma.NullableJsonNullValueInput {
|
||||
if (!value) {
|
||||
return Prisma.DbNull
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a credential (email+password) account for a user.
|
||||
* Uses direct DB write with better-auth's hashPassword for compatibility.
|
||||
*/
|
||||
async function setCredentialPassword(userId: string, password: string) {
|
||||
const hashedPassword = await hashPassword(password)
|
||||
const updated = await prisma.account.updateMany({
|
||||
where: { userId, providerId: 'credential' },
|
||||
data: { password: hashedPassword, accountId: userId },
|
||||
})
|
||||
if (updated.count === 0) {
|
||||
await prisma.account.create({
|
||||
data: {
|
||||
id: crypto.randomUUID(),
|
||||
userId,
|
||||
accountId: userId,
|
||||
providerId: 'credential',
|
||||
password: hashedPassword,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function requireSuperAdmin() {
|
||||
const session = await auth.api.getSession({ headers: await getSanitizedHeaders() })
|
||||
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
|
||||
const isSuperAdmin = session?.user && (
|
||||
session.user.email === superAdminEmail ||
|
||||
(session.user as any).role === 'admin'
|
||||
)
|
||||
|
||||
if (!isSuperAdmin) {
|
||||
return null
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
const createOrgSchema = z.object({
|
||||
name: z.string().min(2, 'Name muss mindestens 2 Zeichen lang sein'),
|
||||
slug: z
|
||||
.string()
|
||||
.min(2, 'Slug muss mindestens 2 Zeichen lang sein')
|
||||
.regex(/^[a-z0-9-]+$/, 'Slug darf nur Kleinbuchstaben, Zahlen und Bindestriche enthalten'),
|
||||
contactEmail: z.string().email('Ungueltige E-Mail Adresse').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('')),
|
||||
logoUrl: z.string().optional().nullable(),
|
||||
plan: z.enum(['pilot', 'standard', 'pro', 'verband']).default('pilot'),
|
||||
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('')),
|
||||
landingPageTitle: z.string().optional(),
|
||||
landingPageText: z.string().optional(),
|
||||
landingPageHeroImage: z.string().optional().nullable(),
|
||||
landingPageHeroOverlayOpacity: z.number().min(0).max(100).optional().default(50),
|
||||
landingPageFeatures: z.string().optional(),
|
||||
landingPageFooter: z.string().optional(),
|
||||
landingPageSectionTitle: z.string().optional(),
|
||||
landingPageButtonText: z.string().optional(),
|
||||
appStoreUrl: z.string().url('Ungueltige URL').optional().or(z.literal('')),
|
||||
playStoreUrl: z.string().url('Ungueltige URL').optional().or(z.literal('')),
|
||||
})
|
||||
|
||||
const updateOrgSchema = z.object({
|
||||
name: z.string().min(2, 'Name muss mindestens 2 Zeichen lang sein'),
|
||||
plan: z.enum(['pilot', 'standard', 'pro', 'verband']),
|
||||
contactEmail: z.string().email('Ungueltige E-Mail Adresse').optional().or(z.literal('')),
|
||||
logoUrl: z.string().optional().nullable(),
|
||||
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('')),
|
||||
landingPageTitle: z.string().optional(),
|
||||
landingPageText: z.string().optional(),
|
||||
landingPageHeroImage: z.string().optional().nullable(),
|
||||
landingPageFeatures: z.string().optional(),
|
||||
landingPageFooter: z.string().optional(),
|
||||
landingPageSectionTitle: z.string().optional(),
|
||||
landingPageButtonText: z.string().optional(),
|
||||
appStoreUrl: z.string().url('Ungueltige URL').optional().or(z.literal('')),
|
||||
playStoreUrl: z.string().url('Ungueltige URL').optional().or(z.literal('')),
|
||||
})
|
||||
|
||||
const createAdminSchema = z.object({
|
||||
orgId: z.string(),
|
||||
name: z.string().min(2, 'Name ist zu kurz'),
|
||||
email: z.string().email('Ungueltige E-Mail Adresse'),
|
||||
password: z.string().min(8, 'Passwort muss mindestens 8 Zeichen lang sein'),
|
||||
})
|
||||
|
||||
const createMemberSchema = z.object({
|
||||
orgId: z.string(),
|
||||
name: z.string().min(2, 'Name ist zu kurz'),
|
||||
email: z.string().email('Ungueltige E-Mail Adresse'),
|
||||
betrieb: z.string().min(2, 'Betrieb ist zu kurz'),
|
||||
sparte: z.string().min(2, 'Sparte ist zu kurz'),
|
||||
ort: z.string().min(2, 'Ort ist zu kurz'),
|
||||
})
|
||||
|
||||
export async function createOrganization(prevState: any, formData: FormData) {
|
||||
const session = await requireSuperAdmin()
|
||||
if (!session) return { success: false, error: 'Nicht autorisiert.' }
|
||||
|
||||
try {
|
||||
const rawData = {
|
||||
name: (formData.get('name') as string).trim(),
|
||||
slug: (formData.get('slug') as string).trim().toLowerCase(),
|
||||
contactEmail: (formData.get('contactEmail') as string).trim(),
|
||||
adminEmail: normalizeEmail(formData.get('adminEmail') as string),
|
||||
adminPassword: formData.get('adminPassword') as string,
|
||||
logoUrl: formData.get('logoUrl') as string,
|
||||
plan: (formData.get('plan') as string) || 'pilot',
|
||||
primaryColor: formData.get('primaryColor') as string,
|
||||
secondaryColor: formData.get('secondaryColor') as string,
|
||||
landingPageTitle: (formData.get('landingPageTitle') as string).trim(),
|
||||
landingPageText: (formData.get('landingPageText') as string).trim(),
|
||||
landingPageHeroImage: formData.get('landingPageHeroImage') as string,
|
||||
landingPageHeroOverlayOpacity: Number(formData.get('landingPageHeroOverlayOpacity') || '50'),
|
||||
landingPageFeatures: (formData.get('landingPageFeatures') as string).trim(),
|
||||
landingPageFooter: (formData.get('landingPageFooter') as string).trim(),
|
||||
landingPageSectionTitle: (formData.get('landingPageSectionTitle') as string || '').trim(),
|
||||
landingPageButtonText: (formData.get('landingPageButtonText') as string || '').trim(),
|
||||
appStoreUrl: (formData.get('appStoreUrl') as string || '').trim(),
|
||||
playStoreUrl: (formData.get('playStoreUrl') as string || '').trim(),
|
||||
}
|
||||
|
||||
const validatedData = createOrgSchema.parse(rawData)
|
||||
|
||||
const existingOrg = await prisma.organization.findUnique({
|
||||
where: { slug: validatedData.slug },
|
||||
})
|
||||
|
||||
if (existingOrg) {
|
||||
return { success: false, error: 'Diese Kurzbezeichnung (Slug) existiert bereits.' }
|
||||
}
|
||||
|
||||
const org = await prisma.organization.create({
|
||||
data: {
|
||||
name: validatedData.name,
|
||||
slug: validatedData.slug,
|
||||
contactEmail: validatedData.contactEmail || validatedData.adminEmail || null,
|
||||
plan: validatedData.plan,
|
||||
primaryColor: validatedData.primaryColor || '#E63946',
|
||||
secondaryColor: validatedData.secondaryColor || null,
|
||||
logoUrl: validatedData.logoUrl || null,
|
||||
landingPageTitle: validatedData.landingPageTitle || null,
|
||||
landingPageText: validatedData.landingPageText || null,
|
||||
landingPageHeroImage: validatedData.landingPageHeroImage || null,
|
||||
// @ts-ignore
|
||||
landingPageHeroOverlayOpacity: validatedData.landingPageHeroOverlayOpacity,
|
||||
landingPageFeatures: toJsonbText(validatedData.landingPageFeatures),
|
||||
landingPageFooter: toJsonbText(validatedData.landingPageFooter),
|
||||
landingPageSectionTitle: validatedData.landingPageSectionTitle || null,
|
||||
landingPageButtonText: validatedData.landingPageButtonText || null,
|
||||
appStoreUrl: validatedData.appStoreUrl || null,
|
||||
playStoreUrl: validatedData.playStoreUrl || null,
|
||||
},
|
||||
})
|
||||
|
||||
if (validatedData.adminEmail) {
|
||||
let user = await prisma.user.findUnique({ where: { email: validatedData.adminEmail } })
|
||||
|
||||
if (!user) {
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
id: crypto.randomUUID(),
|
||||
name: validatedData.adminEmail.split('@')[0],
|
||||
email: validatedData.adminEmail,
|
||||
emailVerified: true,
|
||||
mustChangePassword: !!validatedData.adminPassword,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
// If user exists, we still want to make sure they are verified and maybe force password change
|
||||
user = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
emailVerified: true,
|
||||
...(validatedData.adminPassword ? { mustChangePassword: true } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await prisma.userRole.upsert({
|
||||
where: {
|
||||
orgId_userId: {
|
||||
orgId: org.id,
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
update: { role: 'admin' },
|
||||
create: {
|
||||
orgId: org.id,
|
||||
userId: user.id,
|
||||
role: 'admin',
|
||||
},
|
||||
})
|
||||
|
||||
if (validatedData.adminPassword) {
|
||||
await setCredentialPassword(user.id, validatedData.adminPassword)
|
||||
try {
|
||||
await sendAdminCredentialsEmail({
|
||||
to: validatedData.adminEmail,
|
||||
adminName: user.name || validatedData.adminEmail.split('@')[0],
|
||||
orgName: org.name,
|
||||
password: validatedData.adminPassword,
|
||||
loginUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3010',
|
||||
})
|
||||
} catch (emailError) {
|
||||
console.error('E-Mail konnte nicht gesendet werden:', emailError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath('/superadmin')
|
||||
return { success: true, error: '' }
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return { success: false, error: error.errors[0].message }
|
||||
}
|
||||
return { success: false, error: 'Ein unerwarteter Fehler ist aufgetreten.' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateOrganization(id: string, prevState: any, formData: FormData) {
|
||||
const session = await requireSuperAdmin()
|
||||
if (!session) return { success: false, error: 'Nicht autorisiert.' }
|
||||
|
||||
try {
|
||||
const rawData = {
|
||||
name: (formData.get('name') as string).trim(),
|
||||
plan: formData.get('plan') as string,
|
||||
contactEmail: (formData.get('contactEmail') as string).trim(),
|
||||
logoUrl: formData.get('logoUrl') as string,
|
||||
primaryColor: formData.get('primaryColor') as string,
|
||||
secondaryColor: formData.get('secondaryColor') as string,
|
||||
landingPageTitle: (formData.get('landingPageTitle') as string).trim(),
|
||||
landingPageText: (formData.get('landingPageText') as string).trim(),
|
||||
landingPageHeroImage: formData.get('landingPageHeroImage') as string,
|
||||
landingPageFeatures: (formData.get('landingPageFeatures') as string).trim(),
|
||||
landingPageFooter: (formData.get('landingPageFooter') as string).trim(),
|
||||
landingPageSectionTitle: (formData.get('landingPageSectionTitle') as string || '').trim(),
|
||||
landingPageButtonText: (formData.get('landingPageButtonText') as string || '').trim(),
|
||||
appStoreUrl: (formData.get('appStoreUrl') as string || '').trim(),
|
||||
playStoreUrl: (formData.get('playStoreUrl') as string || '').trim(),
|
||||
}
|
||||
|
||||
const validatedData = updateOrgSchema.parse(rawData)
|
||||
|
||||
await prisma.organization.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: validatedData.name,
|
||||
plan: validatedData.plan,
|
||||
contactEmail: validatedData.contactEmail || null,
|
||||
logoUrl: validatedData.logoUrl || null,
|
||||
primaryColor: validatedData.primaryColor || '#E63946',
|
||||
secondaryColor: validatedData.secondaryColor || null,
|
||||
landingPageTitle: validatedData.landingPageTitle || null,
|
||||
landingPageText: validatedData.landingPageText || null,
|
||||
landingPageHeroImage: validatedData.landingPageHeroImage || null,
|
||||
landingPageFeatures: toJsonbText(validatedData.landingPageFeatures),
|
||||
landingPageFooter: toJsonbText(validatedData.landingPageFooter),
|
||||
landingPageSectionTitle: validatedData.landingPageSectionTitle || null,
|
||||
landingPageButtonText: validatedData.landingPageButtonText || null,
|
||||
appStoreUrl: validatedData.appStoreUrl || null,
|
||||
playStoreUrl: validatedData.playStoreUrl || null,
|
||||
},
|
||||
})
|
||||
|
||||
revalidatePath('/superadmin')
|
||||
revalidatePath(`/superadmin/organizations/${id}`)
|
||||
return { success: true, error: '' }
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return { success: false, error: error.errors[0].message }
|
||||
}
|
||||
return { success: false, error: 'Ein unerwarteter Fehler ist aufgetreten.' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleAiFeature(id: string, enabled: boolean) {
|
||||
const session = await requireSuperAdmin()
|
||||
if (!session) return { success: false, error: 'Nicht autorisiert.' }
|
||||
|
||||
await prisma.organization.update({
|
||||
where: { id },
|
||||
data: { aiEnabled: enabled },
|
||||
})
|
||||
|
||||
revalidatePath('/superadmin')
|
||||
revalidatePath(`/superadmin/organizations/${id}`)
|
||||
return { success: true, error: '' }
|
||||
}
|
||||
|
||||
export async function deleteOrganization(id: string) {
|
||||
const session = await requireSuperAdmin()
|
||||
if (!session) return { success: false, error: 'Nicht autorisiert.' }
|
||||
|
||||
await prisma.organization.delete({ where: { id } })
|
||||
revalidatePath('/superadmin')
|
||||
redirect('/superadmin')
|
||||
}
|
||||
|
||||
export async function createAdmin(prevState: any, formData: FormData) {
|
||||
const session = await requireSuperAdmin()
|
||||
if (!session) return { success: false, error: 'Nicht autorisiert.' }
|
||||
|
||||
try {
|
||||
const rawData = {
|
||||
orgId: formData.get('orgId') as string,
|
||||
name: (formData.get('name') as string).trim(),
|
||||
email: normalizeEmail(formData.get('email') as string),
|
||||
password: formData.get('password') as string,
|
||||
}
|
||||
|
||||
const validatedData = createAdminSchema.parse(rawData)
|
||||
|
||||
let user = await prisma.user.findUnique({ where: { email: validatedData.email } })
|
||||
|
||||
if (!user) {
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
id: crypto.randomUUID(),
|
||||
name: validatedData.name,
|
||||
email: validatedData.email,
|
||||
emailVerified: true,
|
||||
mustChangePassword: true,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
user = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
emailVerified: true,
|
||||
mustChangePassword: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await setCredentialPassword(user.id, validatedData.password)
|
||||
|
||||
await prisma.userRole.upsert({
|
||||
where: {
|
||||
orgId_userId: {
|
||||
orgId: validatedData.orgId,
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
update: { role: 'admin' },
|
||||
create: {
|
||||
orgId: validatedData.orgId,
|
||||
userId: user.id,
|
||||
role: 'admin',
|
||||
},
|
||||
})
|
||||
|
||||
const org = await prisma.organization.findUnique({
|
||||
where: { id: validatedData.orgId },
|
||||
select: { name: true },
|
||||
})
|
||||
|
||||
try {
|
||||
await sendAdminCredentialsEmail({
|
||||
to: validatedData.email,
|
||||
adminName: validatedData.name,
|
||||
orgName: org?.name || 'Ihre Innung',
|
||||
password: validatedData.password,
|
||||
loginUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3010',
|
||||
})
|
||||
} catch (emailError) {
|
||||
console.error('E-Mail konnte nicht gesendet werden (Admin wurde trotzdem angelegt):', emailError)
|
||||
}
|
||||
|
||||
revalidatePath(`/superadmin/organizations/${validatedData.orgId}`)
|
||||
return { success: true, error: '' }
|
||||
} catch (error) {
|
||||
console.error('Failed to create admin:', error)
|
||||
if (error instanceof z.ZodError) {
|
||||
return { success: false, error: error.errors[0].message }
|
||||
}
|
||||
return { success: false, error: 'Ein Fehler ist aufgetreten.' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeUserRole(id: string, orgId: string) {
|
||||
const session = await requireSuperAdmin()
|
||||
if (!session) return { success: false, error: 'Nicht autorisiert.' }
|
||||
|
||||
await prisma.userRole.delete({ where: { id } })
|
||||
revalidatePath(`/superadmin/organizations/${orgId}`)
|
||||
return { success: true, error: '' }
|
||||
}
|
||||
|
||||
export async function updateUserRole(id: string, orgId: string, role: string) {
|
||||
const session = await requireSuperAdmin()
|
||||
if (!session) return { success: false, error: 'Nicht autorisiert.' }
|
||||
|
||||
await prisma.userRole.update({
|
||||
where: { id },
|
||||
data: { role },
|
||||
})
|
||||
revalidatePath(`/superadmin/organizations/${orgId}`)
|
||||
return { success: true, error: '' }
|
||||
}
|
||||
|
||||
export async function removeMember(id: string, orgId: string) {
|
||||
const session = await requireSuperAdmin()
|
||||
if (!session) return { success: false, error: 'Nicht autorisiert.' }
|
||||
|
||||
await prisma.member.delete({ where: { id } })
|
||||
revalidatePath(`/superadmin/organizations/${orgId}`)
|
||||
return { success: true, error: '' }
|
||||
}
|
||||
|
||||
export async function createMember(prevState: any, formData: FormData) {
|
||||
const session = await requireSuperAdmin()
|
||||
if (!session) return { success: false, error: 'Nicht autorisiert.' }
|
||||
|
||||
try {
|
||||
const rawData = {
|
||||
orgId: formData.get('orgId') as string,
|
||||
name: (formData.get('name') as string).trim(),
|
||||
email: normalizeEmail(formData.get('email') as string),
|
||||
betrieb: (formData.get('betrieb') as string).trim(),
|
||||
sparte: (formData.get('sparte') as string).trim(),
|
||||
ort: (formData.get('ort') as string).trim(),
|
||||
}
|
||||
|
||||
const validatedData = createMemberSchema.parse(rawData)
|
||||
|
||||
await prisma.member.create({
|
||||
data: {
|
||||
orgId: validatedData.orgId,
|
||||
name: validatedData.name,
|
||||
email: validatedData.email,
|
||||
betrieb: validatedData.betrieb,
|
||||
sparte: validatedData.sparte,
|
||||
ort: validatedData.ort,
|
||||
status: 'aktiv',
|
||||
},
|
||||
})
|
||||
|
||||
revalidatePath(`/superadmin/organizations/${validatedData.orgId}`)
|
||||
return { success: true, error: '' }
|
||||
} catch (error) {
|
||||
console.error('Failed to create member:', error)
|
||||
if (error instanceof z.ZodError) {
|
||||
return { success: false, error: error.errors[0].message }
|
||||
}
|
||||
return { success: false, error: 'Ein Fehler ist aufgetreten.' }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,30 +1,30 @@
|
|||
import { CreateOrgForm } from '../CreateOrgForm'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function CreateOrgPage() {
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col p-6 gap-6">
|
||||
<div className="flex items-center gap-4 shrink-0">
|
||||
<Link
|
||||
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"
|
||||
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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
|
||||
</svg>
|
||||
</Link>
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-black text-gray-900 tracking-tight font-outfit">
|
||||
Neue Innung anlegen
|
||||
</h1>
|
||||
<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 className="flex-1 overflow-hidden min-h-0">
|
||||
<CreateOrgForm />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { CreateOrgForm } from '../CreateOrgForm'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function CreateOrgPage() {
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col p-6 gap-6">
|
||||
<div className="flex items-center gap-4 shrink-0">
|
||||
<Link
|
||||
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"
|
||||
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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
|
||||
</svg>
|
||||
</Link>
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-black text-gray-900 tracking-tight font-outfit">
|
||||
Neue Innung anlegen
|
||||
</h1>
|
||||
<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 className="flex-1 overflow-hidden min-h-0">
|
||||
<CreateOrgForm />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,53 +1,53 @@
|
|||
import { auth, getSanitizedHeaders } from '@/lib/auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default async function SuperAdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const session = await auth.api.getSession({ headers: await getSanitizedHeaders() })
|
||||
|
||||
if (!session?.user) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
const superAdminEmail = process.env.SUPERADMIN_EMAIL || 'superadmin@innungsapp.de'
|
||||
const isSuperAdmin = session.user.email === superAdminEmail || session.user.role === 'admin'
|
||||
if (!isSuperAdmin) {
|
||||
redirect('/dashboard') // Normal admins go back to dashboard
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col">
|
||||
{/* Super Admin Header */}
|
||||
<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="flex justify-between h-12 items-center">
|
||||
<div className="flex items-center gap-8">
|
||||
<span
|
||||
className="font-bold text-base tracking-tight hover:text-gray-200 transition-colors"
|
||||
style={{ fontFamily: "'Syne', system-ui, sans-serif" }}
|
||||
>
|
||||
<Link href="/superadmin">Super Admin</Link>
|
||||
</span>
|
||||
|
||||
{/* Super Admin Navigation */}
|
||||
<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/landingpages" className="hover:text-white transition-colors">Landingpages</Link>
|
||||
</nav>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">{session.user.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 w-full">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { auth, getSanitizedHeaders } from '@/lib/auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default async function SuperAdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const session = await auth.api.getSession({ headers: await getSanitizedHeaders() })
|
||||
|
||||
if (!session?.user) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
const superAdminEmail = process.env.SUPERADMIN_EMAIL || 'superadmin@innungsapp.de'
|
||||
const isSuperAdmin = session.user.email === superAdminEmail || session.user.role === 'admin'
|
||||
if (!isSuperAdmin) {
|
||||
redirect('/dashboard') // Normal admins go back to dashboard
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col">
|
||||
{/* Super Admin Header */}
|
||||
<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="flex justify-between h-12 items-center">
|
||||
<div className="flex items-center gap-8">
|
||||
<span
|
||||
className="font-bold text-base tracking-tight hover:text-gray-200 transition-colors"
|
||||
style={{ fontFamily: "'Syne', system-ui, sans-serif" }}
|
||||
>
|
||||
<Link href="/superadmin">Super Admin</Link>
|
||||
</span>
|
||||
|
||||
{/* Super Admin Navigation */}
|
||||
<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/landingpages" className="hover:text-white transition-colors">Landingpages</Link>
|
||||
</nav>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">{session.user.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 w-full">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,89 +1,89 @@
|
|||
'use client'
|
||||
|
||||
import { useActionState, useState } from 'react'
|
||||
import { createAdmin } from '../../actions'
|
||||
|
||||
export function CreateAdminForm({ orgId }: { orgId: string }) {
|
||||
const [state, action, isPending] = useActionState(createAdmin, { success: false, error: '' })
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
|
||||
if (!showForm) {
|
||||
return (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
+ Administrator hinzufügen
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 border rounded-xl p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-900">Neuen Admin anlegen</h3>
|
||||
<button
|
||||
onClick={() => setShowForm(false)}
|
||||
className="text-xs text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form action={action} className="space-y-3">
|
||||
<input type="hidden" name="orgId" value={orgId} />
|
||||
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">Name</label>
|
||||
<input
|
||||
name="name"
|
||||
required
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">E-Mail</label>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">Passwort</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
name="password"
|
||||
type="text"
|
||||
required
|
||||
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"
|
||||
/>
|
||||
<p className="text-[10px] text-gray-400 mt-1">Das Passwort muss dem Admin manuell mitgeteilt werden.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{state.error && (
|
||||
<p className="text-xs text-red-600 bg-red-50 p-2 rounded">{state.error}</p>
|
||||
)}
|
||||
|
||||
{state.success && (
|
||||
<p className="text-xs text-green-600 bg-green-50 p-2 rounded">Administrator erfolgreich angelegt.</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
{isPending ? 'Wird angelegt...' : 'Admin anlegen'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useActionState, useState } from 'react'
|
||||
import { createAdmin } from '../../actions'
|
||||
|
||||
export function CreateAdminForm({ orgId }: { orgId: string }) {
|
||||
const [state, action, isPending] = useActionState(createAdmin, { success: false, error: '' })
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
|
||||
if (!showForm) {
|
||||
return (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
+ Administrator hinzufügen
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 border rounded-xl p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-900">Neuen Admin anlegen</h3>
|
||||
<button
|
||||
onClick={() => setShowForm(false)}
|
||||
className="text-xs text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form action={action} className="space-y-3">
|
||||
<input type="hidden" name="orgId" value={orgId} />
|
||||
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">Name</label>
|
||||
<input
|
||||
name="name"
|
||||
required
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">E-Mail</label>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">Passwort</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
name="password"
|
||||
type="text"
|
||||
required
|
||||
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"
|
||||
/>
|
||||
<p className="text-[10px] text-gray-400 mt-1">Das Passwort muss dem Admin manuell mitgeteilt werden.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{state.error && (
|
||||
<p className="text-xs text-red-600 bg-red-50 p-2 rounded">{state.error}</p>
|
||||
)}
|
||||
|
||||
{state.success && (
|
||||
<p className="text-xs text-green-600 bg-green-50 p-2 rounded">Administrator erfolgreich angelegt.</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
{isPending ? 'Wird angelegt...' : 'Admin anlegen'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,94 +1,94 @@
|
|||
'use client'
|
||||
|
||||
'use client'
|
||||
|
||||
import { useActionState } from 'react'
|
||||
import { createMember } from '../../actions'
|
||||
|
||||
const initialState = {
|
||||
success: false,
|
||||
error: '',
|
||||
}
|
||||
|
||||
export function CreateMemberForm({ orgId }: { orgId: string }) {
|
||||
const [state, action, isPending] = useActionState(createMember, initialState)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900 uppercase tracking-wider">Mitglied manuell hinzufügen</h3>
|
||||
<form action={action} className="space-y-3">
|
||||
<input type="hidden" name="orgId" value={orgId} />
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">Name (Ansprechpartner)</label>
|
||||
<input
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">E-Mail</label>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">Betrieb</label>
|
||||
<input
|
||||
name="betrieb"
|
||||
type="text"
|
||||
required
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">Sparte</label>
|
||||
<input
|
||||
name="sparte"
|
||||
type="text"
|
||||
required
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">Ort</label>
|
||||
<input
|
||||
name="ort"
|
||||
type="text"
|
||||
required
|
||||
placeholder="Stadt"
|
||||
className="w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-brand-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{state.error && (
|
||||
<p className="text-xs text-red-600 bg-red-50 p-2 rounded">{state.error}</p>
|
||||
)}
|
||||
|
||||
{state.success && (
|
||||
<p className="text-xs text-green-600 bg-green-50 p-2 rounded">Mitglied erfolgreich angelegt.</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
{isPending ? 'Wird angelegt...' : 'Mitglied anlegen'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
success: false,
|
||||
error: '',
|
||||
}
|
||||
|
||||
export function CreateMemberForm({ orgId }: { orgId: string }) {
|
||||
const [state, action, isPending] = useActionState(createMember, initialState)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900 uppercase tracking-wider">Mitglied manuell hinzufügen</h3>
|
||||
<form action={action} className="space-y-3">
|
||||
<input type="hidden" name="orgId" value={orgId} />
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">Name (Ansprechpartner)</label>
|
||||
<input
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">E-Mail</label>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">Betrieb</label>
|
||||
<input
|
||||
name="betrieb"
|
||||
type="text"
|
||||
required
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">Sparte</label>
|
||||
<input
|
||||
name="sparte"
|
||||
type="text"
|
||||
required
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">Ort</label>
|
||||
<input
|
||||
name="ort"
|
||||
type="text"
|
||||
required
|
||||
placeholder="Stadt"
|
||||
className="w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-brand-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{state.error && (
|
||||
<p className="text-xs text-red-600 bg-red-50 p-2 rounded">{state.error}</p>
|
||||
)}
|
||||
|
||||
{state.success && (
|
||||
<p className="text-xs text-green-600 bg-green-50 p-2 rounded">Mitglied erfolgreich angelegt.</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
{isPending ? 'Wird angelegt...' : 'Mitglied anlegen'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,351 +1,351 @@
|
|||
'use client'
|
||||
|
||||
import { useActionState, useState } from 'react'
|
||||
import { updateOrganization } from '../../actions'
|
||||
|
||||
function jsonToText(value: unknown): string {
|
||||
if (value == null) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((item) => (typeof item === 'string' ? item : JSON.stringify(item)))
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
interface Props {
|
||||
org: {
|
||||
id: string
|
||||
name: string
|
||||
plan: string
|
||||
contactEmail: string | null
|
||||
logoUrl: string | null
|
||||
primaryColor: string | null
|
||||
secondaryColor: string | null
|
||||
landingPageTitle: string | null
|
||||
landingPageText: string | null
|
||||
landingPageSectionTitle: string | null
|
||||
landingPageButtonText: string | null
|
||||
landingPageHeroImage: string | null
|
||||
landingPageHeroOverlayOpacity: number | null
|
||||
landingPageFeatures: unknown
|
||||
landingPageFooter: unknown
|
||||
appStoreUrl: string | null
|
||||
playStoreUrl: string | null
|
||||
}
|
||||
}
|
||||
|
||||
const initialState = { success: false, error: '' }
|
||||
|
||||
export function EditOrgForm({ org }: Props) {
|
||||
const boundAction = updateOrganization.bind(null, org.id)
|
||||
const [state, formAction, isPending] = useActionState(boundAction, initialState)
|
||||
const [logoUrl, setLogoUrl] = useState(org.logoUrl || '')
|
||||
const [heroImageUrl, setHeroImageUrl] = useState(org.landingPageHeroImage || '')
|
||||
const [isUploading, setIsUploading] = useState<{ logo?: boolean; hero?: boolean }>({})
|
||||
const [themeColor, setThemeColor] = useState(org.primaryColor || '#E63946')
|
||||
const [secondaryColor, setSecondaryColor] = useState(org.secondaryColor || '#FFFFFF')
|
||||
|
||||
const initialFeatures = jsonToText(org.landingPageFeatures)
|
||||
const initialFooter = jsonToText(org.landingPageFooter)
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>, type: 'logo' | 'hero') => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
setIsUploading(prev => ({ ...prev, [type]: true }))
|
||||
const uploadFormData = new FormData()
|
||||
uploadFormData.append('file', file)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
body: uploadFormData
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.url) {
|
||||
if (type === 'logo') setLogoUrl(data.url)
|
||||
if (type === 'hero') setHeroImageUrl(data.url)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Upload failed', err)
|
||||
} finally {
|
||||
setIsUploading(prev => ({ ...prev, [type]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border p-6">
|
||||
<h2 className="text-base font-semibold text-gray-900 mb-4">Innung bearbeiten</h2>
|
||||
|
||||
{state.success && (
|
||||
<div className="mb-4 p-3 bg-green-50 text-green-700 rounded-lg text-sm">Änderungen gespeichert.</div>
|
||||
)}
|
||||
{state.error && (
|
||||
<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">
|
||||
{/* BASISDATEN */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900 border-b pb-2">Basisdaten</h3>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name der Innung</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
required
|
||||
defaultValue={org.name}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Plan</label>
|
||||
<select
|
||||
name="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"
|
||||
>
|
||||
<option value="pilot">Pilot</option>
|
||||
<option value="standard">Standard</option>
|
||||
<option value="pro">Pro</option>
|
||||
<option value="verband">Verband</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kontakt E-Mail</label>
|
||||
<input
|
||||
type="email"
|
||||
name="contactEmail"
|
||||
defaultValue={org.contactEmail ?? ''}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* BRANDING */}
|
||||
<div className="space-y-4 pt-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900 border-b pb-2">Branding</h3>
|
||||
|
||||
<input type="hidden" name="logoUrl" value={logoUrl} />
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Logo</label>
|
||||
<div className="flex items-center gap-3">
|
||||
{logoUrl ? (
|
||||
<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" />
|
||||
</div>
|
||||
) : (
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
<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' : ''}`}>
|
||||
{isUploading.logo ? 'Wird hochgeladen...' : 'Logo ändern'}
|
||||
</div>
|
||||
<input type="file" onChange={(e) => handleUpload(e, 'logo')} accept="image/*" className="hidden" disabled={isUploading.logo} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Primärfarbe</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="color"
|
||||
name="primaryColor"
|
||||
value={themeColor}
|
||||
onChange={(e) => setThemeColor(e.target.value)}
|
||||
className="h-9 w-12 p-1 border rounded cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={themeColor}
|
||||
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"
|
||||
pattern="^#([A-Fa-f0-9]{6})$"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Sekundärfarbe</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="color"
|
||||
name="secondaryColor"
|
||||
value={secondaryColor}
|
||||
onChange={(e) => setSecondaryColor(e.target.value)}
|
||||
className="h-9 w-12 p-1 border rounded cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={secondaryColor}
|
||||
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"
|
||||
pattern="^#([A-Fa-f0-9]{6})$"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* LANDING PAGE */}
|
||||
<div className="space-y-4 pt-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900 border-b pb-2">Landing Page</h3>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Hero Titel</label>
|
||||
<input
|
||||
type="text"
|
||||
name="landingPageTitle"
|
||||
defaultValue={org.landingPageTitle ?? ''}
|
||||
placeholder="Zukunft des Handwerks gestalten"
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Hero Untertitel / Text</label>
|
||||
<textarea
|
||||
name="landingPageText"
|
||||
defaultValue={org.landingPageText ?? ''}
|
||||
rows={3}
|
||||
placeholder="Gemeinsam stark für unsere Region."
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Aufmacher Überschrift (Buttons)</label>
|
||||
<input
|
||||
type="text"
|
||||
name="landingPageSectionTitle"
|
||||
defaultValue={org.landingPageSectionTitle ?? ''}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Button Text (CTA)</label>
|
||||
<input
|
||||
type="text"
|
||||
name="landingPageButtonText"
|
||||
defaultValue={org.landingPageButtonText ?? ''}
|
||||
placeholder="Jetzt Mitglied werden"
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="landingPageHeroImage" value={heroImageUrl} />
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Hero Hintergrundbild</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<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' : ''}`}>
|
||||
{isUploading.hero ? 'Wird hochgeladen...' : 'Bild auswählen'}
|
||||
</div>
|
||||
<input type="file" onChange={(e) => handleUpload(e, 'hero')} accept="image/*" className="hidden" disabled={isUploading.hero} />
|
||||
</label>
|
||||
{heroImageUrl && (
|
||||
<button type="button" onClick={() => setHeroImageUrl('')} className="text-red-500 hover:text-red-600 text-sm">
|
||||
Entfernen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1 flex justify-between">
|
||||
<span>Overlay Deckkraft</span>
|
||||
<span className="text-gray-500">{org.landingPageHeroOverlayOpacity ?? 50}%</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
name="landingPageHeroOverlayOpacity"
|
||||
min="0"
|
||||
max="100"
|
||||
defaultValue={org.landingPageHeroOverlayOpacity ?? 50}
|
||||
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>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Vorteile / Features</label>
|
||||
<textarea
|
||||
name="landingPageFeatures"
|
||||
defaultValue={initialFeatures}
|
||||
rows={5}
|
||||
placeholder="Ein Benefit pro Zeile..."
|
||||
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>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">App Store URL</label>
|
||||
<input
|
||||
type="url"
|
||||
name="appStoreUrl"
|
||||
defaultValue={org.appStoreUrl ?? ''}
|
||||
placeholder="https://apps.apple.com/..."
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Google Play URL</label>
|
||||
<input
|
||||
type="url"
|
||||
name="playStoreUrl"
|
||||
defaultValue={org.playStoreUrl ?? ''}
|
||||
placeholder="https://play.google.com/..."
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Footer Text</label>
|
||||
<textarea
|
||||
name="landingPageFooter"
|
||||
defaultValue={initialFooter}
|
||||
rows={2}
|
||||
placeholder="© 2024 Innung. Alle Rechte vorbehalten."
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
{isPending ? 'Wird gespeichert…' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useActionState, useState } from 'react'
|
||||
import { updateOrganization } from '../../actions'
|
||||
|
||||
function jsonToText(value: unknown): string {
|
||||
if (value == null) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((item) => (typeof item === 'string' ? item : JSON.stringify(item)))
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
interface Props {
|
||||
org: {
|
||||
id: string
|
||||
name: string
|
||||
plan: string
|
||||
contactEmail: string | null
|
||||
logoUrl: string | null
|
||||
primaryColor: string | null
|
||||
secondaryColor: string | null
|
||||
landingPageTitle: string | null
|
||||
landingPageText: string | null
|
||||
landingPageSectionTitle: string | null
|
||||
landingPageButtonText: string | null
|
||||
landingPageHeroImage: string | null
|
||||
landingPageHeroOverlayOpacity: number | null
|
||||
landingPageFeatures: unknown
|
||||
landingPageFooter: unknown
|
||||
appStoreUrl: string | null
|
||||
playStoreUrl: string | null
|
||||
}
|
||||
}
|
||||
|
||||
const initialState = { success: false, error: '' }
|
||||
|
||||
export function EditOrgForm({ org }: Props) {
|
||||
const boundAction = updateOrganization.bind(null, org.id)
|
||||
const [state, formAction, isPending] = useActionState(boundAction, initialState)
|
||||
const [logoUrl, setLogoUrl] = useState(org.logoUrl || '')
|
||||
const [heroImageUrl, setHeroImageUrl] = useState(org.landingPageHeroImage || '')
|
||||
const [isUploading, setIsUploading] = useState<{ logo?: boolean; hero?: boolean }>({})
|
||||
const [themeColor, setThemeColor] = useState(org.primaryColor || '#E63946')
|
||||
const [secondaryColor, setSecondaryColor] = useState(org.secondaryColor || '#FFFFFF')
|
||||
|
||||
const initialFeatures = jsonToText(org.landingPageFeatures)
|
||||
const initialFooter = jsonToText(org.landingPageFooter)
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>, type: 'logo' | 'hero') => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
setIsUploading(prev => ({ ...prev, [type]: true }))
|
||||
const uploadFormData = new FormData()
|
||||
uploadFormData.append('file', file)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
body: uploadFormData
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.url) {
|
||||
if (type === 'logo') setLogoUrl(data.url)
|
||||
if (type === 'hero') setHeroImageUrl(data.url)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Upload failed', err)
|
||||
} finally {
|
||||
setIsUploading(prev => ({ ...prev, [type]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border p-6">
|
||||
<h2 className="text-base font-semibold text-gray-900 mb-4">Innung bearbeiten</h2>
|
||||
|
||||
{state.success && (
|
||||
<div className="mb-4 p-3 bg-green-50 text-green-700 rounded-lg text-sm">Änderungen gespeichert.</div>
|
||||
)}
|
||||
{state.error && (
|
||||
<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">
|
||||
{/* BASISDATEN */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900 border-b pb-2">Basisdaten</h3>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name der Innung</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
required
|
||||
defaultValue={org.name}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Plan</label>
|
||||
<select
|
||||
name="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"
|
||||
>
|
||||
<option value="pilot">Pilot</option>
|
||||
<option value="standard">Standard</option>
|
||||
<option value="pro">Pro</option>
|
||||
<option value="verband">Verband</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kontakt E-Mail</label>
|
||||
<input
|
||||
type="email"
|
||||
name="contactEmail"
|
||||
defaultValue={org.contactEmail ?? ''}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* BRANDING */}
|
||||
<div className="space-y-4 pt-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900 border-b pb-2">Branding</h3>
|
||||
|
||||
<input type="hidden" name="logoUrl" value={logoUrl} />
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Logo</label>
|
||||
<div className="flex items-center gap-3">
|
||||
{logoUrl ? (
|
||||
<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" />
|
||||
</div>
|
||||
) : (
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
<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' : ''}`}>
|
||||
{isUploading.logo ? 'Wird hochgeladen...' : 'Logo ändern'}
|
||||
</div>
|
||||
<input type="file" onChange={(e) => handleUpload(e, 'logo')} accept="image/*" className="hidden" disabled={isUploading.logo} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Primärfarbe</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="color"
|
||||
name="primaryColor"
|
||||
value={themeColor}
|
||||
onChange={(e) => setThemeColor(e.target.value)}
|
||||
className="h-9 w-12 p-1 border rounded cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={themeColor}
|
||||
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"
|
||||
pattern="^#([A-Fa-f0-9]{6})$"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Sekundärfarbe</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="color"
|
||||
name="secondaryColor"
|
||||
value={secondaryColor}
|
||||
onChange={(e) => setSecondaryColor(e.target.value)}
|
||||
className="h-9 w-12 p-1 border rounded cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={secondaryColor}
|
||||
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"
|
||||
pattern="^#([A-Fa-f0-9]{6})$"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* LANDING PAGE */}
|
||||
<div className="space-y-4 pt-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900 border-b pb-2">Landing Page</h3>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Hero Titel</label>
|
||||
<input
|
||||
type="text"
|
||||
name="landingPageTitle"
|
||||
defaultValue={org.landingPageTitle ?? ''}
|
||||
placeholder="Zukunft des Handwerks gestalten"
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Hero Untertitel / Text</label>
|
||||
<textarea
|
||||
name="landingPageText"
|
||||
defaultValue={org.landingPageText ?? ''}
|
||||
rows={3}
|
||||
placeholder="Gemeinsam stark für unsere Region."
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Aufmacher Überschrift (Buttons)</label>
|
||||
<input
|
||||
type="text"
|
||||
name="landingPageSectionTitle"
|
||||
defaultValue={org.landingPageSectionTitle ?? ''}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Button Text (CTA)</label>
|
||||
<input
|
||||
type="text"
|
||||
name="landingPageButtonText"
|
||||
defaultValue={org.landingPageButtonText ?? ''}
|
||||
placeholder="Jetzt Mitglied werden"
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="landingPageHeroImage" value={heroImageUrl} />
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Hero Hintergrundbild</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<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' : ''}`}>
|
||||
{isUploading.hero ? 'Wird hochgeladen...' : 'Bild auswählen'}
|
||||
</div>
|
||||
<input type="file" onChange={(e) => handleUpload(e, 'hero')} accept="image/*" className="hidden" disabled={isUploading.hero} />
|
||||
</label>
|
||||
{heroImageUrl && (
|
||||
<button type="button" onClick={() => setHeroImageUrl('')} className="text-red-500 hover:text-red-600 text-sm">
|
||||
Entfernen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1 flex justify-between">
|
||||
<span>Overlay Deckkraft</span>
|
||||
<span className="text-gray-500">{org.landingPageHeroOverlayOpacity ?? 50}%</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
name="landingPageHeroOverlayOpacity"
|
||||
min="0"
|
||||
max="100"
|
||||
defaultValue={org.landingPageHeroOverlayOpacity ?? 50}
|
||||
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>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Vorteile / Features</label>
|
||||
<textarea
|
||||
name="landingPageFeatures"
|
||||
defaultValue={initialFeatures}
|
||||
rows={5}
|
||||
placeholder="Ein Benefit pro Zeile..."
|
||||
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>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">App Store URL</label>
|
||||
<input
|
||||
type="url"
|
||||
name="appStoreUrl"
|
||||
defaultValue={org.appStoreUrl ?? ''}
|
||||
placeholder="https://apps.apple.com/..."
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Google Play URL</label>
|
||||
<input
|
||||
type="url"
|
||||
name="playStoreUrl"
|
||||
defaultValue={org.playStoreUrl ?? ''}
|
||||
placeholder="https://play.google.com/..."
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Footer Text</label>
|
||||
<textarea
|
||||
name="landingPageFooter"
|
||||
defaultValue={initialFooter}
|
||||
rows={2}
|
||||
placeholder="© 2024 Innung. Alle Rechte vorbehalten."
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
{isPending ? 'Wird gespeichert…' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +1,25 @@
|
|||
'use client'
|
||||
|
||||
'use client'
|
||||
|
||||
import { removeMember } from '../../actions'
|
||||
import { useState } from 'react'
|
||||
|
||||
export function MemberActions({ member, orgId }: { member: { id: string, name: string }, orgId: string }) {
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
|
||||
const handleRemove = async () => {
|
||||
if (!confirm(`Möchten Sie das Mitglied ${member.name} wirklich entfernen?`)) return
|
||||
setIsPending(true)
|
||||
await removeMember(member.id, orgId)
|
||||
setIsPending(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleRemove}
|
||||
disabled={isPending}
|
||||
className="text-xs text-red-600 hover:text-red-700 font-medium transition-colors"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
)
|
||||
}
|
||||
import { useState } from 'react'
|
||||
|
||||
export function MemberActions({ member, orgId }: { member: { id: string, name: string }, orgId: string }) {
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
|
||||
const handleRemove = async () => {
|
||||
if (!confirm(`Möchten Sie das Mitglied ${member.name} wirklich entfernen?`)) return
|
||||
setIsPending(true)
|
||||
await removeMember(member.id, orgId)
|
||||
setIsPending(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleRemove}
|
||||
disabled={isPending}
|
||||
className="text-xs text-red-600 hover:text-red-700 font-medium transition-colors"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,42 +1,42 @@
|
|||
'use client'
|
||||
|
||||
'use client'
|
||||
|
||||
import { removeUserRole, updateUserRole } from '../../actions'
|
||||
import { useState } from 'react'
|
||||
|
||||
export function UserRoleActions({ ur, orgId }: { ur: { id: string, role: string, user: { email: string } }, orgId: string }) {
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
|
||||
const handleRemove = async () => {
|
||||
if (!confirm(`Möchten Sie den Zugriff für ${ur.user.email} wirklich entfernen?`)) return
|
||||
setIsPending(true)
|
||||
await removeUserRole(ur.id, orgId)
|
||||
setIsPending(false)
|
||||
}
|
||||
|
||||
const handleToggleRole = async () => {
|
||||
const newRole = ur.role === 'admin' ? 'member' : 'admin'
|
||||
setIsPending(true)
|
||||
await updateUserRole(ur.id, orgId, newRole)
|
||||
setIsPending(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleToggleRole}
|
||||
disabled={isPending}
|
||||
className="text-xs text-gray-600 hover:text-brand-600 font-medium transition-colors"
|
||||
title={ur.role === 'admin' ? 'Zum Mitglied machen' : 'Zum Admin machen'}
|
||||
>
|
||||
{ur.role === 'admin' ? 'Rolle: Admin' : 'Rolle: Mitglied'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRemove}
|
||||
disabled={isPending}
|
||||
className="text-xs text-red-600 hover:text-red-700 font-medium transition-colors"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { useState } from 'react'
|
||||
|
||||
export function UserRoleActions({ ur, orgId }: { ur: { id: string, role: string, user: { email: string } }, orgId: string }) {
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
|
||||
const handleRemove = async () => {
|
||||
if (!confirm(`Möchten Sie den Zugriff für ${ur.user.email} wirklich entfernen?`)) return
|
||||
setIsPending(true)
|
||||
await removeUserRole(ur.id, orgId)
|
||||
setIsPending(false)
|
||||
}
|
||||
|
||||
const handleToggleRole = async () => {
|
||||
const newRole = ur.role === 'admin' ? 'member' : 'admin'
|
||||
setIsPending(true)
|
||||
await updateUserRole(ur.id, orgId, newRole)
|
||||
setIsPending(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleToggleRole}
|
||||
disabled={isPending}
|
||||
className="text-xs text-gray-600 hover:text-brand-600 font-medium transition-colors"
|
||||
title={ur.role === 'admin' ? 'Zum Mitglied machen' : 'Zum Admin machen'}
|
||||
>
|
||||
{ur.role === 'admin' ? 'Rolle: Admin' : 'Rolle: Mitglied'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRemove}
|
||||
disabled={isPending}
|
||||
className="text-xs text-red-600 hover:text-red-700 font-medium transition-colors"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,236 +1,236 @@
|
|||
import { prisma } from '@innungsapp/shared'
|
||||
import { format } from 'date-fns'
|
||||
import { de } from 'date-fns/locale'
|
||||
import Link from 'next/link'
|
||||
import { toggleAiFeature } from './actions'
|
||||
|
||||
const PLAN_LABELS: Record<string, string> = {
|
||||
pilot: 'Pilot',
|
||||
standard: 'Standard',
|
||||
pro: 'Pro',
|
||||
verband: 'Verband',
|
||||
}
|
||||
|
||||
const PLAN_COLORS: Record<string, string> = {
|
||||
pilot: 'bg-gray-100 text-gray-700',
|
||||
standard: 'bg-blue-100 text-blue-800',
|
||||
pro: 'bg-purple-100 text-purple-800',
|
||||
verband: 'bg-amber-100 text-amber-800',
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export default async function SuperAdminPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ q?: string; page?: string }>
|
||||
}) {
|
||||
const { q = '', page = '1' } = await searchParams
|
||||
const currentPage = Math.max(1, parseInt(page, 10))
|
||||
const skip = (currentPage - 1) * PAGE_SIZE
|
||||
|
||||
const where = q
|
||||
? {
|
||||
OR: [
|
||||
{ name: { contains: q } },
|
||||
{ slug: { contains: q } },
|
||||
{ contactEmail: { contains: q } },
|
||||
],
|
||||
}
|
||||
: {}
|
||||
|
||||
const [organizations, total] = await Promise.all([
|
||||
prisma.organization.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: PAGE_SIZE,
|
||||
include: { _count: { select: { members: true, userRoles: true } } },
|
||||
}),
|
||||
prisma.organization.count({ where }),
|
||||
])
|
||||
|
||||
const totalPages = Math.ceil(total / PAGE_SIZE)
|
||||
|
||||
return (
|
||||
<div className="max-w-[1400px] mx-auto space-y-12 py-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="text-left space-y-2">
|
||||
<h1 className="text-3xl font-black text-gray-900 tracking-tight font-outfit">
|
||||
Innungs-Verwaltung <span className="text-[#E63946]">PRO</span>
|
||||
</h1>
|
||||
<p className="text-gray-400 font-medium">Hierüber werden alle Mandanten der Lösung verwaltet.</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
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"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
Neue Innung anlegen
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-12 items-start">
|
||||
{/* List */}
|
||||
<div className="space-y-6">
|
||||
{/* Search & Filter */}
|
||||
<div className="bg-white p-2 rounded-2xl border shadow-sm flex items-center">
|
||||
<form method="GET" className="flex-1 flex gap-2">
|
||||
<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">
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
defaultValue={q}
|
||||
placeholder="Innung suchen..."
|
||||
className="w-full pl-9 pr-4 py-3 bg-transparent text-sm outline-none placeholder:text-gray-300"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
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]"
|
||||
>
|
||||
Suchen
|
||||
</button>
|
||||
{q && (
|
||||
<Link
|
||||
href="/superadmin"
|
||||
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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</Link>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<h2 className="text-sm font-bold text-gray-400 uppercase tracking-widest">
|
||||
Registrierte Innungen ({total})
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{organizations.length === 0 ? (
|
||||
<div className="bg-white p-12 text-center rounded-2xl border border-dashed border-gray-200">
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
<p className="text-gray-500 font-medium">
|
||||
{q ? 'Keine Treffer für Ihre Suche.' : 'Bisher keine Innungen angelegt.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
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 className="flex justify-between items-start gap-6 relative z-10">
|
||||
<Link href={`/superadmin/organizations/${org.id}`} className="flex-1 min-w-0">
|
||||
<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>
|
||||
<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}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-gray-400 font-medium">
|
||||
<div className="flex items-center gap-1.5 font-mono">
|
||||
<span className="text-[#E63946]">@</span>
|
||||
<span>{org.slug}</span>
|
||||
</div>
|
||||
<span className="w-1 h-1 rounded-full bg-gray-200" />
|
||||
<span>{org.contactEmail || 'Keine Kontaktmail'}</span>
|
||||
</div>
|
||||
</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">
|
||||
<form action={async () => {
|
||||
'use server'
|
||||
await toggleAiFeature(org.id, !org.aiEnabled)
|
||||
}}>
|
||||
<button
|
||||
type="submit"
|
||||
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-gray-50 text-gray-400 border-gray-100 hover:bg-green-50 hover:text-green-600'}`}
|
||||
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">
|
||||
<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>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<Link
|
||||
href={`/superadmin/organizations/${org.id}`}
|
||||
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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center gap-6">
|
||||
<div className="flex flex-col">
|
||||
<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>
|
||||
</div>
|
||||
<div className="w-px h-6 bg-gray-100" />
|
||||
<div className="flex flex-col">
|
||||
<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>
|
||||
</div>
|
||||
<div className="w-px h-6 bg-gray-100 ml-auto" />
|
||||
<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-xs font-semibold text-gray-600">{format(org.createdAt, 'dd.MM.yyyy', { locale: de })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<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">
|
||||
Seite {currentPage} / {totalPages}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
{currentPage > 1 && (
|
||||
<Link
|
||||
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]"
|
||||
>
|
||||
← Zurück
|
||||
</Link>
|
||||
)}
|
||||
{currentPage < totalPages && (
|
||||
<Link
|
||||
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]"
|
||||
>
|
||||
Weiter →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
import { format } from 'date-fns'
|
||||
import { de } from 'date-fns/locale'
|
||||
import Link from 'next/link'
|
||||
import { toggleAiFeature } from './actions'
|
||||
|
||||
const PLAN_LABELS: Record<string, string> = {
|
||||
pilot: 'Pilot',
|
||||
standard: 'Standard',
|
||||
pro: 'Pro',
|
||||
verband: 'Verband',
|
||||
}
|
||||
|
||||
const PLAN_COLORS: Record<string, string> = {
|
||||
pilot: 'bg-gray-100 text-gray-700',
|
||||
standard: 'bg-blue-100 text-blue-800',
|
||||
pro: 'bg-purple-100 text-purple-800',
|
||||
verband: 'bg-amber-100 text-amber-800',
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export default async function SuperAdminPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ q?: string; page?: string }>
|
||||
}) {
|
||||
const { q = '', page = '1' } = await searchParams
|
||||
const currentPage = Math.max(1, parseInt(page, 10))
|
||||
const skip = (currentPage - 1) * PAGE_SIZE
|
||||
|
||||
const where = q
|
||||
? {
|
||||
OR: [
|
||||
{ name: { contains: q } },
|
||||
{ slug: { contains: q } },
|
||||
{ contactEmail: { contains: q } },
|
||||
],
|
||||
}
|
||||
: {}
|
||||
|
||||
const [organizations, total] = await Promise.all([
|
||||
prisma.organization.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: PAGE_SIZE,
|
||||
include: { _count: { select: { members: true, userRoles: true } } },
|
||||
}),
|
||||
prisma.organization.count({ where }),
|
||||
])
|
||||
|
||||
const totalPages = Math.ceil(total / PAGE_SIZE)
|
||||
|
||||
return (
|
||||
<div className="max-w-[1400px] mx-auto space-y-12 py-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="text-left space-y-2">
|
||||
<h1 className="text-3xl font-black text-gray-900 tracking-tight font-outfit">
|
||||
Innungs-Verwaltung <span className="text-[#E63946]">PRO</span>
|
||||
</h1>
|
||||
<p className="text-gray-400 font-medium">Hierüber werden alle Mandanten der Lösung verwaltet.</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
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"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
Neue Innung anlegen
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-12 items-start">
|
||||
{/* List */}
|
||||
<div className="space-y-6">
|
||||
{/* Search & Filter */}
|
||||
<div className="bg-white p-2 rounded-2xl border shadow-sm flex items-center">
|
||||
<form method="GET" className="flex-1 flex gap-2">
|
||||
<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">
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
defaultValue={q}
|
||||
placeholder="Innung suchen..."
|
||||
className="w-full pl-9 pr-4 py-3 bg-transparent text-sm outline-none placeholder:text-gray-300"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
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]"
|
||||
>
|
||||
Suchen
|
||||
</button>
|
||||
{q && (
|
||||
<Link
|
||||
href="/superadmin"
|
||||
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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</Link>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<h2 className="text-sm font-bold text-gray-400 uppercase tracking-widest">
|
||||
Registrierte Innungen ({total})
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{organizations.length === 0 ? (
|
||||
<div className="bg-white p-12 text-center rounded-2xl border border-dashed border-gray-200">
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
<p className="text-gray-500 font-medium">
|
||||
{q ? 'Keine Treffer für Ihre Suche.' : 'Bisher keine Innungen angelegt.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
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 className="flex justify-between items-start gap-6 relative z-10">
|
||||
<Link href={`/superadmin/organizations/${org.id}`} className="flex-1 min-w-0">
|
||||
<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>
|
||||
<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}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-gray-400 font-medium">
|
||||
<div className="flex items-center gap-1.5 font-mono">
|
||||
<span className="text-[#E63946]">@</span>
|
||||
<span>{org.slug}</span>
|
||||
</div>
|
||||
<span className="w-1 h-1 rounded-full bg-gray-200" />
|
||||
<span>{org.contactEmail || 'Keine Kontaktmail'}</span>
|
||||
</div>
|
||||
</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">
|
||||
<form action={async () => {
|
||||
'use server'
|
||||
await toggleAiFeature(org.id, !org.aiEnabled)
|
||||
}}>
|
||||
<button
|
||||
type="submit"
|
||||
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-gray-50 text-gray-400 border-gray-100 hover:bg-green-50 hover:text-green-600'}`}
|
||||
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">
|
||||
<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>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<Link
|
||||
href={`/superadmin/organizations/${org.id}`}
|
||||
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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center gap-6">
|
||||
<div className="flex flex-col">
|
||||
<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>
|
||||
</div>
|
||||
<div className="w-px h-6 bg-gray-100" />
|
||||
<div className="flex flex-col">
|
||||
<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>
|
||||
</div>
|
||||
<div className="w-px h-6 bg-gray-100 ml-auto" />
|
||||
<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-xs font-semibold text-gray-600">{format(org.createdAt, 'dd.MM.yyyy', { locale: de })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<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">
|
||||
Seite {currentPage} / {totalPages}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
{currentPage > 1 && (
|
||||
<Link
|
||||
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]"
|
||||
>
|
||||
← Zurück
|
||||
</Link>
|
||||
)}
|
||||
{currentPage < totalPages && (
|
||||
<Link
|
||||
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]"
|
||||
>
|
||||
Weiter →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,181 +1,181 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Sparkles, Copy, Check } from 'lucide-react'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
|
||||
interface AIGeneratorProps {
|
||||
type: 'news' | 'stelle'
|
||||
onApply?: (text: string) => void
|
||||
}
|
||||
|
||||
const THINKING_STEPS = [
|
||||
'KI denkt nach…',
|
||||
'Thema wird analysiert…',
|
||||
'Recherchiere Inhalte…',
|
||||
'Struktur wird geplant…',
|
||||
'Einleitung wird formuliert…',
|
||||
'Hauptteil wird ausgearbeitet…',
|
||||
'Formulierungen werden verfeinert…',
|
||||
'Fachbegriffe werden geprüft…',
|
||||
'Absätze werden aufgeteilt…',
|
||||
'Zwischenüberschriften werden gesetzt…',
|
||||
'Stil wird angepasst…',
|
||||
'Rechtschreibung wird kontrolliert…',
|
||||
'Markdown wird formatiert…',
|
||||
'Überschrift wird optimiert…',
|
||||
'Fazit wird formuliert…',
|
||||
'Länge wird angepasst…',
|
||||
'Ton wird auf Zielgruppe abgestimmt…',
|
||||
'Aufzählungen werden erstellt…',
|
||||
'Fettungen werden gesetzt…',
|
||||
'Satzfluss wird geprüft…',
|
||||
'Grammatik wird überprüft…',
|
||||
'Keywords werden eingebaut…',
|
||||
'Einleitung wird überarbeitet…',
|
||||
'Abschnitte werden umstrukturiert…',
|
||||
'Wiederholungen werden entfernt…',
|
||||
'Zeichensetzung wird geprüft…',
|
||||
'Leerzeilen werden optimiert…',
|
||||
'Fachlich wird validiert…',
|
||||
'Lesbarkeit wird verbessert…',
|
||||
'Zusammenfassung wird erstellt…',
|
||||
'Text wird poliert…',
|
||||
'Letzte Korrekturen…',
|
||||
'Fast fertig…',
|
||||
]
|
||||
|
||||
export function AIGenerator({ type, onApply }: AIGeneratorProps) {
|
||||
const { data: org } = trpc.organizations.me.useQuery()
|
||||
const [prompt, setPrompt] = useState('')
|
||||
const [format, setFormat] = useState('markdown')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [generatedText, setGeneratedText] = useState('')
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [stepIndex, setStepIndex] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading) { setStepIndex(0); return }
|
||||
const interval = setInterval(() => {
|
||||
setStepIndex((i) => (i + 1) % THINKING_STEPS.length)
|
||||
}, 5000)
|
||||
return () => clearInterval(interval)
|
||||
}, [loading])
|
||||
|
||||
async function handleGenerate() {
|
||||
if (!prompt.trim()) return
|
||||
setLoading(true)
|
||||
setGeneratedText('')
|
||||
try {
|
||||
const res = await fetch('/api/ai/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt, type, format }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error('Fehler bei der Generierung')
|
||||
}
|
||||
const data = await res.json()
|
||||
setGeneratedText(data.text)
|
||||
} catch (err) {
|
||||
alert((err as Error).message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopy() {
|
||||
navigator.clipboard.writeText(generatedText)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
if (org && !org.aiEnabled) return null
|
||||
|
||||
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="flex items-center gap-2 mb-2">
|
||||
<Sparkles className="w-5 h-5 text-brand-500" />
|
||||
<h2 className="text-lg font-bold text-gray-900">KI-Assistent</h2>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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'}
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<select
|
||||
value={format}
|
||||
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"
|
||||
>
|
||||
<option value="markdown">Markdown Format</option>
|
||||
<option value="text">Einfacher Text</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
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"
|
||||
>
|
||||
{loading ? 'Generiere...' : 'Generieren'}
|
||||
<Sparkles className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{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-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:150ms]" />
|
||||
<span className="w-2 h-2 rounded-full bg-brand-400 animate-bounce [animation-delay:300ms]" />
|
||||
</div>
|
||||
<span className="text-sm text-brand-700 font-medium transition-all">{THINKING_STEPS[stepIndex]}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{generatedText && (
|
||||
<div className="mt-4 flex-1 flex flex-col min-h-[300px] space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-700">Ergebnis:</span>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
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 ? 'Kopiert!' : 'Kopieren'}
|
||||
</button>
|
||||
{onApply && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onApply(generatedText)}
|
||||
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" />
|
||||
Übernehmen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
readOnly
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Sparkles, Copy, Check } from 'lucide-react'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
|
||||
interface AIGeneratorProps {
|
||||
type: 'news' | 'stelle'
|
||||
onApply?: (text: string) => void
|
||||
}
|
||||
|
||||
const THINKING_STEPS = [
|
||||
'KI denkt nach…',
|
||||
'Thema wird analysiert…',
|
||||
'Recherchiere Inhalte…',
|
||||
'Struktur wird geplant…',
|
||||
'Einleitung wird formuliert…',
|
||||
'Hauptteil wird ausgearbeitet…',
|
||||
'Formulierungen werden verfeinert…',
|
||||
'Fachbegriffe werden geprüft…',
|
||||
'Absätze werden aufgeteilt…',
|
||||
'Zwischenüberschriften werden gesetzt…',
|
||||
'Stil wird angepasst…',
|
||||
'Rechtschreibung wird kontrolliert…',
|
||||
'Markdown wird formatiert…',
|
||||
'Überschrift wird optimiert…',
|
||||
'Fazit wird formuliert…',
|
||||
'Länge wird angepasst…',
|
||||
'Ton wird auf Zielgruppe abgestimmt…',
|
||||
'Aufzählungen werden erstellt…',
|
||||
'Fettungen werden gesetzt…',
|
||||
'Satzfluss wird geprüft…',
|
||||
'Grammatik wird überprüft…',
|
||||
'Keywords werden eingebaut…',
|
||||
'Einleitung wird überarbeitet…',
|
||||
'Abschnitte werden umstrukturiert…',
|
||||
'Wiederholungen werden entfernt…',
|
||||
'Zeichensetzung wird geprüft…',
|
||||
'Leerzeilen werden optimiert…',
|
||||
'Fachlich wird validiert…',
|
||||
'Lesbarkeit wird verbessert…',
|
||||
'Zusammenfassung wird erstellt…',
|
||||
'Text wird poliert…',
|
||||
'Letzte Korrekturen…',
|
||||
'Fast fertig…',
|
||||
]
|
||||
|
||||
export function AIGenerator({ type, onApply }: AIGeneratorProps) {
|
||||
const { data: org } = trpc.organizations.me.useQuery()
|
||||
const [prompt, setPrompt] = useState('')
|
||||
const [format, setFormat] = useState('markdown')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [generatedText, setGeneratedText] = useState('')
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [stepIndex, setStepIndex] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading) { setStepIndex(0); return }
|
||||
const interval = setInterval(() => {
|
||||
setStepIndex((i) => (i + 1) % THINKING_STEPS.length)
|
||||
}, 5000)
|
||||
return () => clearInterval(interval)
|
||||
}, [loading])
|
||||
|
||||
async function handleGenerate() {
|
||||
if (!prompt.trim()) return
|
||||
setLoading(true)
|
||||
setGeneratedText('')
|
||||
try {
|
||||
const res = await fetch('/api/ai/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt, type, format }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error('Fehler bei der Generierung')
|
||||
}
|
||||
const data = await res.json()
|
||||
setGeneratedText(data.text)
|
||||
} catch (err) {
|
||||
alert((err as Error).message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopy() {
|
||||
navigator.clipboard.writeText(generatedText)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
if (org && !org.aiEnabled) return null
|
||||
|
||||
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="flex items-center gap-2 mb-2">
|
||||
<Sparkles className="w-5 h-5 text-brand-500" />
|
||||
<h2 className="text-lg font-bold text-gray-900">KI-Assistent</h2>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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'}
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<select
|
||||
value={format}
|
||||
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"
|
||||
>
|
||||
<option value="markdown">Markdown Format</option>
|
||||
<option value="text">Einfacher Text</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
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"
|
||||
>
|
||||
{loading ? 'Generiere...' : 'Generieren'}
|
||||
<Sparkles className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{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-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:150ms]" />
|
||||
<span className="w-2 h-2 rounded-full bg-brand-400 animate-bounce [animation-delay:300ms]" />
|
||||
</div>
|
||||
<span className="text-sm text-brand-700 font-medium transition-all">{THINKING_STEPS[stepIndex]}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{generatedText && (
|
||||
<div className="mt-4 flex-1 flex flex-col min-h-[300px] space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-700">Ergebnis:</span>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
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 ? 'Kopiert!' : 'Kopieren'}
|
||||
</button>
|
||||
{onApply && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onApply(generatedText)}
|
||||
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" />
|
||||
Übernehmen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
readOnly
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,136 +1,136 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createAuthClient } from 'better-auth/react'
|
||||
|
||||
const authClient = createAuthClient({
|
||||
// Keep auth requests on the current origin (important for tenant subdomains).
|
||||
baseURL: typeof window !== 'undefined'
|
||||
? window.location.origin
|
||||
: (process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3010'),
|
||||
})
|
||||
|
||||
interface LoginFormProps {
|
||||
primaryColor?: string
|
||||
}
|
||||
|
||||
export function LoginForm({ primaryColor = '#C99738' }: LoginFormProps) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [successMessage, setSuccessMessage] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const emailParam = params.get('email')
|
||||
if (emailParam) setEmail(emailParam)
|
||||
const messageParam = params.get('message')
|
||||
if (messageParam === 'password_changed') {
|
||||
setSuccessMessage('Passwort erfolgreich geändert. Bitte melden Sie sich mit Ihrem neuen Passwort an.')
|
||||
}
|
||||
}, [])
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
const result = await authClient.signIn.email({
|
||||
email,
|
||||
password,
|
||||
callbackURL: '/dashboard',
|
||||
})
|
||||
|
||||
setLoading(false)
|
||||
if (result.error) {
|
||||
setError(result.error.message ?? 'E-Mail oder Passwort falsch.')
|
||||
return
|
||||
}
|
||||
|
||||
// Use callbackUrl if present, otherwise go to dashboard
|
||||
// mustChangePassword is handled by the dashboard ForcePasswordChange component
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const callbackUrl = params.get('callbackUrl')
|
||||
|
||||
let target = '/dashboard'
|
||||
if (callbackUrl?.startsWith('/')) {
|
||||
target = callbackUrl
|
||||
|
||||
// Normalize stale tenant-prefixed callback URLs like /test/dashboard
|
||||
// when already on the tenant subdomain test.localhost.
|
||||
const hostname = window.location.hostname
|
||||
const parts = hostname.split('.')
|
||||
const isTenantSubdomain =
|
||||
parts.length > 2 || (parts.length === 2 && parts[1] === 'localhost')
|
||||
const tenantSlug = isTenantSubdomain ? parts[0] : null
|
||||
|
||||
if (tenantSlug && target.startsWith(`/${tenantSlug}/`)) {
|
||||
target = target.slice(tenantSlug.length + 1) || '/dashboard'
|
||||
}
|
||||
}
|
||||
|
||||
window.location.href = target
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{successMessage && (
|
||||
<p className="text-sm text-green-700 bg-green-50 border border-green-200 px-3 py-2 rounded-lg">
|
||||
{successMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-xs font-medium text-gray-600 mb-1 uppercase tracking-wide"
|
||||
>
|
||||
E-Mail-Adresse
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
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"
|
||||
style={{ '--tw-ring-color': primaryColor } as any}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-xs font-medium text-gray-600 mb-1 uppercase tracking-wide"
|
||||
>
|
||||
Passwort
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
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"
|
||||
style={{ '--tw-ring-color': primaryColor } as any}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
style={{ backgroundColor: primaryColor }}
|
||||
>
|
||||
{loading ? 'Bitte warten...' : 'Anmelden'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createAuthClient } from 'better-auth/react'
|
||||
|
||||
const authClient = createAuthClient({
|
||||
// Keep auth requests on the current origin (important for tenant subdomains).
|
||||
baseURL: typeof window !== 'undefined'
|
||||
? window.location.origin
|
||||
: (process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3010'),
|
||||
})
|
||||
|
||||
interface LoginFormProps {
|
||||
primaryColor?: string
|
||||
}
|
||||
|
||||
export function LoginForm({ primaryColor = '#C99738' }: LoginFormProps) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [successMessage, setSuccessMessage] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const emailParam = params.get('email')
|
||||
if (emailParam) setEmail(emailParam)
|
||||
const messageParam = params.get('message')
|
||||
if (messageParam === 'password_changed') {
|
||||
setSuccessMessage('Passwort erfolgreich geändert. Bitte melden Sie sich mit Ihrem neuen Passwort an.')
|
||||
}
|
||||
}, [])
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
const result = await authClient.signIn.email({
|
||||
email,
|
||||
password,
|
||||
callbackURL: '/dashboard',
|
||||
})
|
||||
|
||||
setLoading(false)
|
||||
if (result.error) {
|
||||
setError(result.error.message ?? 'E-Mail oder Passwort falsch.')
|
||||
return
|
||||
}
|
||||
|
||||
// Use callbackUrl if present, otherwise go to dashboard
|
||||
// mustChangePassword is handled by the dashboard ForcePasswordChange component
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const callbackUrl = params.get('callbackUrl')
|
||||
|
||||
let target = '/dashboard'
|
||||
if (callbackUrl?.startsWith('/')) {
|
||||
target = callbackUrl
|
||||
|
||||
// Normalize stale tenant-prefixed callback URLs like /test/dashboard
|
||||
// when already on the tenant subdomain test.localhost.
|
||||
const hostname = window.location.hostname
|
||||
const parts = hostname.split('.')
|
||||
const isTenantSubdomain =
|
||||
parts.length > 2 || (parts.length === 2 && parts[1] === 'localhost')
|
||||
const tenantSlug = isTenantSubdomain ? parts[0] : null
|
||||
|
||||
if (tenantSlug && target.startsWith(`/${tenantSlug}/`)) {
|
||||
target = target.slice(tenantSlug.length + 1) || '/dashboard'
|
||||
}
|
||||
}
|
||||
|
||||
window.location.href = target
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{successMessage && (
|
||||
<p className="text-sm text-green-700 bg-green-50 border border-green-200 px-3 py-2 rounded-lg">
|
||||
{successMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-xs font-medium text-gray-600 mb-1 uppercase tracking-wide"
|
||||
>
|
||||
E-Mail-Adresse
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
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"
|
||||
style={{ '--tw-ring-color': primaryColor } as any}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-xs font-medium text-gray-600 mb-1 uppercase tracking-wide"
|
||||
>
|
||||
Passwort
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
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"
|
||||
style={{ '--tw-ring-color': primaryColor } as any}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
style={{ backgroundColor: primaryColor }}
|
||||
>
|
||||
{loading ? 'Bitte warten...' : 'Anmelden'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,55 +1,55 @@
|
|||
'use client'
|
||||
|
||||
import { createAuthClient } from 'better-auth/react'
|
||||
import { useRouter, usePathname } from 'next/navigation'
|
||||
import { LogOut } from 'lucide-react'
|
||||
|
||||
const authClient = createAuthClient({
|
||||
// Keep auth requests on the current origin (important for tenant subdomains).
|
||||
baseURL: typeof window !== 'undefined'
|
||||
? window.location.origin
|
||||
: (process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3010'),
|
||||
})
|
||||
|
||||
const PAGE_TITLES: Record<string, string> = {
|
||||
'/dashboard': 'Übersicht',
|
||||
'/dashboard/mitglieder': 'Mitglieder',
|
||||
'/dashboard/news': 'News',
|
||||
'/dashboard/termine': 'Termine',
|
||||
'/dashboard/stellen': 'Lehrlingsbörse',
|
||||
'/dashboard/einstellungen': 'Einstellungen',
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
|
||||
const title = Object.entries(PAGE_TITLES)
|
||||
.sort((a, b) => b[0].length - a[0].length)
|
||||
.find(([path]) => pathname === path || pathname.startsWith(path + '/'))?.[1] ?? 'Dashboard'
|
||||
|
||||
async function handleSignOut() {
|
||||
await authClient.signOut()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="h-14 bg-white border-b flex items-center justify-between px-6 flex-shrink-0">
|
||||
<h2
|
||||
className="text-sm font-semibold text-gray-700 tracking-tight"
|
||||
style={{ fontFamily: "'Syne', system-ui, sans-serif" }}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
<LogOut size={14} />
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { createAuthClient } from 'better-auth/react'
|
||||
import { useRouter, usePathname } from 'next/navigation'
|
||||
import { LogOut } from 'lucide-react'
|
||||
|
||||
const authClient = createAuthClient({
|
||||
// Keep auth requests on the current origin (important for tenant subdomains).
|
||||
baseURL: typeof window !== 'undefined'
|
||||
? window.location.origin
|
||||
: (process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3010'),
|
||||
})
|
||||
|
||||
const PAGE_TITLES: Record<string, string> = {
|
||||
'/dashboard': 'Übersicht',
|
||||
'/dashboard/mitglieder': 'Mitglieder',
|
||||
'/dashboard/news': 'News',
|
||||
'/dashboard/termine': 'Termine',
|
||||
'/dashboard/stellen': 'Lehrlingsbörse',
|
||||
'/dashboard/einstellungen': 'Einstellungen',
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
|
||||
const title = Object.entries(PAGE_TITLES)
|
||||
.sort((a, b) => b[0].length - a[0].length)
|
||||
.find(([path]) => pathname === path || pathname.startsWith(path + '/'))?.[1] ?? 'Dashboard'
|
||||
|
||||
async function handleSignOut() {
|
||||
await authClient.signOut()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="h-14 bg-white border-b flex items-center justify-between px-6 flex-shrink-0">
|
||||
<h2
|
||||
className="text-sm font-semibold text-gray-700 tracking-tight"
|
||||
style={{ fontFamily: "'Syne', system-ui, sans-serif" }}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
<LogOut size={14} />
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,56 +1,56 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Keep DATABASE_URL consistent for every Prisma command
|
||||
export DATABASE_URL="${DATABASE_URL:-postgresql://innungsapp:innungsapp@postgres:5432/innungsapp?schema=public}"
|
||||
MIGRATIONS_DIR="./packages/shared/prisma/migrations"
|
||||
|
||||
# Debug: Check environment variables
|
||||
echo "========================================"
|
||||
echo "Environment Variables Check:"
|
||||
echo "========================================"
|
||||
echo "DATABASE_URL: $DATABASE_URL"
|
||||
echo "BETTER_AUTH_URL: ${BETTER_AUTH_URL:-[not set]}"
|
||||
echo "BETTER_AUTH_BASE_URL: ${BETTER_AUTH_BASE_URL:-[not set]}"
|
||||
if [ -n "$BETTER_AUTH_SECRET" ]; then
|
||||
echo "BETTER_AUTH_SECRET: [set]"
|
||||
else
|
||||
echo "BETTER_AUTH_SECRET: [not set]"
|
||||
fi
|
||||
echo "NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-[not set]}"
|
||||
echo "NODE_ENV: ${NODE_ENV:-[not set]}"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
run_with_retries() {
|
||||
attempt=1
|
||||
max_attempts=20
|
||||
|
||||
while [ "$attempt" -le "$max_attempts" ]; do
|
||||
if "$@"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$attempt" -eq "$max_attempts" ]; then
|
||||
echo "Command failed after ${max_attempts} attempts."
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "Database not ready yet. Retry ${attempt}/${max_attempts} in 3s..."
|
||||
attempt=$((attempt + 1))
|
||||
sleep 3
|
||||
done
|
||||
}
|
||||
|
||||
# Prefer migration-based deploys. Fall back to db push when no migrations exist yet.
|
||||
set -- "$MIGRATIONS_DIR"/*/migration.sql
|
||||
if [ -f "$1" ]; then
|
||||
echo "Applying Prisma migrations..."
|
||||
run_with_retries npx prisma migrate deploy --schema=./packages/shared/prisma/schema.prisma
|
||||
else
|
||||
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
|
||||
fi
|
||||
|
||||
echo "Starting Next.js server..."
|
||||
exec node apps/admin/server.js
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Keep DATABASE_URL consistent for every Prisma command
|
||||
export DATABASE_URL="${DATABASE_URL:-postgresql://innungsapp:innungsapp@postgres:5432/innungsapp?schema=public}"
|
||||
MIGRATIONS_DIR="./packages/shared/prisma/migrations"
|
||||
|
||||
# Debug: Check environment variables
|
||||
echo "========================================"
|
||||
echo "Environment Variables Check:"
|
||||
echo "========================================"
|
||||
echo "DATABASE_URL: $DATABASE_URL"
|
||||
echo "BETTER_AUTH_URL: ${BETTER_AUTH_URL:-[not set]}"
|
||||
echo "BETTER_AUTH_BASE_URL: ${BETTER_AUTH_BASE_URL:-[not set]}"
|
||||
if [ -n "$BETTER_AUTH_SECRET" ]; then
|
||||
echo "BETTER_AUTH_SECRET: [set]"
|
||||
else
|
||||
echo "BETTER_AUTH_SECRET: [not set]"
|
||||
fi
|
||||
echo "NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-[not set]}"
|
||||
echo "NODE_ENV: ${NODE_ENV:-[not set]}"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
run_with_retries() {
|
||||
attempt=1
|
||||
max_attempts=20
|
||||
|
||||
while [ "$attempt" -le "$max_attempts" ]; do
|
||||
if "$@"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$attempt" -eq "$max_attempts" ]; then
|
||||
echo "Command failed after ${max_attempts} attempts."
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "Database not ready yet. Retry ${attempt}/${max_attempts} in 3s..."
|
||||
attempt=$((attempt + 1))
|
||||
sleep 3
|
||||
done
|
||||
}
|
||||
|
||||
# Prefer migration-based deploys. Fall back to db push when no migrations exist yet.
|
||||
set -- "$MIGRATIONS_DIR"/*/migration.sql
|
||||
if [ -f "$1" ]; then
|
||||
echo "Applying Prisma migrations..."
|
||||
run_with_retries npx prisma migrate deploy --schema=./packages/shared/prisma/schema.prisma
|
||||
else
|
||||
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
|
||||
fi
|
||||
|
||||
echo "Starting Next.js server..."
|
||||
exec node apps/admin/server.js
|
||||
|
|
|
|||
|
|
@ -1,72 +1,72 @@
|
|||
import { betterAuth } from 'better-auth'
|
||||
import { prismaAdapter } from 'better-auth/adapters/prisma'
|
||||
import { magicLink } from 'better-auth/plugins'
|
||||
import { admin as adminPlugin } from 'better-auth/plugins'
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
import { sendMagicLinkEmail } from './email'
|
||||
import { headers } from 'next/headers'
|
||||
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: prismaAdapter(prisma, {
|
||||
provider: 'postgresql',
|
||||
}),
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
secret: process.env.BETTER_AUTH_SECRET!,
|
||||
baseURL: process.env.BETTER_AUTH_URL!,
|
||||
trustedOrigins: [
|
||||
process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3010',
|
||||
process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3010',
|
||||
'http://localhost:3000',
|
||||
'http://localhost:3001',
|
||||
'http://localhost:3010',
|
||||
'http://localhost:8081',
|
||||
'http://*.localhost:3010',
|
||||
'http://*.localhost:3000',
|
||||
'https://*.innungsapp.de',
|
||||
'https://*.innungsapp.com',
|
||||
// Additional origins from env (comma-separated)
|
||||
...(process.env.BETTER_AUTH_TRUSTED_ORIGINS ?? '').split(',').map((o) => o.trim()).filter(Boolean),
|
||||
],
|
||||
user: {
|
||||
additionalFields: {
|
||||
mustChangePassword: {
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
magicLink({
|
||||
sendMagicLink: async ({ email, url }) => {
|
||||
await sendMagicLinkEmail({ to: email, magicUrl: url })
|
||||
},
|
||||
}),
|
||||
adminPlugin(),
|
||||
],
|
||||
session: {
|
||||
cookieCache: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export type Auth = typeof auth
|
||||
|
||||
export async function getSanitizedHeaders(sourceHeaders?: HeadersInit) {
|
||||
const baseHeaders = sourceHeaders ? new Headers(sourceHeaders) : new Headers(await headers())
|
||||
const sanitizedHeaders = new Headers(baseHeaders)
|
||||
|
||||
// Avoid ENOTFOUND by forcing host to localhost for internal better-auth fetches
|
||||
// We use the host defined in BETTER_AUTH_URL
|
||||
try {
|
||||
const betterAuthUrl = new URL(process.env.BETTER_AUTH_URL || 'http://localhost:3010')
|
||||
sanitizedHeaders.set('host', betterAuthUrl.host)
|
||||
} catch (e) {
|
||||
sanitizedHeaders.set('host', 'localhost:3010')
|
||||
}
|
||||
|
||||
return sanitizedHeaders
|
||||
}
|
||||
import { betterAuth } from 'better-auth'
|
||||
import { prismaAdapter } from 'better-auth/adapters/prisma'
|
||||
import { magicLink } from 'better-auth/plugins'
|
||||
import { admin as adminPlugin } from 'better-auth/plugins'
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
import { sendMagicLinkEmail } from './email'
|
||||
import { headers } from 'next/headers'
|
||||
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: prismaAdapter(prisma, {
|
||||
provider: 'postgresql',
|
||||
}),
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
secret: process.env.BETTER_AUTH_SECRET!,
|
||||
baseURL: process.env.BETTER_AUTH_URL!,
|
||||
trustedOrigins: [
|
||||
process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3010',
|
||||
process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3010',
|
||||
'http://localhost:3000',
|
||||
'http://localhost:3001',
|
||||
'http://localhost:3010',
|
||||
'http://localhost:8081',
|
||||
'http://*.localhost:3010',
|
||||
'http://*.localhost:3000',
|
||||
'https://*.innungsapp.de',
|
||||
'https://*.innungsapp.com',
|
||||
// Additional origins from env (comma-separated)
|
||||
...(process.env.BETTER_AUTH_TRUSTED_ORIGINS ?? '').split(',').map((o) => o.trim()).filter(Boolean),
|
||||
],
|
||||
user: {
|
||||
additionalFields: {
|
||||
mustChangePassword: {
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
magicLink({
|
||||
sendMagicLink: async ({ email, url }) => {
|
||||
await sendMagicLinkEmail({ to: email, magicUrl: url })
|
||||
},
|
||||
}),
|
||||
adminPlugin(),
|
||||
],
|
||||
session: {
|
||||
cookieCache: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export type Auth = typeof auth
|
||||
|
||||
export async function getSanitizedHeaders(sourceHeaders?: HeadersInit) {
|
||||
const baseHeaders = sourceHeaders ? new Headers(sourceHeaders) : new Headers(await headers())
|
||||
const sanitizedHeaders = new Headers(baseHeaders)
|
||||
|
||||
// Avoid ENOTFOUND by forcing host to localhost for internal better-auth fetches
|
||||
// We use the host defined in BETTER_AUTH_URL
|
||||
try {
|
||||
const betterAuthUrl = new URL(process.env.BETTER_AUTH_URL || 'http://localhost:3010')
|
||||
sanitizedHeaders.set('host', betterAuthUrl.host)
|
||||
} catch (e) {
|
||||
sanitizedHeaders.set('host', 'localhost:3010')
|
||||
}
|
||||
|
||||
return sanitizedHeaders
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,151 +1,151 @@
|
|||
import nodemailer from 'nodemailer'
|
||||
|
||||
const SMTP_HOST = (process.env.SMTP_HOST ?? '').trim()
|
||||
const SMTP_HOST_IS_PLACEHOLDER = SMTP_HOST === '' || SMTP_HOST.toLowerCase() === 'smtp.example.com'
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: SMTP_HOST,
|
||||
port: Number(process.env.SMTP_PORT) || 587,
|
||||
secure: process.env.SMTP_SECURE === 'true',
|
||||
auth:
|
||||
process.env.SMTP_USER
|
||||
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }
|
||||
: undefined,
|
||||
})
|
||||
|
||||
async function sendMailOrSkip(mailOptions: any, emailType: string) {
|
||||
if (SMTP_HOST_IS_PLACEHOLDER) {
|
||||
const target = typeof mailOptions?.to === 'string' ? mailOptions.to : 'unknown-recipient'
|
||||
console.warn(`[email] SMTP not configured. Skipping ${emailType} email to ${target}.`)
|
||||
return
|
||||
}
|
||||
|
||||
await transporter.sendMail(mailOptions)
|
||||
}
|
||||
|
||||
export async function sendMagicLinkEmail({
|
||||
to,
|
||||
magicUrl,
|
||||
}: {
|
||||
to: string
|
||||
magicUrl: string
|
||||
}) {
|
||||
await sendMailOrSkip({
|
||||
from: process.env.EMAIL_FROM ?? 'noreply@innungsapp.de',
|
||||
to,
|
||||
subject: 'Ihr Login-Link für InnungsApp',
|
||||
html: `
|
||||
<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;">
|
||||
<h1 style="color: white; margin: 0; font-size: 24px;">InnungsApp</h1>
|
||||
</div>
|
||||
<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>
|
||||
<p style="color: #4b5563;">Klicken Sie auf den folgenden Button, um sich einzuloggen. Der Link ist 24 Stunden gültig.</p>
|
||||
<a href="${magicUrl}"
|
||||
style="display: inline-block; background: #E63946; color: white; padding: 12px 24px;
|
||||
border-radius: 6px; text-decoration: none; font-weight: bold; margin: 16px 0;">
|
||||
Jetzt einloggen
|
||||
</a>
|
||||
<p style="color: #9ca3af; font-size: 14px;">
|
||||
Wenn Sie diesen Link nicht angefordert haben, können Sie diese E-Mail ignorieren.
|
||||
</p>
|
||||
<hr style="border-color: #e5e7eb; margin: 24px 0;" />
|
||||
<p style="color: #9ca3af; font-size: 12px; margin: 0;">
|
||||
InnungsApp · Die digitale Plattform für Innungen
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
}, 'magic link')
|
||||
}
|
||||
|
||||
export async function sendInviteEmail({
|
||||
to,
|
||||
memberName,
|
||||
orgName,
|
||||
apiUrl,
|
||||
}: {
|
||||
to: string
|
||||
memberName: string
|
||||
orgName: string
|
||||
apiUrl: string
|
||||
}) {
|
||||
// Generate magic link for the invite
|
||||
const signInUrl = `${apiUrl}/login?email=${encodeURIComponent(to)}&invited=true`
|
||||
|
||||
await sendMailOrSkip({
|
||||
from: process.env.EMAIL_FROM ?? 'noreply@innungsapp.de',
|
||||
to,
|
||||
subject: `Einladung zur InnungsApp — ${orgName}`,
|
||||
html: `
|
||||
<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;">
|
||||
<h1 style="color: white; margin: 0; font-size: 24px;">InnungsApp</h1>
|
||||
</div>
|
||||
<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>
|
||||
<p style="color: #4b5563;">
|
||||
Sie wurden von der <strong>${orgName}</strong> zur InnungsApp eingeladen.
|
||||
InnungsApp ist die digitale Plattform Ihrer Innung für News, Termine und das Mitgliederverzeichnis.
|
||||
</p>
|
||||
<p style="color: #4b5563;">Klicken Sie auf den Button, um Ihren Account zu aktivieren:</p>
|
||||
<a href="${signInUrl}"
|
||||
style="display: inline-block; background: #E63946; color: white; padding: 12px 24px;
|
||||
border-radius: 6px; text-decoration: none; font-weight: bold; margin: 16px 0;">
|
||||
Jetzt Zugang aktivieren
|
||||
</a>
|
||||
<p style="color: #9ca3af; font-size: 14px;">Kein Passwort nötig — Sie erhalten einen sicheren Login-Link per E-Mail.</p>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
}, 'invite')
|
||||
}
|
||||
|
||||
export async function sendAdminCredentialsEmail({
|
||||
to,
|
||||
adminName,
|
||||
orgName,
|
||||
password,
|
||||
loginUrl,
|
||||
}: {
|
||||
to: string
|
||||
adminName: string
|
||||
orgName: string
|
||||
password: string
|
||||
loginUrl: string
|
||||
}) {
|
||||
await sendMailOrSkip({
|
||||
from: process.env.EMAIL_FROM ?? 'noreply@innungsapp.de',
|
||||
to,
|
||||
subject: `Admin-Zugang für — ${orgName}`,
|
||||
html: `
|
||||
<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;">
|
||||
<h1 style="color: white; margin: 0; font-size: 24px;">InnungsApp Admin</h1>
|
||||
</div>
|
||||
<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>
|
||||
<p style="color: #4b5563;">
|
||||
Sie wurden als Administrator für die <strong>${orgName}</strong> in der InnungsApp freigeschaltet.
|
||||
</p>
|
||||
<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: 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>
|
||||
</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>
|
||||
<a href="${loginUrl}/login?email=${encodeURIComponent(to)}"
|
||||
style="display: inline-block; background: #111827; color: white; padding: 12px 24px;
|
||||
border-radius: 6px; text-decoration: none; font-weight: bold; margin: 16px 0;">
|
||||
Zum Admin-Portal
|
||||
</a>
|
||||
<hr style="border-color: #e5e7eb; margin: 24px 0;" />
|
||||
<p style="color: #9ca3af; font-size: 12px; margin: 0;">
|
||||
InnungsApp · Administrative Portal
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
}, 'admin credentials')
|
||||
}
|
||||
import nodemailer from 'nodemailer'
|
||||
|
||||
const SMTP_HOST = (process.env.SMTP_HOST ?? '').trim()
|
||||
const SMTP_HOST_IS_PLACEHOLDER = SMTP_HOST === '' || SMTP_HOST.toLowerCase() === 'smtp.example.com'
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: SMTP_HOST,
|
||||
port: Number(process.env.SMTP_PORT) || 587,
|
||||
secure: process.env.SMTP_SECURE === 'true',
|
||||
auth:
|
||||
process.env.SMTP_USER
|
||||
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }
|
||||
: undefined,
|
||||
})
|
||||
|
||||
async function sendMailOrSkip(mailOptions: any, emailType: string) {
|
||||
if (SMTP_HOST_IS_PLACEHOLDER) {
|
||||
const target = typeof mailOptions?.to === 'string' ? mailOptions.to : 'unknown-recipient'
|
||||
console.warn(`[email] SMTP not configured. Skipping ${emailType} email to ${target}.`)
|
||||
return
|
||||
}
|
||||
|
||||
await transporter.sendMail(mailOptions)
|
||||
}
|
||||
|
||||
export async function sendMagicLinkEmail({
|
||||
to,
|
||||
magicUrl,
|
||||
}: {
|
||||
to: string
|
||||
magicUrl: string
|
||||
}) {
|
||||
await sendMailOrSkip({
|
||||
from: process.env.EMAIL_FROM ?? 'noreply@innungsapp.de',
|
||||
to,
|
||||
subject: 'Ihr Login-Link für InnungsApp',
|
||||
html: `
|
||||
<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;">
|
||||
<h1 style="color: white; margin: 0; font-size: 24px;">InnungsApp</h1>
|
||||
</div>
|
||||
<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>
|
||||
<p style="color: #4b5563;">Klicken Sie auf den folgenden Button, um sich einzuloggen. Der Link ist 24 Stunden gültig.</p>
|
||||
<a href="${magicUrl}"
|
||||
style="display: inline-block; background: #E63946; color: white; padding: 12px 24px;
|
||||
border-radius: 6px; text-decoration: none; font-weight: bold; margin: 16px 0;">
|
||||
Jetzt einloggen
|
||||
</a>
|
||||
<p style="color: #9ca3af; font-size: 14px;">
|
||||
Wenn Sie diesen Link nicht angefordert haben, können Sie diese E-Mail ignorieren.
|
||||
</p>
|
||||
<hr style="border-color: #e5e7eb; margin: 24px 0;" />
|
||||
<p style="color: #9ca3af; font-size: 12px; margin: 0;">
|
||||
InnungsApp · Die digitale Plattform für Innungen
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
}, 'magic link')
|
||||
}
|
||||
|
||||
export async function sendInviteEmail({
|
||||
to,
|
||||
memberName,
|
||||
orgName,
|
||||
apiUrl,
|
||||
}: {
|
||||
to: string
|
||||
memberName: string
|
||||
orgName: string
|
||||
apiUrl: string
|
||||
}) {
|
||||
// Generate magic link for the invite
|
||||
const signInUrl = `${apiUrl}/login?email=${encodeURIComponent(to)}&invited=true`
|
||||
|
||||
await sendMailOrSkip({
|
||||
from: process.env.EMAIL_FROM ?? 'noreply@innungsapp.de',
|
||||
to,
|
||||
subject: `Einladung zur InnungsApp — ${orgName}`,
|
||||
html: `
|
||||
<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;">
|
||||
<h1 style="color: white; margin: 0; font-size: 24px;">InnungsApp</h1>
|
||||
</div>
|
||||
<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>
|
||||
<p style="color: #4b5563;">
|
||||
Sie wurden von der <strong>${orgName}</strong> zur InnungsApp eingeladen.
|
||||
InnungsApp ist die digitale Plattform Ihrer Innung für News, Termine und das Mitgliederverzeichnis.
|
||||
</p>
|
||||
<p style="color: #4b5563;">Klicken Sie auf den Button, um Ihren Account zu aktivieren:</p>
|
||||
<a href="${signInUrl}"
|
||||
style="display: inline-block; background: #E63946; color: white; padding: 12px 24px;
|
||||
border-radius: 6px; text-decoration: none; font-weight: bold; margin: 16px 0;">
|
||||
Jetzt Zugang aktivieren
|
||||
</a>
|
||||
<p style="color: #9ca3af; font-size: 14px;">Kein Passwort nötig — Sie erhalten einen sicheren Login-Link per E-Mail.</p>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
}, 'invite')
|
||||
}
|
||||
|
||||
export async function sendAdminCredentialsEmail({
|
||||
to,
|
||||
adminName,
|
||||
orgName,
|
||||
password,
|
||||
loginUrl,
|
||||
}: {
|
||||
to: string
|
||||
adminName: string
|
||||
orgName: string
|
||||
password: string
|
||||
loginUrl: string
|
||||
}) {
|
||||
await sendMailOrSkip({
|
||||
from: process.env.EMAIL_FROM ?? 'noreply@innungsapp.de',
|
||||
to,
|
||||
subject: `Admin-Zugang für — ${orgName}`,
|
||||
html: `
|
||||
<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;">
|
||||
<h1 style="color: white; margin: 0; font-size: 24px;">InnungsApp Admin</h1>
|
||||
</div>
|
||||
<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>
|
||||
<p style="color: #4b5563;">
|
||||
Sie wurden als Administrator für die <strong>${orgName}</strong> in der InnungsApp freigeschaltet.
|
||||
</p>
|
||||
<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: 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>
|
||||
</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>
|
||||
<a href="${loginUrl}/login?email=${encodeURIComponent(to)}"
|
||||
style="display: inline-block; background: #111827; color: white; padding: 12px 24px;
|
||||
border-radius: 6px; text-decoration: none; font-weight: bold; margin: 16px 0;">
|
||||
Zum Admin-Portal
|
||||
</a>
|
||||
<hr style="border-color: #e5e7eb; margin: 24px 0;" />
|
||||
<p style="color: #9ca3af; font-size: 12px; margin: 0;">
|
||||
InnungsApp · Administrative Portal
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
}, 'admin credentials')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
import { headers } from 'next/headers'
|
||||
|
||||
const RESERVED_SUBDOMAINS = ['www', 'app', 'admin', 'localhost', 'superadmin', 'api']
|
||||
|
||||
export async function getTenantSlug() {
|
||||
const host = (await headers()).get('host') || ''
|
||||
const domainParts = host.split(':')[0].split('.')
|
||||
|
||||
if (
|
||||
domainParts.length > 2 ||
|
||||
(domainParts.length === 2 && domainParts[1] === 'localhost')
|
||||
) {
|
||||
const slug = domainParts[0]
|
||||
if (!RESERVED_SUBDOMAINS.includes(slug)) {
|
||||
return slug
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
import { headers } from 'next/headers'
|
||||
|
||||
const RESERVED_SUBDOMAINS = ['www', 'app', 'admin', 'localhost', 'superadmin', 'api']
|
||||
|
||||
export async function getTenantSlug() {
|
||||
const host = (await headers()).get('host') || ''
|
||||
const domainParts = host.split(':')[0].split('.')
|
||||
|
||||
if (
|
||||
domainParts.length > 2 ||
|
||||
(domainParts.length === 2 && domainParts[1] === 'localhost')
|
||||
) {
|
||||
const slug = domainParts[0]
|
||||
if (!RESERVED_SUBDOMAINS.includes(slug)) {
|
||||
return slug
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,123 +1,124 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
|
||||
const PUBLIC_PREFIXES = [
|
||||
'/login',
|
||||
'/api/auth',
|
||||
'/api/health',
|
||||
'/api/trpc/stellen.listPublic',
|
||||
'/api/setup',
|
||||
'/registrierung',
|
||||
'/impressum',
|
||||
'/datenschutz',
|
||||
]
|
||||
const PUBLIC_EXACT_PATHS = ['/']
|
||||
const TENANT_SHARED_PATHS = ['/login', '/api', '/superadmin', '/registrierung', '/impressum', '/datenschutz', '/passwort-aendern']
|
||||
|
||||
// Reserved subdomains that shouldn't be treated as tenant slugs
|
||||
const RESERVED_SUBDOMAINS = [
|
||||
'www', 'app', 'admin', 'localhost', 'superadmin', 'api',
|
||||
'logo.png', 'favicon.ico', 'robots.txt', 'sitemap.xml',
|
||||
'apple-touch-icon', 'android-chrome', 'manifest'
|
||||
]
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const url = request.nextUrl
|
||||
const pathname = url.pathname
|
||||
|
||||
// 1. Subdomain Extraction
|
||||
const hostname = request.headers.get('host') || ''
|
||||
const domainParts = hostname.split(':')[0].split('.')
|
||||
let slug = null
|
||||
|
||||
// For localhost: tischler.localhost -> parts: ['tischler', 'localhost']
|
||||
// For production: tischler.innungsapp.de -> parts: ['tischler', 'innungsapp', 'de']
|
||||
if (
|
||||
domainParts.length > 2 ||
|
||||
(domainParts.length === 2 && domainParts[1] === 'localhost')
|
||||
) {
|
||||
const potentialSlug = domainParts[0]
|
||||
if (!RESERVED_SUBDOMAINS.includes(potentialSlug)) {
|
||||
slug = potentialSlug
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize stale tenant-prefixed shared paths like /test/login to /login
|
||||
// before auth checks, otherwise callbackUrl can get stuck on /test/login.
|
||||
if (slug) {
|
||||
for (const sharedPath of TENANT_SHARED_PATHS) {
|
||||
const prefixedPath = `/${slug}${sharedPath}`
|
||||
if (pathname === prefixedPath || pathname.startsWith(`${prefixedPath}/`)) {
|
||||
const canonicalUrl = request.nextUrl.clone()
|
||||
canonicalUrl.pathname = pathname.replace(prefixedPath, sharedPath)
|
||||
return NextResponse.redirect(canonicalUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Allow static files from /public
|
||||
const isStaticFile = pathname.includes('.') && !pathname.startsWith('/api')
|
||||
const isPublic =
|
||||
isStaticFile ||
|
||||
PUBLIC_EXACT_PATHS.includes(pathname) ||
|
||||
PUBLIC_PREFIXES.some((p) => pathname.startsWith(p))
|
||||
|
||||
// 2. Auth Check
|
||||
const sessionToken =
|
||||
request.cookies.get('better-auth.session_token') ??
|
||||
request.cookies.get('__Secure-better-auth.session_token')
|
||||
|
||||
if (!isPublic && !sessionToken) {
|
||||
const loginUrl = new URL('/login', request.url)
|
||||
loginUrl.searchParams.set('callbackUrl', pathname)
|
||||
return NextResponse.redirect(loginUrl)
|
||||
}
|
||||
|
||||
// 3. Subdomain Redirection / Rewrite
|
||||
if (slug) {
|
||||
// Paths that should not be rewritten into the slug folder
|
||||
// because they are shared across the entire app
|
||||
const isSharedPath = TENANT_SHARED_PATHS.some((p) => pathname.startsWith(p)) ||
|
||||
pathname.startsWith('/_next') ||
|
||||
/\.(png|jpg|jpeg|gif|svg|webp|ico|txt|xml)$/i.test(pathname)
|
||||
|
||||
if (!isSharedPath && !pathname.startsWith(`/${slug}`)) {
|
||||
const rewriteUrl = request.nextUrl.clone()
|
||||
rewriteUrl.pathname = `/${slug}${pathname === '/' ? '' : pathname}`
|
||||
return NextResponse.rewrite(rewriteUrl)
|
||||
}
|
||||
} else {
|
||||
// Check if the user is trying to access a path that starts with a potential slug
|
||||
// but they are on the root domain.
|
||||
// Example: localhost/tischler/... should redirect to tischler.localhost/...
|
||||
const pathParts = pathname.split('/')
|
||||
if (pathParts.length > 1) {
|
||||
const potentialSlug = pathParts[1]
|
||||
// Check if it's a known non-reserved path but could be an organization slug
|
||||
// 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 isStaticAsset = /\.(png|jpg|jpeg|gif|svg|webp|ico|txt|xml)$/i.test(potentialSlug)
|
||||
if (potentialSlug && !SHARED_PATHS.includes(potentialSlug) && !isStaticAsset) {
|
||||
// This looks like a tenant path being accessed from the root domain.
|
||||
// Redirect to subdomain.
|
||||
const baseHost = hostname.split('.').slice(-2).join('.') // Simplistic, assumes domain.tld or localhost
|
||||
// For localhost it's special
|
||||
const isLocalhost = hostname.includes('localhost')
|
||||
const newHost = isLocalhost
|
||||
? `${potentialSlug}.localhost${hostname.includes(':') ? `:${hostname.split(':')[1]}` : ''}`
|
||||
: `${potentialSlug}.${baseHost}`
|
||||
|
||||
const remainingPath = '/' + pathParts.slice(2).join('/')
|
||||
return NextResponse.redirect(new URL(remainingPath, `${url.protocol}//${newHost}`))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/((?!_next/static|_next/image|favicon.ico|uploads).*)',
|
||||
],
|
||||
}
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
|
||||
const PUBLIC_PREFIXES = [
|
||||
'/login',
|
||||
'/api/auth',
|
||||
'/api/health',
|
||||
'/api/trpc/stellen.listPublic',
|
||||
'/api/setup',
|
||||
'/registrierung',
|
||||
'/impressum',
|
||||
'/datenschutz',
|
||||
]
|
||||
const PUBLIC_EXACT_PATHS = ['/']
|
||||
const TENANT_SHARED_PATHS = ['/login', '/api', '/superadmin', '/registrierung', '/impressum', '/datenschutz', '/passwort-aendern']
|
||||
|
||||
// Reserved subdomains that shouldn't be treated as tenant slugs
|
||||
const RESERVED_SUBDOMAINS = [
|
||||
'www', 'app', 'admin', 'localhost', 'superadmin', 'api',
|
||||
'logo.png', 'favicon.ico', 'robots.txt', 'sitemap.xml',
|
||||
'apple-touch-icon', 'android-chrome', 'manifest'
|
||||
]
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const url = request.nextUrl
|
||||
const pathname = url.pathname
|
||||
|
||||
// 1. Subdomain Extraction
|
||||
const hostname = request.headers.get('host') || ''
|
||||
const domainParts = hostname.split(':')[0].split('.')
|
||||
let slug = null
|
||||
|
||||
// For localhost: tischler.localhost -> parts: ['tischler', 'localhost']
|
||||
// For production: tischler.innungsapp.de -> parts: ['tischler', 'innungsapp', 'de']
|
||||
if (
|
||||
domainParts.length > 2 ||
|
||||
(domainParts.length === 2 && domainParts[1] === 'localhost')
|
||||
) {
|
||||
const potentialSlug = domainParts[0]
|
||||
if (!RESERVED_SUBDOMAINS.includes(potentialSlug)) {
|
||||
slug = potentialSlug
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize stale tenant-prefixed shared paths like /test/login to /login
|
||||
// before auth checks, otherwise callbackUrl can get stuck on /test/login.
|
||||
if (slug) {
|
||||
for (const sharedPath of TENANT_SHARED_PATHS) {
|
||||
const prefixedPath = `/${slug}${sharedPath}`
|
||||
if (pathname === prefixedPath || pathname.startsWith(`${prefixedPath}/`)) {
|
||||
const canonicalUrl = request.nextUrl.clone()
|
||||
canonicalUrl.pathname = pathname.replace(prefixedPath, sharedPath)
|
||||
return NextResponse.redirect(canonicalUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Allow static files from /public
|
||||
const isStaticFile = pathname.includes('.') && !pathname.startsWith('/api')
|
||||
const isPublic =
|
||||
isStaticFile ||
|
||||
PUBLIC_EXACT_PATHS.includes(pathname) ||
|
||||
PUBLIC_PREFIXES.some((p) => pathname.startsWith(p))
|
||||
|
||||
// 2. Auth Check
|
||||
const sessionToken =
|
||||
request.cookies.get('better-auth.session_token') ??
|
||||
request.cookies.get('__Secure-better-auth.session_token')
|
||||
|
||||
if (!isPublic && !sessionToken) {
|
||||
const loginUrl = new URL('/login', request.url)
|
||||
loginUrl.searchParams.set('callbackUrl', pathname)
|
||||
return NextResponse.redirect(loginUrl)
|
||||
}
|
||||
|
||||
// 3. Subdomain Redirection / Rewrite
|
||||
if (slug) {
|
||||
// Paths that should not be rewritten into the slug folder
|
||||
// because they are shared across the entire app
|
||||
const isSharedPath = TENANT_SHARED_PATHS.some((p) => pathname.startsWith(p)) ||
|
||||
pathname.startsWith('/_next') ||
|
||||
/\.(png|jpg|jpeg|gif|svg|webp|ico|txt|xml)$/i.test(pathname)
|
||||
|
||||
if (!isSharedPath && !pathname.startsWith(`/${slug}`)) {
|
||||
const rewriteUrl = request.nextUrl.clone()
|
||||
rewriteUrl.pathname = `/${slug}${pathname === '/' ? '' : pathname}`
|
||||
return NextResponse.rewrite(rewriteUrl)
|
||||
}
|
||||
} else {
|
||||
// Check if the user is trying to access a path that starts with a potential slug
|
||||
// but they are on the root domain.
|
||||
// Example: localhost/tischler/... should redirect to tischler.localhost/...
|
||||
const pathParts = pathname.split('/')
|
||||
if (pathParts.length > 1) {
|
||||
const potentialSlug = pathParts[1]
|
||||
// Check if it's a known non-reserved path but could be an organization slug
|
||||
// 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 isStaticAsset = /\.(png|jpg|jpeg|gif|svg|webp|ico|txt|xml)$/i.test(potentialSlug)
|
||||
const isValidSlug = /^[a-z0-9][a-z0-9-]*$/.test(potentialSlug)
|
||||
if (potentialSlug && !SHARED_PATHS.includes(potentialSlug) && !isStaticAsset && isValidSlug) {
|
||||
// This looks like a tenant path being accessed from the root domain.
|
||||
// Redirect to subdomain.
|
||||
const baseHost = hostname.split('.').slice(-2).join('.') // Simplistic, assumes domain.tld or localhost
|
||||
// For localhost it's special
|
||||
const isLocalhost = hostname.includes('localhost')
|
||||
const newHost = isLocalhost
|
||||
? `${potentialSlug}.localhost${hostname.includes(':') ? `:${hostname.split(':')[1]}` : ''}`
|
||||
: `${potentialSlug}.${baseHost}`
|
||||
|
||||
const remainingPath = '/' + pathParts.slice(2).join('/')
|
||||
return NextResponse.redirect(new URL(remainingPath, `${url.protocol}//${newHost}`))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/((?!_next/static|_next/image|favicon.ico|uploads).*)',
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
|
|
|||
|
|
@ -1,32 +1,32 @@
|
|||
import type { NextConfig } from 'next'
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
transpilePackages: ['@innungsapp/shared'],
|
||||
output: process.env.DOCKER_BUILD ? 'standalone' : undefined,
|
||||
experimental: {},
|
||||
// Include Prisma binaries in standalone build
|
||||
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/client/**/*',
|
||||
],
|
||||
},
|
||||
webpack: (config, { dev }) => {
|
||||
if (dev) {
|
||||
// Avoid filesystem cache writes on very low-disk dev machines (ENOSPC).
|
||||
config.cache = false
|
||||
}
|
||||
return config
|
||||
},
|
||||
// Serve uploaded files
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/uploads/:path*',
|
||||
destination: '/api/uploads/:path*',
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
import type { NextConfig } from 'next'
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
transpilePackages: ['@innungsapp/shared'],
|
||||
output: process.env.DOCKER_BUILD ? 'standalone' : undefined,
|
||||
experimental: {},
|
||||
// Include Prisma binaries in standalone build
|
||||
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/client/**/*',
|
||||
],
|
||||
},
|
||||
webpack: (config, { dev }) => {
|
||||
if (dev) {
|
||||
// Avoid filesystem cache writes on very low-disk dev machines (ENOSPC).
|
||||
config.cache = false
|
||||
}
|
||||
return config
|
||||
},
|
||||
// Serve uploaded files
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/uploads/:path*',
|
||||
destination: '/api/uploads/:path*',
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
|
|
|
|||
|
|
@ -17,4 +17,4 @@ $map = $json | ConvertFrom-Json
|
|||
if (-not $map.sourcesContent -or $map.sourcesContent.Count -lt 1) { throw 'No sourcesContent in map payload' }
|
||||
$source = $map.sourcesContent[0]
|
||||
[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'
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import { type FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch'
|
||||
import { auth, getSanitizedHeaders } from '@/lib/auth'
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
|
||||
export async function createContext({ req }: FetchCreateContextFnOptions) {
|
||||
const session = await auth.api.getSession({ headers: await getSanitizedHeaders(req.headers) })
|
||||
return {
|
||||
req,
|
||||
session,
|
||||
prisma,
|
||||
}
|
||||
}
|
||||
|
||||
export type Context = Awaited<ReturnType<typeof createContext>>
|
||||
import { type FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch'
|
||||
import { auth, getSanitizedHeaders } from '@/lib/auth'
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
|
||||
export async function createContext({ req }: FetchCreateContextFnOptions) {
|
||||
const session = await auth.api.getSession({ headers: await getSanitizedHeaders(req.headers) })
|
||||
return {
|
||||
req,
|
||||
session,
|
||||
prisma,
|
||||
}
|
||||
}
|
||||
|
||||
export type Context = Awaited<ReturnType<typeof createContext>>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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; }[]>'.
|
||||
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.
|
||||
Type 'number | 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/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'.
|
||||
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 'Promise<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'.
|
||||
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 'Promise<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.
|
||||
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(2,30): error TS2307: Cannot find module '@innungsapp/shared/prisma/client' or its corresponding type declarations.
|
||||
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; }'.
|
||||
Types of property 'sizeBytes' are incompatible.
|
||||
Type 'number | 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/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'.
|
||||
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 'Promise<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'.
|
||||
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 'Promise<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.
|
||||
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(2,30): error TS2307: Cannot find module '@innungsapp/shared/prisma/client' or its corresponding type declarations.
|
||||
|
|
|
|||
|
|
@ -1,140 +1,140 @@
|
|||
import React, { useEffect, useRef } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
ImageBackground,
|
||||
Dimensions,
|
||||
} from 'react-native'
|
||||
|
||||
const { width, height } = Dimensions.get('window')
|
||||
|
||||
export function LoadingScreen() {
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current
|
||||
const scaleAnim = useRef(new Animated.Value(0.95)).current
|
||||
|
||||
useEffect(() => {
|
||||
Animated.parallel([
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 800,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.spring(scaleAnim, {
|
||||
toValue: 1,
|
||||
friction: 8,
|
||||
tension: 40,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ImageBackground
|
||||
source={require('../../assets/loading_bg.png')}
|
||||
style={styles.backgroundImage}
|
||||
resizeMode="cover"
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.content,
|
||||
{
|
||||
opacity: fadeAnim,
|
||||
transform: [{ scale: scaleAnim }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
{/* Card with rounded corners and semi-transparent background */}
|
||||
<View style={styles.glassCard}>
|
||||
<View style={styles.logoContainer}>
|
||||
<View style={styles.logoBox}>
|
||||
<Text style={styles.logoLetter}>I</Text>
|
||||
</View>
|
||||
<Text style={styles.appName}>InnungsApp</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.loaderContainer}>
|
||||
<ActivityIndicator size="large" color="#003B7E" />
|
||||
<Text style={styles.loadingText}>Wird geladen...</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</ImageBackground>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#003B7E',
|
||||
},
|
||||
backgroundImage: {
|
||||
flex: 1,
|
||||
width: width,
|
||||
height: height,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
content: {
|
||||
width: '80%',
|
||||
maxWidth: 320,
|
||||
alignItems: 'center',
|
||||
},
|
||||
glassCard: {
|
||||
width: '100%',
|
||||
padding: 32,
|
||||
borderRadius: 32, // Rounded corners as requested
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.4)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)', // Solid-ish background for clean look without blur
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 20,
|
||||
elevation: 10,
|
||||
},
|
||||
logoContainer: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
logoBox: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
backgroundColor: '#003B7E',
|
||||
borderRadius: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 5,
|
||||
},
|
||||
logoLetter: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 32,
|
||||
fontWeight: '900',
|
||||
},
|
||||
appName: {
|
||||
fontSize: 24,
|
||||
fontWeight: '800',
|
||||
color: '#0F172A',
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
loaderContainer: {
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: 14,
|
||||
color: '#64748B',
|
||||
fontWeight: '600',
|
||||
marginTop: 8,
|
||||
},
|
||||
})
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
ImageBackground,
|
||||
Dimensions,
|
||||
} from 'react-native'
|
||||
|
||||
const { width, height } = Dimensions.get('window')
|
||||
|
||||
export function LoadingScreen() {
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current
|
||||
const scaleAnim = useRef(new Animated.Value(0.95)).current
|
||||
|
||||
useEffect(() => {
|
||||
Animated.parallel([
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 800,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.spring(scaleAnim, {
|
||||
toValue: 1,
|
||||
friction: 8,
|
||||
tension: 40,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ImageBackground
|
||||
source={require('../../assets/loading_bg.png')}
|
||||
style={styles.backgroundImage}
|
||||
resizeMode="cover"
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.content,
|
||||
{
|
||||
opacity: fadeAnim,
|
||||
transform: [{ scale: scaleAnim }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
{/* Card with rounded corners and semi-transparent background */}
|
||||
<View style={styles.glassCard}>
|
||||
<View style={styles.logoContainer}>
|
||||
<View style={styles.logoBox}>
|
||||
<Text style={styles.logoLetter}>I</Text>
|
||||
</View>
|
||||
<Text style={styles.appName}>InnungsApp</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.loaderContainer}>
|
||||
<ActivityIndicator size="large" color="#003B7E" />
|
||||
<Text style={styles.loadingText}>Wird geladen...</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</ImageBackground>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#003B7E',
|
||||
},
|
||||
backgroundImage: {
|
||||
flex: 1,
|
||||
width: width,
|
||||
height: height,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
content: {
|
||||
width: '80%',
|
||||
maxWidth: 320,
|
||||
alignItems: 'center',
|
||||
},
|
||||
glassCard: {
|
||||
width: '100%',
|
||||
padding: 32,
|
||||
borderRadius: 32, // Rounded corners as requested
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.4)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)', // Solid-ish background for clean look without blur
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 20,
|
||||
elevation: 10,
|
||||
},
|
||||
logoContainer: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
logoBox: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
backgroundColor: '#003B7E',
|
||||
borderRadius: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 5,
|
||||
},
|
||||
logoLetter: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 32,
|
||||
fontWeight: '900',
|
||||
},
|
||||
appName: {
|
||||
fontSize: 24,
|
||||
fontWeight: '800',
|
||||
color: '#0F172A',
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
loaderContainer: {
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: 14,
|
||||
color: '#64748B',
|
||||
fontWeight: '600',
|
||||
marginTop: 8,
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,84 +1,84 @@
|
|||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: innungsapp-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: "${POSTGRES_DB:-innungsapp}"
|
||||
POSTGRES_USER: "${POSTGRES_USER:-innungsapp}"
|
||||
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-innungsapp}"
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pg_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-innungsapp} -d ${POSTGRES_DB:-innungsapp}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
|
||||
admin:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/admin/Dockerfile
|
||||
args:
|
||||
BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET}"
|
||||
BETTER_AUTH_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}"
|
||||
container_name: innungsapp-admin
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "3010:3000"
|
||||
environment:
|
||||
# Database — PostgreSQL
|
||||
DATABASE_URL: "${DATABASE_URL:-postgresql://innungsapp:innungsapp@postgres:5432/innungsapp?schema=public}"
|
||||
|
||||
# Auth — CHANGE THESE in production!
|
||||
BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET}"
|
||||
BETTER_AUTH_URL: "${BETTER_AUTH_URL:-https://yourdomain.com}"
|
||||
BETTER_AUTH_BASE_URL: "${BETTER_AUTH_URL:-https://yourdomain.com}"
|
||||
|
||||
# Email (SMTP)
|
||||
EMAIL_FROM: "${EMAIL_FROM:-noreply@innungsapp.de}"
|
||||
SMTP_HOST: "${SMTP_HOST}"
|
||||
SMTP_PORT: "${SMTP_PORT:-587}"
|
||||
SMTP_SECURE: "${SMTP_SECURE:-false}"
|
||||
SMTP_USER: "${SMTP_USER}"
|
||||
SMTP_PASS: "${SMTP_PASS}"
|
||||
|
||||
# Superadmin seed defaults (override in .env)
|
||||
SUPERADMIN_EMAIL: "${SUPERADMIN_EMAIL:-superadmin@innungsapp.de}"
|
||||
SUPERADMIN_PASSWORD: "${SUPERADMIN_PASSWORD:-}"
|
||||
|
||||
# Public URLs
|
||||
NEXT_PUBLIC_APP_URL: "${NEXT_PUBLIC_APP_URL:-https://yourdomain.com}"
|
||||
NEXT_PUBLIC_POSTHOG_KEY: "${NEXT_PUBLIC_POSTHOG_KEY:-}"
|
||||
NEXT_PUBLIC_POSTHOG_HOST: "${NEXT_PUBLIC_POSTHOG_HOST:-https://us.i.posthog.com}"
|
||||
|
||||
# File uploads
|
||||
UPLOAD_DIR: "/app/uploads"
|
||||
UPLOAD_MAX_SIZE_MB: "${UPLOAD_MAX_SIZE_MB:-10}"
|
||||
|
||||
# Node
|
||||
NODE_ENV: "production"
|
||||
pids_limit: 512
|
||||
ulimits:
|
||||
nproc: 65535
|
||||
volumes:
|
||||
# Uploaded files — persists across restarts
|
||||
- uploads_data:/app/uploads
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/api/health | grep -q '\"status\":\"ok\"'"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
volumes:
|
||||
pg_data:
|
||||
uploads_data:
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: innungsapp-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: "${POSTGRES_DB:-innungsapp}"
|
||||
POSTGRES_USER: "${POSTGRES_USER:-innungsapp}"
|
||||
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-innungsapp}"
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pg_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-innungsapp} -d ${POSTGRES_DB:-innungsapp}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
|
||||
admin:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/admin/Dockerfile
|
||||
args:
|
||||
BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET}"
|
||||
BETTER_AUTH_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}"
|
||||
container_name: innungsapp-admin
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "3010:3000"
|
||||
environment:
|
||||
# Database — PostgreSQL
|
||||
DATABASE_URL: "${DATABASE_URL:-postgresql://innungsapp:innungsapp@postgres:5432/innungsapp?schema=public}"
|
||||
|
||||
# Auth — CHANGE THESE in production!
|
||||
BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET}"
|
||||
BETTER_AUTH_URL: "${BETTER_AUTH_URL:-https://yourdomain.com}"
|
||||
BETTER_AUTH_BASE_URL: "${BETTER_AUTH_URL:-https://yourdomain.com}"
|
||||
|
||||
# Email (SMTP)
|
||||
EMAIL_FROM: "${EMAIL_FROM:-noreply@innungsapp.de}"
|
||||
SMTP_HOST: "${SMTP_HOST}"
|
||||
SMTP_PORT: "${SMTP_PORT:-587}"
|
||||
SMTP_SECURE: "${SMTP_SECURE:-false}"
|
||||
SMTP_USER: "${SMTP_USER}"
|
||||
SMTP_PASS: "${SMTP_PASS}"
|
||||
|
||||
# Superadmin seed defaults (override in .env)
|
||||
SUPERADMIN_EMAIL: "${SUPERADMIN_EMAIL:-superadmin@innungsapp.de}"
|
||||
SUPERADMIN_PASSWORD: "${SUPERADMIN_PASSWORD:-}"
|
||||
|
||||
# Public URLs
|
||||
NEXT_PUBLIC_APP_URL: "${NEXT_PUBLIC_APP_URL:-https://yourdomain.com}"
|
||||
NEXT_PUBLIC_POSTHOG_KEY: "${NEXT_PUBLIC_POSTHOG_KEY:-}"
|
||||
NEXT_PUBLIC_POSTHOG_HOST: "${NEXT_PUBLIC_POSTHOG_HOST:-https://us.i.posthog.com}"
|
||||
|
||||
# File uploads
|
||||
UPLOAD_DIR: "/app/uploads"
|
||||
UPLOAD_MAX_SIZE_MB: "${UPLOAD_MAX_SIZE_MB:-10}"
|
||||
|
||||
# Node
|
||||
NODE_ENV: "production"
|
||||
pids_limit: 512
|
||||
ulimits:
|
||||
nproc: 65535
|
||||
volumes:
|
||||
# Uploaded files — persists across restarts
|
||||
- uploads_data:/app/uploads
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/api/health | grep -q '\"status\":\"ok\"'"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
volumes:
|
||||
pg_data:
|
||||
uploads_data:
|
||||
|
|
|
|||
|
|
@ -1,30 +1,30 @@
|
|||
{
|
||||
"name": "innungsapp",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "turbo dev",
|
||||
"build": "turbo build",
|
||||
"lint": "turbo lint",
|
||||
"type-check": "turbo type-check",
|
||||
"db:generate": "pnpm --filter @innungsapp/shared prisma:generate",
|
||||
"db:migrate": "pnpm --filter @innungsapp/shared prisma:migrate",
|
||||
"db:push": "pnpm --filter @innungsapp/shared prisma:push",
|
||||
"db:studio": "pnpm --filter @innungsapp/shared prisma:studio",
|
||||
"db:seed": "pnpm --filter @innungsapp/shared prisma:seed",
|
||||
"db:seed-superadmin": "pnpm --filter @innungsapp/shared prisma:seed-superadmin",
|
||||
"db:reset": "pnpm --filter @innungsapp/shared prisma:migrate -- --reset"
|
||||
},
|
||||
"devDependencies": {
|
||||
"turbo": "^2.3.0",
|
||||
"typescript": "^5.6.0",
|
||||
"undici": "6.23.0"
|
||||
},
|
||||
"packageManager": "pnpm@9.12.0",
|
||||
"engines": {
|
||||
"node": ">=20.0.0",
|
||||
"pnpm": ">=9.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3"
|
||||
}
|
||||
}
|
||||
{
|
||||
"name": "innungsapp",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "turbo dev",
|
||||
"build": "turbo build",
|
||||
"lint": "turbo lint",
|
||||
"type-check": "turbo type-check",
|
||||
"db:generate": "pnpm --filter @innungsapp/shared prisma:generate",
|
||||
"db:migrate": "pnpm --filter @innungsapp/shared prisma:migrate",
|
||||
"db:push": "pnpm --filter @innungsapp/shared prisma:push",
|
||||
"db:studio": "pnpm --filter @innungsapp/shared prisma:studio",
|
||||
"db:seed": "pnpm --filter @innungsapp/shared prisma:seed",
|
||||
"db:seed-superadmin": "pnpm --filter @innungsapp/shared prisma:seed-superadmin",
|
||||
"db:reset": "pnpm --filter @innungsapp/shared prisma:migrate -- --reset"
|
||||
},
|
||||
"devDependencies": {
|
||||
"turbo": "^2.3.0",
|
||||
"typescript": "^5.6.0",
|
||||
"undici": "6.23.0"
|
||||
},
|
||||
"packageManager": "pnpm@9.12.0",
|
||||
"engines": {
|
||||
"node": ">=20.0.0",
|
||||
"pnpm": ">=9.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,30 +1,30 @@
|
|||
{
|
||||
"name": "@innungsapp/shared",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./prisma": "./src/lib/prisma.ts",
|
||||
"./types": "./src/types/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"type-check": "tsc --noEmit",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:push": "prisma db push",
|
||||
"prisma:studio": "prisma studio",
|
||||
"prisma:seed": "tsx prisma/seed.ts",
|
||||
"prisma:seed-superadmin": "tsx prisma/seed-superadmin.ts",
|
||||
"prisma:seed-admin": "tsx prisma/seed-admin-password.ts",
|
||||
"prisma:seed-demo-members": "tsx prisma/seed-demo-members.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"prisma": "^5.20.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
{
|
||||
"name": "@innungsapp/shared",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./prisma": "./src/lib/prisma.ts",
|
||||
"./types": "./src/types/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"type-check": "tsc --noEmit",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:push": "prisma db push",
|
||||
"prisma:studio": "prisma studio",
|
||||
"prisma:seed": "tsx prisma/seed.ts",
|
||||
"prisma:seed-superadmin": "tsx prisma/seed-superadmin.ts",
|
||||
"prisma:seed-admin": "tsx prisma/seed-admin-password.ts",
|
||||
"prisma:seed-demo-members": "tsx prisma/seed-demo-members.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"prisma": "^5.20.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -1,350 +1,350 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "user" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"email_verified" BOOLEAN NOT NULL DEFAULT false,
|
||||
"image" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"role" TEXT,
|
||||
"banned" BOOLEAN DEFAULT false,
|
||||
"ban_reason" TEXT,
|
||||
"ban_expires" TIMESTAMP(3),
|
||||
"must_change_password" BOOLEAN DEFAULT false,
|
||||
|
||||
CONSTRAINT "user_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "session" (
|
||||
"id" TEXT NOT NULL,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"ip_address" TEXT,
|
||||
"user_agent" TEXT,
|
||||
"user_id" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "session_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "account" (
|
||||
"id" TEXT NOT NULL,
|
||||
"account_id" TEXT NOT NULL,
|
||||
"provider_id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"access_token" TEXT,
|
||||
"refresh_token" TEXT,
|
||||
"id_token" TEXT,
|
||||
"access_token_expires_at" TIMESTAMP(3),
|
||||
"refresh_token_expires_at" TIMESTAMP(3),
|
||||
"scope" TEXT,
|
||||
"password" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "account_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "verification" (
|
||||
"id" TEXT NOT NULL,
|
||||
"identifier" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||
"created_at" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "verification_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "organizations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"plan" TEXT NOT NULL DEFAULT 'pilot',
|
||||
"logo_url" TEXT,
|
||||
"primary_color" TEXT NOT NULL DEFAULT '#E63946',
|
||||
"secondary_color" TEXT,
|
||||
"contact_email" TEXT,
|
||||
"avv_accepted" BOOLEAN NOT NULL DEFAULT false,
|
||||
"avv_accepted_at" TIMESTAMP(3),
|
||||
"landing_page_title" TEXT,
|
||||
"landing_page_text" TEXT,
|
||||
"landing_page_section_title" TEXT,
|
||||
"landing_page_button_text" TEXT,
|
||||
"landing_page_hero_image" TEXT,
|
||||
"landing_page_hero_overlay_opacity" INTEGER DEFAULT 50,
|
||||
"landing_page_features" JSONB,
|
||||
"landing_page_footer" JSONB,
|
||||
"app_store_url" TEXT,
|
||||
"play_store_url" TEXT,
|
||||
"ai_enabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "organizations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "members" (
|
||||
"id" TEXT NOT NULL,
|
||||
"org_id" TEXT NOT NULL,
|
||||
"user_id" TEXT,
|
||||
"name" TEXT NOT NULL,
|
||||
"betrieb" TEXT NOT NULL,
|
||||
"sparte" TEXT NOT NULL,
|
||||
"ort" TEXT NOT NULL,
|
||||
"telefon" TEXT,
|
||||
"email" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'aktiv',
|
||||
"ist_ausbildungsbetrieb" BOOLEAN NOT NULL DEFAULT false,
|
||||
"seit" INTEGER,
|
||||
"avatar_url" TEXT,
|
||||
"push_token" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "members_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "user_roles" (
|
||||
"id" TEXT NOT NULL,
|
||||
"org_id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"role" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "user_roles_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "news" (
|
||||
"id" TEXT NOT NULL,
|
||||
"org_id" TEXT NOT NULL,
|
||||
"author_id" TEXT,
|
||||
"title" TEXT NOT NULL,
|
||||
"body" TEXT NOT NULL,
|
||||
"kategorie" TEXT NOT NULL,
|
||||
"published_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "news_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "news_reads" (
|
||||
"id" TEXT NOT NULL,
|
||||
"news_id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"read_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "news_reads_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "news_attachments" (
|
||||
"id" TEXT NOT NULL,
|
||||
"news_id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"storage_path" TEXT NOT NULL,
|
||||
"mime_type" TEXT,
|
||||
"size_bytes" INTEGER,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "news_attachments_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "stellen" (
|
||||
"id" TEXT NOT NULL,
|
||||
"org_id" TEXT NOT NULL,
|
||||
"member_id" TEXT NOT NULL,
|
||||
"sparte" TEXT NOT NULL,
|
||||
"stellen_anz" INTEGER NOT NULL DEFAULT 1,
|
||||
"verguetung" TEXT,
|
||||
"lehrjahr" TEXT,
|
||||
"beschreibung" TEXT,
|
||||
"kontakt_email" TEXT NOT NULL,
|
||||
"kontakt_name" TEXT,
|
||||
"aktiv" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "stellen_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "termine" (
|
||||
"id" TEXT NOT NULL,
|
||||
"org_id" TEXT NOT NULL,
|
||||
"titel" TEXT NOT NULL,
|
||||
"datum" TIMESTAMP(3) NOT NULL,
|
||||
"uhrzeit" TEXT,
|
||||
"ende_datum" TIMESTAMP(3),
|
||||
"ende_uhrzeit" TEXT,
|
||||
"ort" TEXT,
|
||||
"adresse" TEXT,
|
||||
"typ" TEXT NOT NULL,
|
||||
"beschreibung" TEXT,
|
||||
"max_teilnehmer" INTEGER,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "termine_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "termin_anmeldungen" (
|
||||
"id" TEXT NOT NULL,
|
||||
"termin_id" TEXT NOT NULL,
|
||||
"member_id" TEXT NOT NULL,
|
||||
"angemeldet_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "termin_anmeldungen_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "conversations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"org_id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "conversations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "conversation_members" (
|
||||
"id" TEXT NOT NULL,
|
||||
"conversation_id" TEXT NOT NULL,
|
||||
"member_id" TEXT NOT NULL,
|
||||
"last_read_at" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "conversation_members_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "messages" (
|
||||
"id" TEXT NOT NULL,
|
||||
"conversation_id" TEXT NOT NULL,
|
||||
"sender_id" TEXT NOT NULL,
|
||||
"body" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "messages_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_email_key" ON "user"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "session_token_key" ON "session"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "organizations_slug_key" ON "organizations"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "members_user_id_key" ON "members"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "members_org_id_idx" ON "members"("org_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "members_status_idx" ON "members"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_roles_org_id_user_id_key" ON "user_roles"("org_id", "user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "news_org_id_idx" ON "news"("org_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "news_published_at_idx" ON "news"("published_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "news_reads_news_id_user_id_key" ON "news_reads"("news_id", "user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "stellen_org_id_idx" ON "stellen"("org_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "stellen_aktiv_idx" ON "stellen"("aktiv");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "termine_org_id_idx" ON "termine"("org_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "termine_datum_idx" ON "termine"("datum");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "termin_anmeldungen_termin_id_member_id_key" ON "termin_anmeldungen"("termin_id", "member_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "conversations_org_id_idx" ON "conversations"("org_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "conversation_members_conversation_id_member_id_key" ON "conversation_members"("conversation_id", "member_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "messages_conversation_id_idx" ON "messages"("conversation_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "members" ADD CONSTRAINT "members_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "members" ADD CONSTRAINT "members_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- 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;
|
||||
|
||||
-- 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;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "news" ADD CONSTRAINT "news_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "news" ADD CONSTRAINT "news_author_id_fkey" FOREIGN KEY ("author_id") REFERENCES "members"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- 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;
|
||||
|
||||
-- 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;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "stellen" ADD CONSTRAINT "stellen_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "stellen" ADD CONSTRAINT "stellen_member_id_fkey" FOREIGN KEY ("member_id") REFERENCES "members"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "termine" ADD CONSTRAINT "termine_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- 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;
|
||||
|
||||
-- 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;
|
||||
|
||||
-- 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;
|
||||
|
||||
-- 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;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "messages" ADD CONSTRAINT "messages_conversation_id_fkey" FOREIGN KEY ("conversation_id") REFERENCES "conversations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "messages" ADD CONSTRAINT "messages_sender_id_fkey" FOREIGN KEY ("sender_id") REFERENCES "members"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
-- CreateTable
|
||||
CREATE TABLE "user" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"email_verified" BOOLEAN NOT NULL DEFAULT false,
|
||||
"image" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"role" TEXT,
|
||||
"banned" BOOLEAN DEFAULT false,
|
||||
"ban_reason" TEXT,
|
||||
"ban_expires" TIMESTAMP(3),
|
||||
"must_change_password" BOOLEAN DEFAULT false,
|
||||
|
||||
CONSTRAINT "user_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "session" (
|
||||
"id" TEXT NOT NULL,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"ip_address" TEXT,
|
||||
"user_agent" TEXT,
|
||||
"user_id" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "session_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "account" (
|
||||
"id" TEXT NOT NULL,
|
||||
"account_id" TEXT NOT NULL,
|
||||
"provider_id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"access_token" TEXT,
|
||||
"refresh_token" TEXT,
|
||||
"id_token" TEXT,
|
||||
"access_token_expires_at" TIMESTAMP(3),
|
||||
"refresh_token_expires_at" TIMESTAMP(3),
|
||||
"scope" TEXT,
|
||||
"password" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "account_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "verification" (
|
||||
"id" TEXT NOT NULL,
|
||||
"identifier" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||
"created_at" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "verification_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "organizations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"plan" TEXT NOT NULL DEFAULT 'pilot',
|
||||
"logo_url" TEXT,
|
||||
"primary_color" TEXT NOT NULL DEFAULT '#E63946',
|
||||
"secondary_color" TEXT,
|
||||
"contact_email" TEXT,
|
||||
"avv_accepted" BOOLEAN NOT NULL DEFAULT false,
|
||||
"avv_accepted_at" TIMESTAMP(3),
|
||||
"landing_page_title" TEXT,
|
||||
"landing_page_text" TEXT,
|
||||
"landing_page_section_title" TEXT,
|
||||
"landing_page_button_text" TEXT,
|
||||
"landing_page_hero_image" TEXT,
|
||||
"landing_page_hero_overlay_opacity" INTEGER DEFAULT 50,
|
||||
"landing_page_features" JSONB,
|
||||
"landing_page_footer" JSONB,
|
||||
"app_store_url" TEXT,
|
||||
"play_store_url" TEXT,
|
||||
"ai_enabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "organizations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "members" (
|
||||
"id" TEXT NOT NULL,
|
||||
"org_id" TEXT NOT NULL,
|
||||
"user_id" TEXT,
|
||||
"name" TEXT NOT NULL,
|
||||
"betrieb" TEXT NOT NULL,
|
||||
"sparte" TEXT NOT NULL,
|
||||
"ort" TEXT NOT NULL,
|
||||
"telefon" TEXT,
|
||||
"email" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'aktiv',
|
||||
"ist_ausbildungsbetrieb" BOOLEAN NOT NULL DEFAULT false,
|
||||
"seit" INTEGER,
|
||||
"avatar_url" TEXT,
|
||||
"push_token" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "members_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "user_roles" (
|
||||
"id" TEXT NOT NULL,
|
||||
"org_id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"role" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "user_roles_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "news" (
|
||||
"id" TEXT NOT NULL,
|
||||
"org_id" TEXT NOT NULL,
|
||||
"author_id" TEXT,
|
||||
"title" TEXT NOT NULL,
|
||||
"body" TEXT NOT NULL,
|
||||
"kategorie" TEXT NOT NULL,
|
||||
"published_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "news_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "news_reads" (
|
||||
"id" TEXT NOT NULL,
|
||||
"news_id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"read_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "news_reads_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "news_attachments" (
|
||||
"id" TEXT NOT NULL,
|
||||
"news_id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"storage_path" TEXT NOT NULL,
|
||||
"mime_type" TEXT,
|
||||
"size_bytes" INTEGER,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "news_attachments_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "stellen" (
|
||||
"id" TEXT NOT NULL,
|
||||
"org_id" TEXT NOT NULL,
|
||||
"member_id" TEXT NOT NULL,
|
||||
"sparte" TEXT NOT NULL,
|
||||
"stellen_anz" INTEGER NOT NULL DEFAULT 1,
|
||||
"verguetung" TEXT,
|
||||
"lehrjahr" TEXT,
|
||||
"beschreibung" TEXT,
|
||||
"kontakt_email" TEXT NOT NULL,
|
||||
"kontakt_name" TEXT,
|
||||
"aktiv" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "stellen_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "termine" (
|
||||
"id" TEXT NOT NULL,
|
||||
"org_id" TEXT NOT NULL,
|
||||
"titel" TEXT NOT NULL,
|
||||
"datum" TIMESTAMP(3) NOT NULL,
|
||||
"uhrzeit" TEXT,
|
||||
"ende_datum" TIMESTAMP(3),
|
||||
"ende_uhrzeit" TEXT,
|
||||
"ort" TEXT,
|
||||
"adresse" TEXT,
|
||||
"typ" TEXT NOT NULL,
|
||||
"beschreibung" TEXT,
|
||||
"max_teilnehmer" INTEGER,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "termine_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "termin_anmeldungen" (
|
||||
"id" TEXT NOT NULL,
|
||||
"termin_id" TEXT NOT NULL,
|
||||
"member_id" TEXT NOT NULL,
|
||||
"angemeldet_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "termin_anmeldungen_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "conversations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"org_id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "conversations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "conversation_members" (
|
||||
"id" TEXT NOT NULL,
|
||||
"conversation_id" TEXT NOT NULL,
|
||||
"member_id" TEXT NOT NULL,
|
||||
"last_read_at" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "conversation_members_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "messages" (
|
||||
"id" TEXT NOT NULL,
|
||||
"conversation_id" TEXT NOT NULL,
|
||||
"sender_id" TEXT NOT NULL,
|
||||
"body" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "messages_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_email_key" ON "user"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "session_token_key" ON "session"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "organizations_slug_key" ON "organizations"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "members_user_id_key" ON "members"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "members_org_id_idx" ON "members"("org_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "members_status_idx" ON "members"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_roles_org_id_user_id_key" ON "user_roles"("org_id", "user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "news_org_id_idx" ON "news"("org_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "news_published_at_idx" ON "news"("published_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "news_reads_news_id_user_id_key" ON "news_reads"("news_id", "user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "stellen_org_id_idx" ON "stellen"("org_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "stellen_aktiv_idx" ON "stellen"("aktiv");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "termine_org_id_idx" ON "termine"("org_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "termine_datum_idx" ON "termine"("datum");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "termin_anmeldungen_termin_id_member_id_key" ON "termin_anmeldungen"("termin_id", "member_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "conversations_org_id_idx" ON "conversations"("org_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "conversation_members_conversation_id_member_id_key" ON "conversation_members"("conversation_id", "member_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "messages_conversation_id_idx" ON "messages"("conversation_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "members" ADD CONSTRAINT "members_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "members" ADD CONSTRAINT "members_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- 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;
|
||||
|
||||
-- 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;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "news" ADD CONSTRAINT "news_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "news" ADD CONSTRAINT "news_author_id_fkey" FOREIGN KEY ("author_id") REFERENCES "members"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- 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;
|
||||
|
||||
-- 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;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "stellen" ADD CONSTRAINT "stellen_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "stellen" ADD CONSTRAINT "stellen_member_id_fkey" FOREIGN KEY ("member_id") REFERENCES "members"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "termine" ADD CONSTRAINT "termine_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- 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;
|
||||
|
||||
-- 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;
|
||||
|
||||
-- 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;
|
||||
|
||||
-- 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;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "messages" ADD CONSTRAINT "messages_conversation_id_fkey" FOREIGN KEY ("conversation_id") REFERENCES "conversations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "messages" ADD CONSTRAINT "messages_sender_id_fkey" FOREIGN KEY ("sender_id") REFERENCES "members"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
||||
|
|
@ -1,337 +1,337 @@
|
|||
// InnungsApp — Prisma Schema
|
||||
// Stack: PostgreSQL + Prisma ORM + better-auth
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// BETTER-AUTH TABLES
|
||||
// =============================================
|
||||
|
||||
model User {
|
||||
id String @id
|
||||
name String
|
||||
email String @unique
|
||||
emailVerified Boolean @default(false) @map("email_verified")
|
||||
image String?
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// better-auth admin plugin fields
|
||||
role String?
|
||||
banned Boolean? @default(false)
|
||||
banReason String? @map("ban_reason")
|
||||
banExpires DateTime? @map("ban_expires")
|
||||
|
||||
// Password management
|
||||
mustChangePassword Boolean? @default(false) @map("must_change_password")
|
||||
|
||||
// App relations
|
||||
sessions Session[]
|
||||
accounts Account[]
|
||||
member Member?
|
||||
userRoles UserRole[]
|
||||
|
||||
@@map("user")
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id
|
||||
expiresAt DateTime @map("expires_at")
|
||||
token String @unique
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
ipAddress String? @map("ip_address")
|
||||
userAgent String? @map("user_agent")
|
||||
userId String @map("user_id")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("session")
|
||||
}
|
||||
|
||||
model Account {
|
||||
id String @id
|
||||
accountId String @map("account_id")
|
||||
providerId String @map("provider_id")
|
||||
userId String @map("user_id")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
accessToken String? @map("access_token")
|
||||
refreshToken String? @map("refresh_token")
|
||||
idToken String? @map("id_token")
|
||||
accessTokenExpiresAt DateTime? @map("access_token_expires_at")
|
||||
refreshTokenExpiresAt DateTime? @map("refresh_token_expires_at")
|
||||
scope String?
|
||||
password String?
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("account")
|
||||
}
|
||||
|
||||
model Verification {
|
||||
id String @id
|
||||
identifier String
|
||||
value String
|
||||
expiresAt DateTime @map("expires_at")
|
||||
createdAt DateTime? @default(now()) @map("created_at")
|
||||
updatedAt DateTime? @updatedAt @map("updated_at")
|
||||
|
||||
@@map("verification")
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// ORGANIZATIONS
|
||||
// =============================================
|
||||
|
||||
model Organization {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
slug String @unique
|
||||
plan String @default("pilot") // pilot | standard | pro | verband
|
||||
logoUrl String? @map("logo_url")
|
||||
primaryColor String @default("#E63946") @map("primary_color")
|
||||
secondaryColor String? @map("secondary_color")
|
||||
contactEmail String? @map("contact_email")
|
||||
avvAccepted Boolean @default(false) @map("avv_accepted")
|
||||
avvAcceptedAt DateTime? @map("avv_accepted_at")
|
||||
landingPageTitle String? @map("landing_page_title")
|
||||
landingPageText String? @map("landing_page_text")
|
||||
landingPageSectionTitle String? @map("landing_page_section_title")
|
||||
landingPageButtonText String? @map("landing_page_button_text")
|
||||
landingPageHeroImage String? @map("landing_page_hero_image")
|
||||
landingPageHeroOverlayOpacity Int? @default(50) @map("landing_page_hero_overlay_opacity")
|
||||
landingPageFeatures Json? @map("landing_page_features") @db.JsonB
|
||||
landingPageFooter Json? @map("landing_page_footer") @db.JsonB
|
||||
appStoreUrl String? @map("app_store_url")
|
||||
playStoreUrl String? @map("play_store_url")
|
||||
aiEnabled Boolean @default(false) @map("ai_enabled")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
members Member[]
|
||||
userRoles UserRole[]
|
||||
news News[]
|
||||
stellen Stelle[]
|
||||
termine Termin[]
|
||||
|
||||
@@map("organizations")
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// MEMBERS
|
||||
// =============================================
|
||||
|
||||
model Member {
|
||||
id String @id @default(uuid())
|
||||
orgId String @map("org_id")
|
||||
userId String? @unique @map("user_id") // NULL until magic-link clicked
|
||||
name String
|
||||
betrieb String
|
||||
sparte String
|
||||
ort String
|
||||
telefon String?
|
||||
email String
|
||||
status String @default("aktiv") // aktiv | ruhend | ausgetreten
|
||||
istAusbildungsbetrieb Boolean @default(false) @map("ist_ausbildungsbetrieb")
|
||||
seit Int?
|
||||
avatarUrl String? @map("avatar_url")
|
||||
pushToken String? @map("push_token")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
newsAuthored News[] @relation("NewsAuthor")
|
||||
stellen Stelle[]
|
||||
terminAnmeldungen TerminAnmeldung[]
|
||||
sentMessages Message[]
|
||||
conversationMembers ConversationMember[]
|
||||
|
||||
@@index([orgId])
|
||||
@@index([status])
|
||||
@@map("members")
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// USER ROLES (multi-tenancy)
|
||||
// =============================================
|
||||
|
||||
model UserRole {
|
||||
id String @id @default(uuid())
|
||||
orgId String @map("org_id")
|
||||
userId String @map("user_id")
|
||||
role String // admin | member
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([orgId, userId])
|
||||
@@map("user_roles")
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// NEWS
|
||||
// =============================================
|
||||
|
||||
model News {
|
||||
id String @id @default(uuid())
|
||||
orgId String @map("org_id")
|
||||
authorId String? @map("author_id")
|
||||
title String
|
||||
body String // Markdown
|
||||
kategorie String // Wichtig | Pruefung | Foerderung | Veranstaltung | Allgemein
|
||||
publishedAt DateTime? @map("published_at") // NULL = Entwurf
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
author Member? @relation("NewsAuthor", fields: [authorId], references: [id], onDelete: SetNull)
|
||||
reads NewsRead[]
|
||||
attachments NewsAttachment[]
|
||||
|
||||
@@index([orgId])
|
||||
@@index([publishedAt])
|
||||
@@map("news")
|
||||
}
|
||||
|
||||
model NewsRead {
|
||||
id String @id @default(uuid())
|
||||
newsId String @map("news_id")
|
||||
userId String @map("user_id")
|
||||
readAt DateTime @default(now()) @map("read_at")
|
||||
|
||||
news News @relation(fields: [newsId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([newsId, userId])
|
||||
@@map("news_reads")
|
||||
}
|
||||
|
||||
model NewsAttachment {
|
||||
id String @id @default(uuid())
|
||||
newsId String @map("news_id")
|
||||
name String
|
||||
storagePath String @map("storage_path")
|
||||
mimeType String? @map("mime_type")
|
||||
sizeBytes Int? @map("size_bytes")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
news News @relation(fields: [newsId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("news_attachments")
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// STELLENANGEBOTE (Lehrlingsbörse)
|
||||
// =============================================
|
||||
|
||||
model Stelle {
|
||||
id String @id @default(uuid())
|
||||
orgId String @map("org_id")
|
||||
memberId String @map("member_id")
|
||||
sparte String
|
||||
stellenAnz Int @default(1) @map("stellen_anz")
|
||||
verguetung String? // "600-800 € / Monat"
|
||||
lehrjahr String? // "1. Lehrjahr" | "beliebig"
|
||||
beschreibung String?
|
||||
kontaktEmail String @map("kontakt_email")
|
||||
kontaktName String? @map("kontakt_name")
|
||||
aktiv Boolean @default(true)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
member Member @relation(fields: [memberId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([orgId])
|
||||
@@index([aktiv])
|
||||
@@map("stellen")
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// TERMINE
|
||||
// =============================================
|
||||
|
||||
model Termin {
|
||||
id String @id @default(uuid())
|
||||
orgId String @map("org_id")
|
||||
titel String
|
||||
datum DateTime
|
||||
uhrzeit String? // stored as "HH:MM"
|
||||
endeDatum DateTime? @map("ende_datum")
|
||||
endeUhrzeit String? @map("ende_uhrzeit")
|
||||
ort String?
|
||||
adresse String?
|
||||
typ String // Pruefung | Versammlung | Kurs | Event | Sonstiges
|
||||
beschreibung String?
|
||||
maxTeilnehmer Int? @map("max_teilnehmer") // NULL = unbegrenzt
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
anmeldungen TerminAnmeldung[]
|
||||
|
||||
@@index([orgId])
|
||||
@@index([datum])
|
||||
@@map("termine")
|
||||
}
|
||||
|
||||
model TerminAnmeldung {
|
||||
id String @id @default(uuid())
|
||||
terminId String @map("termin_id")
|
||||
memberId String @map("member_id")
|
||||
angemeldetAt DateTime @default(now()) @map("angemeldet_at")
|
||||
|
||||
termin Termin @relation(fields: [terminId], references: [id], onDelete: Cascade)
|
||||
member Member @relation(fields: [memberId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([terminId, memberId])
|
||||
@@map("termin_anmeldungen")
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// DIREKTNACHRICHTEN (Chat)
|
||||
// =============================================
|
||||
|
||||
model Conversation {
|
||||
id String @id @default(uuid())
|
||||
orgId String @map("org_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
members ConversationMember[]
|
||||
messages Message[]
|
||||
|
||||
@@index([orgId])
|
||||
@@map("conversations")
|
||||
}
|
||||
|
||||
model ConversationMember {
|
||||
id String @id @default(uuid())
|
||||
conversationId String @map("conversation_id")
|
||||
memberId String @map("member_id")
|
||||
lastReadAt DateTime? @map("last_read_at")
|
||||
|
||||
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
||||
member Member @relation(fields: [memberId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([conversationId, memberId])
|
||||
@@map("conversation_members")
|
||||
}
|
||||
|
||||
model Message {
|
||||
id String @id @default(uuid())
|
||||
conversationId String @map("conversation_id")
|
||||
senderId String @map("sender_id")
|
||||
body String
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
||||
sender Member @relation(fields: [senderId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([conversationId])
|
||||
@@map("messages")
|
||||
}
|
||||
// InnungsApp — Prisma Schema
|
||||
// Stack: PostgreSQL + Prisma ORM + better-auth
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// BETTER-AUTH TABLES
|
||||
// =============================================
|
||||
|
||||
model User {
|
||||
id String @id
|
||||
name String
|
||||
email String @unique
|
||||
emailVerified Boolean @default(false) @map("email_verified")
|
||||
image String?
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// better-auth admin plugin fields
|
||||
role String?
|
||||
banned Boolean? @default(false)
|
||||
banReason String? @map("ban_reason")
|
||||
banExpires DateTime? @map("ban_expires")
|
||||
|
||||
// Password management
|
||||
mustChangePassword Boolean? @default(false) @map("must_change_password")
|
||||
|
||||
// App relations
|
||||
sessions Session[]
|
||||
accounts Account[]
|
||||
member Member?
|
||||
userRoles UserRole[]
|
||||
|
||||
@@map("user")
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id
|
||||
expiresAt DateTime @map("expires_at")
|
||||
token String @unique
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
ipAddress String? @map("ip_address")
|
||||
userAgent String? @map("user_agent")
|
||||
userId String @map("user_id")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("session")
|
||||
}
|
||||
|
||||
model Account {
|
||||
id String @id
|
||||
accountId String @map("account_id")
|
||||
providerId String @map("provider_id")
|
||||
userId String @map("user_id")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
accessToken String? @map("access_token")
|
||||
refreshToken String? @map("refresh_token")
|
||||
idToken String? @map("id_token")
|
||||
accessTokenExpiresAt DateTime? @map("access_token_expires_at")
|
||||
refreshTokenExpiresAt DateTime? @map("refresh_token_expires_at")
|
||||
scope String?
|
||||
password String?
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("account")
|
||||
}
|
||||
|
||||
model Verification {
|
||||
id String @id
|
||||
identifier String
|
||||
value String
|
||||
expiresAt DateTime @map("expires_at")
|
||||
createdAt DateTime? @default(now()) @map("created_at")
|
||||
updatedAt DateTime? @updatedAt @map("updated_at")
|
||||
|
||||
@@map("verification")
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// ORGANIZATIONS
|
||||
// =============================================
|
||||
|
||||
model Organization {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
slug String @unique
|
||||
plan String @default("pilot") // pilot | standard | pro | verband
|
||||
logoUrl String? @map("logo_url")
|
||||
primaryColor String @default("#E63946") @map("primary_color")
|
||||
secondaryColor String? @map("secondary_color")
|
||||
contactEmail String? @map("contact_email")
|
||||
avvAccepted Boolean @default(false) @map("avv_accepted")
|
||||
avvAcceptedAt DateTime? @map("avv_accepted_at")
|
||||
landingPageTitle String? @map("landing_page_title")
|
||||
landingPageText String? @map("landing_page_text")
|
||||
landingPageSectionTitle String? @map("landing_page_section_title")
|
||||
landingPageButtonText String? @map("landing_page_button_text")
|
||||
landingPageHeroImage String? @map("landing_page_hero_image")
|
||||
landingPageHeroOverlayOpacity Int? @default(50) @map("landing_page_hero_overlay_opacity")
|
||||
landingPageFeatures Json? @map("landing_page_features") @db.JsonB
|
||||
landingPageFooter Json? @map("landing_page_footer") @db.JsonB
|
||||
appStoreUrl String? @map("app_store_url")
|
||||
playStoreUrl String? @map("play_store_url")
|
||||
aiEnabled Boolean @default(false) @map("ai_enabled")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
members Member[]
|
||||
userRoles UserRole[]
|
||||
news News[]
|
||||
stellen Stelle[]
|
||||
termine Termin[]
|
||||
|
||||
@@map("organizations")
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// MEMBERS
|
||||
// =============================================
|
||||
|
||||
model Member {
|
||||
id String @id @default(uuid())
|
||||
orgId String @map("org_id")
|
||||
userId String? @unique @map("user_id") // NULL until magic-link clicked
|
||||
name String
|
||||
betrieb String
|
||||
sparte String
|
||||
ort String
|
||||
telefon String?
|
||||
email String
|
||||
status String @default("aktiv") // aktiv | ruhend | ausgetreten
|
||||
istAusbildungsbetrieb Boolean @default(false) @map("ist_ausbildungsbetrieb")
|
||||
seit Int?
|
||||
avatarUrl String? @map("avatar_url")
|
||||
pushToken String? @map("push_token")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
newsAuthored News[] @relation("NewsAuthor")
|
||||
stellen Stelle[]
|
||||
terminAnmeldungen TerminAnmeldung[]
|
||||
sentMessages Message[]
|
||||
conversationMembers ConversationMember[]
|
||||
|
||||
@@index([orgId])
|
||||
@@index([status])
|
||||
@@map("members")
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// USER ROLES (multi-tenancy)
|
||||
// =============================================
|
||||
|
||||
model UserRole {
|
||||
id String @id @default(uuid())
|
||||
orgId String @map("org_id")
|
||||
userId String @map("user_id")
|
||||
role String // admin | member
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([orgId, userId])
|
||||
@@map("user_roles")
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// NEWS
|
||||
// =============================================
|
||||
|
||||
model News {
|
||||
id String @id @default(uuid())
|
||||
orgId String @map("org_id")
|
||||
authorId String? @map("author_id")
|
||||
title String
|
||||
body String // Markdown
|
||||
kategorie String // Wichtig | Pruefung | Foerderung | Veranstaltung | Allgemein
|
||||
publishedAt DateTime? @map("published_at") // NULL = Entwurf
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
author Member? @relation("NewsAuthor", fields: [authorId], references: [id], onDelete: SetNull)
|
||||
reads NewsRead[]
|
||||
attachments NewsAttachment[]
|
||||
|
||||
@@index([orgId])
|
||||
@@index([publishedAt])
|
||||
@@map("news")
|
||||
}
|
||||
|
||||
model NewsRead {
|
||||
id String @id @default(uuid())
|
||||
newsId String @map("news_id")
|
||||
userId String @map("user_id")
|
||||
readAt DateTime @default(now()) @map("read_at")
|
||||
|
||||
news News @relation(fields: [newsId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([newsId, userId])
|
||||
@@map("news_reads")
|
||||
}
|
||||
|
||||
model NewsAttachment {
|
||||
id String @id @default(uuid())
|
||||
newsId String @map("news_id")
|
||||
name String
|
||||
storagePath String @map("storage_path")
|
||||
mimeType String? @map("mime_type")
|
||||
sizeBytes Int? @map("size_bytes")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
news News @relation(fields: [newsId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("news_attachments")
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// STELLENANGEBOTE (Lehrlingsbörse)
|
||||
// =============================================
|
||||
|
||||
model Stelle {
|
||||
id String @id @default(uuid())
|
||||
orgId String @map("org_id")
|
||||
memberId String @map("member_id")
|
||||
sparte String
|
||||
stellenAnz Int @default(1) @map("stellen_anz")
|
||||
verguetung String? // "600-800 € / Monat"
|
||||
lehrjahr String? // "1. Lehrjahr" | "beliebig"
|
||||
beschreibung String?
|
||||
kontaktEmail String @map("kontakt_email")
|
||||
kontaktName String? @map("kontakt_name")
|
||||
aktiv Boolean @default(true)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
member Member @relation(fields: [memberId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([orgId])
|
||||
@@index([aktiv])
|
||||
@@map("stellen")
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// TERMINE
|
||||
// =============================================
|
||||
|
||||
model Termin {
|
||||
id String @id @default(uuid())
|
||||
orgId String @map("org_id")
|
||||
titel String
|
||||
datum DateTime
|
||||
uhrzeit String? // stored as "HH:MM"
|
||||
endeDatum DateTime? @map("ende_datum")
|
||||
endeUhrzeit String? @map("ende_uhrzeit")
|
||||
ort String?
|
||||
adresse String?
|
||||
typ String // Pruefung | Versammlung | Kurs | Event | Sonstiges
|
||||
beschreibung String?
|
||||
maxTeilnehmer Int? @map("max_teilnehmer") // NULL = unbegrenzt
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
anmeldungen TerminAnmeldung[]
|
||||
|
||||
@@index([orgId])
|
||||
@@index([datum])
|
||||
@@map("termine")
|
||||
}
|
||||
|
||||
model TerminAnmeldung {
|
||||
id String @id @default(uuid())
|
||||
terminId String @map("termin_id")
|
||||
memberId String @map("member_id")
|
||||
angemeldetAt DateTime @default(now()) @map("angemeldet_at")
|
||||
|
||||
termin Termin @relation(fields: [terminId], references: [id], onDelete: Cascade)
|
||||
member Member @relation(fields: [memberId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([terminId, memberId])
|
||||
@@map("termin_anmeldungen")
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// DIREKTNACHRICHTEN (Chat)
|
||||
// =============================================
|
||||
|
||||
model Conversation {
|
||||
id String @id @default(uuid())
|
||||
orgId String @map("org_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
members ConversationMember[]
|
||||
messages Message[]
|
||||
|
||||
@@index([orgId])
|
||||
@@map("conversations")
|
||||
}
|
||||
|
||||
model ConversationMember {
|
||||
id String @id @default(uuid())
|
||||
conversationId String @map("conversation_id")
|
||||
memberId String @map("member_id")
|
||||
lastReadAt DateTime? @map("last_read_at")
|
||||
|
||||
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
||||
member Member @relation(fields: [memberId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([conversationId, memberId])
|
||||
@@map("conversation_members")
|
||||
}
|
||||
|
||||
model Message {
|
||||
id String @id @default(uuid())
|
||||
conversationId String @map("conversation_id")
|
||||
senderId String @map("sender_id")
|
||||
body String
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
||||
sender Member @relation(fields: [senderId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([conversationId])
|
||||
@@map("messages")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,85 +1,85 @@
|
|||
const { PrismaClient } = require('@prisma/client')
|
||||
const { randomBytes, scrypt } = require('crypto')
|
||||
const { promisify } = require('util')
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
const scryptAsync = promisify(scrypt)
|
||||
|
||||
async function hashPassword(password) {
|
||||
const salt = randomBytes(16).toString('hex')
|
||||
const key = await scryptAsync(password.normalize('NFKC'), salt, 64, {
|
||||
N: 16384,
|
||||
r: 16,
|
||||
p: 1,
|
||||
maxmem: 128 * 16384 * 16 * 2,
|
||||
})
|
||||
return `${salt}:${key.toString('hex')}`
|
||||
}
|
||||
|
||||
function getEnv(name) {
|
||||
return (process.env[name] || '').trim()
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const email = (getEnv('SUPERADMIN_EMAIL') || 'superadmin@innungsapp.de').toLowerCase()
|
||||
const name = getEnv('SUPERADMIN_NAME') || 'Super Admin'
|
||||
const userId = getEnv('SUPERADMIN_USER_ID') || 'superadmin-user-id'
|
||||
const accountId = getEnv('SUPERADMIN_ACCOUNT_ID') || 'superadmin-account-id'
|
||||
|
||||
let password = getEnv('SUPERADMIN_PASSWORD')
|
||||
if (!password) {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new Error('SUPERADMIN_PASSWORD must be set in production.')
|
||||
}
|
||||
|
||||
password = 'demo1234'
|
||||
console.warn('SUPERADMIN_PASSWORD not set. Using development fallback password.')
|
||||
}
|
||||
|
||||
console.log(`Seeding superadmin user for ${email}...`)
|
||||
const hash = await hashPassword(password)
|
||||
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email },
|
||||
update: {
|
||||
name,
|
||||
emailVerified: true,
|
||||
role: 'admin',
|
||||
},
|
||||
create: {
|
||||
id: userId,
|
||||
name,
|
||||
email,
|
||||
emailVerified: true,
|
||||
role: 'admin',
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.account.upsert({
|
||||
where: { id: accountId },
|
||||
update: {
|
||||
accountId: user.id,
|
||||
providerId: 'credential',
|
||||
userId: user.id,
|
||||
password: hash,
|
||||
},
|
||||
create: {
|
||||
id: accountId,
|
||||
accountId: user.id,
|
||||
providerId: 'credential',
|
||||
userId: user.id,
|
||||
password: hash,
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`Done. Login: ${email} / ${password}`)
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
const { PrismaClient } = require('@prisma/client')
|
||||
const { randomBytes, scrypt } = require('crypto')
|
||||
const { promisify } = require('util')
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
const scryptAsync = promisify(scrypt)
|
||||
|
||||
async function hashPassword(password) {
|
||||
const salt = randomBytes(16).toString('hex')
|
||||
const key = await scryptAsync(password.normalize('NFKC'), salt, 64, {
|
||||
N: 16384,
|
||||
r: 16,
|
||||
p: 1,
|
||||
maxmem: 128 * 16384 * 16 * 2,
|
||||
})
|
||||
return `${salt}:${key.toString('hex')}`
|
||||
}
|
||||
|
||||
function getEnv(name) {
|
||||
return (process.env[name] || '').trim()
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const email = (getEnv('SUPERADMIN_EMAIL') || 'superadmin@innungsapp.de').toLowerCase()
|
||||
const name = getEnv('SUPERADMIN_NAME') || 'Super Admin'
|
||||
const userId = getEnv('SUPERADMIN_USER_ID') || 'superadmin-user-id'
|
||||
const accountId = getEnv('SUPERADMIN_ACCOUNT_ID') || 'superadmin-account-id'
|
||||
|
||||
let password = getEnv('SUPERADMIN_PASSWORD')
|
||||
if (!password) {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new Error('SUPERADMIN_PASSWORD must be set in production.')
|
||||
}
|
||||
|
||||
password = 'demo1234'
|
||||
console.warn('SUPERADMIN_PASSWORD not set. Using development fallback password.')
|
||||
}
|
||||
|
||||
console.log(`Seeding superadmin user for ${email}...`)
|
||||
const hash = await hashPassword(password)
|
||||
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email },
|
||||
update: {
|
||||
name,
|
||||
emailVerified: true,
|
||||
role: 'admin',
|
||||
},
|
||||
create: {
|
||||
id: userId,
|
||||
name,
|
||||
email,
|
||||
emailVerified: true,
|
||||
role: 'admin',
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.account.upsert({
|
||||
where: { id: accountId },
|
||||
update: {
|
||||
accountId: user.id,
|
||||
providerId: 'credential',
|
||||
userId: user.id,
|
||||
password: hash,
|
||||
},
|
||||
create: {
|
||||
id: accountId,
|
||||
accountId: user.id,
|
||||
providerId: 'credential',
|
||||
userId: user.id,
|
||||
password: hash,
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`Done. Login: ${email} / ${password}`)
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,82 +1,82 @@
|
|||
import { PrismaClient } from '@prisma/client'
|
||||
import { scrypt, randomBytes } from 'crypto'
|
||||
import { promisify } from 'util'
|
||||
|
||||
const scryptAsync = promisify(scrypt)
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function hashPassword(password: string): Promise<string> {
|
||||
const salt = randomBytes(16).toString('hex')
|
||||
const key = await scryptAsync(password.normalize('NFKC'), salt, 64, {
|
||||
N: 16384, r: 16, p: 1, maxmem: 128 * 16384 * 16 * 2,
|
||||
}) as Buffer
|
||||
return `${salt}:${key.toString('hex')}`
|
||||
}
|
||||
|
||||
function getEnv(name: string): string {
|
||||
return (process.env[name] ?? '').trim()
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const email = getEnv('SUPERADMIN_EMAIL').toLowerCase() || 'superadmin@innungsapp.de'
|
||||
const name = getEnv('SUPERADMIN_NAME') || 'Super Admin'
|
||||
const userId = getEnv('SUPERADMIN_USER_ID') || 'superadmin-user-id'
|
||||
const accountId = getEnv('SUPERADMIN_ACCOUNT_ID') || 'superadmin-account-id'
|
||||
|
||||
let password = getEnv('SUPERADMIN_PASSWORD')
|
||||
if (!password) {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new Error('SUPERADMIN_PASSWORD must be set in production.')
|
||||
}
|
||||
|
||||
password = 'demo1234'
|
||||
console.warn('SUPERADMIN_PASSWORD not set. Using development fallback password.')
|
||||
}
|
||||
|
||||
console.log(`Seeding superadmin user for ${email}...`)
|
||||
const hash = await hashPassword(password)
|
||||
|
||||
const superAdminUser = await prisma.user.upsert({
|
||||
where: { email },
|
||||
update: {
|
||||
name,
|
||||
emailVerified: true,
|
||||
role: 'admin',
|
||||
},
|
||||
create: {
|
||||
id: userId,
|
||||
name,
|
||||
email,
|
||||
emailVerified: true,
|
||||
role: 'admin',
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.account.upsert({
|
||||
where: { id: accountId },
|
||||
update: {
|
||||
accountId: superAdminUser.id,
|
||||
userId: superAdminUser.id,
|
||||
providerId: 'credential',
|
||||
password: hash,
|
||||
},
|
||||
create: {
|
||||
id: accountId,
|
||||
accountId: superAdminUser.id,
|
||||
providerId: 'credential',
|
||||
userId: superAdminUser.id,
|
||||
password: hash,
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`Done. Login: ${email} / ${password}`)
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import { scrypt, randomBytes } from 'crypto'
|
||||
import { promisify } from 'util'
|
||||
|
||||
const scryptAsync = promisify(scrypt)
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function hashPassword(password: string): Promise<string> {
|
||||
const salt = randomBytes(16).toString('hex')
|
||||
const key = await scryptAsync(password.normalize('NFKC'), salt, 64, {
|
||||
N: 16384, r: 16, p: 1, maxmem: 128 * 16384 * 16 * 2,
|
||||
}) as Buffer
|
||||
return `${salt}:${key.toString('hex')}`
|
||||
}
|
||||
|
||||
function getEnv(name: string): string {
|
||||
return (process.env[name] ?? '').trim()
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const email = getEnv('SUPERADMIN_EMAIL').toLowerCase() || 'superadmin@innungsapp.de'
|
||||
const name = getEnv('SUPERADMIN_NAME') || 'Super Admin'
|
||||
const userId = getEnv('SUPERADMIN_USER_ID') || 'superadmin-user-id'
|
||||
const accountId = getEnv('SUPERADMIN_ACCOUNT_ID') || 'superadmin-account-id'
|
||||
|
||||
let password = getEnv('SUPERADMIN_PASSWORD')
|
||||
if (!password) {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new Error('SUPERADMIN_PASSWORD must be set in production.')
|
||||
}
|
||||
|
||||
password = 'demo1234'
|
||||
console.warn('SUPERADMIN_PASSWORD not set. Using development fallback password.')
|
||||
}
|
||||
|
||||
console.log(`Seeding superadmin user for ${email}...`)
|
||||
const hash = await hashPassword(password)
|
||||
|
||||
const superAdminUser = await prisma.user.upsert({
|
||||
where: { email },
|
||||
update: {
|
||||
name,
|
||||
emailVerified: true,
|
||||
role: 'admin',
|
||||
},
|
||||
create: {
|
||||
id: userId,
|
||||
name,
|
||||
email,
|
||||
emailVerified: true,
|
||||
role: 'admin',
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.account.upsert({
|
||||
where: { id: accountId },
|
||||
update: {
|
||||
accountId: superAdminUser.id,
|
||||
userId: superAdminUser.id,
|
||||
providerId: 'credential',
|
||||
password: hash,
|
||||
},
|
||||
create: {
|
||||
id: accountId,
|
||||
accountId: superAdminUser.id,
|
||||
providerId: 'credential',
|
||||
userId: superAdminUser.id,
|
||||
password: hash,
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`Done. Login: ${email} / ${password}`)
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
export { prisma } from './lib/prisma'
|
||||
export { Prisma } from '@prisma/client'
|
||||
export * from './types/index'
|
||||
export { prisma } from './lib/prisma'
|
||||
export { Prisma } from '@prisma/client'
|
||||
export * from './types/index'
|
||||
|
|
|
|||
Loading…
Reference in New Issue