push
This commit is contained in:
parent
b7f8221095
commit
253c3c1c6d
|
|
@ -5,9 +5,9 @@
|
||||||
## 1. Geschäftsmodell-Überblick
|
## 1. Geschäftsmodell-Überblick
|
||||||
|
|
||||||
**Typ:** B2B SaaS (Business-to-Business, Software as a Service)
|
**Typ:** B2B SaaS (Business-to-Business, Software as a Service)
|
||||||
**Käufer:** Innungen und Kreishandwerkerschaften
|
**Käufer:** Kreisverbände (als Multiplikatoren) & Innungen
|
||||||
**Endnutzer:** Mitglieder der Innungen (Handwerksbetriebe, Azubis)
|
**Endnutzer:** Mitglieder der Innungen (Handwerksbetriebe, Azubis)
|
||||||
**Vertrieb:** Direct Sales (Phase 1) → Verbands-Partnerschaft (Phase 2-3)
|
**Vertrieb:** Multiplier Strategy (targeting 240 Kreisverbände)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -17,10 +17,11 @@
|
||||||
|
|
||||||
| Plan | Preis | Mitglieder | Laufzeit |
|
| Plan | Preis | Mitglieder | Laufzeit |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| **Pilot** | 0 € (3 Monate) | bis 50 | Testphase |
|
| **Kreisverband Setup** | 5.000 € (einmalig) | p. Verband | Implementierung |
|
||||||
| **Starter** | 99 € / Monat | bis 100 | Monatlich kündbar |
|
| **Gilden-Account** | 150–300 € / Monat | p. Innung | Jährlich / Monatlich |
|
||||||
| **Standard** | 199 € / Monat | bis 300 | Monatlich kündbar |
|
| **Starter (Direkt)** | 99 € / Monat | bis 100 | Monatlich kündbar |
|
||||||
| **Pro** | 349 € / Monat | unbegrenzt | Monatlich kündbar |
|
| **Standard (Direkt)** | 199 € / Monat | bis 300 | Monatlich kündbar |
|
||||||
|
| **Pro (Direkt)** | 349 € / Monat | unbegrenzt | Monatlich kündbar |
|
||||||
|
|
||||||
### Jahresvertrag (15 % Rabatt)
|
### Jahresvertrag (15 % Rabatt)
|
||||||
|
|
||||||
|
|
@ -88,31 +89,26 @@ Erwarteter Churn: **< 1 % pro Monat** (= 11,4 % annual) für das erste Jahr, sin
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Revenue-Projektionen
|
## 5. Revenue-Projektionen (Multiplier-Modell)
|
||||||
|
|
||||||
### Szenario: Konservativ
|
### Fokus: NRW Markt-Potential
|
||||||
|
|
||||||
| Quartal | Neue Innungen | Gesamt Innungen | MRR |
|
| Segment | Anzahl | Setup Rev. (einmalig) | MRR (recurring) | ARR |
|
||||||
|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| Q1 2026 | 5 (Piloten, kostenlos) | 5 | 0 € |
|
| **Top 10 NRW Kreisverbände** | 10 KV / 311 Gilden | 50.000 € | 61.889 € | 742.668 € |
|
||||||
| Q2 2026 | 5 zahlend | 10 | 750 € |
|
| **Gesamt NRW Potential** | 40 KV / ~1.000 Gilden | 200.000 € | 199.000 € | 2.388.000 € |
|
||||||
| Q3 2026 | 10 zahlend | 20 | 2.200 € |
|
| **Gesamt DE Potential** | 240 KV / ~7.500 Gilden | 1.200.000 € | 1.492.500 € | 17.910.000 € |
|
||||||
| Q4 2026 | 15 zahlend | 35 | 5.500 € |
|
|
||||||
| Q1 2027 | 20 zahlend | 55 | 9.500 € |
|
|
||||||
| Q2 2027 | 30 zahlend | 85 | 16.000 € |
|
|
||||||
|
|
||||||
**ARR Ende 2026:** ~66.000 €
|
*Annahmen: Durchschnitt €199 MRR pro Gilde/Innung; €5.000 Setup pro Kreisverband.*
|
||||||
**ARR Ende 2027:** ~192.000 €
|
|
||||||
|
|
||||||
### Szenario: Optimistisch (mit HWK-Partner)
|
---
|
||||||
|
|
||||||
| Zeitpunkt | Innungen | MRR | ARR |
|
**Top 3 NRW "Cash-Cow" Targets:**
|
||||||
|---|---|---|---|
|
1. **KH Niederrhein:** 41 Innungen → €5.000 Setup + €8.159 MRR.
|
||||||
| Q4 2026 (HWK-Deal) | 80 | 14.000 € | 168.000 € |
|
2. **KH Gütersloh-Bielefeld:** 43 Innungen → €5.000 Setup + €8.557 MRR.
|
||||||
| Q2 2027 | 250 | 47.000 € | 564.000 € |
|
3. **KH Ruhr:** 39 Innungen → €5.000 Setup + €7.761 MRR.
|
||||||
| Q4 2027 | 500 | 100.000 € | 1.200.000 € |
|
|
||||||
|
|
||||||
**Break-even bei:** ~8 zahlende Innungen à 199 € (Infrastrukturkosten ~80 €/Monat)
|
**Break-even:** Bereits nach dem 1. Kreisverband-Setup (NRW Niederrhein) ist die Basis-Infrastruktur für das erste Jahr finanziert.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -144,24 +140,24 @@ Erwarteter Churn: **< 1 % pro Monat** (= 11,4 % annual) für das erste Jahr, sin
|
||||||
|
|
||||||
## 7. Distributionsstrategie
|
## 7. Distributionsstrategie
|
||||||
|
|
||||||
### Phase 1: Direct Sales (Monat 1–6)
|
### Phase 1: Kreisverband Multiplier (NRW)
|
||||||
|
|
||||||
**Ziel:** 5–10 Piloten in Baden-Württemberg
|
**Ziel:** 5 Kreisverbände in NRW als Kunden (ca. 250 angeschlossene Innungen)
|
||||||
|
|
||||||
**Taktik:**
|
**Taktik:**
|
||||||
1. Kaltakquise-Mail an 50 Innungen in BW (SHK, Elektro, Bau, Dachdecker)
|
1. Kaltakquise-LinkedIn/Mail an 40 Hauptgeschäftsführer (HGF) in NRW
|
||||||
2. Kostenloser 3-Monats-Pilot als Türöffner
|
2. White-Label-Demo für den Kreisverband (Multiplier-Effekt)
|
||||||
3. Demo-Call → Figma-Prototype zeigen
|
3. Demo-Call → "InnungsApp NRW Edition" zeigen
|
||||||
4. Pilot live, Feedback sammeln, Testimonials sammeln
|
4. Pilot mit einem Verband, Onboarding der ersten 10–20 Innungen
|
||||||
|
|
||||||
**Aufwand:** ~10h/Woche Sales, 1 Person
|
**Aufwand:** ~10h/Woche Sales, 1 Person
|
||||||
|
|
||||||
**Erwartete Conversion:**
|
**Erwartete Conversion:**
|
||||||
- 50 angeschriebene Innungen
|
- 40 angeschriebene Kreisverbände
|
||||||
- 15 Antworten (30 %)
|
- 12 Antworten (30 %)
|
||||||
- 8 Demo-Calls
|
- 8 Demo-Calls
|
||||||
- 5 Piloten (10 %)
|
- 5 Abschlüsse (KV-Setup)
|
||||||
- 3 zahlende Kunden nach Pilot (60 % Pilot-Conversion)
|
- 250 automatisch erreichte Innungsendnutzer (indirekt)
|
||||||
|
|
||||||
### Phase 2: Regionale HWK-Partnerschaft (Monat 6–12)
|
### Phase 2: Regionale HWK-Partnerschaft (Monat 6–12)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,54 +4,68 @@
|
||||||
|
|
||||||
## 1. Markt-Entry Strategie
|
## 1. Markt-Entry Strategie
|
||||||
|
|
||||||
### Fokus: Baden-Württemberg First
|
### Fokus: North Rhine-Westphalia (NRW) First
|
||||||
|
|
||||||
**Warum BW als erstes Bundesland?**
|
**Warum NRW als erstes Bundesland?**
|
||||||
- 1.000+ Innungen in BW (10–15 % des Gesamtmarkts)
|
- Größter Markt in DE (17.5M+ Einwohner, höchste Dichte an Handwerksbetrieben)
|
||||||
- HWK Baden-Württemberg hat 3 Kammern (Stuttgart, Karlsruhe, Freiburg) mit regionaler Entscheidungshoheit
|
- 40+ Kreishandwerkerschaften in NRW (ca. 1/6 des Gesamtmarkts)
|
||||||
- Geografisch erreichbar für persönliche Demos
|
- Starke Ballungszentren (Ruhrgebiet, Köln/Düsseldorf) erlauben hohe Lead-Dichte
|
||||||
- ZDH-Präsident kommt aus BW (Netzwerkeffekt)
|
- Bekannte digitale Pionier-Verbände in der Region (z.B. Köln, Düsseldorf, Münster)
|
||||||
|
|
||||||
**Zielsegment Phase 1:**
|
**Zielsegment Phase 1:**
|
||||||
- Handwerk-Innungen mit 50–300 Mitgliedern
|
- Kreisverbände (240 in DE) als Multiplikatoren
|
||||||
|
- Handwerk-Innungen mit 50–300 Mitgliedern via Kreisverbände
|
||||||
- Branchen: SHK, Elektrotechnik, Bau, Dachdecker (hoher Fachkräftemangel)
|
- Branchen: SHK, Elektrotechnik, Bau, Dachdecker (hoher Fachkräftemangel)
|
||||||
- Geschäftsführer ist digital affin (< 55 Jahre, LinkedIn-Präsenz)
|
- Entscheidungsebene: Hauptgeschäftsführer (HGF) der Kreisverbände
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Phase 1: Direct Sales (Monat 1–6)
|
## 2. Phase 1: Direct Sales (Monat 1–6)
|
||||||
|
|
||||||
### Ziel
|
### Ziel
|
||||||
5 zahlende Innungen in BW
|
5 zahlende Kreisverbände in NRW
|
||||||
|
|
||||||
### Prospecting
|
### Prospecting
|
||||||
|
|
||||||
**Lead-Generierung:**
|
**Lead-Generierung:**
|
||||||
1. Scraping: innungsverzeichnis.de / HWK-Websites → alle BW-Innungen mit Kontaktdaten
|
1. Scraping: kh-online.de / HWK-Websites → alle NRW Kreishandwerkerschaften
|
||||||
2. LinkedIn: Geschäftsführer und Obermeister identifizieren
|
2. LinkedIn: Hauptgeschäftsführer (HGF) identifizieren
|
||||||
3. Branchen-Priorisierung: SHK > Elektro > Bau > Dachdecker
|
3. Ballungszentrum-Fokus: Köln > Düsseldorf > Ruhrgebiet
|
||||||
|
|
||||||
**Ziel-Lead-Liste: 100 Innungen in BW**
|
**Ziel-Lead-Liste: Top 10 Kreisverbände in NRW**
|
||||||
|
| Rang | Kreisverband / Kreishandwerkerschaft (KH) | Innungen | Fokus-Region |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | **KH Niederrhein** (Krefeld, Viersen, Neuss) | 41 | Niederrhein |
|
||||||
|
| 2 | **KH Ruhr** (Bochum, Herne, Ennepe-Ruhr) | 39 | Ruhrgebiet |
|
||||||
|
| 3 | **KH Gütersloh-Bielefeld** | 43 | Ostwestfalen-Lippe |
|
||||||
|
| 4 | **KH Köln** | 31 | Köln / Rheinschiene |
|
||||||
|
| 5 | **KH Münster** | 34 | Münsterland |
|
||||||
|
| 6 | **KH Dortmund und Lünen** | 23 | Westfalen |
|
||||||
|
| 7 | **KH Düsseldorf** | 25+ | Düsseldorf |
|
||||||
|
| 8 | **KH Borken** | 31 | Münsterland |
|
||||||
|
| 9 | **KH Steinfurt-Warendorf** | 24 | Münsterland |
|
||||||
|
| 10 | **KH Bonn • Rhein-Sieg** | 20+ | Bonn / Rhein-Sieg |
|
||||||
|
|
||||||
### Outreach-Sequenz
|
### Outreach-Sequenz
|
||||||
|
|
||||||
**E-Mail 1 (Tag 1): Kaltakquise**
|
**E-Mail 1 (Tag 1): Kreisverband-Outreach (HGF-Fokus)**
|
||||||
```
|
```
|
||||||
Betreff: Digitale Mitgliederverwaltung für [Innungsname]
|
Betreff: Digitale Lösung für Ihre [Anzahl] Innungen — InnungsApp
|
||||||
|
|
||||||
Hallo Frau/Herr [Name],
|
Hallo Frau/Herr [Name],
|
||||||
|
|
||||||
ich bin Timo Knuth und entwickle InnungsApp — eine App, die Innungen von
|
ich bin Timo Knuth und entwickle InnungsApp — eine Plattform, mit der Sie
|
||||||
Excel und WhatsApp befreit.
|
Ihre [Anzahl] Innungen zentral digitalisieren und von Excel/WhatsApp befreien.
|
||||||
|
|
||||||
Drei Dinge in 30 Sekunden:
|
Drei Vorteile für Ihren Kreisverband:
|
||||||
- Mitgliederverzeichnis mit Suche auf dem Smartphone
|
- Zentrale Mitgliederverwaltung für alle angeschlossenen Innungen
|
||||||
- Rundschreiben mit Push-Benachrichtigung (Sie sehen, wer es gelesen hat)
|
- Digitale Rundschreiben mit Push-Benachrichtigung & Lesebestätigung
|
||||||
- Lehrlingsbörse — Azubis finden, direkt über Ihre Innung
|
- Exklusive Lehrlingsbörse für den gesamten Kreisverband
|
||||||
|
|
||||||
Für [Innungsname] würde ich das 3 Monate kostenlos zur Verfügung stellen.
|
Wir bieten für Kreisverbände ein White-Label-Setup (€5.000 einmalig)
|
||||||
|
und attraktive Gilden-Konditionen an.
|
||||||
|
|
||||||
Haben Sie 20 Minuten für eine kurze Demo nächste Woche?
|
Haben Sie 20 Minuten für eine kurze Demo für Ihre Innungsgeschäftsführer?
|
||||||
|
|
||||||
Mit freundlichen Grüßen
|
Mit freundlichen Grüßen
|
||||||
Timo Knuth
|
Timo Knuth
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,8 @@ npx expo start
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
pnpm --filter @innungsapp/admin dev
|
pnpm --filter @innungsapp/admin dev -- --port 3032
|
||||||
|
|
||||||
px expo start --clear
|
npx expo start --clear
|
||||||
|
|
||||||
|
Demo: admin@demo.de / demo1234
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
1. Pflichtdokumente (Rechtstexte)
|
||||||
|
Impressum (Anbieterkennzeichnung):
|
||||||
|
Wo? Auf der Landingpage, im Admin-Dashboard und in der mobilen App (meist im Einstellungs-Menü).
|
||||||
|
Was muss rein? Name, Adresse, Rechtsform (z. B. GmbH, UG), Vertretungsberechtigte, Kontaktmöglichkeiten (E-Mail, Telefon), Handelsregister (falls vorhanden) und USt-IdNr.
|
||||||
|
Darf maximal 2 Klicks entfernt sein (Impressumspflicht nach § 5 DDG).
|
||||||
|
Datenschutzerklärung:
|
||||||
|
Wo? Auf der Landingpage, im Admin-Dashboard und in der mobilen App. Außerdem musst du sie beim Einreichen der App in den Apple App Store und Google Play Store verlinken.
|
||||||
|
Was muss rein? Welche Daten du sammelst (z. B. E-Mail, IP-Adresse, hochgeladene Dateien), auf welcher Rechtsgrundlage (z. B. Vertragserfüllung für die App-Nutzung), wie lange du sie speicherst und an wen du sie weitergibst (deine Hosting-Anbieter). Du musst auch über die Nutzerrechte (Auskunft, Löschung) aufklären.
|
||||||
|
2. Cookie-Banner & Tracking (TDDDG)
|
||||||
|
Technisch notwendige Cookies: Wenn du nur Cookies für den Login-Check nutzt (das macht better-auth voraussichtlich mit Session-Cookies), brauchst du kein nerviges Cookie-Banner. Du musst diese Cookies nur in der Datenschutzerklärung erwähnen.
|
||||||
|
Tracking & Analytics: Falls du auf der Landingpage oder in der App Dinge wie Google Analytics, Facebook Pixel, Mixpanel oder PostHog einbaust, musst du dir vorher die aktive, freiwillige Zustimmung der Nutzer holen (Cookie-Banner / Consent Dialog in der App).
|
||||||
|
3. Account-Löschung (Besonders wichtig für die App Stores!)
|
||||||
|
Sowohl Apple als auch Google schreiben mittlerweile streng vor, dass Nutzer, die in einer App ein Konto erstellen können, dieses Konto auch in der App wieder löschen können müssen.
|
||||||
|
Wichtig: Es reicht nicht, das Konto nur zu deaktivieren. Die User-Daten müssen in der Datenbank gelöscht werden (Ausnahme: Aufbewahrungspflichten wie Rechnungen).
|
||||||
|
Du brauchst zudem einen Web-Link, über den Nutzer die Löschung außerhalb der App beantragen/durchführen können (z. B. auf deiner Landingpage).
|
||||||
|
4. Verträge zur Auftragsverarbeitung (AV-Verträge / DPA)
|
||||||
|
Du darfst personenbezogene Daten nicht einfach so auf fremden Servern speichern, ohne einen Vertrag mit dem Anbieter zu haben (Art. 28 DSGVO). Du brauchst (bzw. musst digital akzeptieren) AV-Verträge von:
|
||||||
|
|
||||||
|
Deinem Server/Hosting-Anbieter (z. B. Hetzner, Vercel, AWS).
|
||||||
|
Dem Anbieter deines SMTP-Servers (der die E-Mails wie Magic Links versendet).
|
||||||
|
Jedem externen Tool, das Nutzerdaten sieht (z. B. Sentry für Error Tracking, falls genutzt).
|
||||||
|
5. Technische Sicherheit & Grundsätze (In deiner App)
|
||||||
|
Datenminimierung: Sammle nur Daten, die du wirklich für die App brauchst.
|
||||||
|
Verschlüsselung: Alle Verbindungen (EXPO_PUBLIC_API_URL und NEXT_PUBLIC_APP_URL) müssen im Live-Betrieb zwingend über HTTPS laufen.
|
||||||
|
Passwörter/Sicherheit: Da du better-auth nutzt, wird das Thema Passwortverschlüsselung & Session-Management glücklicherweise schon sicher für dich geklärt, aber du bist dennoch für die sichere Konfiguration verantwortlich (z. B. einen sicheren BETTER_AUTH_SECRET im Live-Betrieb nutzen).
|
||||||
|
Server-Standort: Achte darauf, wo deine SQLite-Datenbank bzw. dein Server liegt. Ein Serverstandort in Deutschland oder der EU macht den Datenschutz erheblich einfacher, da du keine komplizierten "Drittland-Transfers" belegen musst.
|
||||||
|
6. Spezifische App Store Anforderungen
|
||||||
|
Apple App Store ("App Privacy"): Du musst in App Store Connect genaue Fragen beantworten (welche Daten sammelst du? Sind sie mit dem Benutzer verknüpft? Wofür werden sie genutzt?), die dann als "Privacy Nutrition Labels" im App Store angezeigt werden.
|
||||||
|
Google Play Store ("Data Safety"): Ähnliches Formular in der Google Play Console. Auch hier musst du erklären, was du sammelst, ob es verschlüsselt ist und ob der Nutzer die Löschung beantragen kann.
|
||||||
|
Zusammenfassende To-Do-Liste für den Live-Gang:
|
||||||
|
Impressum erstellen und in Web/App verlinken.
|
||||||
|
Datenschutzerklärung für App und Webseite generieren lassen (geht gut über Tools wie eRecht24, IT-Recht Kanzlei oder den Datenschutz-Generator von Dr. Schwenke).
|
||||||
|
Einen "Account Löschen"-Button tief in den App-Einstellungen einbauen.
|
||||||
|
AV-Verträge mit dem Hoster (und z. B. dem E-Mail-Provider) abschließen (sind meist nur 2 Klicks im Dashboard der Anbieter).
|
||||||
|
SSL/HTTPS auf dem Server aktivieren.
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
# Dependencies (rebuilt in Docker)
|
||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
|
||||||
|
# Next.js build cache
|
||||||
|
**/.next
|
||||||
|
**/out
|
||||||
|
|
||||||
|
# Expo / Mobile (not needed for admin Docker build)
|
||||||
|
apps/mobile
|
||||||
|
|
||||||
|
# Dev databases
|
||||||
|
**/*.db
|
||||||
|
**/*.db-journal
|
||||||
|
**/*.db-wal
|
||||||
|
**/*.db-shm
|
||||||
|
|
||||||
|
# Uploads (mounted as volume)
|
||||||
|
apps/admin/uploads
|
||||||
|
|
||||||
|
# Env files
|
||||||
|
**/.env
|
||||||
|
**/.env.local
|
||||||
|
**/.env.development
|
||||||
|
**/.env.production
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
**/*.log
|
||||||
|
**/npm-debug.log*
|
||||||
|
|
||||||
|
# TypeScript build info
|
||||||
|
**/*.tsbuildinfo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
|
@ -25,6 +25,8 @@ SMTP_PASS=""
|
||||||
# ADMIN APP (Next.js)
|
# ADMIN APP (Next.js)
|
||||||
# =============================================
|
# =============================================
|
||||||
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||||
|
NEXT_PUBLIC_POSTHOG_KEY=""
|
||||||
|
NEXT_PUBLIC_POSTHOG_HOST="https://us.i.posthog.com"
|
||||||
|
|
||||||
# =============================================
|
# =============================================
|
||||||
# MOBILE APP (Expo)
|
# MOBILE APP (Expo)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
# =============================================
|
||||||
|
# Produktion — .env Vorlage
|
||||||
|
# Kopieren als: innungsapp/.env
|
||||||
|
# =============================================
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
|
||||||
|
# Uploads
|
||||||
|
UPLOAD_MAX_SIZE_MB="10"
|
||||||
|
|
@ -108,13 +108,148 @@ Alle API-Endpunkte sind typsicher über tRPC definiert:
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
### Admin (Vercel)
|
### Admin — Docker (empfohlen für Self-Hosting)
|
||||||
|
|
||||||
|
**Voraussetzungen:** Docker + Docker Compose auf dem Server installiert.
|
||||||
|
|
||||||
|
#### Schritt 1: Repository klonen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo-url>
|
||||||
|
cd innungsapp
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Schritt 2: Umgebungsvariablen anlegen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.production.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Dann `.env` öffnen und **alle Werte** befüllen:
|
||||||
|
|
||||||
|
| Variable | Beschreibung |
|
||||||
|
|---|---|
|
||||||
|
| `BETTER_AUTH_SECRET` | Zufälliger String (min. 32 Zeichen) — z.B. `openssl rand -hex 32` |
|
||||||
|
| `BETTER_AUTH_URL` | Öffentliche URL der App, z.B. `https://app.deine-innung.de` |
|
||||||
|
| `NEXT_PUBLIC_APP_URL` | Gleicher Wert wie `BETTER_AUTH_URL` |
|
||||||
|
| `EMAIL_FROM` | Absender-Adresse für Magic Links |
|
||||||
|
| `SMTP_HOST` | SMTP-Server-Adresse |
|
||||||
|
| `SMTP_PORT` | Meistens `587` (STARTTLS) oder `465` (SSL) |
|
||||||
|
| `SMTP_USER` | SMTP-Benutzername |
|
||||||
|
| `SMTP_PASS` | SMTP-Passwort |
|
||||||
|
|
||||||
|
#### Schritt 3: Container bauen und starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Build dauert beim ersten Mal ~2–3 Minuten. Danach läuft die App auf **Port 3000**.
|
||||||
|
|
||||||
|
Logs prüfen:
|
||||||
|
```bash
|
||||||
|
docker compose logs -f admin
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Schritt 4: Superadmin anlegen (nur beim ersten Start)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec admin node -e "
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const { scryptSync, randomBytes } = require('crypto');
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
// Superadmin wird via seed-superadmin.ts angelegt
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
Einfacher: Den Seed direkt ausführen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec -w /app admin \
|
||||||
|
node packages/shared/prisma/seed-superadmin.js
|
||||||
|
```
|
||||||
|
|
||||||
|
> Standard-Login nach Seed: `superadmin@innungsapp.de` / `demo1234`
|
||||||
|
> **Passwort sofort in den Einstellungen ändern!**
|
||||||
|
|
||||||
|
#### Schritt 5: Reverse Proxy (HTTPS)
|
||||||
|
|
||||||
|
Nginx-Beispielkonfiguration für `app.deine-innung.de`:
|
||||||
|
|
||||||
|
```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;
|
||||||
|
|
||||||
|
client_max_body_size 20M;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
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;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
SSL-Zertifikat mit Certbot:
|
||||||
|
```bash
|
||||||
|
certbot --nginx -d app.deine-innung.de
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Updates einspielen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Datenbank und Uploads bleiben dabei erhalten (Docker Volumes).
|
||||||
|
|
||||||
|
#### Häufige Befehle
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Status prüfen
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
# Logs ansehen
|
||||||
|
docker compose logs -f admin
|
||||||
|
|
||||||
|
# Container neustarten
|
||||||
|
docker compose restart admin
|
||||||
|
|
||||||
|
# In Container einloggen
|
||||||
|
docker compose exec admin sh
|
||||||
|
|
||||||
|
# App stoppen
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# App stoppen + Daten löschen (Vorsicht!)
|
||||||
|
docker compose down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Admin — Vercel (Alternative)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Umgebungsvariablen in Vercel setzen:
|
# Umgebungsvariablen in Vercel setzen:
|
||||||
# DATABASE_URL, BETTER_AUTH_SECRET, BETTER_AUTH_URL, SMTP_*
|
# DATABASE_URL, BETTER_AUTH_SECRET, BETTER_AUTH_URL, SMTP_*
|
||||||
|
|
||||||
# Deploy
|
|
||||||
vercel --cwd apps/admin
|
vercel --cwd apps/admin
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
# =============================================
|
||||||
|
# Stage 1: Dependencies
|
||||||
|
# =============================================
|
||||||
|
FROM node:20-alpine AS deps
|
||||||
|
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
|
||||||
|
|
||||||
|
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-alpine AS builder
|
||||||
|
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
|
||||||
|
|
||||||
|
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 2>/dev/null || true
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Generate Prisma client
|
||||||
|
RUN pnpm --filter @innungsapp/shared prisma:generate
|
||||||
|
|
||||||
|
# Build the admin app
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
RUN pnpm --filter @innungsapp/admin build
|
||||||
|
|
||||||
|
# =============================================
|
||||||
|
# Stage 3: Production Runner
|
||||||
|
# =============================================
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
|
||||||
|
|
||||||
|
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
|
||||||
|
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 --from=builder /app/node_modules/.pnpm ./node_modules/.pnpm 2>/dev/null || true
|
||||||
|
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
|
||||||
|
COPY --from=builder /app/node_modules/prisma ./node_modules/prisma
|
||||||
|
COPY --from=builder /app/node_modules/.bin/prisma ./node_modules/.bin/prisma
|
||||||
|
|
||||||
|
# Create uploads directory
|
||||||
|
RUN mkdir -p /app/uploads && chown nextjs:nodejs /app/uploads
|
||||||
|
|
||||||
|
# Create SQLite data directory
|
||||||
|
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
|
||||||
|
|
||||||
|
# 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"]
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
'use server'
|
||||||
|
|
||||||
|
import { auth, getSanitizedHeaders } from '@/lib/auth'
|
||||||
|
import { prisma } from '@innungsapp/shared'
|
||||||
|
import { headers } from 'next/headers'
|
||||||
|
import { revalidatePath } from 'next/cache'
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
|
export async function changePasswordAndDisableMustChange(prevState: any, formData: FormData) {
|
||||||
|
const currentPassword = formData.get('currentPassword') as string
|
||||||
|
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.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
let redirectUrl: string | null = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Update password using better-auth
|
||||||
|
// This will throw if the current password is invalid or other error occurs
|
||||||
|
await auth.api.changePassword({
|
||||||
|
headers: sanitizedHeaders,
|
||||||
|
body: {
|
||||||
|
newPassword,
|
||||||
|
currentPassword,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update mustChangePassword flag in database
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
data: { mustChangePassword: false }
|
||||||
|
})
|
||||||
|
|
||||||
|
const slug = formData.get('slug') as string
|
||||||
|
|
||||||
|
// Sign out so the user has to re-login with the new password
|
||||||
|
await auth.api.signOut({ headers: sanitizedHeaders })
|
||||||
|
|
||||||
|
redirectUrl = `/login?message=password_changed&callbackUrl=/${slug}/dashboard`
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Password reset exception:', e)
|
||||||
|
// BetterAuth errors often have a message or code
|
||||||
|
const errorMessage = e?.message?.toLowerCase() || ''
|
||||||
|
if (errorMessage.includes('invalid') && errorMessage.includes('password')) {
|
||||||
|
return { success: false, error: 'Das aktuelle Passwort ist nicht korrekt.' }
|
||||||
|
}
|
||||||
|
return { success: false, error: 'Ein unerwarteter Fehler ist aufgetreten.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (redirectUrl) {
|
||||||
|
redirect(redirectUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useActionState, useState } from 'react'
|
||||||
|
import { changePasswordAndDisableMustChange } from '../actions'
|
||||||
|
|
||||||
|
export function ForcePasswordChange({ slug }: { slug: string }) {
|
||||||
|
const [state, action, isPending] = useActionState(changePasswordAndDisableMustChange, { success: false, error: '' })
|
||||||
|
|
||||||
|
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 ändern</h1>
|
||||||
|
<p className="text-gray-500 text-sm">
|
||||||
|
Dies ist Ihre erste Anmeldung mit den vom Administrator vergebenen Zugangsdaten.
|
||||||
|
Bitte vergeben Sie ein neues, sicheres Passwort.
|
||||||
|
</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">Aktuelles (temporäres) Passwort</label>
|
||||||
|
<input
|
||||||
|
name="currentPassword"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
placeholder="••••••••"
|
||||||
|
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">Neues Passwort</label>
|
||||||
|
<input
|
||||||
|
name="newPassword"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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 aktualisieren'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -100,6 +100,33 @@ export default function EinstellungenPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Registrierungslink */}
|
||||||
|
<div className="bg-white rounded-lg border p-6 space-y-4">
|
||||||
|
<h2 className="font-semibold text-gray-900">Registrierungslink</h2>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Teilen Sie diesen Link mit neuen Mitgliedern. Sie können sich damit selbst registrieren
|
||||||
|
und erhalten einen Aktivierungslink per E-Mail.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
readOnly
|
||||||
|
value={`${typeof window !== 'undefined' ? window.location.origin : ''}/registrierung/${org.slug}`}
|
||||||
|
className="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm bg-gray-50 text-gray-700 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
navigator.clipboard.writeText(
|
||||||
|
`${window.location.origin}/registrierung/${org.slug}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Kopieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Plan Info */}
|
{/* Plan Info */}
|
||||||
<div className="bg-white rounded-lg border p-6">
|
<div className="bg-white rounded-lg border p-6">
|
||||||
<h2 className="font-semibold text-gray-900 mb-2">Plan</h2>
|
<h2 className="font-semibold text-gray-900 mb-2">Plan</h2>
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
import { Sidebar } from '@/components/layout/Sidebar'
|
||||||
|
import { Header } from '@/components/layout/Header'
|
||||||
|
import { auth, getSanitizedHeaders } from '@/lib/auth'
|
||||||
|
import { headers } from 'next/headers'
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
import { prisma } from '@innungsapp/shared'
|
||||||
|
import { ForcePasswordChange } from './ForcePasswordChange'
|
||||||
|
|
||||||
|
export default async function DashboardLayout({
|
||||||
|
children,
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
params: Promise<{ slug: string }>
|
||||||
|
}) {
|
||||||
|
const sanitizedHeaders = await getSanitizedHeaders()
|
||||||
|
const session = await auth.api.getSession({ headers: sanitizedHeaders })
|
||||||
|
if (!session?.user) {
|
||||||
|
redirect('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Superadmin Redirect
|
||||||
|
const superAdminEmail = process.env.SUPERADMIN_EMAIL || 'superadmin@innungsapp.de'
|
||||||
|
if (session.user.email === superAdminEmail) {
|
||||||
|
redirect('/superadmin')
|
||||||
|
}
|
||||||
|
|
||||||
|
const { slug } = await params
|
||||||
|
const org = await prisma.organization.findUnique({
|
||||||
|
where: { slug }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Basic security: Check if the user is an admin of this organization
|
||||||
|
const userRole = org
|
||||||
|
? await prisma.userRole.findUnique({
|
||||||
|
where: { orgId_userId: { orgId: org.id, userId: session.user.id } }
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
|
||||||
|
// If not found for this slug, check if user is admin of ANY org and redirect there
|
||||||
|
if (!userRole || userRole.role !== 'admin') {
|
||||||
|
const anyAdminRole = await prisma.userRole.findFirst({
|
||||||
|
where: { userId: session.user.id, role: 'admin' },
|
||||||
|
include: { org: true },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
})
|
||||||
|
console.error('[Dashboard] Zugriff verweigert Debug:', {
|
||||||
|
sessionUserId: session.user.id,
|
||||||
|
sessionUserEmail: session.user.email,
|
||||||
|
slug,
|
||||||
|
orgFound: !!org,
|
||||||
|
orgId: org?.id,
|
||||||
|
userRoleFound: !!userRole,
|
||||||
|
userRoleRole: userRole?.role,
|
||||||
|
anyAdminRoleFound: !!anyAdminRole,
|
||||||
|
anyAdminRoleOrgSlug: anyAdminRole?.org?.slug,
|
||||||
|
})
|
||||||
|
if (anyAdminRole?.org?.slug && anyAdminRole.org.slug !== slug) {
|
||||||
|
redirect(`/${anyAdminRole.org.slug}/dashboard`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ONLY admins are allowed in the administrative portal
|
||||||
|
if (!userRole || userRole.role !== 'admin') {
|
||||||
|
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">
|
||||||
|
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-red-100 text-red-600">
|
||||||
|
<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="M12 9v3.75m0-10.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.75c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.57-.598-3.75h-.152c-3.196 0-6.1-1.248-8.25-3.286Zm0 13.036h.008v.008H12v-.008Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 mb-2">Zugriff verweigert</h1>
|
||||||
|
<p className="text-gray-500 mb-6 text-sm">
|
||||||
|
Dieses Portal ist ausschließlich für Administratoren reserviert. Ihr Account verfügt nicht über die notwendigen Berechtigungen für diesen Bereich.
|
||||||
|
</p>
|
||||||
|
<form action={async () => {
|
||||||
|
'use server'
|
||||||
|
const { auth } = await import('@/lib/auth')
|
||||||
|
const { headers } = await import('next/headers')
|
||||||
|
await auth.api.signOut({ headers: await headers() })
|
||||||
|
redirect('/login')
|
||||||
|
}}>
|
||||||
|
<button type="submit" className="text-sm font-medium text-brand-600 hover:text-brand-700">
|
||||||
|
Abmelden und mit anderem Konto anmelden
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force Password Change Check
|
||||||
|
// @ts-ignore - mustChangePassword is added via additionalFields
|
||||||
|
if (session.user.mustChangePassword) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex flex-col items-center justify-center p-4">
|
||||||
|
<ForcePasswordChange slug={slug} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject Primary Color Theme
|
||||||
|
const primaryColor = org?.primaryColor || '#E63946'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen bg-gray-50">
|
||||||
|
<style>{`
|
||||||
|
:root {
|
||||||
|
--color-brand-primary: ${primaryColor};
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
<Sidebar orgName={org?.name} logoUrl={org?.logoUrl} />
|
||||||
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
|
<Header />
|
||||||
|
<main className="flex-1 overflow-y-auto p-6">{children}</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,271 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { use } 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 { useState, useEffect } from 'react'
|
||||||
|
import { SPARTEN, MEMBER_STATUS_LABELS } from '@innungsapp/shared'
|
||||||
|
import { Trash2 } from 'lucide-react'
|
||||||
|
|
||||||
|
export default function MitgliedEditPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
}) {
|
||||||
|
const { id } = use(params)
|
||||||
|
const router = useRouter()
|
||||||
|
const { data: member, isLoading } = trpc.members.byId.useQuery({ id })
|
||||||
|
const updateMutation = trpc.members.update.useMutation({
|
||||||
|
onSuccess: () => router.push('/dashboard/mitglieder'),
|
||||||
|
})
|
||||||
|
const deleteMutation = trpc.members.delete.useMutation({
|
||||||
|
onSuccess: () => router.push('/dashboard/mitglieder'),
|
||||||
|
})
|
||||||
|
const resendMutation = trpc.members.resendInvite.useMutation()
|
||||||
|
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
name: '',
|
||||||
|
betrieb: '',
|
||||||
|
sparte: '',
|
||||||
|
ort: '',
|
||||||
|
telefon: '',
|
||||||
|
email: '',
|
||||||
|
status: 'aktiv' as 'aktiv' | 'ruhend' | 'ausgetreten',
|
||||||
|
istAusbildungsbetrieb: false,
|
||||||
|
seit: undefined as number | undefined,
|
||||||
|
role: 'member' as 'member' | 'admin',
|
||||||
|
password: '',
|
||||||
|
})
|
||||||
|
const [isChangingPassword, setIsChangingPassword] = useState(false)
|
||||||
|
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (member) {
|
||||||
|
setForm({
|
||||||
|
name: member.name || '',
|
||||||
|
betrieb: member.betrieb || '',
|
||||||
|
sparte: member.sparte || '',
|
||||||
|
ort: member.ort || '',
|
||||||
|
telefon: member.telefon ?? '',
|
||||||
|
email: member.email || '',
|
||||||
|
status: (member.status as 'aktiv' | 'ruhend' | 'ausgetreten') || 'aktiv',
|
||||||
|
istAusbildungsbetrieb: member.istAusbildungsbetrieb || false,
|
||||||
|
seit: member.seit ?? undefined,
|
||||||
|
// @ts-ignore
|
||||||
|
role: member.role || 'member',
|
||||||
|
password: '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [member])
|
||||||
|
|
||||||
|
if (isLoading) return <div className="text-gray-500">Wird geladen...</div>
|
||||||
|
if (!member) return null
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
updateMutation.mutate({ id, data: form })
|
||||||
|
}
|
||||||
|
|
||||||
|
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-2xl space-y-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link href="/dashboard/mitglieder" 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">Mitglied bearbeiten</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Invite Status */}
|
||||||
|
<div className="bg-white rounded-lg border p-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-700">App-Zugang</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
|
{member.userId
|
||||||
|
? 'Mitglied hat sich eingeloggt'
|
||||||
|
: 'Noch nicht eingeladen / eingeloggt'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{!member.userId && (
|
||||||
|
<button
|
||||||
|
onClick={() => resendMutation.mutate({ memberId: id })}
|
||||||
|
disabled={resendMutation.isPending}
|
||||||
|
className="text-sm text-brand-600 hover:underline disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{resendMutation.isPending ? 'Sende...' : resendMutation.isSuccess ? 'Gesendet' : 'Einladung senden'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6 pb-20">
|
||||||
|
<form onSubmit={handleSubmit} className="bg-white rounded-lg border p-6 space-y-6">
|
||||||
|
{/* Section: Stammdaten */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Stammdaten</p>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
||||||
|
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Betrieb</label>
|
||||||
|
<input value={form.betrieb} onChange={(e) => setForm({ ...form, betrieb: e.target.value })} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Sparte</label>
|
||||||
|
<select value={form.sparte} onChange={(e) => setForm({ ...form, sparte: e.target.value })} className={inputClass}>
|
||||||
|
{SPARTEN.map((s) => <option key={s}>{s}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Ort</label>
|
||||||
|
<input value={form.ort} onChange={(e) => setForm({ ...form, ort: e.target.value })} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section: 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">E-Mail</label>
|
||||||
|
<input type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Telefon</label>
|
||||||
|
<input type="tel" value={form.telefon} onChange={(e) => setForm({ ...form, telefon: e.target.value })} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section: Status */}
|
||||||
|
<div className="border-t pt-5">
|
||||||
|
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Status</p>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
||||||
|
<select value={form.status} onChange={(e) => setForm({ ...form, status: e.target.value as typeof form.status })} className={inputClass}>
|
||||||
|
{(['aktiv', 'ruhend', 'ausgetreten'] as const).map((s) => (
|
||||||
|
<option key={s} value={s}>{MEMBER_STATUS_LABELS[s]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Rolle</label>
|
||||||
|
<select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value as 'member' | 'admin' })} className={inputClass}>
|
||||||
|
<option value="member">Mitglied</option>
|
||||||
|
<option value="admin">Administrator</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Passwort</label>
|
||||||
|
{isChangingPassword ? (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Neues Passwort festlegen"
|
||||||
|
value={form.password}
|
||||||
|
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setIsChangingPassword(false); setForm({ ...form, password: '' }) }}
|
||||||
|
className="text-xs text-gray-400 hover:text-gray-600 px-2"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
readOnly
|
||||||
|
value="••••••••"
|
||||||
|
className={`${inputClass} bg-gray-50 text-gray-400 cursor-default`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsChangingPassword(true)}
|
||||||
|
className="text-xs text-brand-600 hover:underline px-2 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{member.userId ? 'Ändern' : 'Setzen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Mitglied seit</label>
|
||||||
|
<input type="number" value={form.seit ?? ''} onChange={(e) => setForm({ ...form, seit: e.target.value ? Number(e.target.value) : undefined })} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.istAusbildungsbetrieb} onChange={(e) => setForm({ ...form, istAusbildungsbetrieb: e.target.checked })} className="rounded border-gray-300 text-brand-500 focus:ring-brand-500" />
|
||||||
|
<span className="text-sm text-gray-700">Ausbildungsbetrieb</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(updateMutation.error || deleteMutation.error) && (
|
||||||
|
<p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg">
|
||||||
|
{getTrpcErrorMessage(updateMutation.error || deleteMutation.error)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-2 border-t">
|
||||||
|
<button type="submit" disabled={updateMutation.isPending} 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">
|
||||||
|
{updateMutation.isPending ? 'Wird gespeichert...' : 'Speichern'}
|
||||||
|
</button>
|
||||||
|
<Link href="/dashboard/mitglieder" className="px-6 py-2 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-100 transition-colors">
|
||||||
|
Abbrechen
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Danger Zone */}
|
||||||
|
<div className="bg-red-50 rounded-lg border border-red-100 p-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold text-red-900">Mitglied löschen</p>
|
||||||
|
<p className="text-xs text-red-700 mt-1 max-w-sm">
|
||||||
|
Dies entfernt das Mitglied permanent. Der App-Zugang wird ebenfalls entzogen.
|
||||||
|
Diese Aktion kann nicht rückgängig gemacht werden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showConfirmDelete ? (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => deleteMutation.mutate({ id })}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
className="bg-red-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-red-700 transition-colors shadow-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deleteMutation.isPending ? 'Lösche...' : 'Endgültig löschen'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowConfirmDelete(false)}
|
||||||
|
className="bg-white text-gray-700 px-4 py-2 rounded-lg text-sm font-medium border border-gray-200 hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowConfirmDelete(true)}
|
||||||
|
className="text-red-600 hover:text-red-700 font-medium text-sm flex items-center gap-1 bg-white px-4 py-2 rounded-lg border border-red-200 hover:bg-red-50 transition-all shadow-sm"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,6 @@ import { SPARTEN } from '@innungsapp/shared'
|
||||||
|
|
||||||
export default function MitgliedNeuPage() {
|
export default function MitgliedNeuPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [sendInvite, setSendInvite] = useState(true)
|
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
betrieb: '',
|
betrieb: '',
|
||||||
|
|
@ -19,25 +18,20 @@ export default function MitgliedNeuPage() {
|
||||||
status: 'aktiv' as const,
|
status: 'aktiv' as const,
|
||||||
istAusbildungsbetrieb: false,
|
istAusbildungsbetrieb: false,
|
||||||
seit: new Date().getFullYear(),
|
seit: new Date().getFullYear(),
|
||||||
|
role: 'member' as 'member' | 'admin',
|
||||||
|
password: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const createMutation = trpc.members.create.useMutation({
|
const createMutation = trpc.members.create.useMutation({
|
||||||
onSuccess: () => router.push('/dashboard/mitglieder'),
|
onSuccess: () => router.push('/dashboard/mitglieder'),
|
||||||
})
|
})
|
||||||
const inviteMutation = trpc.members.invite.useMutation({
|
|
||||||
onSuccess: () => router.push('/dashboard/mitglieder'),
|
|
||||||
})
|
|
||||||
|
|
||||||
const isPending = createMutation.isPending || inviteMutation.isPending
|
const isPending = createMutation.isPending
|
||||||
const error = createMutation.error ?? inviteMutation.error
|
const error = createMutation.error
|
||||||
|
|
||||||
function handleSubmit(e: React.FormEvent) {
|
function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (sendInvite) {
|
createMutation.mutate(form)
|
||||||
inviteMutation.mutate(form)
|
|
||||||
} else {
|
|
||||||
createMutation.mutate(form)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -130,6 +124,27 @@ export default function MitgliedNeuPage() {
|
||||||
<option value="ausgetreten">Ausgetreten</option>
|
<option value="ausgetreten">Ausgetreten</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Rolle</label>
|
||||||
|
<select
|
||||||
|
value={form.role}
|
||||||
|
onChange={(e) => setForm({ ...form, role: e.target.value as typeof form.role })}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<option value="member">Mitglied</option>
|
||||||
|
<option value="admin">Administrator</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Passwort</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Mind. 8 Zeichen"
|
||||||
|
value={form.password}
|
||||||
|
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||||
|
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="col-span-2">
|
<div className="col-span-2">
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
|
|
@ -143,23 +158,6 @@ export default function MitgliedNeuPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t pt-4">
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={sendInvite}
|
|
||||||
onChange={(e) => setSendInvite(e.target.checked)}
|
|
||||||
className="rounded border-gray-300 text-brand-500 focus:ring-brand-500"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-gray-700 font-medium">
|
|
||||||
Einladungs-E-Mail senden
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<p className="text-xs text-gray-500 mt-1 ml-6">
|
|
||||||
Das Mitglied erhält eine E-Mail mit einem Login-Link.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg">
|
<p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg">
|
||||||
{error.message}
|
{error.message}
|
||||||
|
|
@ -172,7 +170,7 @@ export default function MitgliedNeuPage() {
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="bg-brand-500 text-white px-6 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 disabled:opacity-60 transition-colors"
|
className="bg-brand-500 text-white px-6 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 disabled:opacity-60 transition-colors"
|
||||||
>
|
>
|
||||||
{isPending ? 'Wird gespeichert...' : sendInvite ? 'Speichern & Einladung senden' : 'Speichern'}
|
{isPending ? 'Wird gespeichert...' : 'Speichern'}
|
||||||
</button>
|
</button>
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard/mitglieder"
|
href="/dashboard/mitglieder"
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { prisma } from '@innungsapp/shared'
|
import { prisma } from '@innungsapp/shared'
|
||||||
import { auth } from '@/lib/auth'
|
import { auth, getSanitizedHeaders } from '@/lib/auth'
|
||||||
import { headers } from 'next/headers'
|
import { headers } from 'next/headers'
|
||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
|
@ -20,11 +20,13 @@ export default async function MitgliederPage(props: {
|
||||||
const search = typeof searchParams.q === 'string' ? searchParams.q : ''
|
const search = typeof searchParams.q === 'string' ? searchParams.q : ''
|
||||||
const statusFilter = typeof searchParams.status === 'string' ? searchParams.status : undefined
|
const statusFilter = typeof searchParams.status === 'string' ? searchParams.status : undefined
|
||||||
|
|
||||||
const session = await auth.api.getSession({ headers: await headers() })
|
const sanitizedHeaders = await getSanitizedHeaders()
|
||||||
|
const session = await auth.api.getSession({ headers: sanitizedHeaders })
|
||||||
if (!session?.user) redirect('/login')
|
if (!session?.user) redirect('/login')
|
||||||
|
|
||||||
const userRole = await prisma.userRole.findFirst({
|
const userRole = await prisma.userRole.findFirst({
|
||||||
where: { userId: session.user.id },
|
where: { userId: session.user.id },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
})
|
})
|
||||||
if (!userRole || userRole.role !== 'admin') redirect('/dashboard')
|
if (!userRole || userRole.role !== 'admin') redirect('/dashboard')
|
||||||
|
|
||||||
|
|
@ -43,12 +45,70 @@ export default async function MitgliederPage(props: {
|
||||||
orderBy: { name: 'asc' },
|
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 => a.userId))
|
||||||
|
// Map userId → member record so admin entries show real member data
|
||||||
|
const memberByUserId = new Map(members.filter(m => m.userId).map(m => [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 => {
|
||||||
|
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 => !adminUserIds.has(m.userId ?? '')).map(m => ({
|
||||||
|
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, b) => a.name.localeCompare(b.name))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Mitglieder</h1>
|
<h1 className="text-2xl font-bold text-gray-900">Mitglieder</h1>
|
||||||
<p className="text-gray-500 mt-1">{members.length} Einträge</p>
|
<p className="text-gray-500 mt-1">{combinedList.length} Einträge</p>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard/mitglieder/neu"
|
href="/dashboard/mitglieder/neu"
|
||||||
|
|
@ -92,7 +152,7 @@ export default async function MitgliederPage(props: {
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name / Betrieb</th>
|
<th>Name / Betrieb</th>
|
||||||
<th>Sparte</th>
|
<th>Rolle</th>
|
||||||
<th>Ort</th>
|
<th>Ort</th>
|
||||||
<th>Mitglied seit</th>
|
<th>Mitglied seit</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
|
|
@ -101,7 +161,7 @@ export default async function MitgliederPage(props: {
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{members.map((m) => (
|
{combinedList.map((m) => (
|
||||||
<tr key={m.id}>
|
<tr key={m.id}>
|
||||||
<td>
|
<td>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -109,14 +169,18 @@ export default async function MitgliederPage(props: {
|
||||||
<p className="text-xs text-gray-500">{m.betrieb}</p>
|
<p className="text-xs text-gray-500">{m.betrieb}</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{m.sparte}</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.ort}</td>
|
||||||
<td>{m.seit ?? '—'}</td>
|
<td>{m.seit ?? '—'}</td>
|
||||||
<td>
|
<td>
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium ${STATUS_COLORS[m.status]}`}
|
className={`inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium ${STATUS_COLORS[m.status]}`}
|
||||||
>
|
>
|
||||||
{MEMBER_STATUS_LABELS[m.status]}
|
{MEMBER_STATUS_LABELS[m.status as keyof typeof MEMBER_STATUS_LABELS] || 'Aktiv'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
|
@ -128,7 +192,7 @@ export default async function MitgliederPage(props: {
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<Link
|
<Link
|
||||||
href={`/dashboard/mitglieder/${m.id}`}
|
href={`/dashboard/mitglieder/${m.realId}`}
|
||||||
className="text-sm text-brand-600 hover:underline"
|
className="text-sm text-brand-600 hover:underline"
|
||||||
>
|
>
|
||||||
Bearbeiten
|
Bearbeiten
|
||||||
|
|
@ -138,7 +202,7 @@ export default async function MitgliederPage(props: {
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{members.length === 0 && (
|
{combinedList.length === 0 && (
|
||||||
<div className="text-center py-12 text-gray-500">
|
<div className="text-center py-12 text-gray-500">
|
||||||
Keine Mitglieder gefunden
|
Keine Mitglieder gefunden
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -32,18 +32,42 @@ export default function NewsEditPage({ params }: { params: Promise<{ id: string
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [body, setBody] = useState('')
|
const [body, setBody] = useState('')
|
||||||
const [kategorie, setKategorie] = useState('Allgemein')
|
const [kategorie, setKategorie] = useState('Allgemein')
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [attachments, setAttachments] = useState<
|
||||||
|
Array<{ name: string; storagePath: string; sizeBytes: number; mimeType?: string | null }>
|
||||||
|
>([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (news) {
|
if (news) {
|
||||||
setTitle(news.title)
|
setTitle(news.title)
|
||||||
setBody(news.body)
|
setBody(news.body)
|
||||||
setKategorie(news.kategorie)
|
setKategorie(news.kategorie)
|
||||||
|
if (news.attachments) {
|
||||||
|
setAttachments(news.attachments.map(a => ({ ...a, sizeBytes: a.sizeBytes ?? 0 })))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [news])
|
}, [news])
|
||||||
|
|
||||||
if (isLoading) return <div className="text-gray-500 text-sm">Wird geladen...</div>
|
if (isLoading) return <div className="text-gray-500 text-sm">Wird geladen...</div>
|
||||||
if (!news) return <div className="text-gray-500 text-sm">Beitrag nicht gefunden.</div>
|
if (!news) return <div className="text-gray-500 text-sm">Beitrag nicht gefunden.</div>
|
||||||
|
|
||||||
|
async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
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) {
|
function handleSave(publishNow: boolean) {
|
||||||
if (!title.trim() || !body.trim()) return
|
if (!title.trim() || !body.trim()) return
|
||||||
updateMutation.mutate({
|
updateMutation.mutate({
|
||||||
|
|
@ -53,6 +77,12 @@ export default function NewsEditPage({ params }: { params: Promise<{ id: string
|
||||||
body,
|
body,
|
||||||
kategorie: kategorie as never,
|
kategorie: kategorie as never,
|
||||||
publishedAt: publishNow ? new Date().toISOString() : undefined,
|
publishedAt: publishNow ? new Date().toISOString() : undefined,
|
||||||
|
attachments: attachments.map((a) => ({
|
||||||
|
name: a.name,
|
||||||
|
storagePath: a.storagePath,
|
||||||
|
sizeBytes: a.sizeBytes,
|
||||||
|
mimeType: a.mimeType || 'application/pdf',
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -120,6 +150,41 @@ export default function NewsEditPage({ params }: { params: Promise<{ id: string
|
||||||
</div>
|
</div>
|
||||||
</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 && (
|
{updateMutation.error && (
|
||||||
<p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg">
|
<p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg">
|
||||||
{getTrpcErrorMessage(updateMutation.error)}
|
{getTrpcErrorMessage(updateMutation.error)}
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
'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 dynamic from 'next/dynamic'
|
||||||
|
import { AIGenerator } from '@/components/ai-generator'
|
||||||
|
|
||||||
|
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 NewsNeuPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const DEFAULT_BODY = '## Inhalt\n\nHier können Sie Ihren Beitrag verfassen.'
|
||||||
|
const [body, setBody] = useState(DEFAULT_BODY)
|
||||||
|
const [kategorie, setKategorie] = useState('Allgemein')
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [attachments, setAttachments] = useState<
|
||||||
|
Array<{ name: string; storagePath: string; sizeBytes: number; url: string }>
|
||||||
|
>([])
|
||||||
|
|
||||||
|
const createMutation = trpc.news.create.useMutation({
|
||||||
|
onSuccess: () => router.push('/dashboard/news'),
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSubmit(publishNow: boolean) {
|
||||||
|
if (!title.trim() || !body.trim()) return
|
||||||
|
createMutation.mutate({
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
kategorie: kategorie as never,
|
||||||
|
publishedAt: publishNow ? new Date().toISOString() : null,
|
||||||
|
attachments: attachments.map((a) => ({
|
||||||
|
name: a.name,
|
||||||
|
storagePath: a.storagePath,
|
||||||
|
sizeBytes: a.sizeBytes,
|
||||||
|
mimeType: 'application/pdf', // fallback/default; the API handles it
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl space-y-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href="/dashboard/news" className="text-gray-400 hover:text-gray-600">
|
||||||
|
← Zurück
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Beitrag erstellen</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-start">
|
||||||
|
<div className="lg:col-span-2 bg-white rounded-xl border shadow-sm p-6 space-y-4">
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Titel *</label>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Aussagekräftiger Titel..."
|
||||||
|
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>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
|
||||||
|
<select
|
||||||
|
value={kategorie}
|
||||||
|
onChange={(e) => setKategorie(e.target.value)}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{KATEGORIEN.map((k) => (
|
||||||
|
<option key={k.value} value={k.value}>{k.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 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-sm font-medium text-gray-700 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>
|
||||||
|
<span className="text-gray-400">({Math.round(a.sizeBytes / 1024)} KB)</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</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
|
||||||
|
onClick={() => handleSubmit(true)}
|
||||||
|
disabled={createMutation.isPending}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Jetzt publizieren
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSubmit(false)}
|
||||||
|
disabled={createMutation.isPending}
|
||||||
|
className="px-6 py-2 rounded-lg text-sm font-medium text-gray-700 border hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
Als Entwurf speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-1 sticky top-6">
|
||||||
|
<AIGenerator
|
||||||
|
type="news"
|
||||||
|
onApply={(generated) => {
|
||||||
|
// Replace placeholder if untouched, otherwise append
|
||||||
|
setBody(body === DEFAULT_BODY ? generated : body + '\n\n' + generated)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { prisma } from '@innungsapp/shared'
|
import { prisma } from '@innungsapp/shared'
|
||||||
import { auth } from '@/lib/auth'
|
import { auth, getSanitizedHeaders } from '@/lib/auth'
|
||||||
import { headers } from 'next/headers'
|
import { headers } from 'next/headers'
|
||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
|
@ -16,7 +16,8 @@ const KATEGORIE_COLORS: Record<string, string> = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function NewsPage() {
|
export default async function NewsPage() {
|
||||||
const session = await auth.api.getSession({ headers: await headers() })
|
const sanitizedHeaders = await getSanitizedHeaders()
|
||||||
|
const session = await auth.api.getSession({ headers: sanitizedHeaders })
|
||||||
if (!session?.user) redirect('/login')
|
if (!session?.user) redirect('/login')
|
||||||
const userRole = await prisma.userRole.findFirst({
|
const userRole = await prisma.userRole.findFirst({
|
||||||
where: { userId: session.user.id, role: 'admin' },
|
where: { userId: session.user.id, role: 'admin' },
|
||||||
|
|
@ -0,0 +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) => (
|
||||||
|
<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) => (
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +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) => 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) => (
|
||||||
|
<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,5 +1,5 @@
|
||||||
import { prisma } from '@innungsapp/shared'
|
import { prisma } from '@innungsapp/shared'
|
||||||
import { auth } from '@/lib/auth'
|
import { auth, getSanitizedHeaders } from '@/lib/auth'
|
||||||
import { headers } from 'next/headers'
|
import { headers } from 'next/headers'
|
||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
|
|
@ -8,7 +8,8 @@ import { DeactivateButton } from './DeactivateButton'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
|
||||||
export default async function StellenPage() {
|
export default async function StellenPage() {
|
||||||
const session = await auth.api.getSession({ headers: await headers() })
|
const sanitizedHeaders = await getSanitizedHeaders()
|
||||||
|
const session = await auth.api.getSession({ headers: sanitizedHeaders })
|
||||||
if (!session?.user) redirect('/login')
|
if (!session?.user) redirect('/login')
|
||||||
const userRole = await prisma.userRole.findFirst({
|
const userRole = await prisma.userRole.findFirst({
|
||||||
where: { userId: session.user.id, role: 'admin' },
|
where: { userId: session.user.id, role: 'admin' },
|
||||||
|
|
@ -0,0 +1,214 @@
|
||||||
|
'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 { format } from 'date-fns'
|
||||||
|
|
||||||
|
const TYPEN = [
|
||||||
|
{ value: 'Pruefung', label: 'Prüfung' },
|
||||||
|
{ value: 'Versammlung', label: 'Versammlung' },
|
||||||
|
{ value: 'Kurs', label: 'Kurs' },
|
||||||
|
{ value: 'Event', label: 'Event' },
|
||||||
|
{ value: 'Sonstiges', label: 'Sonstiges' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function TerminEditPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = use(params)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const { data: termin, isLoading } = trpc.termine.byId.useQuery({ id })
|
||||||
|
const updateMutation = trpc.termine.update.useMutation({
|
||||||
|
onSuccess: () => router.push('/dashboard/termine'),
|
||||||
|
})
|
||||||
|
const deleteMutation = trpc.termine.delete.useMutation({
|
||||||
|
onSuccess: () => router.push('/dashboard/termine'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
titel: '',
|
||||||
|
datum: '',
|
||||||
|
uhrzeit: '',
|
||||||
|
endeDatum: '',
|
||||||
|
endeUhrzeit: '',
|
||||||
|
ort: '',
|
||||||
|
adresse: '',
|
||||||
|
typ: 'Versammlung',
|
||||||
|
beschreibung: '',
|
||||||
|
maxTeilnehmer: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (termin) {
|
||||||
|
setForm({
|
||||||
|
titel: termin.titel,
|
||||||
|
datum: format(new Date(termin.datum), 'yyyy-MM-dd'),
|
||||||
|
uhrzeit: termin.uhrzeit ?? '',
|
||||||
|
endeDatum: termin.endeDatum ? format(new Date(termin.endeDatum), 'yyyy-MM-dd') : '',
|
||||||
|
endeUhrzeit: termin.endeUhrzeit ?? '',
|
||||||
|
ort: termin.ort ?? '',
|
||||||
|
adresse: termin.adresse ?? '',
|
||||||
|
typ: termin.typ,
|
||||||
|
beschreibung: termin.beschreibung ?? '',
|
||||||
|
maxTeilnehmer: termin.maxTeilnehmer ? String(termin.maxTeilnehmer) : '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [termin])
|
||||||
|
|
||||||
|
if (isLoading) return <div className="text-gray-500 text-sm">Wird geladen...</div>
|
||||||
|
if (!termin) return <div className="text-gray-500 text-sm">Termin nicht gefunden.</div>
|
||||||
|
|
||||||
|
const F = (field: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) =>
|
||||||
|
setForm((prev) => ({ ...prev, [field]: e.target.value }))
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
updateMutation.mutate({
|
||||||
|
id,
|
||||||
|
data: {
|
||||||
|
titel: form.titel,
|
||||||
|
datum: form.datum,
|
||||||
|
uhrzeit: form.uhrzeit || undefined,
|
||||||
|
endeDatum: form.endeDatum || null,
|
||||||
|
endeUhrzeit: form.endeUhrzeit || null,
|
||||||
|
ort: form.ort || undefined,
|
||||||
|
adresse: form.adresse || undefined,
|
||||||
|
typ: form.typ as never,
|
||||||
|
beschreibung: form.beschreibung || undefined,
|
||||||
|
maxTeilnehmer: form.maxTeilnehmer ? Number(form.maxTeilnehmer) : null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
'w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl space-y-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href="/dashboard/termine" className="text-gray-400 hover:text-gray-600">
|
||||||
|
← Zurück
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Termin bearbeiten</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="bg-white rounded-xl border shadow-sm p-6 space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Titel *</label>
|
||||||
|
<input required value={form.titel} onChange={F('titel')} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Typ *</label>
|
||||||
|
<select value={form.typ} onChange={F('typ')} className={inputClass}>
|
||||||
|
{TYPEN.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Max. Teilnehmer</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={form.maxTeilnehmer}
|
||||||
|
onChange={F('maxTeilnehmer')}
|
||||||
|
placeholder="Leer = unbegrenzt"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Datum *</label>
|
||||||
|
<input required type="date" value={form.datum} onChange={F('datum')} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Uhrzeit (von)</label>
|
||||||
|
<input type="time" value={form.uhrzeit} onChange={F('uhrzeit')} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Ende Datum</label>
|
||||||
|
<input type="date" value={form.endeDatum} onChange={F('endeDatum')} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Ende Uhrzeit</label>
|
||||||
|
<input type="time" value={form.endeUhrzeit} onChange={F('endeUhrzeit')} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Ort</label>
|
||||||
|
<input value={form.ort} onChange={F('ort')} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Adresse</label>
|
||||||
|
<input value={form.adresse} onChange={F('adresse')} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
|
||||||
|
<textarea
|
||||||
|
value={form.beschreibung}
|
||||||
|
onChange={F('beschreibung')}
|
||||||
|
rows={4}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</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">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={updateMutation.isPending}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{updateMutation.isPending ? 'Wird gespeichert...' : 'Speichern'}
|
||||||
|
</button>
|
||||||
|
<Link href="/dashboard/termine" className="px-6 py-2 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-100 transition-colors">
|
||||||
|
Abbrechen
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm('Termin 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>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{termin.anmeldungen.length > 0 && (
|
||||||
|
<div className="bg-white rounded-xl border shadow-sm p-6">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-700">
|
||||||
|
Anmeldungen ({termin.anmeldungen.length}
|
||||||
|
{termin.maxTeilnehmer ? ` / ${termin.maxTeilnehmer}` : ''})
|
||||||
|
</h2>
|
||||||
|
<a href={`/api/export/termin/${id}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-sm border border-gray-300 text-gray-700 px-3 py-1.5 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
Teilnehmerliste exportieren
|
||||||
|
</button>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{termin.anmeldungen.map((a) => (
|
||||||
|
<li key={a.id} className="text-sm text-gray-600">
|
||||||
|
{a.member.name}
|
||||||
|
{a.member.betrieb && <span className="text-gray-400"> · {a.member.betrieb}</span>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { prisma } from '@innungsapp/shared'
|
import { prisma } from '@innungsapp/shared'
|
||||||
import { auth } from '@/lib/auth'
|
import { auth, getSanitizedHeaders } from '@/lib/auth'
|
||||||
import { headers } from 'next/headers'
|
import { headers } from 'next/headers'
|
||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
|
@ -16,7 +16,8 @@ const TYP_COLORS: Record<string, string> = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function TerminePage() {
|
export default async function TerminePage() {
|
||||||
const session = await auth.api.getSession({ headers: await headers() })
|
const sanitizedHeaders = await getSanitizedHeaders()
|
||||||
|
const session = await auth.api.getSession({ headers: sanitizedHeaders })
|
||||||
if (!session?.user) redirect('/login')
|
if (!session?.user) redirect('/login')
|
||||||
const userRole = await prisma.userRole.findFirst({
|
const userRole = await prisma.userRole.findFirst({
|
||||||
where: { userId: session.user.id, role: 'admin' },
|
where: { userId: session.user.id, role: 'admin' },
|
||||||
|
|
@ -0,0 +1,379 @@
|
||||||
|
import { prisma } from '@innungsapp/shared'
|
||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
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 = org.landingPageFeatures || '✅ Exzellente Ausbildung\n✅ Starke Gemeinschaft\n✅ Politische Interessenvertretung'
|
||||||
|
const footer = 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const body = await req.json()
|
||||||
|
const { orgName, context } = body
|
||||||
|
|
||||||
|
if (!orgName || !context) {
|
||||||
|
return NextResponse.json({ error: 'orgName and context are required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = getProvider()
|
||||||
|
const client = createClient(provider)
|
||||||
|
const model = getModel(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)
|
||||||
|
return NextResponse.json({ error: error?.message || 'Failed to generate content' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
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-5-mini'
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasApiKey(provider: LlmProvider): boolean {
|
||||||
|
if (provider === 'openrouter') return !!process.env.OPENROUTER_API_KEY
|
||||||
|
return !!process.env.OPENAI_API_KEY
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateText({
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
systemMessage,
|
||||||
|
prompt,
|
||||||
|
}: {
|
||||||
|
provider: LlmProvider
|
||||||
|
model: string
|
||||||
|
systemMessage: string
|
||||||
|
prompt: string
|
||||||
|
}) {
|
||||||
|
const client = createClient(provider)
|
||||||
|
const completion = await client.chat.completions.create({
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: systemMessage },
|
||||||
|
{ role: 'user', content: prompt },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
return completion.choices[0]?.message?.content || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const { prompt, type, format } = await req.json()
|
||||||
|
const primaryProvider = getProvider()
|
||||||
|
const primaryModel = getModel(primaryProvider)
|
||||||
|
|
||||||
|
if (!prompt) {
|
||||||
|
return NextResponse.json({ error: 'Prompt is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
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'}.`
|
||||||
|
} else {
|
||||||
|
systemMessage = `Du bist ein hilfreicher KI-Assistent. Antworte immer auf Deutsch.`
|
||||||
|
}
|
||||||
|
|
||||||
|
const attempts: Array<{ provider: LlmProvider; model: string; reason: string }> = []
|
||||||
|
|
||||||
|
if (hasApiKey(primaryProvider)) {
|
||||||
|
attempts.push({
|
||||||
|
provider: primaryProvider,
|
||||||
|
model: primaryModel,
|
||||||
|
reason: 'primary',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback requested: if primary fails, try OpenAI GPT-5 mini when OPENAI_API_KEY is present.
|
||||||
|
if (primaryProvider !== 'openai' && hasApiKey('openai')) {
|
||||||
|
attempts.push({
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-5-mini',
|
||||||
|
reason: 'fallback_openai',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempts.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'No AI provider key configured (OPENROUTER_API_KEY or OPENAI_API_KEY).' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastError: any = null
|
||||||
|
|
||||||
|
for (const attempt of attempts) {
|
||||||
|
try {
|
||||||
|
const text = await generateText({
|
||||||
|
provider: attempt.provider,
|
||||||
|
model: attempt.model,
|
||||||
|
systemMessage,
|
||||||
|
prompt,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
text,
|
||||||
|
provider: attempt.provider,
|
||||||
|
model: attempt.model,
|
||||||
|
fallbackUsed: attempt.reason !== 'primary',
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
lastError = error
|
||||||
|
console.error('AI attempt failed:', {
|
||||||
|
provider: attempt.provider,
|
||||||
|
model: attempt.model,
|
||||||
|
message: error?.message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: lastError?.message || 'All AI providers failed' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('AI Generate Error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error?.message || 'Internal Server Error' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { prisma } from '@innungsapp/shared'
|
||||||
|
import { headers } from 'next/headers'
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() })
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
import { auth } 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: 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"`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json({ status: 'ok', timestamp: new Date().toISOString() })
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@innungsapp/shared'
|
||||||
|
import { sendInviteEmail } from '@/lib/email'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest, { params }: { params: Promise<{ slug: string }> }) {
|
||||||
|
const { slug } = await params
|
||||||
|
const body = await req.json().catch(() => null)
|
||||||
|
|
||||||
|
if (!body?.name || !body?.email) {
|
||||||
|
return NextResponse.json({ error: 'Name und E-Mail sind erforderlich.' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const name: string = String(body.name).trim()
|
||||||
|
const email: string = String(body.email).trim().toLowerCase()
|
||||||
|
|
||||||
|
const org = await prisma.organization.findUnique({
|
||||||
|
where: { slug },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!org) {
|
||||||
|
return NextResponse.json({ error: 'Organisation nicht gefunden.' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if email already registered in this org
|
||||||
|
const existing = await prisma.member.findFirst({
|
||||||
|
where: { orgId: org.id, email },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Still send invite so user can log in — don't reveal whether they exist
|
||||||
|
await sendInviteEmail({
|
||||||
|
to: email,
|
||||||
|
memberName: existing.name,
|
||||||
|
orgName: org.name,
|
||||||
|
apiUrl: process.env.BETTER_AUTH_URL!,
|
||||||
|
})
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create member record
|
||||||
|
await prisma.member.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
orgId: org.id,
|
||||||
|
betrieb: '-',
|
||||||
|
sparte: '-',
|
||||||
|
ort: '-',
|
||||||
|
status: 'aktiv',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create auth user (may already exist)
|
||||||
|
try {
|
||||||
|
await auth.api.createUser({
|
||||||
|
body: { name, email, role: 'user', password: undefined },
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// User may already exist in auth system
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendInviteEmail({
|
||||||
|
to: email,
|
||||||
|
memberName: name,
|
||||||
|
orgName: org.name,
|
||||||
|
apiUrl: process.env.BETTER_AUTH_URL!,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@innungsapp/shared'
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest, { params }: { params: Promise<{ slug: string }> }) {
|
||||||
|
const { slug } = await params
|
||||||
|
const body = await req.json().catch(() => null)
|
||||||
|
|
||||||
|
if (!body?.email) {
|
||||||
|
return NextResponse.json({ error: 'E-Mail ist erforderlich.' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const email: string = String(body.email).trim().toLowerCase()
|
||||||
|
const name: string = String(body.name ?? '').trim() || email.split('@')[0]
|
||||||
|
|
||||||
|
const org = await prisma.organization.findUnique({
|
||||||
|
where: { slug },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!org) {
|
||||||
|
return NextResponse.json({ error: 'Organisation nicht gefunden.' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up the auth user that better-auth just created
|
||||||
|
const authUser = await prisma.user.findUnique({
|
||||||
|
where: { email },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!authUser) {
|
||||||
|
return NextResponse.json({ error: 'Benutzer nicht gefunden. Bitte zuerst registrieren.' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotent: skip if member already exists (linked to this user)
|
||||||
|
const existingMember = await prisma.member.findFirst({
|
||||||
|
where: { orgId: org.id, userId: authUser.id },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existingMember) {
|
||||||
|
// Member may exist without userId (created by admin before user registered)
|
||||||
|
const unlinkedMember = await prisma.member.findFirst({
|
||||||
|
where: { orgId: org.id, email, userId: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (unlinkedMember) {
|
||||||
|
await prisma.member.update({
|
||||||
|
where: { id: unlinkedMember.id },
|
||||||
|
data: { userId: authUser.id },
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await prisma.member.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
orgId: org.id,
|
||||||
|
userId: authUser.id,
|
||||||
|
betrieb: '-',
|
||||||
|
sparte: '-',
|
||||||
|
ort: '-',
|
||||||
|
status: 'aktiv',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotent: skip if role already exists
|
||||||
|
const existingRole = await prisma.userRole.findFirst({
|
||||||
|
where: { userId: authUser.id, orgId: org.id },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existingRole) {
|
||||||
|
await prisma.userRole.create({
|
||||||
|
data: {
|
||||||
|
userId: authUser.id,
|
||||||
|
orgId: org.id,
|
||||||
|
role: 'member',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import { readFile } from 'fs/promises'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
|
||||||
const UPLOAD_DIR = process.env.UPLOAD_DIR ?? './uploads'
|
const UPLOAD_DIR = process.env.UPLOAD_DIR ?? './uploads'
|
||||||
|
// Added comment to force recompile after ENOSPC
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,337 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, type ReactNode } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { Syne } from 'next/font/google'
|
||||||
|
import { Moon, Sun } from 'lucide-react'
|
||||||
|
|
||||||
|
const syne = Syne({ subsets: ['latin'], weight: ['400', '500', '600', '700', '800'] })
|
||||||
|
|
||||||
|
type LegalPageShellProps = {
|
||||||
|
title: string
|
||||||
|
subtitle: string
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LegalPageShell({ title, subtitle, children }: LegalPageShellProps) {
|
||||||
|
const [theme, setTheme] = useState('theme-light')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const savedTheme = localStorage.getItem('theme')
|
||||||
|
if (savedTheme) {
|
||||||
|
setTheme(savedTheme)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
const newTheme = theme === 'theme-dark' ? 'theme-light' : 'theme-dark'
|
||||||
|
setTheme(newTheme)
|
||||||
|
localStorage.setItem('theme', newTheme)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style>{`
|
||||||
|
.theme-light {
|
||||||
|
--bg: #FAFAFA;
|
||||||
|
--nav-bg: rgba(250, 250, 250, 0.85);
|
||||||
|
--ink: #111111;
|
||||||
|
--ink-muted: rgba(17, 17, 17, 0.62);
|
||||||
|
--ink-faint: rgba(17, 17, 17, 0.1);
|
||||||
|
--gold: #C9973A;
|
||||||
|
--gold-light: #B8862D;
|
||||||
|
--gold-faint: rgba(201, 151, 58, 0.08);
|
||||||
|
--card-bg: rgba(255, 255, 255, 0.64);
|
||||||
|
--glass-border: rgba(17, 17, 17, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-dark {
|
||||||
|
--bg: #0C0B09;
|
||||||
|
--nav-bg: rgba(12, 11, 9, 0.85);
|
||||||
|
--ink: #EAE6DA;
|
||||||
|
--ink-muted: rgba(234, 230, 218, 0.58);
|
||||||
|
--ink-faint: rgba(234, 230, 218, 0.12);
|
||||||
|
--gold: #C9973A;
|
||||||
|
--gold-light: #DFB25C;
|
||||||
|
--gold-faint: rgba(201, 151, 58, 0.16);
|
||||||
|
--card-bg: rgba(20, 19, 17, 0.45);
|
||||||
|
--glass-border: rgba(234, 230, 218, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
background-image:
|
||||||
|
radial-gradient(circle at 15% 40%, var(--gold-faint), transparent 28%),
|
||||||
|
radial-gradient(circle at 84% 22%, var(--gold-faint), transparent 24%);
|
||||||
|
transition: background 0.25s, color 0.25s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 40;
|
||||||
|
height: 64px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--nav-bg);
|
||||||
|
border-bottom: 1px solid var(--ink-faint);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-inner {
|
||||||
|
max-width: 1280px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-accent { color: var(--gold); }
|
||||||
|
|
||||||
|
.logo-pro {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
background: var(--gold-faint);
|
||||||
|
color: var(--gold);
|
||||||
|
padding: 3px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-left: 8px;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover { color: var(--ink); }
|
||||||
|
|
||||||
|
.theme-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-btn:hover { color: var(--ink); }
|
||||||
|
|
||||||
|
.main-wrap {
|
||||||
|
max-width: 980px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 132px 32px 76px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--gold);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
line-height: 0.96;
|
||||||
|
font-size: clamp(2rem, 5vw, 3.3rem);
|
||||||
|
margin: 0 0 22px;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
margin: 0 0 30px;
|
||||||
|
max-width: 760px;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
line-height: 1.7;
|
||||||
|
font-size: 0.98rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 34px;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-sections {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-section h2 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 1rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-section p {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: var(--ink);
|
||||||
|
line-height: 1.7;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-section .muted { color: var(--ink-muted); }
|
||||||
|
|
||||||
|
.legal-list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-list li {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
line-height: 1.7;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-link {
|
||||||
|
color: var(--gold);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-link:hover {
|
||||||
|
color: var(--gold-light);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
border-top: 1px solid var(--ink-faint);
|
||||||
|
padding: 34px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-inner {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-copy {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
font-family: Georgia, serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link:hover { color: var(--ink); }
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.nav-inner,
|
||||||
|
.main-wrap,
|
||||||
|
.footer-inner {
|
||||||
|
padding-left: 18px;
|
||||||
|
padding-right: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links .nav-link[data-hide-mobile="true"] { display: none; }
|
||||||
|
|
||||||
|
.legal-card { padding: 22px 18px; }
|
||||||
|
|
||||||
|
.footer-inner {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
<div className={`legal-page ${syne.className} ${theme}`}>
|
||||||
|
<nav className="nav">
|
||||||
|
<div className="nav-inner">
|
||||||
|
<Link href="/" className="logo">
|
||||||
|
Innungs<span className="logo-accent">App</span>{' '}
|
||||||
|
<span className="logo-pro">PRO</span>
|
||||||
|
</Link>
|
||||||
|
<div className="nav-links">
|
||||||
|
<Link href="/#leistungen" className="nav-link" data-hide-mobile="true">
|
||||||
|
Leistungen
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="theme-btn"
|
||||||
|
aria-label="Theme wechseln"
|
||||||
|
title={theme === 'theme-dark' ? 'Light Mode' : 'Dark Mode'}
|
||||||
|
>
|
||||||
|
{theme === 'theme-dark' ? <Sun size={18} /> : <Moon size={18} />}
|
||||||
|
</button>
|
||||||
|
<Link href="/login" className="nav-link">
|
||||||
|
Login
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main className="main-wrap">
|
||||||
|
<div className="eyebrow">Rechtliches</div>
|
||||||
|
<h1 className="page-title">{title}</h1>
|
||||||
|
<p className="page-subtitle">{subtitle}</p>
|
||||||
|
<div className="legal-card">{children}</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="footer">
|
||||||
|
<div className="footer-inner">
|
||||||
|
<p className="footer-copy">
|
||||||
|
© {new Date().getFullYear()} InnungsApp SaaS. Alle Rechte vorbehalten.
|
||||||
|
</p>
|
||||||
|
<div className="footer-links">
|
||||||
|
<Link href="/impressum" className="footer-link">
|
||||||
|
Impressum
|
||||||
|
</Link>
|
||||||
|
<Link href="/datenschutz" className="footer-link">
|
||||||
|
Datenschutz
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
import { Sidebar } from '@/components/layout/Sidebar'
|
|
||||||
import { Header } from '@/components/layout/Header'
|
|
||||||
import { auth } from '@/lib/auth'
|
|
||||||
import { headers } from 'next/headers'
|
|
||||||
import { redirect } from 'next/navigation'
|
|
||||||
|
|
||||||
export default async function DashboardLayout({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode
|
|
||||||
}) {
|
|
||||||
const session = await auth.api.getSession({ headers: await headers() })
|
|
||||||
|
|
||||||
if (!session?.user) {
|
|
||||||
redirect('/login')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Superadmin Redirect
|
|
||||||
const superAdminEmail = process.env.SUPERADMIN_EMAIL || 'superadmin@innungsapp.de'
|
|
||||||
if (session.user.email === superAdminEmail) {
|
|
||||||
redirect('/superadmin')
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-screen bg-gray-50">
|
|
||||||
<Sidebar />
|
|
||||||
<div className="flex-1 flex flex-col min-w-0">
|
|
||||||
<Header />
|
|
||||||
<main className="flex-1 overflow-y-auto p-6">{children}</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,175 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { use } 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 { useState, useEffect } from 'react'
|
|
||||||
import { SPARTEN, MEMBER_STATUS_LABELS } from '@innungsapp/shared'
|
|
||||||
|
|
||||||
export default function MitgliedEditPage({
|
|
||||||
params,
|
|
||||||
}: {
|
|
||||||
params: Promise<{ id: string }>
|
|
||||||
}) {
|
|
||||||
const { id } = use(params)
|
|
||||||
const router = useRouter()
|
|
||||||
const { data: member, isLoading } = trpc.members.byId.useQuery({ id })
|
|
||||||
const updateMutation = trpc.members.update.useMutation({
|
|
||||||
onSuccess: () => router.push('/dashboard/mitglieder'),
|
|
||||||
})
|
|
||||||
const resendMutation = trpc.members.resendInvite.useMutation()
|
|
||||||
|
|
||||||
const [form, setForm] = useState({
|
|
||||||
name: '',
|
|
||||||
betrieb: '',
|
|
||||||
sparte: '',
|
|
||||||
ort: '',
|
|
||||||
telefon: '',
|
|
||||||
email: '',
|
|
||||||
status: 'aktiv' as 'aktiv' | 'ruhend' | 'ausgetreten',
|
|
||||||
istAusbildungsbetrieb: false,
|
|
||||||
seit: undefined as number | undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (member) {
|
|
||||||
setForm({
|
|
||||||
name: member.name,
|
|
||||||
betrieb: member.betrieb,
|
|
||||||
sparte: member.sparte,
|
|
||||||
ort: member.ort,
|
|
||||||
telefon: member.telefon ?? '',
|
|
||||||
email: member.email,
|
|
||||||
status: member.status as 'aktiv' | 'ruhend' | 'ausgetreten',
|
|
||||||
istAusbildungsbetrieb: member.istAusbildungsbetrieb,
|
|
||||||
seit: member.seit ?? undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [member])
|
|
||||||
|
|
||||||
if (isLoading) return <div className="text-gray-500">Wird geladen...</div>
|
|
||||||
if (!member) return null
|
|
||||||
|
|
||||||
function handleSubmit(e: React.FormEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
updateMutation.mutate({ id, data: form })
|
|
||||||
}
|
|
||||||
|
|
||||||
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-2xl space-y-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Link href="/dashboard/mitglieder" 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">Mitglied bearbeiten</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Invite Status */}
|
|
||||||
<div className="bg-white rounded-lg border p-4 flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-gray-700">App-Zugang</p>
|
|
||||||
<p className="text-xs text-gray-500 mt-0.5">
|
|
||||||
{member.userId
|
|
||||||
? 'Mitglied hat sich eingeloggt'
|
|
||||||
: 'Noch nicht eingeladen / eingeloggt'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{!member.userId && (
|
|
||||||
<button
|
|
||||||
onClick={() => resendMutation.mutate({ memberId: id })}
|
|
||||||
disabled={resendMutation.isPending}
|
|
||||||
className="text-sm text-brand-600 hover:underline disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{resendMutation.isPending ? 'Sende...' : resendMutation.isSuccess ? 'Gesendet' : 'Einladung senden'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="bg-white rounded-lg border p-6 space-y-6">
|
|
||||||
{/* Section: Stammdaten */}
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Stammdaten</p>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="col-span-2">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
|
||||||
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className={inputClass} />
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Betrieb</label>
|
|
||||||
<input value={form.betrieb} onChange={(e) => setForm({ ...form, betrieb: e.target.value })} className={inputClass} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Sparte</label>
|
|
||||||
<select value={form.sparte} onChange={(e) => setForm({ ...form, sparte: e.target.value })} className={inputClass}>
|
|
||||||
{SPARTEN.map((s) => <option key={s}>{s}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Ort</label>
|
|
||||||
<input value={form.ort} onChange={(e) => setForm({ ...form, ort: e.target.value })} className={inputClass} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Section: 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">E-Mail</label>
|
|
||||||
<input type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} className={inputClass} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Telefon</label>
|
|
||||||
<input type="tel" value={form.telefon} onChange={(e) => setForm({ ...form, telefon: e.target.value })} className={inputClass} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Section: Status */}
|
|
||||||
<div className="border-t pt-5">
|
|
||||||
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Status</p>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
|
||||||
<select value={form.status} onChange={(e) => setForm({ ...form, status: e.target.value as typeof form.status })} className={inputClass}>
|
|
||||||
{(['aktiv', 'ruhend', 'ausgetreten'] as const).map((s) => (
|
|
||||||
<option key={s} value={s}>{MEMBER_STATUS_LABELS[s]}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Mitglied seit</label>
|
|
||||||
<input type="number" value={form.seit ?? ''} onChange={(e) => setForm({ ...form, seit: e.target.value ? Number(e.target.value) : undefined })} className={inputClass} />
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2">
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input type="checkbox" checked={form.istAusbildungsbetrieb} onChange={(e) => setForm({ ...form, istAusbildungsbetrieb: e.target.checked })} className="rounded border-gray-300 text-brand-500 focus:ring-brand-500" />
|
|
||||||
<span className="text-sm text-gray-700">Ausbildungsbetrieb</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</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 gap-3 pt-2 border-t">
|
|
||||||
<button type="submit" disabled={updateMutation.isPending} 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">
|
|
||||||
{updateMutation.isPending ? 'Wird gespeichert...' : 'Speichern'}
|
|
||||||
</button>
|
|
||||||
<Link href="/dashboard/mitglieder" 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,159 +0,0 @@
|
||||||
'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 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 NewsNeuPage() {
|
|
||||||
const router = useRouter()
|
|
||||||
const [title, setTitle] = useState('')
|
|
||||||
const [body, setBody] = useState('## Inhalt\n\nHier können Sie Ihren Beitrag verfassen.')
|
|
||||||
const [kategorie, setKategorie] = useState('Allgemein')
|
|
||||||
const [uploading, setUploading] = useState(false)
|
|
||||||
const [attachments, setAttachments] = useState<
|
|
||||||
Array<{ name: string; storagePath: string; sizeBytes: number; url: string }>
|
|
||||||
>([])
|
|
||||||
|
|
||||||
const createMutation = trpc.news.create.useMutation({
|
|
||||||
onSuccess: () => router.push('/dashboard/news'),
|
|
||||||
})
|
|
||||||
|
|
||||||
function handleSubmit(publishNow: boolean) {
|
|
||||||
if (!title.trim() || !body.trim()) return
|
|
||||||
createMutation.mutate({
|
|
||||||
title,
|
|
||||||
body,
|
|
||||||
kategorie: kategorie as never,
|
|
||||||
publishedAt: publishNow ? new Date().toISOString() : null,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-4xl space-y-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Link href="/dashboard/news" className="text-gray-400 hover:text-gray-600">
|
|
||||||
← Zurück
|
|
||||||
</Link>
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Beitrag erstellen</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-xl border shadow-sm p-6 space-y-4">
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<div className="col-span-2">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Titel *</label>
|
|
||||||
<input
|
|
||||||
required
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
placeholder="Aussagekräftiger Titel..."
|
|
||||||
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>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
|
|
||||||
<select
|
|
||||||
value={kategorie}
|
|
||||||
onChange={(e) => setKategorie(e.target.value)}
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
{KATEGORIEN.map((k) => (
|
|
||||||
<option key={k.value} value={k.value}>{k.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 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-sm font-medium text-gray-700 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>
|
|
||||||
<span className="text-gray-400">({Math.round(a.sizeBytes / 1024)} KB)</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</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
|
|
||||||
onClick={() => handleSubmit(true)}
|
|
||||||
disabled={createMutation.isPending}
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
Jetzt publizieren
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleSubmit(false)}
|
|
||||||
disabled={createMutation.isPending}
|
|
||||||
className="px-6 py-2 rounded-lg text-sm font-medium text-gray-700 border hover:bg-gray-50 transition-colors"
|
|
||||||
>
|
|
||||||
Als Entwurf speichern
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,125 +1,108 @@
|
||||||
import { prisma } from '@innungsapp/shared'
|
|
||||||
import { auth } from '@/lib/auth'
|
import { auth } from '@/lib/auth'
|
||||||
|
import { prisma } from '@innungsapp/shared'
|
||||||
import { headers } from 'next/headers'
|
import { headers } from 'next/headers'
|
||||||
import { redirect } from 'next/navigation'
|
|
||||||
import { StatsCards } from '@/components/stats/StatsCards'
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { format } from 'date-fns'
|
import { redirect } from 'next/navigation'
|
||||||
import { de } from 'date-fns/locale'
|
|
||||||
import { NEWS_KATEGORIE_LABELS, TERMIN_TYP_LABELS } from '@innungsapp/shared'
|
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function GlobalDashboardRedirect() {
|
||||||
const session = await auth.api.getSession({ headers: await headers() })
|
const headerList = await headers()
|
||||||
if (!session?.user) redirect('/login')
|
const host = headerList.get('host') || ''
|
||||||
|
const session = await auth.api.getSession({ headers: headerList })
|
||||||
|
|
||||||
const userRole = await prisma.userRole.findFirst({
|
if (!session?.user) {
|
||||||
where: { userId: session.user.id },
|
redirect('/login')
|
||||||
include: { org: true },
|
}
|
||||||
})
|
|
||||||
if (!userRole) redirect('/login')
|
|
||||||
|
|
||||||
const orgId = userRole.orgId
|
// Superadmin logic
|
||||||
const now = new Date()
|
const superAdminEmail = process.env.SUPERADMIN_EMAIL || 'superadmin@innungsapp.de'
|
||||||
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
|
const isSuperAdmin = session.user.email === superAdminEmail || session.user.role === 'admin'
|
||||||
|
|
||||||
const [activeMembers, newsThisWeek, upcomingTermine, activeStellen, recentNews, nextTermine] =
|
if (isSuperAdmin) {
|
||||||
await Promise.all([
|
redirect('/superadmin')
|
||||||
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 (
|
const userRoles = await prisma.userRole.findMany({
|
||||||
<div className="space-y-8">
|
where: { userId: session.user.id, role: 'admin' },
|
||||||
<div>
|
include: {
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Übersicht</h1>
|
org: {
|
||||||
<p className="text-gray-500 mt-1">{userRole.org.name}</p>
|
select: { id: true, name: true, slug: true },
|
||||||
</div>
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
})
|
||||||
|
|
||||||
<StatsCards
|
if (userRoles.length === 1) {
|
||||||
stats={[
|
const slug = userRoles[0].org.slug
|
||||||
{ label: 'Aktive Mitglieder', value: activeMembers, icon: '👥' },
|
const protocol = host.includes('localhost') ? 'http' : 'https'
|
||||||
{ 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">
|
// Construct the subdomain URL
|
||||||
{/* Recent News */}
|
let newHost = host
|
||||||
<div className="bg-white rounded-lg border p-6">
|
if (host.includes('localhost')) {
|
||||||
<div className="flex items-center justify-between mb-4">
|
const port = host.includes(':') ? `:${host.split(':')[1]}` : ''
|
||||||
<h2 className="font-semibold text-gray-900">Neueste Beiträge</h2>
|
newHost = `${slug}.localhost${port}`
|
||||||
<Link href="/dashboard/news" className="text-sm text-brand-600 hover:underline">
|
} else {
|
||||||
Alle anzeigen
|
// Assumes domain.tld
|
||||||
</Link>
|
const parts = host.split('.')
|
||||||
</div>
|
if (parts.length === 2) {
|
||||||
<div className="space-y-3">
|
newHost = `${slug}.${host}`
|
||||||
{recentNews.map((n) => (
|
} else if (parts.length > 2) {
|
||||||
<div key={n.id} className="flex items-start gap-3 py-2 border-b last:border-0">
|
newHost = `${slug}.${parts.slice(-2).join('.')}`
|
||||||
<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
|
redirect(`${protocol}://${newHost}/dashboard`)
|
||||||
? format(n.publishedAt, 'dd. MMM yyyy', { locale: de })
|
}
|
||||||
: 'Entwurf'}{' '}
|
|
||||||
· {n.author?.name ?? 'Unbekannt'}
|
const getOrgUrl = (slug: string, currentHost: string) => {
|
||||||
</p>
|
const protocol = currentHost.includes('localhost') ? 'http' : 'https'
|
||||||
</div>
|
let newHost = currentHost
|
||||||
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full whitespace-nowrap">
|
if (currentHost.includes('localhost')) {
|
||||||
{NEWS_KATEGORIE_LABELS[n.kategorie]}
|
const port = currentHost.includes(':') ? `:${currentHost.split(':')[1]}` : ''
|
||||||
</span>
|
newHost = `${slug}.localhost${port}`
|
||||||
</div>
|
} else {
|
||||||
))}
|
const parts = currentHost.split('.')
|
||||||
</div>
|
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 } = await import('@/lib/auth')
|
||||||
|
const { headers } = await import('next/headers')
|
||||||
|
await auth.api.signOut({ headers: await headers() })
|
||||||
|
redirect('/login')
|
||||||
|
}}>
|
||||||
|
<button type="submit" className="text-sm font-medium text-brand-600 hover:text-brand-700">
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</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) => (
|
|
||||||
<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,182 +0,0 @@
|
||||||
'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'
|
|
||||||
|
|
||||||
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-2xl 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>
|
|
||||||
|
|
||||||
<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) => 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) => (
|
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Datenschutz | InnungsApp PRO',
|
||||||
|
description: 'Datenschutzerklaerung der InnungsApp PRO.',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DatenschutzLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,218 @@
|
||||||
|
import LegalPageShell from '../components/LegalPageShell'
|
||||||
|
|
||||||
|
export default function DatenschutzPage() {
|
||||||
|
return (
|
||||||
|
<LegalPageShell
|
||||||
|
title="Datenschutzerklaerung"
|
||||||
|
subtitle="Informationen zur Verarbeitung personenbezogener Daten fuer Website, Admin-Portal und App-Dienste."
|
||||||
|
>
|
||||||
|
<div className="legal-sections">
|
||||||
|
<section className="legal-section">
|
||||||
|
<h2>1. Verantwortlicher</h2>
|
||||||
|
<p>Johannes Tils, Zeppelinstr. 21, 42781 Haan, Deutschland</p>
|
||||||
|
<p>
|
||||||
|
E-Mail:{' '}
|
||||||
|
<a className="legal-link" href="mailto:johannestils@aol.com">
|
||||||
|
johannestils@aol.com
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p className="muted">
|
||||||
|
Diese Erklaerung gilt fuer die Nutzung von innungsapp.com und den dazugehoerigen App-Diensten.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="legal-section">
|
||||||
|
<h2>2. Rollen nach DSGVO</h2>
|
||||||
|
<p>
|
||||||
|
Bei der Bereitstellung der Plattform fuer Innungen gilt regelmaessig: Die jeweilige Innung bzw.
|
||||||
|
Organisation ist Verantwortlicher, InnungsApp ist Auftragsverarbeiter.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Fuer diese Verarbeitung wird vor dem Go-Live ein Auftragsverarbeitungsvertrag (AVV) gemaess Art.
|
||||||
|
28 DSGVO abgeschlossen.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Bei reinem Besuch der Landingpage (ohne Kundenkonto) verarbeiten wir Daten als eigener
|
||||||
|
Verantwortlicher.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="legal-section">
|
||||||
|
<h2>3. Zwecke und Rechtsgrundlagen</h2>
|
||||||
|
<p>Wir verarbeiten personenbezogene Daten insbesondere fuer folgende Zwecke:</p>
|
||||||
|
<ul className="legal-list">
|
||||||
|
<li>Bereitstellung der Plattform und Nutzerkonten</li>
|
||||||
|
<li>Mitgliederverwaltung, Kommunikation und Terminfunktionen</li>
|
||||||
|
<li>Versand von E-Mails, z. B. Einladungen und Login-Links</li>
|
||||||
|
<li>Sicherheits-, Betriebs- und Missbrauchspraevention</li>
|
||||||
|
<li>Optionale KI-Unterstuetzung ueber OpenRouter</li>
|
||||||
|
<li>Optionale Reichweitenmessung der Landingpage ueber PostHog (nur nach Einwilligung)</li>
|
||||||
|
</ul>
|
||||||
|
<p className="muted">
|
||||||
|
Rechtsgrundlagen sind insbesondere Art. 6 Abs. 1 lit. b DSGVO (Vertrag/Vertragsanbahnung), lit.
|
||||||
|
c DSGVO (rechtliche Pflicht), lit. f DSGVO (berechtigtes Interesse) und soweit erforderlich lit.
|
||||||
|
a DSGVO (Einwilligung).
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="legal-section">
|
||||||
|
<h2>4. Verarbeitete Datenkategorien</h2>
|
||||||
|
<ul className="legal-list">
|
||||||
|
<li>Stammdaten, z. B. Name, E-Mail, Telefonnummer, Organisation</li>
|
||||||
|
<li>Nutzungsdaten und technische Protokolldaten, z. B. IP-Adresse, Zeitstempel, Events</li>
|
||||||
|
<li>Inhaltsdaten, z. B. Nachrichten, Termine, hochgeladene Dateien und Dokumente</li>
|
||||||
|
<li>Push-Token fuer Benachrichtigungen</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="legal-section">
|
||||||
|
<h2>5. Empfaenger und Dienstleister</h2>
|
||||||
|
<p>Wir setzen folgende Kategorien von Empfaengern bzw. Unterauftragsverarbeitern ein:</p>
|
||||||
|
<ul className="legal-list">
|
||||||
|
<li>Hosting-Infrastruktur in den USA (Texas); Administration erfolgt durch uns aus der EU unter strengen Zugriffsbeschraenkungen</li>
|
||||||
|
<li>E-Mail-Infrastruktur in den USA (Texas); Administration erfolgt durch uns aus der EU unter strengen Zugriffsbeschraenkungen</li>
|
||||||
|
<li>OpenRouter fuer optionale KI-Funktionen, sofern von der Innung aktiviert</li>
|
||||||
|
<li>PostHog fuer optionale Webanalyse der Landingpage (nur nach Einwilligung)</li>
|
||||||
|
<li>Apple APNs und Google FCM fuer Push-Benachrichtigungen</li>
|
||||||
|
</ul>
|
||||||
|
<p className="muted">
|
||||||
|
Eine aktuelle Liste eingesetzter Unterauftragsverarbeiter stellen wir auf Anfrage bzw. im AVV
|
||||||
|
bereit.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="legal-section">
|
||||||
|
<h2>6. Drittlanduebermittlung</h2>
|
||||||
|
<p>
|
||||||
|
Eine Verarbeitung personenbezogener Daten kann in den USA stattfinden. Soweit
|
||||||
|
Drittlanduebermittlungen an externe Empfaenger erfolgen (z. B. Anbieter/Provider in den USA),
|
||||||
|
schliessen wir EU-Standardvertragsklauseln (SCC) als geeignete Garantien nach Art. 44 ff. DSGVO
|
||||||
|
ab.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Sofern Daten in den USA verarbeitet werden, dokumentieren wir zusaetzlich Transfer Impact
|
||||||
|
Assessments (TIA) und setzen technische sowie organisatorische Schutzmassnahmen um, insbesondere
|
||||||
|
Verschluesselung bei Uebertragung (TLS) und Speicherung, Zugriffsbeschraenkungen (MFA,
|
||||||
|
rollenbasiert), Protokollierung sowie regelmaessige Berechtigungspruefungen. Details und aktuelle
|
||||||
|
Unterauftragsverarbeiter sind im AVV dokumentiert.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="legal-section">
|
||||||
|
<h2>7. KI-Funktionen ueber OpenRouter (optional)</h2>
|
||||||
|
<p>
|
||||||
|
KI-Funktionen sind optional und werden nur genutzt, wenn die jeweilige Innung diese Funktion
|
||||||
|
aktiviert.
|
||||||
|
</p>
|
||||||
|
<ul className="legal-list">
|
||||||
|
<li>Verarbeitete Daten: Texteingaben, Prompts und generierte Antworten</li>
|
||||||
|
<li>Zweck: Formulierungshilfen und inhaltliche Unterstuetzung in der Plattform</li>
|
||||||
|
<li>Rechtsgrundlage: je nach Einsatz Art. 6 Abs. 1 lit. b, lit. f oder lit. a DSGVO</li>
|
||||||
|
<li>Drittlandbezug: kann je nach Modellanbieter bestehen</li>
|
||||||
|
</ul>
|
||||||
|
<p className="muted">
|
||||||
|
Es sollten keine besonderen Kategorien personenbezogener Daten in KI-Prompts eingegeben werden,
|
||||||
|
sofern dies nicht ausdruecklich freigegeben und vertraglich geregelt ist.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="legal-section">
|
||||||
|
<h2>8. Push-Benachrichtigungen</h2>
|
||||||
|
<p>
|
||||||
|
Fuer Push-Benachrichtigungen nutzen wir die Plattformdienste Apple Push Notification Service (APNs)
|
||||||
|
und Firebase Cloud Messaging (FCM). Dabei werden technische Push-Token verarbeitet.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Dabei kann eine Uebermittlung in Drittlaender (insb. USA) nicht ausgeschlossen werden; in der Regel
|
||||||
|
werden jedoch nur technische Token und Zustellinformationen verarbeitet.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="legal-section">
|
||||||
|
<h2>9. Sicherheit und Protokollierung (TOMs)</h2>
|
||||||
|
<ul className="legal-list">
|
||||||
|
<li>Transportverschluesselung (TLS) und abgesicherte Admin-Zugaenge</li>
|
||||||
|
<li>Rollen- und Berechtigungskonzepte nach dem Need-to-know-Prinzip</li>
|
||||||
|
<li>Protokollierung sicherheitsrelevanter Zugriffe und Systemereignisse</li>
|
||||||
|
<li>Backup- und Wiederherstellungsprozesse</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="legal-section">
|
||||||
|
<h2>10. Speicherdauer und Loeschung</h2>
|
||||||
|
<p>
|
||||||
|
Wir speichern personenbezogene Daten nur so lange, wie es fuer die genannten Zwecke erforderlich
|
||||||
|
ist oder gesetzliche Aufbewahrungspflichten bestehen.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Konkrete Loesch- und Aufbewahrungsfristen werden im AVV, im Loeschkonzept und in den vertraglichen
|
||||||
|
Vereinbarungen mit der jeweiligen Innung geregelt.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
System- und Sicherheitsprotokolle speichern wir in der Regel fuer 90 Tage. Technische
|
||||||
|
Debug-/Fehlerprotokolle speichern wir in der Regel fuer 30 Tage.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Backups werden als Rolling-Backups in der Regel nach 90 Tagen ueberschrieben, sofern keine
|
||||||
|
gesetzlichen Aufbewahrungspflichten entgegenstehen.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="legal-section">
|
||||||
|
<h2>11. Cookies, Consent und Webanalyse</h2>
|
||||||
|
<p>
|
||||||
|
Auf der Landingpage setzen wir optionale Analyse mit PostHog nur nach vorheriger Einwilligung ein.
|
||||||
|
Vor Einwilligung wird PostHog nicht gestartet.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Im Rahmen der Webanalyse koennen Nutzungsdaten (z. B. Seitenaufrufe, Interaktionen und technische
|
||||||
|
Metadaten) verarbeitet werden. Die Speicherdauer der Analysedaten betraegt 12 Monate.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
PostHog wird in der EU-Region betrieben. Sollte im Einzelfall eine abweichende Region genutzt
|
||||||
|
werden, informieren wir darueber vorab und holen ggf. erforderliche Einwilligungen ein.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Ihre Consent-Entscheidung wird lokal auf Ihrem Geraet gespeichert und kann jederzeit ueber den Link
|
||||||
|
"Cookie-Einstellungen" im Footer geaendert werden.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="legal-section">
|
||||||
|
<h2>12. Ihre Rechte</h2>
|
||||||
|
<p>
|
||||||
|
Ihnen stehen insbesondere die Rechte auf Auskunft, Berichtigung, Loeschung, Einschraenkung der
|
||||||
|
Verarbeitung, Datenuebertragbarkeit sowie Widerspruch zu.
|
||||||
|
</p>
|
||||||
|
<p className="muted">
|
||||||
|
Wenn Daten im Auftrag einer Innung verarbeitet werden, richten Sie Anfragen bitte primaer an die
|
||||||
|
jeweilige Innung als Verantwortliche.
|
||||||
|
</p>
|
||||||
|
<p className="muted">
|
||||||
|
Als Auftragsverarbeiter unterstuetzen wir die jeweilige Innung bei der Erfuellung von
|
||||||
|
Betroffenenrechten gemaess den Regelungen im AVV.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="legal-section">
|
||||||
|
<h2>13. Konto- und Datenloeschung</h2>
|
||||||
|
<p>
|
||||||
|
Loeschanfragen koennen per E-Mail an{' '}
|
||||||
|
<a className="legal-link" href="mailto:johannestils@aol.com">
|
||||||
|
johannestils@aol.com
|
||||||
|
</a>{' '}
|
||||||
|
gestellt werden. Zur Sicherheit kann eine Identitaetspruefung erforderlich sein. Die Bearbeitung
|
||||||
|
erfolgt in der Regel innerhalb von 30 Tagen.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="legal-section">
|
||||||
|
<h2>14. Beschwerderecht</h2>
|
||||||
|
<p>Sie haben das Recht, sich bei einer Datenschutz-Aufsichtsbehoerde zu beschweren.</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</LegalPageShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 133 B |
Binary file not shown.
|
After Width: | Height: | Size: 407 KiB |
|
|
@ -0,0 +1,14 @@
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Impressum | InnungsApp PRO',
|
||||||
|
description: 'Impressum der InnungsApp PRO.',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ImpressumLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
import LegalPageShell from '../components/LegalPageShell'
|
||||||
|
|
||||||
|
export default function ImpressumPage() {
|
||||||
|
return (
|
||||||
|
<LegalPageShell
|
||||||
|
title="Impressum"
|
||||||
|
subtitle="Anbieterkennzeichnung und Pflichtangaben fuer die Nutzung von innungsapp.com."
|
||||||
|
>
|
||||||
|
<div className="legal-sections">
|
||||||
|
<section className="legal-section">
|
||||||
|
<h2>Angaben gemaess § 5 DDG</h2>
|
||||||
|
<p>Johannes Tils</p>
|
||||||
|
<p>Einzelunternehmer</p>
|
||||||
|
<p>Zeppelinstr. 21</p>
|
||||||
|
<p>42781 Haan</p>
|
||||||
|
<p>Deutschland</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="legal-section">
|
||||||
|
<h2>Kontakt</h2>
|
||||||
|
<p>Telefon: 015771172597</p>
|
||||||
|
<p>
|
||||||
|
E-Mail:{' '}
|
||||||
|
<a className="legal-link" href="mailto:johannestils@aol.com">
|
||||||
|
johannestils@aol.com
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="legal-section">
|
||||||
|
<h2>Umsatzsteuer-ID gemaess § 27a UStG</h2>
|
||||||
|
<p>DE356594917</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="legal-section">
|
||||||
|
<h2>Handelsregister</h2>
|
||||||
|
<p>Nicht vorhanden.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="legal-section">
|
||||||
|
<h2>Verantwortlich fuer journalistisch-redaktionelle Inhalte gemaess § 18 Abs. 2 MStV (soweit einschlaegig)</h2>
|
||||||
|
<p>Johannes Tils</p>
|
||||||
|
<p>Zeppelinstr. 21</p>
|
||||||
|
<p>42781 Haan</p>
|
||||||
|
<p>Deutschland</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</LegalPageShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,15 +1,50 @@
|
||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
import { Inter } from 'next/font/google'
|
import { Inter, Outfit } from 'next/font/google'
|
||||||
import './globals.css'
|
import './globals.css'
|
||||||
import { Providers } from './providers'
|
import { Providers } from './providers'
|
||||||
|
import { prisma } from '@innungsapp/shared'
|
||||||
|
import { getTenantSlug } from '@/lib/tenant'
|
||||||
|
|
||||||
const inter = Inter({ subsets: ['latin'] })
|
const inter = Inter({ subsets: ['latin'], variable: '--font-inter' })
|
||||||
|
const outfit = Outfit({ subsets: ['latin'], variable: '--font-outfit' })
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
title: 'InnungsApp PRO | Die moderne Vereinssoftware für das Handwerk',
|
const slug = await getTenantSlug()
|
||||||
description: 'Zettelwirtschaft war gestern. Reduzieren Sie den Verwaltungsaufwand Ihrer Innung um 10 Std/Woche. Die perfekte Handwerk Software inkl. CRM & App. Kontaktieren Sie uns für eine Beratung!',
|
let org = null
|
||||||
|
if (slug) {
|
||||||
|
org = await prisma.organization.findUnique({
|
||||||
|
where: { slug }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = org ? `${org.name} | InnungsApp` : 'InnungsApp PRO | Die moderne Vereinssoftware fuer das Handwerk'
|
||||||
|
const description = org
|
||||||
|
? `Willkommen im offiziellen Portal der ${org.name}.`
|
||||||
|
: 'Digitale Mitgliederverwaltung, Push-News und Lehrlingsboerse fuer Handwerksinnungen.'
|
||||||
|
const icon = org?.logoUrl || '/favicon.ico'
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
icons: {
|
||||||
|
icon: icon,
|
||||||
|
},
|
||||||
|
metadataBase: new URL('https://innungsapp.com'),
|
||||||
|
openGraph: {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
url: 'https://innungsapp.com',
|
||||||
|
siteName: 'InnungsApp PRO',
|
||||||
|
locale: 'de_DE',
|
||||||
|
type: 'website',
|
||||||
|
images: [{ url: org?.logoUrl || '/mobile-mockup.png' }],
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Default export remains the component, but we remove the static metadata object below
|
||||||
|
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -17,7 +52,7 @@ export default function RootLayout({
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
<body className={inter.className}>
|
<body className={`${inter.variable} ${outfit.variable} font-sans bg-gray-50`}>
|
||||||
<Providers>{children}</Providers>
|
<Providers>{children}</Providers>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Login | InnungsApp PRO',
|
||||||
|
description: 'Melden Sie sich bei Ihrem InnungsApp Konto an.',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoginLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
|
@ -1,180 +1,106 @@
|
||||||
'use client'
|
import Link from 'next/link'
|
||||||
|
import { getTenantSlug } from '@/lib/tenant'
|
||||||
|
import { prisma } from '@innungsapp/shared'
|
||||||
|
import { LoginForm } from '@/components/auth/LoginForm'
|
||||||
|
|
||||||
import { useState } from 'react'
|
export default async function LoginPage() {
|
||||||
import { createAuthClient } from 'better-auth/react'
|
const slug = await getTenantSlug()
|
||||||
import { magicLinkClient } from 'better-auth/client/plugins'
|
|
||||||
const authClient = createAuthClient({
|
|
||||||
plugins: [magicLinkClient()],
|
|
||||||
})
|
|
||||||
|
|
||||||
type Mode = 'password' | 'magic'
|
let org = null
|
||||||
|
if (slug) {
|
||||||
export default function LoginPage() {
|
org = await prisma.organization.findUnique({
|
||||||
const [mode, setMode] = useState<Mode>('password')
|
where: { slug }
|
||||||
const [email, setEmail] = useState('')
|
})
|
||||||
const [password, setPassword] = useState('')
|
|
||||||
const [sent, setSent] = useState(false)
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
setLoading(true)
|
|
||||||
setError('')
|
|
||||||
|
|
||||||
if (mode === 'password') {
|
|
||||||
const result = await authClient.signIn.email({
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
callbackURL: '/dashboard',
|
|
||||||
})
|
|
||||||
setLoading(false)
|
|
||||||
if (result.error) {
|
|
||||||
setError(result.error.message ?? 'E-Mail oder Passwort falsch.')
|
|
||||||
} else {
|
|
||||||
window.location.href = '/dashboard'
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const result = await authClient.signIn.magicLink({
|
|
||||||
email,
|
|
||||||
callbackURL: '/dashboard',
|
|
||||||
})
|
|
||||||
setLoading(false)
|
|
||||||
if (result.error) {
|
|
||||||
setError(result.error.message ?? 'Ein Fehler ist aufgetreten.')
|
|
||||||
} else {
|
|
||||||
setSent(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const primaryColor = org?.primaryColor || '#E63946'
|
||||||
|
const orgName = org?.name || 'InnungsApp'
|
||||||
|
const logoUrl = org?.logoUrl || '/logo.png'
|
||||||
|
const secondaryText = org ? `Verwaltungsportal für die ${org.name}` : 'Verwaltungsportal für Innungen'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
<div className="min-h-screen bg-gray-50 flex flex-col">
|
||||||
<div className="w-full max-w-sm">
|
<main className="flex-1 flex items-center justify-center p-4">
|
||||||
{/* Logo */}
|
<div className="w-full max-w-sm">
|
||||||
<div className="text-center mb-8">
|
<div className="mb-4">
|
||||||
<h1
|
<Link
|
||||||
className="text-3xl font-bold text-gray-900 tracking-tight"
|
href={slug ? `/${slug}` : "/"}
|
||||||
style={{ fontFamily: "'Syne', system-ui, sans-serif" }}
|
aria-label="Zur Startseite"
|
||||||
>
|
className="inline-flex h-10 w-10 items-center justify-center rounded-lg border border-gray-200 bg-white text-gray-600 shadow-sm transition-colors hover:text-gray-900 hover:border-gray-300"
|
||||||
Innungs<span className="text-brand-500">App</span>
|
>
|
||||||
</h1>
|
<svg
|
||||||
<p className="text-sm text-gray-500 mt-1">Verwaltungsportal für Innungen</p>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
</div>
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
<div className="bg-white rounded-lg border p-8">
|
stroke="currentColor"
|
||||||
{sent ? (
|
strokeWidth="2"
|
||||||
<div className="text-center py-4">
|
strokeLinecap="round"
|
||||||
<div className="w-14 h-14 bg-green-50 border border-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
strokeLinejoin="round"
|
||||||
<svg className="w-7 h-7 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
className="h-5 w-5"
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
aria-hidden="true"
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-2">E-Mail gesendet</h2>
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
Login-Link an <strong className="text-gray-700">{email}</strong> gesendet. Bitte prüfen Sie Ihr Postfach.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={() => setSent(false)}
|
|
||||||
className="mt-6 text-brand-600 text-sm hover:underline"
|
|
||||||
>
|
>
|
||||||
Andere E-Mail verwenden
|
<path d="M3 11.5 12 4l9 7.5" />
|
||||||
</button>
|
<path d="M5.5 10.5V20h13V10.5" />
|
||||||
|
<path d="M10 20v-5h4v5" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-white shadow-md border border-gray-100 overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={logoUrl}
|
||||||
|
alt={orgName}
|
||||||
|
className={`h-10 w-10 object-contain ${!org?.logoUrl ? 'brightness-110 scale-[1.6]' : ''}`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<h1
|
||||||
<>
|
className="text-3xl font-bold text-gray-900 tracking-tight"
|
||||||
<h2
|
style={{ fontFamily: "'Syne', system-ui, sans-serif" }}
|
||||||
className="text-lg font-semibold text-gray-900 mb-5"
|
>
|
||||||
style={{ fontFamily: "'Syne', system-ui, sans-serif" }}
|
{org ? (
|
||||||
>
|
<>
|
||||||
Anmelden
|
<span style={{ color: primaryColor }}>{org.name.split(' ')[0]}</span>
|
||||||
</h2>
|
{org.name.includes(' ') ? ` ${org.name.split(' ').slice(1).join(' ')}` : ''}
|
||||||
|
</>
|
||||||
{/* Mode toggle */}
|
) : (
|
||||||
<div className="flex rounded-lg border border-gray-200 p-0.5 mb-5 bg-gray-50">
|
<>
|
||||||
<button
|
Innungs<span className="text-brand-500">App</span>
|
||||||
type="button"
|
</>
|
||||||
onClick={() => setMode('password')}
|
|
||||||
className={`flex-1 py-1.5 text-sm rounded-md font-medium transition-all ${
|
|
||||||
mode === 'password'
|
|
||||||
? 'bg-white shadow-sm text-gray-900 border border-gray-200'
|
|
||||||
: 'text-gray-500 hover:text-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Passwort
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setMode('magic')}
|
|
||||||
className={`flex-1 py-1.5 text-sm rounded-md font-medium transition-all ${
|
|
||||||
mode === 'magic'
|
|
||||||
? 'bg-white shadow-sm text-gray-900 border border-gray-200'
|
|
||||||
: 'text-gray-500 hover:text-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Magic Link
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
<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:ring-brand-500 focus:border-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{mode === 'password' && (
|
|
||||||
<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: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 hover:bg-brand-600 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
|
|
||||||
>
|
|
||||||
{loading
|
|
||||||
? 'Bitte warten...'
|
|
||||||
: mode === 'password'
|
|
||||||
? 'Anmelden'
|
|
||||||
: 'Magic Link senden'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{mode === 'password' && (
|
|
||||||
<p className="mt-4 text-center text-xs text-gray-400">
|
|
||||||
Demo: admin@demo.de / demo1234
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</h1>
|
||||||
)}
|
<p className="text-sm text-gray-500 mt-1">{secondaryText}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg border p-8">
|
||||||
|
<h2
|
||||||
|
className="text-lg font-semibold text-gray-900 mb-5"
|
||||||
|
style={{ fontFamily: "'Syne', system-ui, sans-serif" }}
|
||||||
|
>
|
||||||
|
Anmelden
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<LoginForm primaryColor={primaryColor} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
|
|
||||||
|
<footer className="border-t border-gray-200 bg-white/80 backdrop-blur">
|
||||||
|
<div className="max-w-4xl mx-auto px-4 py-4 flex flex-wrap items-center justify-between gap-3 text-xs text-gray-500">
|
||||||
|
<p>(c) {new Date().getFullYear()} InnungsApp</p>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href={slug ? `/${slug}` : "/"} className="hover:text-gray-700">
|
||||||
|
Home
|
||||||
|
</Link>
|
||||||
|
<Link href="/impressum" className="hover:text-gray-700">
|
||||||
|
Impressum
|
||||||
|
</Link>
|
||||||
|
<Link href="/datenschutz" className="hover:text-gray-700">
|
||||||
|
Datenschutz
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ const FEATURES = [
|
||||||
{
|
{
|
||||||
num: '02',
|
num: '02',
|
||||||
title: 'Push-News & Kommunikation',
|
title: 'Push-News & Kommunikation',
|
||||||
desc: 'Statt ungeöffneter E-Mails: direkte Push-Benachrichtigungen mit extrem hoher Erreichbarkeit. Sofort, zuverlässig, DSGVO-konform.',
|
desc: 'Statt ungeöffneter E-Mails: direkte Push-Benachrichtigungen mit hoher Erreichbarkeit. Sofort, zuverlässig und datenschutzorientiert konfigurierbar.',
|
||||||
tags: ['Push-Notifications', 'News-Feed', 'Direktkommunikation'],
|
tags: ['Push-Notifications', 'News-Feed', 'Direktkommunikation'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -36,16 +36,27 @@ const FEATURES = [
|
||||||
]
|
]
|
||||||
|
|
||||||
const STATS = [
|
const STATS = [
|
||||||
{ num: '10 Std.', label: 'Zeitersparnis pro Woche' },
|
{ num: 'Bis zu 10 Std.', label: 'Zeitersparnis je nach Prozess' },
|
||||||
{ num: 'Echtzeit', label: 'Kommunikation' },
|
{ num: 'Echtzeit', label: 'Kommunikation' },
|
||||||
{ num: 'Cloud', label: 'Sicher & Überall' },
|
{ num: 'Cloud', label: 'Sicher konzipiert & ueberall nutzbar' },
|
||||||
{ num: '100 %', label: 'DSGVO-konform' },
|
{ num: 'AVV', label: 'Art.-28-Vertrag vor Go-Live' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const CONTACT_EMAIL = 'johannestils@aol.com'
|
||||||
|
const CONTACT_SUBJECT = 'Anfrage InnungsApp PRO'
|
||||||
|
const CONTACT_WEBMAIL_HREF = `https://mail.google.com/mail/?view=cm&fs=1&to=${encodeURIComponent(CONTACT_EMAIL)}&su=${encodeURIComponent(CONTACT_SUBJECT)}`
|
||||||
|
const COOKIE_CONSENT_KEY = 'innungsapp_cookie_consent'
|
||||||
|
const COOKIE_CONSENT_EVENT = 'innungsapp:cookie-consent-granted'
|
||||||
|
const COOKIE_CONSENT_TIMESTAMP_KEY = 'innungsapp_cookie_consent_timestamp'
|
||||||
|
const COOKIE_CONSENT_SOURCE_KEY = 'innungsapp_cookie_consent_source'
|
||||||
|
type CookieConsentState = 'loading' | 'pending' | 'accepted' | 'declined'
|
||||||
|
|
||||||
export default function RootPage() {
|
export default function RootPage() {
|
||||||
const [theme, setTheme] = useState('theme-light');
|
const [theme, setTheme] = useState('theme-light');
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [openFaq, setOpenFaq] = useState<number | null>(null);
|
const [openFaq, setOpenFaq] = useState<number | null>(null);
|
||||||
|
const [cookieConsent, setCookieConsent] = useState<CookieConsentState>('loading')
|
||||||
|
const [showMailFallback, setShowMailFallback] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
|
|
@ -53,8 +64,70 @@ export default function RootPage() {
|
||||||
if (savedTheme) {
|
if (savedTheme) {
|
||||||
setTheme(savedTheme);
|
setTheme(savedTheme);
|
||||||
}
|
}
|
||||||
|
const savedConsent = window.localStorage.getItem(COOKIE_CONSENT_KEY)
|
||||||
|
if (savedConsent === 'accepted' || savedConsent === 'declined') {
|
||||||
|
setCookieConsent(savedConsent)
|
||||||
|
} else {
|
||||||
|
setCookieConsent('pending')
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (cookieConsent === 'accepted') {
|
||||||
|
window.posthog?.opt_in_capturing?.()
|
||||||
|
const sendPageView = () => {
|
||||||
|
window.posthog?.capture?.('landing_page_viewed', {
|
||||||
|
path: window.location.pathname,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (window.posthog?.capture) {
|
||||||
|
sendPageView()
|
||||||
|
} else {
|
||||||
|
window.dispatchEvent(new Event(COOKIE_CONSENT_EVENT))
|
||||||
|
window.setTimeout(sendPageView, 150)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cookieConsent === 'declined') {
|
||||||
|
window.posthog?.opt_out_capturing?.()
|
||||||
|
}
|
||||||
|
}, [cookieConsent])
|
||||||
|
|
||||||
|
const setCookieChoice = (choice: Exclude<CookieConsentState, 'pending' | 'loading'>) => {
|
||||||
|
window.localStorage.setItem(COOKIE_CONSENT_KEY, choice)
|
||||||
|
window.localStorage.setItem(COOKIE_CONSENT_TIMESTAMP_KEY, new Date().toISOString())
|
||||||
|
window.localStorage.setItem(COOKIE_CONSENT_SOURCE_KEY, 'landing_banner')
|
||||||
|
setCookieConsent(choice)
|
||||||
|
if (choice === 'accepted') {
|
||||||
|
window.dispatchEvent(new Event(COOKIE_CONSENT_EVENT))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCookieSettings = () => {
|
||||||
|
window.localStorage.removeItem(COOKIE_CONSENT_KEY)
|
||||||
|
setCookieConsent('pending')
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackLandingCta = (placement: string) => {
|
||||||
|
if (cookieConsent !== 'accepted') return
|
||||||
|
const sendCta = () => {
|
||||||
|
window.posthog?.capture?.('landing_cta_clicked', {
|
||||||
|
placement,
|
||||||
|
target: 'contact_email',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (window.posthog?.capture) {
|
||||||
|
sendCta()
|
||||||
|
} else {
|
||||||
|
window.dispatchEvent(new Event(COOKIE_CONSENT_EVENT))
|
||||||
|
window.setTimeout(sendCta, 150)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleContactCtaClick = (placement: string) => {
|
||||||
|
trackLandingCta(placement)
|
||||||
|
setShowMailFallback(true)
|
||||||
|
}
|
||||||
|
|
||||||
const toggleTheme = () => {
|
const toggleTheme = () => {
|
||||||
const newTheme = theme === 'theme-dark' ? 'theme-light' : 'theme-dark';
|
const newTheme = theme === 'theme-dark' ? 'theme-light' : 'theme-dark';
|
||||||
setTheme(newTheme);
|
setTheme(newTheme);
|
||||||
|
|
@ -78,7 +151,7 @@ export default function RootPage() {
|
||||||
"price": "0",
|
"price": "0",
|
||||||
"priceCurrency": "EUR"
|
"priceCurrency": "EUR"
|
||||||
},
|
},
|
||||||
"description": "Zettelwirtschaft war gestern. Reduzieren Sie den Verwaltungsaufwand Ihrer Innung um 10 Std/Woche. Die perfekte Handwerk Software inkl. CRM & App."
|
"description": "Cloudbasierte Verwaltungssoftware für Handwerksinnungen mit Mitgliederverwaltung, Kommunikation und Terminmanagement."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"@type": "FAQPage",
|
"@type": "FAQPage",
|
||||||
|
|
@ -96,7 +169,7 @@ export default function RootPage() {
|
||||||
"name": "Ist die Plattform DSGVO-konform?",
|
"name": "Ist die Plattform DSGVO-konform?",
|
||||||
"acceptedAnswer": {
|
"acceptedAnswer": {
|
||||||
"@type": "Answer",
|
"@type": "Answer",
|
||||||
"text": "Ja. 100% Hosting in Deutschland und streng nach DSGVO-Standards entwickelt."
|
"text": "Wir arbeiten mit DSGVO-orientierten Prozessen. Vor Go-Live wird ein AV-Vertrag geschlossen; bei Datenverarbeitung in den USA nutzen wir SCC, dokumentieren TIA und setzen zusaetzliche Schutzmassnahmen ein. Die konkrete Compliance haengt von Konfiguration und Vertraegen ab."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -462,9 +535,90 @@ export default function RootPage() {
|
||||||
.footer-link {
|
.footer-link {
|
||||||
font-size: 0.8125rem; color: var(--ink-muted);
|
font-size: 0.8125rem; color: var(--ink-muted);
|
||||||
text-decoration: none; transition: color 0.15s;
|
text-decoration: none; transition: color 0.15s;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
.footer-link:hover { color: var(--ink); }
|
.footer-link:hover { color: var(--ink); }
|
||||||
|
|
||||||
|
.mail-fallback {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
text-align: center;
|
||||||
|
font-family: 'Georgia', serif;
|
||||||
|
}
|
||||||
|
.mail-fallback-link {
|
||||||
|
color: var(--gold);
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px solid var(--gold);
|
||||||
|
}
|
||||||
|
.mail-fallback-row {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cookie-banner {
|
||||||
|
position: fixed;
|
||||||
|
left: 16px;
|
||||||
|
right: 16px;
|
||||||
|
bottom: 16px;
|
||||||
|
z-index: 60;
|
||||||
|
border: 1px solid var(--ink-faint);
|
||||||
|
background: var(--nav-bg);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
.cookie-banner-inner {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.cookie-banner-text {
|
||||||
|
color: var(--ink-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
max-width: 760px;
|
||||||
|
font-family: 'Georgia', serif;
|
||||||
|
}
|
||||||
|
.cookie-banner-link {
|
||||||
|
color: var(--gold);
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px solid var(--gold);
|
||||||
|
}
|
||||||
|
.cookie-banner-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.cookie-btn {
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--ink-faint);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.cookie-btn-primary {
|
||||||
|
background: var(--gold);
|
||||||
|
color: var(--bg);
|
||||||
|
border-color: var(--gold);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
/* Horizontal rule decoration */
|
/* Horizontal rule decoration */
|
||||||
.section-marker {
|
.section-marker {
|
||||||
font-size: 0.6875rem; letter-spacing: 0.14em; text-transform: uppercase;
|
font-size: 0.6875rem; letter-spacing: 0.14em; text-transform: uppercase;
|
||||||
|
|
@ -498,9 +652,15 @@ export default function RootPage() {
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Link href="/login" className="nav-link">Login</Link>
|
<Link href="/login" className="nav-link">Login</Link>
|
||||||
<Link href="/login" className="btn-primary">
|
<a
|
||||||
|
href={CONTACT_WEBMAIL_HREF}
|
||||||
|
className="btn-primary"
|
||||||
|
onClick={() => handleContactCtaClick('nav')}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
Team kontaktieren <ArrowRight size={13} />
|
Team kontaktieren <ArrowRight size={13} />
|
||||||
</Link>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
@ -524,12 +684,38 @@ export default function RootPage() {
|
||||||
Ihre Mitgliederverwaltung, Push-News und Lehrstellenbörse — gebündelt in einer sicheren Cloud-Plattform, die Ihre Prozesse automatisiert und den Innungsalltag vereinfacht.
|
Ihre Mitgliederverwaltung, Push-News und Lehrstellenbörse — gebündelt in einer sicheren Cloud-Plattform, die Ihre Prozesse automatisiert und den Innungsalltag vereinfacht.
|
||||||
</p>
|
</p>
|
||||||
<div className="hero-cta" style={{ marginTop: '32px', maxWidth: '300px' }}>
|
<div className="hero-cta" style={{ marginTop: '32px', maxWidth: '300px' }}>
|
||||||
<Link href="/login" className="btn-primary-lg" style={{ justifyContent: 'center' }}>
|
<a
|
||||||
|
href={CONTACT_WEBMAIL_HREF}
|
||||||
|
className="btn-primary-lg"
|
||||||
|
style={{ justifyContent: 'center' }}
|
||||||
|
onClick={() => handleContactCtaClick('hero')}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
Team kontaktieren <ArrowRight size={16} />
|
Team kontaktieren <ArrowRight size={16} />
|
||||||
</Link>
|
</a>
|
||||||
<div style={{ fontSize: '0.75rem', color: 'var(--ink-muted)', textAlign: 'center', marginTop: '8px' }}>
|
<div style={{ fontSize: '0.75rem', color: 'var(--ink-muted)', textAlign: 'center', marginTop: '8px' }}>
|
||||||
Individuelle Beratung. Keine Vertragsbindung.
|
Individuelle Beratung. Keine Vertragsbindung.
|
||||||
</div>
|
</div>
|
||||||
|
{showMailFallback && (
|
||||||
|
<div className="mail-fallback">
|
||||||
|
Kein Mailprogramm? Schreiben Sie direkt an{' '}
|
||||||
|
<a className="mail-fallback-link" href={`mailto:${CONTACT_EMAIL}`}>
|
||||||
|
{CONTACT_EMAIL}
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
<div className="mail-fallback-row">
|
||||||
|
<a
|
||||||
|
className="mail-fallback-link"
|
||||||
|
href={CONTACT_WEBMAIL_HREF}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
In Webmail öffnen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -558,6 +744,9 @@ export default function RootPage() {
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<p className="hero-desc" style={{ marginTop: '16px', fontSize: '0.85rem' }}>
|
||||||
|
* Angaben zur Zeitersparnis sind Erfahrungswerte und hängen von Ausgangsprozess, Datengrundlage und Nutzungsumfang ab.
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -639,7 +828,7 @@ export default function RootPage() {
|
||||||
<li className="comp-item"><span className="comp-icon-green">✓</span> Push-Nachrichten in Echtzeit</li>
|
<li className="comp-item"><span className="comp-icon-green">✓</span> Push-Nachrichten in Echtzeit</li>
|
||||||
<li className="comp-item"><span className="comp-icon-green">✓</span> Automatisierte 1-Klick Event-Zusagen</li>
|
<li className="comp-item"><span className="comp-icon-green">✓</span> Automatisierte 1-Klick Event-Zusagen</li>
|
||||||
<li className="comp-item"><span className="comp-icon-green">✓</span> Integrierte, lokale Lehrlingsbörse</li>
|
<li className="comp-item"><span className="comp-icon-green">✓</span> Integrierte, lokale Lehrlingsbörse</li>
|
||||||
<li className="comp-item"><span className="comp-icon-green">✓</span> 10 Stunden Zeitersparnis pro Woche</li>
|
<li className="comp-item"><span className="comp-icon-green">✓</span> Bis zu 10 Stunden Zeitersparnis je nach Prozess</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -653,13 +842,13 @@ export default function RootPage() {
|
||||||
<h2 className="cta-h2" style={{ marginBottom: '32px', fontSize: 'clamp(2rem, 4vw, 3rem)' }}>Die smarte Lösung für moderne Handwerksorganisationen</h2>
|
<h2 className="cta-h2" style={{ marginBottom: '32px', fontSize: 'clamp(2rem, 4vw, 3rem)' }}>Die smarte Lösung für moderne Handwerksorganisationen</h2>
|
||||||
<div className="aeo-text">
|
<div className="aeo-text">
|
||||||
<p>
|
<p>
|
||||||
<strong>InnungsApp PRO</strong> ist die führende cloudbasierte Verwaltungssoftware, die speziell auf die Bedürfnisse von <strong>Handwerksinnungen in Deutschland</strong> zugeschnitten ist. Mit einem tiefen Verständnis für die administrativen Herausforderungen des Handwerks bietet unsere Software eine sichere, DSGVO-konforme Umgebung zur zentralen Verwaltung von Mitgliedsdaten.
|
<strong>InnungsApp PRO</strong> ist eine cloudbasierte Verwaltungssoftware, die speziell auf die Bedürfnisse von <strong>Handwerksinnungen in Deutschland</strong> zugeschnitten ist. Die Plattform bündelt Mitgliederverwaltung, Kommunikation und Eventmanagement in einer zentralen Oberfläche.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Im Durchschnitt reduzieren Innungen ihren administrativen Aufwand nach der Einführung um <strong>bis zu 10 Stunden pro Woche</strong>. Statt ineffizienter E-Mail-Verteiler nutzt die Plattform moderne Push-Technologie, welche für höchste Erreichbarkeit sorgt. Die Integration einer eigenen Lehrlingsbörse löst zudem aktiv das Problem des Nachwuchsmangels, indem sie Betriebe und junge Talente lokal vernetzt.
|
Je nach bestehender Prozesslandschaft ist eine Reduktion des administrativen Aufwands um <strong>bis zu 10 Stunden pro Woche</strong> möglich. Statt ineffizienter E-Mail-Verteiler nutzt die Plattform Push-Technologie für schnelle Zustellung und eine bessere Erreichbarkeit in der Praxis.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Alle Serverstandorte befinden sich in Deutschland, wodurch höchste Datenschutzstandards garantiert werden. Egal ob Eventmanagement, Kommunikation oder Mitgliederverwaltung – die InnungsApp PRO bündelt alle essenziellen Funktionen in einer einzigen, intuitiven Oberfläche.
|
Teile der Infrastruktur laufen auf Servern in Texas (USA). Für Drittlandtransfers werden EU-Standardvertragsklauseln (SCC), dokumentierte Transfer Impact Assessments (TIA) und zusätzliche Schutzmaßnahmen eingesetzt. Vor Produktivbetrieb wird mit jeder Innung ein AV-Vertrag nach Art. 28 DSGVO geschlossen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -678,7 +867,7 @@ export default function RootPage() {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: 'Ist die Plattform DSGVO-konform?',
|
q: 'Ist die Plattform DSGVO-konform?',
|
||||||
a: 'Ja, vollständig. Alle Daten werden ausschließlich auf zertifizierten Servern in Deutschland gespeichert. Wir schließen mit Ihnen einen Auftragsverarbeitungsvertrag (AVV) ab und haben die Software von Anfang an nach dem Prinzip "Privacy by Design" entwickelt. Kein Datentransfer in Drittländer.',
|
a: 'Wir arbeiten mit DSGVO-orientierten Prozessen. Vor Go-Live wird ein AV-Vertrag (Art. 28 DSGVO) geschlossen, TOMs sind dokumentiert, und bei Datenverarbeitung in den USA nutzen wir SCC plus Transfer-Folgenabschaetzung (TIA) und zusaetzliche Schutzmassnahmen. Die konkrete Compliance haengt von der gewaehlten Konfiguration und den vertraglichen Einstellungen der Innung ab.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: 'Können meine Mitglieder die App sofort benutzen?',
|
q: 'Können meine Mitglieder die App sofort benutzen?',
|
||||||
|
|
@ -694,7 +883,7 @@ export default function RootPage() {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: 'Wie funktionieren Push-Benachrichtigungen?',
|
q: 'Wie funktionieren Push-Benachrichtigungen?',
|
||||||
a: 'Wenn Sie als Admin eine News veröffentlichen, erhalten alle aktiven Mitglieder sofort eine Push-Nachricht auf ihr Smartphone — auch wenn die App im Hintergrund läuft. Die Öffnungsrate liegt typischerweise bei über 80 %, verglichen mit unter 20 % bei E-Mail-Newslettern.',
|
a: 'Wenn Sie als Admin eine News veröffentlichen, erhalten aktive Mitglieder sofort eine Push-Nachricht auf ihr Smartphone — auch wenn die App im Hintergrund läuft. In vielen Innungen liegt die Interaktion mit Push-Nachrichten spürbar über klassischen E-Mail-Newslettern.',
|
||||||
},
|
},
|
||||||
].map((faq, i) => (
|
].map((faq, i) => (
|
||||||
<div key={i} className="faq-item" style={i === 5 ? { borderBottom: 'none' } : {}}>
|
<div key={i} className="faq-item" style={i === 5 ? { borderBottom: 'none' } : {}}>
|
||||||
|
|
@ -725,10 +914,35 @@ export default function RootPage() {
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="cta-right">
|
<div className="cta-right">
|
||||||
<Link href="/login" className="btn-primary-lg">
|
<a
|
||||||
|
href={CONTACT_WEBMAIL_HREF}
|
||||||
|
className="btn-primary-lg"
|
||||||
|
onClick={() => handleContactCtaClick('bottom')}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
Jetzt Team kontaktieren <ArrowUpRight size={18} />
|
Jetzt Team kontaktieren <ArrowUpRight size={18} />
|
||||||
</Link>
|
</a>
|
||||||
<p className="cta-note">Persönliche Beratung. Keine Vertragsbindung.</p>
|
<p className="cta-note">Persönliche Beratung. Keine Vertragsbindung.</p>
|
||||||
|
{showMailFallback && (
|
||||||
|
<div className="mail-fallback">
|
||||||
|
Kein Mailprogramm? Schreiben Sie direkt an{' '}
|
||||||
|
<a className="mail-fallback-link" href={`mailto:${CONTACT_EMAIL}`}>
|
||||||
|
{CONTACT_EMAIL}
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
<div className="mail-fallback-row">
|
||||||
|
<a
|
||||||
|
className="mail-fallback-link"
|
||||||
|
href={CONTACT_WEBMAIL_HREF}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
In Webmail öffnen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -744,11 +958,40 @@ export default function RootPage() {
|
||||||
© {new Date().getFullYear()} InnungsApp SaaS. Alle Rechte vorbehalten.
|
© {new Date().getFullYear()} InnungsApp SaaS. Alle Rechte vorbehalten.
|
||||||
</p>
|
</p>
|
||||||
<div className="footer-links">
|
<div className="footer-links">
|
||||||
<Link href="#" className="footer-link">Impressum</Link>
|
<Link href="/impressum" className="footer-link">Impressum</Link>
|
||||||
<Link href="#" className="footer-link">Datenschutz</Link>
|
<Link href="/datenschutz" className="footer-link">Datenschutz</Link>
|
||||||
|
<button type="button" onClick={openCookieSettings} className="footer-link">
|
||||||
|
Cookie-Einstellungen
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
{cookieConsent === 'pending' && (
|
||||||
|
<div className="cookie-banner" role="dialog" aria-live="polite" aria-label="Cookie Hinweis">
|
||||||
|
<div className="cookie-banner-inner">
|
||||||
|
<p className="cookie-banner-text">
|
||||||
|
Wir nutzen optionale Analyse-Cookies (PostHog), um die Landingpage zu verbessern.
|
||||||
|
PostHog startet erst nach Ihrer Zustimmung. Mehr Infos in der{' '}
|
||||||
|
<Link href="/datenschutz" className="cookie-banner-link">
|
||||||
|
Datenschutzerklärung
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
<div className="cookie-banner-actions">
|
||||||
|
<button className="cookie-btn" onClick={() => setCookieChoice('declined')}>
|
||||||
|
Nur notwendige
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="cookie-btn cookie-btn-primary"
|
||||||
|
onClick={() => setCookieChoice('accepted')}
|
||||||
|
>
|
||||||
|
Cookies akzeptieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +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:3032'),
|
||||||
|
})
|
||||||
|
|
||||||
|
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 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { use } from 'react'
|
||||||
|
|
||||||
|
export default function RegistrierungPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||||
|
const { slug } = use(params)
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')
|
||||||
|
const [errorMsg, setErrorMsg] = useState('')
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setStatus('loading')
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/registrierung/${slug}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, email }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
setErrorMsg(data.error ?? 'Ein Fehler ist aufgetreten.')
|
||||||
|
setStatus('error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStatus('success')
|
||||||
|
} catch {
|
||||||
|
setErrorMsg('Netzwerkfehler. Bitte versuchen Sie es erneut.')
|
||||||
|
setStatus('error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'success') {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border p-8 max-w-md w-full text-center">
|
||||||
|
<div className="text-4xl mb-4">✉️</div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 mb-2">E-Mail wird gesendet</h1>
|
||||||
|
<p className="text-gray-600 text-sm">
|
||||||
|
Bitte prüfen Sie Ihr Postfach. Sie erhalten in Kürze einen Aktivierungslink.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border p-8 max-w-md w-full">
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 mb-1">Mitglied werden</h1>
|
||||||
|
<p className="text-sm text-gray-500 mb-6">
|
||||||
|
Registrieren Sie sich für die InnungsApp Ihres Verbandes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Name *</label>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Max Mustermann"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">E-Mail *</label>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="max@musterfirma.de"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status === 'error' && (
|
||||||
|
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg">{errorMsg}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={status === 'loading'}
|
||||||
|
className="w-full bg-blue-600 text-white py-2 px-4 rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-60 transition-colors"
|
||||||
|
>
|
||||||
|
{status === 'loading' ? 'Wird gesendet...' : 'Aktivierungslink anfordern'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import type { MetadataRoute } from 'next'
|
||||||
|
|
||||||
|
export default function robots(): MetadataRoute.Robots {
|
||||||
|
return {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
userAgent: '*',
|
||||||
|
allow: '/',
|
||||||
|
disallow: ['/api/', '/dashboard', '/superadmin', '/registrierung', '/login'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sitemap: 'https://innungsapp.com/sitemap.xml',
|
||||||
|
host: 'https://innungsapp.com',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
import type { MetadataRoute } from 'next'
|
||||||
|
|
||||||
|
const BASE_URL = 'https://innungsapp.com'
|
||||||
|
|
||||||
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
|
const lastModified = new Date()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
url: `${BASE_URL}/`,
|
||||||
|
lastModified,
|
||||||
|
changeFrequency: 'weekly',
|
||||||
|
priority: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `${BASE_URL}/impressum`,
|
||||||
|
lastModified,
|
||||||
|
changeFrequency: 'monthly',
|
||||||
|
priority: 0.3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `${BASE_URL}/datenschutz`,
|
||||||
|
lastModified,
|
||||||
|
changeFrequency: 'monthly',
|
||||||
|
priority: 0.3,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -1,81 +1,426 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useActionState } from 'react'
|
import { useActionState, useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
import { createOrganization } from './actions'
|
import { createOrganization } from './actions'
|
||||||
|
import { LandingPagePreview } from './LandingPagePreview'
|
||||||
|
|
||||||
const initialState = {
|
const initialState = { success: false, error: '' }
|
||||||
success: false,
|
|
||||||
error: '',
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CreateOrgForm() {
|
export function CreateOrgForm() {
|
||||||
const [state, formAction, isPending] = useActionState(createOrganization, initialState)
|
const [state, formAction, isPending] = useActionState(createOrganization, initialState)
|
||||||
|
const router = useRouter()
|
||||||
|
const [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 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 (
|
return (
|
||||||
<div className="bg-white p-6 rounded-xl border shadow-sm">
|
<div className="flex w-full h-full gap-6">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Neue Innung anlegen</h2>
|
<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.success && (
|
{state.error && (
|
||||||
<div className="mb-4 p-3 bg-green-50 text-green-700 rounded-lg text-sm">
|
<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">
|
||||||
Innung wurde erfolgreich angelegt!
|
{state.error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{state.error && (
|
{/* Stepper Header (matched to screenshot) */}
|
||||||
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">
|
<div className="flex items-center justify-start gap-2 sm:gap-4 mb-8 shrink-0 overflow-x-auto pb-2">
|
||||||
{state.error}
|
{[1, 2, 3, 4, 5].map((s) => (
|
||||||
</div>
|
<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}
|
||||||
<form action={formAction} className="space-y-4">
|
</div>
|
||||||
<div>
|
{s < 5 && (
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<div className={`h-[3px] w-8 sm:w-12 rounded-full transition-all duration-500 ${step > s ? 'bg-[#E63946]' : 'bg-gray-100'}`} />
|
||||||
Name der Innung
|
)}
|
||||||
</label>
|
</div>
|
||||||
<input
|
))}
|
||||||
type="text"
|
|
||||||
name="name"
|
|
||||||
required
|
|
||||||
placeholder="z.B. Tischler-Innung Berlin"
|
|
||||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<form action={formAction} className="flex-1 shrink-0 space-y-6">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
{step !== 1 && (
|
||||||
Kurzbezeichnung (Slug)
|
<>
|
||||||
</label>
|
<input type="hidden" name="name" value={formData.name} />
|
||||||
<p className="text-xs text-gray-500 mb-2">Für interne Zuordnung (nur Kleinbuchstaben, ohne Leerzeichen).</p>
|
<input type="hidden" name="slug" value={formData.slug} />
|
||||||
<input
|
</>
|
||||||
type="text"
|
)}
|
||||||
name="slug"
|
<input type="hidden" name="contactEmail" value={formData.contactEmail} />
|
||||||
required
|
<input type="hidden" name="adminEmail" value={formData.adminEmail} />
|
||||||
placeholder="tischler-berlin"
|
<input type="hidden" name="adminPassword" value={formData.adminPassword} />
|
||||||
pattern="^[a-z0-9-]+$"
|
<input type="hidden" name="logoUrl" value={formData.logoUrl} />
|
||||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
<input type="hidden" name="plan" value={formData.plan} />
|
||||||
/>
|
<input type="hidden" name="primaryColor" value={formData.primaryColor} />
|
||||||
</div>
|
<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} />
|
||||||
|
|
||||||
<div>
|
{step === 1 && (
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<div className="space-y-5 animate-in fade-in slide-in-from-right-4 duration-300">
|
||||||
Kontakt E-Mail (Optional)
|
<div>
|
||||||
</label>
|
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Name der Innung</label>
|
||||||
<input
|
<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" />
|
||||||
type="email"
|
</div>
|
||||||
name="contactEmail"
|
<div>
|
||||||
placeholder="info@tischler-berlin.de"
|
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Kurzbezeichnung (Slug)</label>
|
||||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
<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 ? `${formData.slug}.localhost:3032` : 'ihr-slug.localhost:3032'}</span></p>
|
||||||
</div>
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
{step === 2 && (
|
||||||
type="submit"
|
<div className="space-y-5 animate-in fade-in slide-in-from-right-4 duration-300">
|
||||||
disabled={isPending}
|
<div className="grid grid-cols-2 gap-4">
|
||||||
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"
|
<div>
|
||||||
>
|
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Initialer Admin (Email)</label>
|
||||||
{isPending ? 'Wird angelegt...' : 'Innung anlegen'}
|
<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" />
|
||||||
</button>
|
</div>
|
||||||
</form>
|
<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={`http://${formData.slug}.localhost:3032`} target="_blank" rel="noreferrer" className="text-[#E63946] font-bold text-lg hover:underline block break-all">
|
||||||
|
{formData.slug}.localhost:3032
|
||||||
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,49 +1,482 @@
|
||||||
'use server'
|
'use server'
|
||||||
|
|
||||||
import { prisma } from '@innungsapp/shared'
|
import { prisma } from '@innungsapp/shared'
|
||||||
|
import { auth, getSanitizedHeaders } from '@/lib/auth'
|
||||||
import { revalidatePath } from 'next/cache'
|
import { revalidatePath } from 'next/cache'
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
import { headers } from 'next/headers'
|
||||||
import { z } from 'zod'
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a credential (email+password) account for a user.
|
||||||
|
* Tries auth.api.updateUser first (uses better-auth's internal hashing, guaranteed compatible).
|
||||||
|
* Falls back to direct DB write if that fails.
|
||||||
|
*/
|
||||||
|
async function setCredentialPassword(userId: string, password: string) {
|
||||||
|
// Primary: use better-auth's own API to ensure correct hash format
|
||||||
|
try {
|
||||||
|
const authHeaders = await getSanitizedHeaders()
|
||||||
|
await auth.api.updateUser({
|
||||||
|
body: { userId, password },
|
||||||
|
headers: authHeaders,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[setCredentialPassword] auth.api.updateUser failed, falling back to direct write:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: write directly with better-auth compatible hash
|
||||||
|
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 headers() })
|
||||||
|
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({
|
const createOrgSchema = z.object({
|
||||||
name: z.string().min(2, 'Name muss mindestens 2 Zeichen lang sein'),
|
name: z.string().min(2, 'Name muss mindestens 2 Zeichen lang sein'),
|
||||||
slug: z.string().min(2, 'Slug muss mindestens 2 Zeichen lang sein').regex(/^[a-z0-9-]+$/, 'Slug darf nur Kleinbuchstaben, Zahlen und Bindestriche enthalten'),
|
slug: z
|
||||||
contactEmail: z.string().email('Ungültige E-Mail Adresse').optional().or(z.literal('')),
|
.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(),
|
||||||
|
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(),
|
||||||
|
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) {
|
export async function createOrganization(prevState: any, formData: FormData) {
|
||||||
try {
|
const session = await requireSuperAdmin()
|
||||||
const rawData = {
|
if (!session) return { success: false, error: 'Nicht autorisiert.' }
|
||||||
name: formData.get('name') as string,
|
|
||||||
slug: (formData.get('slug') as string).toLowerCase(),
|
|
||||||
contactEmail: formData.get('contactEmail') as string,
|
|
||||||
}
|
|
||||||
|
|
||||||
const validatedData = createOrgSchema.parse(rawData)
|
try {
|
||||||
|
const rawData = {
|
||||||
// Check if slug exists
|
name: (formData.get('name') as string).trim(),
|
||||||
const existingOrg = await prisma.organization.findUnique({
|
slug: (formData.get('slug') as string).trim().toLowerCase(),
|
||||||
where: { slug: validatedData.slug }
|
contactEmail: (formData.get('contactEmail') as string).trim(),
|
||||||
})
|
adminEmail: normalizeEmail(formData.get('adminEmail') as string),
|
||||||
|
adminPassword: formData.get('adminPassword') as string,
|
||||||
if (existingOrg) {
|
logoUrl: formData.get('logoUrl') as string,
|
||||||
return { success: false, error: 'Diese Kurzbezeichnung (Slug) existiert bereits.' }
|
plan: (formData.get('plan') as string) || 'pilot',
|
||||||
}
|
primaryColor: formData.get('primaryColor') as string,
|
||||||
|
secondaryColor: formData.get('secondaryColor') as string,
|
||||||
await prisma.organization.create({
|
landingPageTitle: (formData.get('landingPageTitle') as string).trim(),
|
||||||
data: {
|
landingPageText: (formData.get('landingPageText') as string).trim(),
|
||||||
name: validatedData.name,
|
landingPageHeroImage: formData.get('landingPageHeroImage') as string,
|
||||||
slug: validatedData.slug,
|
landingPageHeroOverlayOpacity: Number(formData.get('landingPageHeroOverlayOpacity') || '50'),
|
||||||
contactEmail: validatedData.contactEmail || null,
|
landingPageFeatures: (formData.get('landingPageFeatures') as string).trim(),
|
||||||
plan: 'pilot',
|
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(),
|
||||||
revalidatePath('/superadmin')
|
playStoreUrl: (formData.get('playStoreUrl') as string || '').trim(),
|
||||||
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.' }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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: validatedData.landingPageFeatures || null,
|
||||||
|
landingPageFooter: validatedData.landingPageFooter || null,
|
||||||
|
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:3032',
|
||||||
|
})
|
||||||
|
} 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: validatedData.landingPageFeatures || null,
|
||||||
|
landingPageFooter: validatedData.landingPageFooter || null,
|
||||||
|
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:3032',
|
||||||
|
})
|
||||||
|
} 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.' }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
import { prisma } from '@innungsapp/shared'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { ExternalLink, Settings, Layout, Search } from 'lucide-react'
|
||||||
|
|
||||||
|
export default async function LandingPagesOverview({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ q?: string }>
|
||||||
|
}) {
|
||||||
|
const { q = '' } = await searchParams
|
||||||
|
|
||||||
|
const organizations = await prisma.organization.findMany({
|
||||||
|
where: q ? {
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: q, mode: 'insensitive' } },
|
||||||
|
{ slug: { contains: q, mode: 'insensitive' } },
|
||||||
|
]
|
||||||
|
} : {},
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 animate-in fade-in duration-500">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-black text-gray-900 tracking-tight font-outfit">
|
||||||
|
Landingpage-Verwaltung
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 font-medium">Alle Mandanten-Landingpages auf einen Blick verwalten.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative group w-full md:w-72">
|
||||||
|
<div className="absolute inset-y-0 left-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-[#E63946] transition-colors">
|
||||||
|
<Search size={18} />
|
||||||
|
</div>
|
||||||
|
<form method="GET">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
name="q"
|
||||||
|
defaultValue={q}
|
||||||
|
placeholder="Landingpage suchen..."
|
||||||
|
className="w-full pl-10 pr-4 py-2.5 bg-white border rounded-xl text-sm outline-none focus:border-[#E63946] focus:ring-4 focus:ring-red-500/5 transition-all shadow-sm"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{organizations.length === 0 ? (
|
||||||
|
<div className="col-span-full py-20 bg-white border border-dashed rounded-3xl flex flex-col items-center justify-center text-center">
|
||||||
|
<div className="bg-gray-50 p-4 rounded-2xl mb-4 text-gray-400">
|
||||||
|
<Layout size={40} strokeWidth={1.5} />
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-500 font-medium">Keine Landingpages gefunden.</p>
|
||||||
|
{q && <Link href="/superadmin/landingpages" className="text-[#E63946] font-bold mt-2 text-sm hover:underline">Suche zurücksetzen</Link>}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
organizations.map((org) => (
|
||||||
|
<div key={org.id} className="group bg-white rounded-3xl border border-gray-100 p-6 hover:border-[#E63946] hover:shadow-2xl hover:shadow-red-500/5 transition-all duration-500 flex flex-col h-full relative overflow-hidden">
|
||||||
|
{/* Accent line */}
|
||||||
|
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-gray-50 via-gray-100 to-gray-50 group-hover:from-red-100 group-hover:via-[#E63946] group-hover:to-red-100 transition-all duration-500" />
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="font-black text-xl text-gray-900 group-hover:text-[#E63946] transition-colors truncate max-w-[200px]">
|
||||||
|
{org.name}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-mono text-gray-400">
|
||||||
|
<span className="text-[#E63946] opacity-50">/</span>
|
||||||
|
<span>{org.slug}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-2.5 bg-gray-50 rounded-2xl text-gray-400 group-hover:bg-red-50 group-hover:text-[#E63946] transition-all duration-500">
|
||||||
|
<Layout size={20} strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-4">
|
||||||
|
<div className="bg-gray-50/50 rounded-2xl p-4 border border-gray-100">
|
||||||
|
<div className="flex items-center justify-between text-[11px] font-bold uppercase tracking-widest text-gray-400 mb-2">
|
||||||
|
<span>Status</span>
|
||||||
|
<span className="flex items-center gap-1 text-green-500">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
|
||||||
|
Online
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-gray-600 truncate">
|
||||||
|
{org.landingPageTitle || 'Standard-Title'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 grid grid-cols-2 gap-3">
|
||||||
|
<a
|
||||||
|
href={`/${org.slug}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center justify-center gap-2 py-3 bg-gray-50 text-gray-600 rounded-2xl text-sm font-bold hover:bg-gray-100 transition-all border border-transparent"
|
||||||
|
>
|
||||||
|
<ExternalLink size={16} />
|
||||||
|
Ansehen
|
||||||
|
</a>
|
||||||
|
<Link
|
||||||
|
href={`/superadmin/organizations/${org.id}`}
|
||||||
|
className="flex items-center justify-center gap-2 py-3 bg-gray-900 text-white rounded-2xl text-sm font-bold hover:bg-black transition-all hover:shadow-lg hover:shadow-black/10 shadow-sm"
|
||||||
|
>
|
||||||
|
<Settings size={16} />
|
||||||
|
Editieren
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -15,7 +15,8 @@ export default async function SuperAdminLayout({
|
||||||
}
|
}
|
||||||
|
|
||||||
const superAdminEmail = process.env.SUPERADMIN_EMAIL || 'superadmin@innungsapp.de'
|
const superAdminEmail = process.env.SUPERADMIN_EMAIL || 'superadmin@innungsapp.de'
|
||||||
if (session.user.email !== superAdminEmail) {
|
const isSuperAdmin = session.user.email === superAdminEmail || session.user.role === 'admin'
|
||||||
|
if (!isSuperAdmin) {
|
||||||
redirect('/dashboard') // Normal admins go back to dashboard
|
redirect('/dashboard') // Normal admins go back to dashboard
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -25,12 +26,20 @@ export default async function SuperAdminLayout({
|
||||||
<header className="bg-gray-900 text-white border-t-2 border-brand-500 border-b border-gray-800">
|
<header className="bg-gray-900 text-white border-t-2 border-brand-500 border-b border-gray-800">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex justify-between h-12 items-center">
|
<div className="flex justify-between h-12 items-center">
|
||||||
<span
|
<div className="flex items-center gap-8">
|
||||||
className="font-bold text-base tracking-tight"
|
<span
|
||||||
style={{ fontFamily: "'Syne', system-ui, sans-serif" }}
|
className="font-bold text-base tracking-tight hover:text-gray-200 transition-colors"
|
||||||
>
|
style={{ fontFamily: "'Syne', system-ui, sans-serif" }}
|
||||||
Super Admin
|
>
|
||||||
</span>
|
<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>
|
<span className="text-xs text-gray-400">{session.user.email}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { deleteOrganization } from '../../actions'
|
||||||
|
|
||||||
|
export function DeleteOrgButton({ id, name }: { id: string; name: string }) {
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!confirm(`Innung "${name}" wirklich unwiderruflich löschen? Alle Daten (Mitglieder, News, Termine, Stellen) werden gelöscht.`)) return
|
||||||
|
await deleteOrganization(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="w-full mt-2 px-4 py-2 bg-red-600 text-white text-sm font-medium rounded-lg hover:bg-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
Innung löschen
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,344 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useActionState, useState } from 'react'
|
||||||
|
import { updateOrganization } from '../../actions'
|
||||||
|
|
||||||
|
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: string | null
|
||||||
|
landingPageFooter: string | null
|
||||||
|
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')
|
||||||
|
|
||||||
|
let initialFeatures = ''
|
||||||
|
try {
|
||||||
|
if (org.landingPageFeatures) {
|
||||||
|
const parsed = JSON.parse(org.landingPageFeatures)
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
initialFeatures = parsed.join('\n')
|
||||||
|
} else {
|
||||||
|
initialFeatures = org.landingPageFeatures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
initialFeatures = org.landingPageFeatures || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
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={org.landingPageFooter ?? ''}
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,234 @@
|
||||||
|
import { prisma } from '@innungsapp/shared'
|
||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
import { de } from 'date-fns/locale'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { EditOrgForm } from './EditOrgForm'
|
||||||
|
import { DeleteOrgButton } from './DeleteOrgButton'
|
||||||
|
import { CreateAdminForm } from './CreateAdminForm'
|
||||||
|
import { CreateMemberForm } from './CreateMemberForm'
|
||||||
|
import { UserRoleActions } from './UserRoleActions'
|
||||||
|
import { MemberActions } from './MemberActions'
|
||||||
|
import { toggleAiFeature } from '../../actions'
|
||||||
|
|
||||||
|
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',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function OrgDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params
|
||||||
|
|
||||||
|
const org = await prisma.organization.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
members: true,
|
||||||
|
userRoles: true,
|
||||||
|
news: true,
|
||||||
|
termine: true,
|
||||||
|
stellen: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
userRoles: {
|
||||||
|
include: { user: true },
|
||||||
|
},
|
||||||
|
members: {
|
||||||
|
take: 5,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: { id: true, name: true, betrieb: true, status: true, createdAt: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!org) notFound()
|
||||||
|
|
||||||
|
const planColor = PLAN_COLORS[org.plan] ?? 'bg-gray-100 text-gray-700'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
|
<Link href="/superadmin" className="hover:text-gray-900 transition-colors">
|
||||||
|
← Alle Innungen
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">{org.name}</h1>
|
||||||
|
<span className={`text-xs font-semibold px-2.5 py-0.5 rounded ${planColor}`}>
|
||||||
|
{org.plan}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mt-1 text-sm text-gray-500">
|
||||||
|
<span className="font-mono bg-gray-100 px-2 py-0.5 rounded text-[11px]">{org.slug}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>Erstellt {format(org.createdAt, 'dd. MMMM yyyy', { locale: de })}</span>
|
||||||
|
{org.avvAccepted && (
|
||||||
|
<>
|
||||||
|
<span>•</span>
|
||||||
|
<span className="text-green-600">AVV akzeptiert</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3">
|
||||||
|
{[
|
||||||
|
{ label: 'Mitglieder', value: org._count.members },
|
||||||
|
{ label: 'Admins', value: org._count.userRoles },
|
||||||
|
{ label: 'News', value: org._count.news },
|
||||||
|
{ label: 'Termine', value: org._count.termine },
|
||||||
|
{ label: 'Stellen', value: org._count.stellen },
|
||||||
|
].map(({ label, value }) => (
|
||||||
|
<div key={label} className="bg-white rounded-xl border p-4 text-center">
|
||||||
|
<div className="text-2xl font-bold text-gray-900">{value}</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-0.5">{label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Edit form */}
|
||||||
|
<div className="lg:col-span-1 space-y-4">
|
||||||
|
<EditOrgForm org={org} />
|
||||||
|
|
||||||
|
{/* KI-Assistent */}
|
||||||
|
<div className="bg-white rounded-xl border p-4 space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900">KI-Assistent</h3>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
|
Aktiviert den KI-Chat-Assistenten für Mitglieder dieser Innung.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className={`text-sm font-medium ${org.aiEnabled ? 'text-green-700' : 'text-gray-400'}`}>
|
||||||
|
{org.aiEnabled ? 'Aktiviert' : 'Deaktiviert'}
|
||||||
|
</span>
|
||||||
|
<form action={async () => {
|
||||||
|
'use server'
|
||||||
|
await toggleAiFeature(org.id, !org.aiEnabled)
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none ${org.aiEnabled ? 'bg-green-500' : 'bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${org.aiEnabled ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Danger zone */}
|
||||||
|
<div className="bg-white rounded-xl border border-red-200 p-4">
|
||||||
|
<h3 className="text-sm font-semibold text-red-700 mb-1">Gefahrenzone</h3>
|
||||||
|
<p className="text-xs text-gray-500 mb-3">
|
||||||
|
Das Löschen einer Innung entfernt alle zugehörigen Daten unwiderruflich.
|
||||||
|
</p>
|
||||||
|
<DeleteOrgButton id={org.id} name={org.name} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right column: admins + recent members */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{/* Admins */}
|
||||||
|
<div className="bg-white rounded-xl border overflow-hidden">
|
||||||
|
<div className="p-4 border-b flex items-center justify-between">
|
||||||
|
<h2 className="text-base font-semibold text-gray-900">
|
||||||
|
Nutzer & Rollen ({org.userRoles.length})
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-gray-50/50 border-b">
|
||||||
|
<CreateAdminForm orgId={org.id} />
|
||||||
|
</div>
|
||||||
|
<div className="divide-y">
|
||||||
|
{org.userRoles.length === 0 ? (
|
||||||
|
<p className="p-4 text-sm text-gray-400">Noch keine Nutzer zugewiesen.</p>
|
||||||
|
) : (
|
||||||
|
org.userRoles.map((ur) => (
|
||||||
|
<div key={ur.id} className="p-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-900">{ur.user.name}</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{ur.user.email}
|
||||||
|
<span className="ml-2 font-mono text-[10px] bg-gray-100 px-1 py-0.5 rounded">
|
||||||
|
{ur.role}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{ur.user.emailVerified ? (
|
||||||
|
<span className="text-[10px] text-green-600 bg-green-50 px-2 py-0.5 rounded-full uppercase font-bold tracking-wider">
|
||||||
|
Verifiziert
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] text-amber-600 bg-amber-50 px-2 py-0.5 rounded-full uppercase font-bold tracking-wider">
|
||||||
|
Eingeladen
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<UserRoleActions ur={ur} orgId={org.id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent members */}
|
||||||
|
<div className="bg-white rounded-xl border overflow-hidden">
|
||||||
|
<div className="p-4 border-b flex items-center justify-between">
|
||||||
|
<h2 className="text-base font-semibold text-gray-900">
|
||||||
|
Mitglieder
|
||||||
|
</h2>
|
||||||
|
<span className="text-xs text-gray-400">{org._count.members} gesamt</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-gray-50/50 border-b">
|
||||||
|
<CreateMemberForm orgId={org.id} />
|
||||||
|
</div>
|
||||||
|
<div className="divide-y">
|
||||||
|
{org.members.length === 0 ? (
|
||||||
|
<p className="p-4 text-sm text-gray-400">Noch keine Mitglieder.</p>
|
||||||
|
) : (
|
||||||
|
org.members.map((m) => (
|
||||||
|
<div key={m.id} className="p-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-900">{m.name}</div>
|
||||||
|
<div className="text-xs text-gray-500">{m.betrieb}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span
|
||||||
|
className={`text-xs px-2 py-0.5 rounded-full ${m.status === 'aktiv'
|
||||||
|
? 'bg-green-50 text-green-700'
|
||||||
|
: 'bg-gray-100 text-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m.status}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{format(m.createdAt, 'dd.MM.yy', { locale: de })}
|
||||||
|
</span>
|
||||||
|
<MemberActions member={m} orgId={org.id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,81 +1,233 @@
|
||||||
import { prisma } from '@innungsapp/shared'
|
import { prisma } from '@innungsapp/shared'
|
||||||
import { CreateOrgForm } from './CreateOrgForm'
|
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import { de } from 'date-fns/locale'
|
import { de } from 'date-fns/locale'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { toggleAiFeature } from './actions'
|
||||||
|
|
||||||
export default async function SuperAdminPage() {
|
const PLAN_LABELS: Record<string, string> = {
|
||||||
const organizations = await prisma.organization.findMany({
|
pilot: 'Pilot',
|
||||||
orderBy: { createdAt: 'desc' },
|
standard: 'Standard',
|
||||||
include: {
|
pro: 'Pro',
|
||||||
_count: {
|
verband: 'Verband',
|
||||||
select: {
|
}
|
||||||
members: true,
|
|
||||||
userRoles: true,
|
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, mode: 'insensitive' } },
|
||||||
|
{ slug: { contains: q, mode: 'insensitive' } },
|
||||||
|
{ contactEmail: { contains: q, mode: 'insensitive' } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="max-w-[1400px] mx-auto space-y-12 py-4">
|
||||||
<div>
|
<div className="flex justify-between items-center">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Innungs-Verwaltung (Multi-Tenant)</h1>
|
<div className="text-left space-y-2">
|
||||||
<p className="text-gray-500 mt-1">Hierüber werden alle Mandanten der Lösung verwaltet.</p>
|
<h1 className="text-3xl font-black text-gray-900 tracking-tight font-outfit">
|
||||||
</div>
|
Innungs-Verwaltung <span className="text-[#E63946]">PRO</span>
|
||||||
|
</h1>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
<p className="text-gray-400 font-medium">Hierüber werden alle Mandanten der Lösung verwaltet.</p>
|
||||||
{/* Form: Create new org */}
|
|
||||||
<div className="lg:col-span-1">
|
|
||||||
<CreateOrgForm />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* List of orgs */}
|
<Link
|
||||||
<div className="lg:col-span-2">
|
href="/superadmin/create"
|
||||||
<div className="bg-white rounded-lg border overflow-hidden">
|
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"
|
||||||
<div className="p-6 border-b">
|
>
|
||||||
<h2 className="text-lg font-semibold text-gray-900">Aktive Innungen ({organizations.length})</h2>
|
<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>
|
||||||
|
|
||||||
<div className="divide-y">
|
<div className="flex flex-col gap-4">
|
||||||
{organizations.length === 0 ? (
|
{organizations.length === 0 ? (
|
||||||
<div className="p-8 text-center text-gray-500">
|
<div className="bg-white p-12 text-center rounded-2xl border border-dashed border-gray-200">
|
||||||
Bisher keine Innungen angelegt.
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
organizations.map((org) => (
|
organizations.map((org) => (
|
||||||
<div key={org.id} className="p-5 hover:bg-gray-50 border-l-[3px] border-transparent hover:border-brand-500 transition-all">
|
<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">
|
<div className="flex justify-between items-start gap-6 relative z-10">
|
||||||
<div>
|
<Link href={`/superadmin/organizations/${org.id}`} className="flex-1 min-w-0">
|
||||||
<h3 className="font-bold text-gray-900 text-lg">{org.name}</h3>
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<div className="flex items-center gap-3 mt-1 text-sm text-gray-500">
|
<h3 className="font-bold text-lg text-gray-900 group-hover:text-[#E63946] transition-colors">{org.name}</h3>
|
||||||
<span className="font-mono bg-gray-100 px-2 py-0.5 rounded text-[11px]">{org.slug}</span>
|
<span className={`text-[10px] font-black uppercase tracking-tighter px-2 py-0.5 rounded-full border ${PLAN_COLORS[org.plan] ?? 'bg-gray-100 text-gray-700'}`}>
|
||||||
<span>•</span>
|
{org.plan}
|
||||||
<span>{org.contactEmail || 'Keine E-Mail'}</span>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
<span className="bg-blue-100 text-blue-800 text-xs font-semibold px-2.5 py-0.5 rounded">
|
|
||||||
{org.plan}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap gap-4 text-sm">
|
<div className="mt-6 flex items-center gap-6">
|
||||||
<div className="bg-gray-50 px-3 py-2 rounded-lg border inline-block">
|
<div className="flex flex-col">
|
||||||
<span className="text-gray-500 block text-xs uppercase tracking-wider font-semibold">Mitglieder</span>
|
<span className="text-[10px] text-gray-400 font-bold uppercase tracking-wider">Mitglieder</span>
|
||||||
<span className="font-bold text-gray-900">{org._count.members}</span>
|
<span className="font-bold text-gray-900">{org._count.members}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-50 px-3 py-2 rounded-lg border inline-block">
|
<div className="w-px h-6 bg-gray-100" />
|
||||||
<span className="text-gray-500 block text-xs uppercase tracking-wider font-semibold">Admins</span>
|
<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>
|
<span className="font-bold text-gray-900">{org._count.userRoles}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-50 px-3 py-2 rounded-lg border inline-block">
|
<div className="w-px h-6 bg-gray-100 ml-auto" />
|
||||||
<span className="text-gray-500 block text-xs uppercase tracking-wider font-semibold">Erstellt am</span>
|
<div className="flex flex-col items-end">
|
||||||
<span className="font-bold text-gray-900">{format(org.createdAt, 'dd.MM.yyyy', { locale: de })}</span>
|
<span className="text-[10px] text-gray-400 font-bold uppercase tracking-wider">Erstellt am</span>
|
||||||
|
<span className="text-xs font-semibold text-gray-600">{format(org.createdAt, 'dd.MM.yyyy', { locale: de })}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
'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:3032'),
|
||||||
|
})
|
||||||
|
|
||||||
|
interface LoginFormProps {
|
||||||
|
primaryColor?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginForm({ primaryColor = '#E63946' }: 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')
|
||||||
|
window.location.href = callbackUrl || '/dashboard'
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,12 @@ import { createAuthClient } from 'better-auth/react'
|
||||||
import { useRouter, usePathname } from 'next/navigation'
|
import { useRouter, usePathname } from 'next/navigation'
|
||||||
import { LogOut } from 'lucide-react'
|
import { LogOut } from 'lucide-react'
|
||||||
|
|
||||||
const authClient = createAuthClient()
|
const authClient = createAuthClient({
|
||||||
|
// Keep auth requests on the current origin (important for tenant subdomains).
|
||||||
|
baseURL: typeof window !== 'undefined'
|
||||||
|
? window.location.origin
|
||||||
|
: (process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3032'),
|
||||||
|
})
|
||||||
|
|
||||||
const PAGE_TITLES: Record<string, string> = {
|
const PAGE_TITLES: Record<string, string> = {
|
||||||
'/dashboard': 'Übersicht',
|
'/dashboard': 'Übersicht',
|
||||||
|
|
|
||||||
|
|
@ -14,20 +14,24 @@ const navItems = [
|
||||||
{ href: '/dashboard/einstellungen', label: 'Einstellungen', icon: Settings },
|
{ href: '/dashboard/einstellungen', label: 'Einstellungen', icon: Settings },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar({ orgName, logoUrl }: { orgName?: string; logoUrl?: string | null }) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-64 bg-white border-r flex flex-col flex-shrink-0">
|
<aside className="w-64 bg-white border-r flex flex-col flex-shrink-0">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="px-6 py-5 border-b">
|
<div className="px-6 py-5 border-b flex items-center gap-3">
|
||||||
<Link href="/dashboard">
|
<Link href="/dashboard" className="flex items-center gap-3 w-full">
|
||||||
<span
|
{logoUrl ? (
|
||||||
className="text-xl font-bold text-gray-900 tracking-tight"
|
<img src={logoUrl} alt={orgName || 'Logo'} className="h-8 max-w-[120px] object-contain" />
|
||||||
style={{ fontFamily: "'Syne', system-ui, sans-serif" }}
|
) : (
|
||||||
>
|
<span
|
||||||
Innungs<span className="text-brand-500">App</span>
|
className="text-xl font-bold text-gray-900 tracking-tight leading-tight line-clamp-2"
|
||||||
</span>
|
style={{ fontFamily: "'Syne', system-ui, sans-serif" }}
|
||||||
|
>
|
||||||
|
{orgName || <span>Innungs<span className="text-brand-500">App</span></span>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -0,0 +1,11 @@
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Run Prisma migrations on startup
|
||||||
|
echo "Running database migrations..."
|
||||||
|
DATABASE_URL="${DATABASE_URL:-file:/app/data/prod.db}" \
|
||||||
|
node_modules/.bin/prisma migrate deploy \
|
||||||
|
--schema=./packages/shared/prisma/schema.prisma
|
||||||
|
|
||||||
|
echo "Starting Next.js server..."
|
||||||
|
exec node apps/admin/server.js
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
const POSTHOG_KEY = process.env.NEXT_PUBLIC_POSTHOG_KEY
|
||||||
|
const POSTHOG_HOST = process.env.NEXT_PUBLIC_POSTHOG_HOST ?? 'https://eu.i.posthog.com'
|
||||||
|
const COOKIE_CONSENT_KEY = 'innungsapp_cookie_consent'
|
||||||
|
const COOKIE_CONSENT_EVENT = 'innungsapp:cookie-consent-granted'
|
||||||
|
|
||||||
|
export {}
|
||||||
|
|
||||||
|
type PostHogApi = {
|
||||||
|
__SV?: number
|
||||||
|
init?: (token: string, config: Record<string, unknown>) => void
|
||||||
|
capture?: (event: string, properties?: Record<string, unknown>) => void
|
||||||
|
opt_in_capturing?: () => void
|
||||||
|
opt_out_capturing?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
posthog?: PostHogApi
|
||||||
|
__innungsappPosthogInitialized?: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensurePosthogSnippetLoaded() {
|
||||||
|
const w = window as any
|
||||||
|
if (w.posthog?.__SV) return
|
||||||
|
|
||||||
|
;(function loadSnippet(doc: Document, ph: any) {
|
||||||
|
const base: any = Array.isArray(ph) ? ph : []
|
||||||
|
if (base.__SV) return
|
||||||
|
|
||||||
|
w.posthog = base
|
||||||
|
base._i = base._i || []
|
||||||
|
|
||||||
|
base.init = function init(token: string, config: Record<string, unknown>, name?: string) {
|
||||||
|
const target = name ? (base[name] = base[name] || []) : base
|
||||||
|
|
||||||
|
const setMethod = (obj: any, method: string) => {
|
||||||
|
obj[method] = function methodStub(...args: unknown[]) {
|
||||||
|
obj.push([method, ...args])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const methods = [
|
||||||
|
'capture',
|
||||||
|
'identify',
|
||||||
|
'alias',
|
||||||
|
'group',
|
||||||
|
'set_config',
|
||||||
|
'reset',
|
||||||
|
'register',
|
||||||
|
'register_once',
|
||||||
|
'unregister',
|
||||||
|
'opt_in_capturing',
|
||||||
|
'opt_out_capturing',
|
||||||
|
'has_opted_in_capturing',
|
||||||
|
'has_opted_out_capturing',
|
||||||
|
'isFeatureEnabled',
|
||||||
|
'reloadFeatureFlags',
|
||||||
|
]
|
||||||
|
|
||||||
|
methods.forEach((method) => setMethod(target, method))
|
||||||
|
|
||||||
|
target.people = target.people || []
|
||||||
|
const peopleMethods = ['set', 'set_once', 'unset', 'increment', 'append', 'union', 'track_charge', 'clear_charges', 'delete_user']
|
||||||
|
peopleMethods.forEach((method) => setMethod(target.people, method))
|
||||||
|
|
||||||
|
const script = doc.createElement('script')
|
||||||
|
script.type = 'text/javascript'
|
||||||
|
script.async = true
|
||||||
|
script.src = `${(config.api_host as string).replace('.i.posthog.com', '-assets.i.posthog.com')}/static/array.js`
|
||||||
|
const firstScript = doc.getElementsByTagName('script')[0]
|
||||||
|
if (firstScript?.parentNode) {
|
||||||
|
firstScript.parentNode.insertBefore(script, firstScript)
|
||||||
|
} else {
|
||||||
|
doc.head.appendChild(script)
|
||||||
|
}
|
||||||
|
|
||||||
|
base._i.push([token, config, name])
|
||||||
|
}
|
||||||
|
|
||||||
|
base.__SV = 1
|
||||||
|
})(document, w.posthog || [])
|
||||||
|
}
|
||||||
|
|
||||||
|
function initPosthog() {
|
||||||
|
if (typeof window === 'undefined' || !POSTHOG_KEY) return
|
||||||
|
if (window.__innungsappPosthogInitialized) return
|
||||||
|
|
||||||
|
ensurePosthogSnippetLoaded()
|
||||||
|
|
||||||
|
window.posthog?.init?.(POSTHOG_KEY, {
|
||||||
|
api_host: POSTHOG_HOST,
|
||||||
|
defaults: '2026-01-30',
|
||||||
|
autocapture: false,
|
||||||
|
capture_pageview: false,
|
||||||
|
respect_dnt: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
window.__innungsappPosthogInitialized = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined' && POSTHOG_KEY) {
|
||||||
|
const consent = window.localStorage.getItem(COOKIE_CONSENT_KEY)
|
||||||
|
if (consent === 'accepted') {
|
||||||
|
initPosthog()
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener(COOKIE_CONSENT_EVENT, initPosthog)
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,8 @@ import { magicLink } from 'better-auth/plugins'
|
||||||
import { admin as adminPlugin } from 'better-auth/plugins'
|
import { admin as adminPlugin } from 'better-auth/plugins'
|
||||||
import { prisma } from '@innungsapp/shared'
|
import { prisma } from '@innungsapp/shared'
|
||||||
import { sendMagicLinkEmail } from './email'
|
import { sendMagicLinkEmail } from './email'
|
||||||
|
import { headers } from 'next/headers'
|
||||||
|
|
||||||
|
|
||||||
export const auth = betterAuth({
|
export const auth = betterAuth({
|
||||||
database: prismaAdapter(prisma, {
|
database: prismaAdapter(prisma, {
|
||||||
|
|
@ -17,10 +19,25 @@ export const auth = betterAuth({
|
||||||
trustedOrigins: [
|
trustedOrigins: [
|
||||||
process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3032',
|
process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3032',
|
||||||
process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3032',
|
process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3032',
|
||||||
'http://192.168.178.115:3032',
|
'http://localhost:3000',
|
||||||
'http://localhost:8081', // Expo dev client
|
'http://localhost:3001',
|
||||||
'http://192.168.178.115:8081',
|
'http://localhost:3032',
|
||||||
|
'http://localhost:8081',
|
||||||
|
'http://*.localhost:3032',
|
||||||
|
'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: [
|
plugins: [
|
||||||
magicLink({
|
magicLink({
|
||||||
sendMagicLink: async ({ email, url }) => {
|
sendMagicLink: async ({ email, url }) => {
|
||||||
|
|
@ -38,3 +55,19 @@ export const auth = betterAuth({
|
||||||
})
|
})
|
||||||
|
|
||||||
export type Auth = typeof auth
|
export type Auth = typeof auth
|
||||||
|
|
||||||
|
export async function getSanitizedHeaders() {
|
||||||
|
const allHeaders = await headers()
|
||||||
|
const sanitizedHeaders = new Headers(allHeaders)
|
||||||
|
|
||||||
|
// 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:3032')
|
||||||
|
sanitizedHeaders.set('host', betterAuthUrl.host)
|
||||||
|
} catch (e) {
|
||||||
|
sanitizedHeaders.set('host', 'localhost:3032')
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitizedHeaders
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -88,3 +88,51 @@ export async function sendInviteEmail({
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function sendAdminCredentialsEmail({
|
||||||
|
to,
|
||||||
|
adminName,
|
||||||
|
orgName,
|
||||||
|
password,
|
||||||
|
loginUrl,
|
||||||
|
}: {
|
||||||
|
to: string
|
||||||
|
adminName: string
|
||||||
|
orgName: string
|
||||||
|
password: string
|
||||||
|
loginUrl: string
|
||||||
|
}) {
|
||||||
|
await transporter.sendMail({
|
||||||
|
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>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +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
|
||||||
|
}
|
||||||
|
|
@ -1,24 +1,97 @@
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import type { NextRequest } from 'next/server'
|
import type { NextRequest } from 'next/server'
|
||||||
|
|
||||||
const PUBLIC_PATHS = ['/login', '/api/auth', '/api/trpc/stellen.listPublic', '/api/setup']
|
const PUBLIC_PREFIXES = [
|
||||||
|
'/login',
|
||||||
|
'/api/auth',
|
||||||
|
'/api/trpc/stellen.listPublic',
|
||||||
|
'/api/setup',
|
||||||
|
'/registrierung',
|
||||||
|
'/impressum',
|
||||||
|
'/datenschutz',
|
||||||
|
]
|
||||||
|
const PUBLIC_EXACT_PATHS = ['/']
|
||||||
|
|
||||||
|
// Reserved subdomains that shouldn't be treated as tenant slugs
|
||||||
|
const RESERVED_SUBDOMAINS = ['www', 'app', 'admin', 'localhost', 'superadmin', 'api']
|
||||||
|
|
||||||
export function middleware(request: NextRequest) {
|
export function middleware(request: NextRequest) {
|
||||||
const pathname = request.nextUrl.pathname
|
const url = request.nextUrl
|
||||||
const isPublic = PUBLIC_PATHS.some((p) => pathname.startsWith(p))
|
const pathname = url.pathname
|
||||||
|
|
||||||
if (isPublic) return NextResponse.next()
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 =
|
const sessionToken =
|
||||||
request.cookies.get('better-auth.session_token') ??
|
request.cookies.get('better-auth.session_token') ??
|
||||||
request.cookies.get('__Secure-better-auth.session_token')
|
request.cookies.get('__Secure-better-auth.session_token')
|
||||||
|
|
||||||
if (!sessionToken) {
|
if (!isPublic && !sessionToken) {
|
||||||
const loginUrl = new URL('/login', request.url)
|
const loginUrl = new URL('/login', request.url)
|
||||||
loginUrl.searchParams.set('callbackUrl', pathname)
|
loginUrl.searchParams.set('callbackUrl', pathname)
|
||||||
return NextResponse.redirect(loginUrl)
|
return NextResponse.redirect(loginUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. Subdomain Redirection / Rewrite
|
||||||
|
if (slug) {
|
||||||
|
// Paths that should not be rewritten into the slug folder
|
||||||
|
// because they are shared across the entire app
|
||||||
|
const SHARED_PATHS = ['/login', '/api', '/superadmin', '/registrierung', '/impressum', '/datenschutz', '/passwort-aendern']
|
||||||
|
const isSharedPath = SHARED_PATHS.some((p) => pathname.startsWith(p)) || pathname.startsWith('/_next')
|
||||||
|
|
||||||
|
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']
|
||||||
|
if (potentialSlug && !SHARED_PATHS.includes(potentialSlug)) {
|
||||||
|
// 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()
|
return NextResponse.next()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,15 @@ import type { NextConfig } from 'next'
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
transpilePackages: ['@innungsapp/shared'],
|
transpilePackages: ['@innungsapp/shared'],
|
||||||
|
output: 'standalone',
|
||||||
experimental: {},
|
experimental: {},
|
||||||
|
webpack: (config, { dev }) => {
|
||||||
|
if (dev) {
|
||||||
|
// Avoid filesystem cache writes on very low-disk dev machines (ENOSPC).
|
||||||
|
config.cache = false
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
},
|
||||||
// Serve uploaded files
|
// Serve uploaded files
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
return [
|
return [
|
||||||
|
|
|
||||||
|
|
@ -13,34 +13,36 @@
|
||||||
"type-check": "tsc --noEmit"
|
"type-check": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@google/genai": "^1.42.0",
|
||||||
"@innungsapp/shared": "workspace:*",
|
"@innungsapp/shared": "workspace:*",
|
||||||
|
"@tanstack/react-query": "^5.59.0",
|
||||||
"@trpc/client": "^11.0.0",
|
"@trpc/client": "^11.0.0",
|
||||||
"@trpc/react-query": "^11.0.0",
|
"@trpc/react-query": "^11.0.0",
|
||||||
"@trpc/server": "^11.0.0",
|
"@trpc/server": "^11.0.0",
|
||||||
"@tanstack/react-query": "^5.59.0",
|
|
||||||
"better-auth": "^1.2.0",
|
|
||||||
"next": "15.3.4",
|
|
||||||
"react": "^19.0.0",
|
|
||||||
"react-dom": "^19.0.0",
|
|
||||||
"zod": "^3.23.0",
|
|
||||||
"superjson": "^2.2.1",
|
|
||||||
"nodemailer": "^6.9.0",
|
|
||||||
"date-fns": "^3.6.0",
|
|
||||||
"@uiw/react-md-editor": "^4.0.4",
|
"@uiw/react-md-editor": "^4.0.4",
|
||||||
"lucide-react": "^0.460.0",
|
"better-auth": "^1.2.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"tailwind-merge": "^2.5.0"
|
"date-fns": "^3.6.0",
|
||||||
|
"lucide-react": "^0.460.0",
|
||||||
|
"next": "15.3.4",
|
||||||
|
"nodemailer": "^6.9.0",
|
||||||
|
"openai": "^6.22.0",
|
||||||
|
"react": "19.0.0",
|
||||||
|
"react-dom": "19.0.0",
|
||||||
|
"superjson": "^2.2.1",
|
||||||
|
"tailwind-merge": "^2.5.0",
|
||||||
|
"zod": "^3.23.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/react": "^19.0.0",
|
|
||||||
"@types/react-dom": "^19.0.0",
|
|
||||||
"@types/nodemailer": "^6.4.17",
|
"@types/nodemailer": "^6.4.17",
|
||||||
"tailwindcss": "^3.4.0",
|
"@types/react": "19.0.0",
|
||||||
|
"@types/react-dom": "19.0.0",
|
||||||
"autoprefixer": "^10.4.0",
|
"autoprefixer": "^10.4.0",
|
||||||
"postcss": "^8.4.0",
|
|
||||||
"typescript": "^5.6.0",
|
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
"eslint-config-next": "^15.0.0"
|
"eslint-config-next": "^15.0.0",
|
||||||
|
"postcss": "^8.4.0",
|
||||||
|
"tailwindcss": "^3.4.0",
|
||||||
|
"typescript": "^5.6.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 407 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 64 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.9 KiB |
|
|
@ -0,0 +1,20 @@
|
||||||
|
$pagePath = "C:\Users\a931627\Documents\stadtwerke-saas-analysis\innungsapp\apps\admin\.next\server\app\superadmin\page.js"
|
||||||
|
$outPath = "C:\Users\a931627\Documents\stadtwerke-saas-analysis\innungsapp\apps\admin\app\superadmin\actions.ts"
|
||||||
|
$text = Get-Content -Raw $pagePath
|
||||||
|
$sourceMarker = "sourceURL=webpack-internal:///(ssr)/./app/superadmin/actions.ts"
|
||||||
|
$idxSource = $text.IndexOf($sourceMarker)
|
||||||
|
if ($idxSource -lt 0) { throw 'sourceURL marker not found' }
|
||||||
|
$mapPrefix = "sourceMappingURL=data:application/json;charset=utf-8;base64,"
|
||||||
|
$idxMapStart = $text.LastIndexOf($mapPrefix, $idxSource)
|
||||||
|
if ($idxMapStart -lt 0) { throw 'source map prefix not found' }
|
||||||
|
$endMarker = "\\n//# sourceURL=webpack-internal:///(ssr)/./app/superadmin/actions.ts"
|
||||||
|
$idxMapEnd = $text.IndexOf($endMarker, $idxMapStart)
|
||||||
|
if ($idxMapEnd -lt 0) { throw 'source map end marker not found' }
|
||||||
|
$base64Start = $idxMapStart + $mapPrefix.Length
|
||||||
|
$base64 = $text.Substring($base64Start, $idxMapEnd - $base64Start)
|
||||||
|
$json = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($base64))
|
||||||
|
$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'
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
import { auth } from './lib/auth'
|
|
||||||
import { PrismaClient } from '@prisma/client'
|
|
||||||
|
|
||||||
const prisma = new PrismaClient()
|
|
||||||
|
|
||||||
async function seed() {
|
|
||||||
console.log('Cleaning up existing superadmin if exists...')
|
|
||||||
// Delete the existing user to avoid constraints
|
|
||||||
await prisma.user.deleteMany({
|
|
||||||
where: { email: 'superadmin@innungsapp.de' },
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log('Seeding superadmin via better-auth API...')
|
|
||||||
|
|
||||||
try {
|
|
||||||
const user = await auth.api.signUpEmail({
|
|
||||||
body: {
|
|
||||||
email: 'superadmin@innungsapp.de',
|
|
||||||
password: 'demo1234',
|
|
||||||
name: 'Super Admin',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Check if better-auth actually uses the Prisma DB directly here
|
|
||||||
// It should, as auth is configured with the prisma instance
|
|
||||||
console.log('Superadmin created successfully! ID:', user.user.id)
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Error creating superadmin:', err.message || err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
seed()
|
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => prisma.$disconnect())
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { newsRouter } from './news'
|
||||||
import { termineRouter } from './termine'
|
import { termineRouter } from './termine'
|
||||||
import { stellenRouter } from './stellen'
|
import { stellenRouter } from './stellen'
|
||||||
import { organizationsRouter } from './organizations'
|
import { organizationsRouter } from './organizations'
|
||||||
|
import { messagesRouter } from './messages'
|
||||||
|
|
||||||
export const appRouter = router({
|
export const appRouter = router({
|
||||||
members: membersRouter,
|
members: membersRouter,
|
||||||
|
|
@ -11,6 +12,7 @@ export const appRouter = router({
|
||||||
termine: termineRouter,
|
termine: termineRouter,
|
||||||
stellen: stellenRouter,
|
stellen: stellenRouter,
|
||||||
organizations: organizationsRouter,
|
organizations: organizationsRouter,
|
||||||
|
messages: messagesRouter,
|
||||||
})
|
})
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter
|
export type AppRouter = typeof appRouter
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,59 @@
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { router, memberProcedure, adminProcedure } from '../trpc'
|
import { router, memberProcedure, adminProcedure } from '../trpc'
|
||||||
import { auth } from '@/lib/auth'
|
import { auth, getSanitizedHeaders } from '@/lib/auth'
|
||||||
import { sendInviteEmail } from '@/lib/email'
|
import { sendInviteEmail, sendAdminCredentialsEmail } from '@/lib/email'
|
||||||
|
import crypto from 'node:crypto'
|
||||||
|
import { prisma } from '@innungsapp/shared'
|
||||||
|
// @ts-ignore — Better Auth exposes its password utilities via `better-auth/crypto`
|
||||||
|
import { hashPassword } from 'better-auth/crypto'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a credential (email+password) account for a user that has no such account yet.
|
||||||
|
* Better Auth stores passwords in the `account` table with providerId='credential'.
|
||||||
|
* Uses Better Auth's own `hashPassword` to ensure the hash format matches its verifyPassword.
|
||||||
|
*/
|
||||||
|
async function createCredentialAccount(userId: string, password: string): Promise<void> {
|
||||||
|
const hashed = await hashPassword(password)
|
||||||
|
await prisma.account.create({
|
||||||
|
data: {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
accountId: userId,
|
||||||
|
providerId: 'credential',
|
||||||
|
userId,
|
||||||
|
password: hashed,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a user + credential account directly in the DB, bypassing Better Auth's admin API.
|
||||||
|
* auth.api.createUser requires the calling session to have role='admin' in Better Auth's own
|
||||||
|
* user table, which our custom role system doesn't set. This avoids the 403 FORBIDDEN error.
|
||||||
|
*/
|
||||||
|
async function createUserDirectly(opts: { email: string; name: string; password: string; mustChangePassword?: boolean }) {
|
||||||
|
const userId = crypto.randomUUID()
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
id: userId,
|
||||||
|
name: opts.name,
|
||||||
|
email: opts.email,
|
||||||
|
emailVerified: false,
|
||||||
|
mustChangePassword: opts.mustChangePassword ?? false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// Try better-auth API first (guaranteed correct hash format).
|
||||||
|
// Falls back to direct DB write if API fails (e.g. admin permissions not available).
|
||||||
|
try {
|
||||||
|
const authHeaders = await getSanitizedHeaders()
|
||||||
|
await auth.api.updateUser({
|
||||||
|
body: { userId, password: opts.password },
|
||||||
|
headers: authHeaders,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
await createCredentialAccount(userId, opts.password)
|
||||||
|
}
|
||||||
|
return { id: userId }
|
||||||
|
}
|
||||||
|
|
||||||
const MemberInput = z.object({
|
const MemberInput = z.object({
|
||||||
name: z.string().min(2),
|
name: z.string().min(2),
|
||||||
|
|
@ -13,6 +65,8 @@ const MemberInput = z.object({
|
||||||
status: z.enum(['aktiv', 'ruhend', 'ausgetreten']).default('aktiv'),
|
status: z.enum(['aktiv', 'ruhend', 'ausgetreten']).default('aktiv'),
|
||||||
istAusbildungsbetrieb: z.boolean().default(false),
|
istAusbildungsbetrieb: z.boolean().default(false),
|
||||||
seit: z.number().int().min(1900).max(2100).optional(),
|
seit: z.number().int().min(1900).max(2100).optional(),
|
||||||
|
role: z.enum(['member', 'admin']).optional().default('member'),
|
||||||
|
password: z.preprocess((val) => (val === '' ? undefined : val), z.string().min(8).optional()),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const membersRouter = router({
|
export const membersRouter = router({
|
||||||
|
|
@ -49,67 +103,270 @@ export const membersRouter = router({
|
||||||
return members
|
return members
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a single member by ID
|
|
||||||
*/
|
|
||||||
byId: memberProcedure
|
byId: memberProcedure
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const member = await ctx.prisma.member.findFirst({
|
let member = await ctx.prisma.member.findFirst({
|
||||||
where: { id: input.id, orgId: ctx.orgId },
|
where: { id: input.id, orgId: ctx.orgId },
|
||||||
})
|
})
|
||||||
if (!member) throw new Error('Member not found')
|
|
||||||
return member
|
let role = 'member'
|
||||||
|
if (member?.userId) {
|
||||||
|
const ur = await ctx.prisma.userRole.findUnique({ where: { orgId_userId: { orgId: ctx.orgId, userId: member.userId } } })
|
||||||
|
if (ur && ur.role === 'admin') role = 'admin'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!member) {
|
||||||
|
// Try finding the member by userId (list page uses userId as ID for admins)
|
||||||
|
const memberByUserId = await ctx.prisma.member.findFirst({
|
||||||
|
where: { userId: input.id, orgId: ctx.orgId },
|
||||||
|
})
|
||||||
|
if (memberByUserId) {
|
||||||
|
const ur2 = await ctx.prisma.userRole.findUnique({
|
||||||
|
where: { orgId_userId: { orgId: ctx.orgId, userId: input.id } },
|
||||||
|
})
|
||||||
|
if (ur2?.role === 'admin') role = 'admin'
|
||||||
|
return { ...memberByUserId, role }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Check if the ID belongs to a user who is an admin in this org
|
||||||
|
const adminRole = await ctx.prisma.userRole.findUnique({
|
||||||
|
where: { orgId_userId: { orgId: ctx.orgId, userId: input.id } },
|
||||||
|
include: { user: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!adminRole) {
|
||||||
|
// Last resort A: find member by ID regardless of org (org mismatch scenario)
|
||||||
|
const memberAnyOrg = await ctx.prisma.member.findUnique({ where: { id: input.id } })
|
||||||
|
if (memberAnyOrg) {
|
||||||
|
const callerHasAccess = await ctx.prisma.userRole.findFirst({
|
||||||
|
where: { userId: ctx.session.user.id, orgId: memberAnyOrg.orgId, role: 'admin' }
|
||||||
|
})
|
||||||
|
if (callerHasAccess) {
|
||||||
|
const ur = memberAnyOrg.userId
|
||||||
|
? await ctx.prisma.userRole.findUnique({
|
||||||
|
where: { orgId_userId: { orgId: memberAnyOrg.orgId, userId: memberAnyOrg.userId } }
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
return { ...memberAnyOrg, role: ur?.role ?? 'member' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort B: input.id is a userId whose UserRole is in a different org than ctx.orgId
|
||||||
|
const roleAnyOrg = await ctx.prisma.userRole.findFirst({
|
||||||
|
where: { userId: input.id },
|
||||||
|
include: { user: true },
|
||||||
|
})
|
||||||
|
if (roleAnyOrg) {
|
||||||
|
const callerHasAccess = await ctx.prisma.userRole.findFirst({
|
||||||
|
where: { userId: ctx.session.user.id, orgId: roleAnyOrg.orgId, role: 'admin' }
|
||||||
|
})
|
||||||
|
if (callerHasAccess) {
|
||||||
|
const memberRecord = await ctx.prisma.member.findFirst({
|
||||||
|
where: { userId: input.id, orgId: roleAnyOrg.orgId }
|
||||||
|
})
|
||||||
|
if (memberRecord) return { ...memberRecord, role: roleAnyOrg.role }
|
||||||
|
// Admin without member record — return mock
|
||||||
|
return {
|
||||||
|
id: roleAnyOrg.userId,
|
||||||
|
orgId: roleAnyOrg.orgId,
|
||||||
|
userId: roleAnyOrg.userId,
|
||||||
|
name: roleAnyOrg.user.name,
|
||||||
|
betrieb: 'Administrator',
|
||||||
|
sparte: 'Sonderfunktion',
|
||||||
|
ort: '',
|
||||||
|
telefon: '',
|
||||||
|
email: roleAnyOrg.user.email,
|
||||||
|
status: 'aktiv',
|
||||||
|
istAusbildungsbetrieb: false,
|
||||||
|
seit: new Date().getFullYear(),
|
||||||
|
avatarUrl: null,
|
||||||
|
pushToken: null,
|
||||||
|
createdAt: roleAnyOrg.createdAt,
|
||||||
|
updatedAt: roleAnyOrg.createdAt,
|
||||||
|
role: roleAnyOrg.role,
|
||||||
|
} as any
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Member not found')
|
||||||
|
}
|
||||||
|
if (adminRole.role !== 'admin') throw new Error('Member not found')
|
||||||
|
|
||||||
|
// Mock a Member object so the frontend form doesn't crash
|
||||||
|
member = {
|
||||||
|
id: adminRole.userId, // use userId here to update
|
||||||
|
orgId: ctx.orgId,
|
||||||
|
userId: adminRole.userId,
|
||||||
|
name: adminRole.user.name,
|
||||||
|
betrieb: 'Administrator',
|
||||||
|
sparte: 'Sonderfunktion',
|
||||||
|
ort: '',
|
||||||
|
telefon: '',
|
||||||
|
email: adminRole.user.email,
|
||||||
|
status: 'aktiv',
|
||||||
|
istAusbildungsbetrieb: false,
|
||||||
|
seit: new Date().getFullYear(),
|
||||||
|
avatarUrl: null,
|
||||||
|
pushToken: null,
|
||||||
|
createdAt: adminRole.createdAt,
|
||||||
|
updatedAt: adminRole.createdAt,
|
||||||
|
} as any
|
||||||
|
role = 'admin'
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...member, role }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new member (admin only)
|
|
||||||
*/
|
|
||||||
create: adminProcedure.input(MemberInput).mutation(async ({ ctx, input }) => {
|
create: adminProcedure.input(MemberInput).mutation(async ({ ctx, input }) => {
|
||||||
|
const { role, password, ...rest } = input
|
||||||
|
|
||||||
|
// 1. Create the member record
|
||||||
const member = await ctx.prisma.member.create({
|
const member = await ctx.prisma.member.create({
|
||||||
data: {
|
data: { ...rest, orgId: ctx.orgId },
|
||||||
...input,
|
|
||||||
orgId: ctx.orgId,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 2. Create a User account if a password was provided OR role is 'admin',
|
||||||
|
// so the role is always persisted (no email sent here).
|
||||||
|
if (password || role === 'admin') {
|
||||||
|
try {
|
||||||
|
const authHeaders = await getSanitizedHeaders()
|
||||||
|
const existing = await ctx.prisma.user.findUnique({ where: { email: input.email } })
|
||||||
|
let userId: string | undefined = existing?.id
|
||||||
|
const effectivePassword = password || crypto.randomBytes(8).toString('hex')
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
// Create user + credential account directly via Prisma.
|
||||||
|
// auth.api.createUser requires the caller to have role='admin' in Better Auth's own
|
||||||
|
// user table (not our custom UserRole table), which causes a 403 FORBIDDEN.
|
||||||
|
const newUserRecord = await createUserDirectly({
|
||||||
|
name: input.name,
|
||||||
|
email: input.email,
|
||||||
|
password: effectivePassword,
|
||||||
|
mustChangePassword: true,
|
||||||
|
})
|
||||||
|
userId = newUserRecord.id
|
||||||
|
} else if (password) {
|
||||||
|
// User exists and a password was explicitly set.
|
||||||
|
// Check if they already have a credential account; if not, create one directly.
|
||||||
|
const credAccount = await ctx.prisma.account.findFirst({
|
||||||
|
where: { userId, providerId: 'credential' }
|
||||||
|
})
|
||||||
|
if (credAccount) {
|
||||||
|
// Credential account exists — update the password via Better Auth
|
||||||
|
await auth.api.updateUser({ body: { userId, password, name: input.name }, headers: authHeaders })
|
||||||
|
} else {
|
||||||
|
// No credential account yet — create one directly in the DB
|
||||||
|
await createCredentialAccount(userId, password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
await ctx.prisma.member.update({ where: { id: member.id }, data: { userId } })
|
||||||
|
await ctx.prisma.userRole.upsert({
|
||||||
|
where: { orgId_userId: { orgId: ctx.orgId, userId } },
|
||||||
|
create: { orgId: ctx.orgId, userId, role: role ?? 'member' },
|
||||||
|
update: { role: role ?? 'member' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to create user account during member creation', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return member
|
return member
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create member + send invite email (admin only)
|
* Create member + send invite email (admin only)
|
||||||
*/
|
*/
|
||||||
invite: adminProcedure
|
invite: adminProcedure
|
||||||
.input(MemberInput)
|
.input(MemberInput)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const { role, password, ...memberData } = input
|
||||||
|
const authHeaders = await getSanitizedHeaders()
|
||||||
|
|
||||||
// 1. Create member record
|
// 1. Create member record
|
||||||
const member = await ctx.prisma.member.create({
|
const member = await ctx.prisma.member.create({
|
||||||
data: { ...input, orgId: ctx.orgId },
|
data: { ...memberData, orgId: ctx.orgId },
|
||||||
})
|
})
|
||||||
|
|
||||||
// 2. Create/get User via better-auth admin
|
|
||||||
try {
|
|
||||||
await auth.api.createUser({
|
|
||||||
body: {
|
|
||||||
name: input.name,
|
|
||||||
email: input.email,
|
|
||||||
role: 'user',
|
|
||||||
password: undefined,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
// User may already exist — that's ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Send magic link
|
|
||||||
const org = await ctx.prisma.organization.findUniqueOrThrow({
|
const org = await ctx.prisma.organization.findUniqueOrThrow({
|
||||||
where: { id: ctx.orgId },
|
where: { id: ctx.orgId },
|
||||||
})
|
})
|
||||||
await sendInviteEmail({
|
|
||||||
to: input.email,
|
// 2. Create/get User directly via Prisma to avoid auth.api.createUser 403 FORBIDDEN.
|
||||||
memberName: input.name,
|
// (Better Auth's admin API requires the caller's session user to have role='admin' in
|
||||||
orgName: org.name,
|
// its own user table, which our custom UserRole system doesn't set.)
|
||||||
apiUrl: process.env.BETTER_AUTH_URL!,
|
let targetUserId: string | undefined
|
||||||
})
|
try {
|
||||||
|
const effectivePassword = password || (role === 'admin' ? crypto.randomBytes(6).toString('hex') : undefined)
|
||||||
|
|
||||||
|
if (effectivePassword) {
|
||||||
|
const newUserId = (await createUserDirectly({
|
||||||
|
name: input.name,
|
||||||
|
email: input.email,
|
||||||
|
password: effectivePassword,
|
||||||
|
mustChangePassword: !password && role === 'admin',
|
||||||
|
})).id
|
||||||
|
targetUserId = newUserId
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetUserId) {
|
||||||
|
// link user to member
|
||||||
|
await ctx.prisma.member.update({
|
||||||
|
where: { id: member.id },
|
||||||
|
data: { userId: targetUserId }
|
||||||
|
})
|
||||||
|
|
||||||
|
// if admin, set role
|
||||||
|
if (role === 'admin') {
|
||||||
|
await ctx.prisma.userRole.upsert({
|
||||||
|
where: { orgId_userId: { orgId: ctx.orgId, userId: targetUserId } },
|
||||||
|
create: { orgId: ctx.orgId, userId: targetUserId, role: 'admin' },
|
||||||
|
update: { role: 'admin' }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Send admin credentials
|
||||||
|
await sendAdminCredentialsEmail({
|
||||||
|
to: input.email,
|
||||||
|
adminName: input.name,
|
||||||
|
orgName: org.name,
|
||||||
|
password: password!,
|
||||||
|
loginUrl: process.env.BETTER_AUTH_URL!
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// User may already exist
|
||||||
|
const existingUser = await ctx.prisma.user.findUnique({ where: { email: input.email } })
|
||||||
|
if (existingUser) {
|
||||||
|
targetUserId = existingUser.id
|
||||||
|
await ctx.prisma.member.update({
|
||||||
|
where: { id: member.id },
|
||||||
|
data: { userId: targetUserId }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (role === 'admin') {
|
||||||
|
await ctx.prisma.userRole.upsert({
|
||||||
|
where: { orgId_userId: { orgId: ctx.orgId, userId: targetUserId } },
|
||||||
|
create: { orgId: ctx.orgId, userId: targetUserId, role: 'admin' },
|
||||||
|
update: { role: 'admin' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Send magic link for members (if not admin or if admin creation failed to send credentials)
|
||||||
|
if (role === 'member') {
|
||||||
|
await sendInviteEmail({
|
||||||
|
to: input.email,
|
||||||
|
memberName: input.name,
|
||||||
|
orgName: org.name,
|
||||||
|
apiUrl: process.env.BETTER_AUTH_URL!,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return member
|
return member
|
||||||
}),
|
}),
|
||||||
|
|
@ -118,20 +375,188 @@ export const membersRouter = router({
|
||||||
* Update member (admin only)
|
* Update member (admin only)
|
||||||
*/
|
*/
|
||||||
update: adminProcedure
|
update: adminProcedure
|
||||||
.input(z.object({ id: z.string(), data: MemberInput.partial() }))
|
.input(z.object({ id: z.string(), data: MemberInput.partial().extend({ role: z.enum(['member', 'admin']).optional() }) }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const member = await ctx.prisma.member.updateMany({
|
const { role, password, ...memberData } = input.data
|
||||||
|
|
||||||
|
let member = await ctx.prisma.member.findFirst({
|
||||||
where: { id: input.id, orgId: ctx.orgId },
|
where: { id: input.id, orgId: ctx.orgId },
|
||||||
data: input.data,
|
|
||||||
})
|
})
|
||||||
// Keep user.name in sync when member name changes
|
|
||||||
if (input.data.name) {
|
// If not found by member ID, try by userId (list page links use userId for admins)
|
||||||
const m = await ctx.prisma.member.findFirst({ where: { id: input.id }, select: { userId: true } })
|
if (!member) {
|
||||||
if (m?.userId) {
|
member = await ctx.prisma.member.findFirst({
|
||||||
await ctx.prisma.user.update({ where: { id: m.userId }, data: { name: input.data.name } })
|
where: { userId: input.id, orgId: ctx.orgId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// For existing members, targetUserId is their associated user ID (can be null).
|
||||||
|
// For fallback admins (no member record), input.id is the User ID.
|
||||||
|
let targetUserId = member ? member.userId : input.id
|
||||||
|
|
||||||
|
// If they don't have a User record yet, but we want to update their role to Admin,
|
||||||
|
// we need to pre-create their User record so we can attach the UserRole.
|
||||||
|
if (member && !targetUserId && role === 'admin') {
|
||||||
|
const email = memberData.email || member.email
|
||||||
|
const name = memberData.name || member.name
|
||||||
|
// Always generate a password – without one Better Auth creates no credential account
|
||||||
|
// and the user can never log in with email/password.
|
||||||
|
const effectivePassword = password || crypto.randomBytes(8).toString('hex')
|
||||||
|
try {
|
||||||
|
const user = await ctx.prisma.user.findUnique({ where: { email } })
|
||||||
|
if (user) {
|
||||||
|
targetUserId = user.id
|
||||||
|
// If the existing user has no credential account (e.g. OAuth-only), set a password now
|
||||||
|
const credAccount = await ctx.prisma.account.findFirst({
|
||||||
|
where: { userId: user.id, providerId: 'credential' }
|
||||||
|
})
|
||||||
|
if (!credAccount) {
|
||||||
|
// No credential account — create one directly (updateUser can't create from scratch)
|
||||||
|
await createCredentialAccount(user.id, effectivePassword)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const newUserId = (await createUserDirectly({
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
password: effectivePassword,
|
||||||
|
mustChangePassword: !password,
|
||||||
|
})).id
|
||||||
|
targetUserId = newUserId
|
||||||
|
}
|
||||||
|
if (targetUserId) {
|
||||||
|
await ctx.prisma.member.update({
|
||||||
|
where: { id: member.id },
|
||||||
|
data: { userId: targetUserId }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to pre-create user for pending invitee admin upgrade", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return member
|
|
||||||
|
if (member) {
|
||||||
|
if (Object.keys(memberData).length > 0) {
|
||||||
|
await ctx.prisma.member.update({
|
||||||
|
where: { id: member.id },
|
||||||
|
data: memberData as any,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback: Creating skeleton member for former pure-admin that is now turning to member
|
||||||
|
const existingAdmin = await ctx.prisma.userRole.findUnique({
|
||||||
|
where: { orgId_userId: { orgId: ctx.orgId, userId: targetUserId as string } }
|
||||||
|
})
|
||||||
|
if (!existingAdmin || existingAdmin.role !== 'admin') throw new Error('Member not found')
|
||||||
|
|
||||||
|
// Since we are creating a member profile, ensure required fields
|
||||||
|
const createData = {
|
||||||
|
betrieb: memberData.betrieb || 'Administrator',
|
||||||
|
sparte: memberData.sparte || 'Sonderfunktion',
|
||||||
|
ort: memberData.ort || '',
|
||||||
|
telefon: memberData.telefon || '',
|
||||||
|
email: memberData.email || 'no-reply@innungsapp.de',
|
||||||
|
status: memberData.status || 'aktiv',
|
||||||
|
name: memberData.name || 'Unbekannt',
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingMemberByUserId = await ctx.prisma.member.findFirst({
|
||||||
|
where: { userId: targetUserId as string, orgId: ctx.orgId }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existingMemberByUserId) {
|
||||||
|
// Member record already exists — just update it with any new data
|
||||||
|
await ctx.prisma.member.update({
|
||||||
|
where: { id: existingMemberByUserId.id },
|
||||||
|
data: createData as any,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await ctx.prisma.member.create({
|
||||||
|
data: {
|
||||||
|
...createData,
|
||||||
|
orgId: ctx.orgId,
|
||||||
|
userId: targetUserId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role && targetUserId) {
|
||||||
|
// Update the role in UserRole
|
||||||
|
await ctx.prisma.userRole.upsert({
|
||||||
|
where: { orgId_userId: { orgId: ctx.orgId, userId: targetUserId } },
|
||||||
|
create: { orgId: ctx.orgId, userId: targetUserId, role },
|
||||||
|
update: { role }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// When promoting to admin, ensure a credential (email+password) account exists
|
||||||
|
// so the user can log in to the admin dashboard.
|
||||||
|
if (role === 'admin' && targetUserId && !password) {
|
||||||
|
const credAccount = await ctx.prisma.account.findFirst({
|
||||||
|
where: { userId: targetUserId, providerId: 'credential' },
|
||||||
|
})
|
||||||
|
if (!credAccount) {
|
||||||
|
const generatedPassword = crypto.randomBytes(8).toString('hex')
|
||||||
|
await createCredentialAccount(targetUserId, generatedPassword)
|
||||||
|
try {
|
||||||
|
const targetUser = await ctx.prisma.user.findUnique({ where: { id: targetUserId } })
|
||||||
|
const org = await ctx.prisma.organization.findUnique({ where: { id: ctx.orgId } })
|
||||||
|
if (targetUser && org) {
|
||||||
|
await sendAdminCredentialsEmail({
|
||||||
|
to: targetUser.email,
|
||||||
|
adminName: targetUser.name,
|
||||||
|
orgName: org.name,
|
||||||
|
password: generatedPassword,
|
||||||
|
loginUrl: process.env.BETTER_AUTH_URL!,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to send admin credentials email after auto-credential creation', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update password if provided
|
||||||
|
if (password && targetUserId) {
|
||||||
|
// Check if the user already has a credential account.
|
||||||
|
// auth.api.updateUser can update an existing credential account password, but CANNOT create one.
|
||||||
|
const existingCredAccount = await ctx.prisma.account.findFirst({
|
||||||
|
where: { userId: targetUserId, providerId: 'credential' }
|
||||||
|
})
|
||||||
|
if (existingCredAccount) {
|
||||||
|
const authHeaders = await getSanitizedHeaders()
|
||||||
|
let nameForUpdate = memberData.name
|
||||||
|
if (!nameForUpdate) {
|
||||||
|
const existingUser = await ctx.prisma.user.findUnique({ where: { id: targetUserId }, select: { name: true } })
|
||||||
|
nameForUpdate = existingUser?.name ?? undefined
|
||||||
|
}
|
||||||
|
await auth.api.updateUser({
|
||||||
|
body: {
|
||||||
|
userId: targetUserId,
|
||||||
|
password,
|
||||||
|
...(nameForUpdate && { name: nameForUpdate }),
|
||||||
|
},
|
||||||
|
headers: authHeaders,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// No credential account — create one from scratch using Better Auth's own hash format
|
||||||
|
await createCredentialAccount(targetUserId, password)
|
||||||
|
}
|
||||||
|
// Admin has set a password → user must change it on next login
|
||||||
|
await ctx.prisma.user.update({
|
||||||
|
where: { id: targetUserId },
|
||||||
|
data: { mustChangePassword: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep user.name in sync when member name changes
|
||||||
|
if (memberData.name && targetUserId) {
|
||||||
|
const user = await ctx.prisma.user.findUnique({ where: { id: targetUserId } })
|
||||||
|
if (user) {
|
||||||
|
await ctx.prisma.user.update({ where: { id: targetUserId }, data: { name: memberData.name } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return member || { success: true }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -165,4 +590,84 @@ export const membersRouter = router({
|
||||||
})
|
})
|
||||||
return member
|
return member
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update own member profile
|
||||||
|
*/
|
||||||
|
updateMe: memberProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
name: z.string().min(2).optional(),
|
||||||
|
email: z.string().email().optional(),
|
||||||
|
telefon: z.string().optional(),
|
||||||
|
ort: z.string().min(2).optional(),
|
||||||
|
betrieb: z.string().min(2).optional(),
|
||||||
|
sparte: z.string().min(2).optional(),
|
||||||
|
istAusbildungsbetrieb: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const member = await ctx.prisma.member.findFirst({
|
||||||
|
where: { userId: ctx.session.user.id, orgId: ctx.orgId },
|
||||||
|
})
|
||||||
|
if (!member) throw new Error('Member not found')
|
||||||
|
|
||||||
|
const updated = await ctx.prisma.member.update({
|
||||||
|
where: { id: member.id },
|
||||||
|
data: input,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Keep user.name in sync when member name changes
|
||||||
|
if (input.name) {
|
||||||
|
await ctx.prisma.user.update({
|
||||||
|
where: { id: ctx.session.user.id },
|
||||||
|
data: { name: input.name },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return updated
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete member (admin only)
|
||||||
|
*/
|
||||||
|
delete: adminProcedure
|
||||||
|
.input(z.object({ id: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const member = await ctx.prisma.member.findFirst({
|
||||||
|
where: { id: input.id, orgId: ctx.orgId },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!member) {
|
||||||
|
// Fallback for user-based "pure" admins
|
||||||
|
const adminRole = await ctx.prisma.userRole.findUnique({
|
||||||
|
where: { orgId_userId: { orgId: ctx.orgId, userId: input.id } }
|
||||||
|
})
|
||||||
|
if (!adminRole) throw new Error('Member not found')
|
||||||
|
|
||||||
|
if (adminRole.userId === ctx.session.user.id) {
|
||||||
|
throw new Error('Sie können Ihren eigenen Account nicht löschen.')
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.prisma.userRole.delete({ where: { id: adminRole.id } })
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (member.userId === ctx.session.user.id) {
|
||||||
|
throw new Error('Sie können Ihren eigenen Account nicht löschen.')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Remove UserRole link if exists
|
||||||
|
if (member.userId) {
|
||||||
|
await ctx.prisma.userRole.deleteMany({
|
||||||
|
where: { orgId: ctx.orgId, userId: member.userId }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Delete member profile
|
||||||
|
await ctx.prisma.member.delete({
|
||||||
|
where: { id: member.id }
|
||||||
|
})
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,184 @@
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { router, memberProcedure } from '../trpc'
|
||||||
|
import { TRPCError } from '@trpc/server'
|
||||||
|
|
||||||
|
export const messagesRouter = router({
|
||||||
|
// List all conversations for the current member
|
||||||
|
getConversations: memberProcedure.query(async ({ ctx }) => {
|
||||||
|
const member = await ctx.prisma.member.findFirst({
|
||||||
|
where: { userId: ctx.session.user.id, orgId: ctx.orgId },
|
||||||
|
})
|
||||||
|
if (!member) throw new TRPCError({ code: 'NOT_FOUND' })
|
||||||
|
|
||||||
|
const convMembers = await ctx.prisma.conversationMember.findMany({
|
||||||
|
where: { memberId: member.id },
|
||||||
|
include: {
|
||||||
|
conversation: {
|
||||||
|
include: {
|
||||||
|
members: {
|
||||||
|
include: { member: { select: { id: true, name: true, betrieb: true, avatarUrl: true } } },
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 1,
|
||||||
|
select: { body: true, createdAt: true, senderId: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { conversation: { updatedAt: 'desc' } },
|
||||||
|
})
|
||||||
|
|
||||||
|
return convMembers.map((cm) => {
|
||||||
|
const other = cm.conversation.members.find((m) => m.memberId !== member.id)?.member
|
||||||
|
const lastMsg = cm.conversation.messages[0] ?? null
|
||||||
|
const unread =
|
||||||
|
lastMsg &&
|
||||||
|
(!cm.lastReadAt || lastMsg.createdAt > cm.lastReadAt) &&
|
||||||
|
lastMsg.senderId !== member.id
|
||||||
|
return {
|
||||||
|
conversationId: cm.conversationId,
|
||||||
|
other,
|
||||||
|
lastMessage: lastMsg,
|
||||||
|
hasUnread: !!unread,
|
||||||
|
updatedAt: cm.conversation.updatedAt,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Get or create a 1-on-1 conversation between current member and another
|
||||||
|
getOrCreate: memberProcedure
|
||||||
|
.input(z.object({ otherMemberId: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const member = await ctx.prisma.member.findFirst({
|
||||||
|
where: { userId: ctx.session.user.id, orgId: ctx.orgId },
|
||||||
|
})
|
||||||
|
if (!member) throw new TRPCError({ code: 'NOT_FOUND' })
|
||||||
|
|
||||||
|
const other = await ctx.prisma.member.findFirst({
|
||||||
|
where: { id: input.otherMemberId, orgId: ctx.orgId },
|
||||||
|
})
|
||||||
|
if (!other) throw new TRPCError({ code: 'NOT_FOUND', message: 'Mitglied nicht gefunden' })
|
||||||
|
|
||||||
|
// Find existing conversation between the two members in this org
|
||||||
|
const existing = await ctx.prisma.conversation.findFirst({
|
||||||
|
where: {
|
||||||
|
orgId: ctx.orgId,
|
||||||
|
members: { every: { memberId: { in: [member.id, other.id] } } },
|
||||||
|
AND: [
|
||||||
|
{ members: { some: { memberId: member.id } } },
|
||||||
|
{ members: { some: { memberId: other.id } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existing) return { conversationId: existing.id }
|
||||||
|
|
||||||
|
const conv = await ctx.prisma.conversation.create({
|
||||||
|
data: {
|
||||||
|
orgId: ctx.orgId,
|
||||||
|
members: {
|
||||||
|
create: [{ memberId: member.id }, { memberId: other.id }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return { conversationId: conv.id }
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Get messages for a conversation
|
||||||
|
getMessages: memberProcedure
|
||||||
|
.input(z.object({ conversationId: z.string(), cursor: z.string().optional() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const member = await ctx.prisma.member.findFirst({
|
||||||
|
where: { userId: ctx.session.user.id, orgId: ctx.orgId },
|
||||||
|
})
|
||||||
|
if (!member) throw new TRPCError({ code: 'NOT_FOUND' })
|
||||||
|
|
||||||
|
// Verify membership in conversation
|
||||||
|
const cm = await ctx.prisma.conversationMember.findUnique({
|
||||||
|
where: { conversationId_memberId: { conversationId: input.conversationId, memberId: member.id } },
|
||||||
|
})
|
||||||
|
if (!cm) throw new TRPCError({ code: 'FORBIDDEN' })
|
||||||
|
|
||||||
|
const messages = await ctx.prisma.message.findMany({
|
||||||
|
where: { conversationId: input.conversationId },
|
||||||
|
include: { sender: { select: { id: true, name: true, avatarUrl: true } } },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 40,
|
||||||
|
...(input.cursor ? { skip: 1, cursor: { id: input.cursor } } : {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mark as read
|
||||||
|
await ctx.prisma.conversationMember.update({
|
||||||
|
where: { conversationId_memberId: { conversationId: input.conversationId, memberId: member.id } },
|
||||||
|
data: { lastReadAt: new Date() },
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages: messages.reverse(),
|
||||||
|
nextCursor: messages.length === 40 ? messages[0]?.id : undefined,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Send a message
|
||||||
|
sendMessage: memberProcedure
|
||||||
|
.input(z.object({ conversationId: z.string(), body: z.string().min(1).max(2000) }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const member = await ctx.prisma.member.findFirst({
|
||||||
|
where: { userId: ctx.session.user.id, orgId: ctx.orgId },
|
||||||
|
})
|
||||||
|
if (!member) throw new TRPCError({ code: 'NOT_FOUND' })
|
||||||
|
|
||||||
|
const cm = await ctx.prisma.conversationMember.findUnique({
|
||||||
|
where: { conversationId_memberId: { conversationId: input.conversationId, memberId: member.id } },
|
||||||
|
})
|
||||||
|
if (!cm) throw new TRPCError({ code: 'FORBIDDEN' })
|
||||||
|
|
||||||
|
const message = await ctx.prisma.message.create({
|
||||||
|
data: {
|
||||||
|
conversationId: input.conversationId,
|
||||||
|
senderId: member.id,
|
||||||
|
body: input.body.trim(),
|
||||||
|
},
|
||||||
|
include: { sender: { select: { id: true, name: true, avatarUrl: true } } },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update conversation updatedAt so it sorts to top
|
||||||
|
await ctx.prisma.conversation.update({
|
||||||
|
where: { id: input.conversationId },
|
||||||
|
data: { updatedAt: new Date() },
|
||||||
|
})
|
||||||
|
|
||||||
|
return message
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Count total unread conversations
|
||||||
|
unreadCount: memberProcedure.query(async ({ ctx }) => {
|
||||||
|
const member = await ctx.prisma.member.findFirst({
|
||||||
|
where: { userId: ctx.session.user.id, orgId: ctx.orgId },
|
||||||
|
})
|
||||||
|
if (!member) return { count: 0 }
|
||||||
|
|
||||||
|
const convMembers = await ctx.prisma.conversationMember.findMany({
|
||||||
|
where: { memberId: member.id },
|
||||||
|
include: {
|
||||||
|
conversation: {
|
||||||
|
include: {
|
||||||
|
messages: {
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 1,
|
||||||
|
select: { createdAt: true, senderId: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const count = convMembers.filter((cm) => {
|
||||||
|
const last = cm.conversation.messages[0]
|
||||||
|
return last && last.senderId !== member.id && (!cm.lastReadAt || last.createdAt > cm.lastReadAt)
|
||||||
|
}).length
|
||||||
|
|
||||||
|
return { count }
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
@ -7,6 +7,16 @@ const NewsInput = z.object({
|
||||||
body: z.string().min(10),
|
body: z.string().min(10),
|
||||||
kategorie: z.enum(['Wichtig', 'Pruefung', 'Foerderung', 'Veranstaltung', 'Allgemein']),
|
kategorie: z.enum(['Wichtig', 'Pruefung', 'Foerderung', 'Veranstaltung', 'Allgemein']),
|
||||||
publishedAt: z.string().datetime().optional().nullable(),
|
publishedAt: z.string().datetime().optional().nullable(),
|
||||||
|
attachments: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
name: z.string(),
|
||||||
|
storagePath: z.string(),
|
||||||
|
sizeBytes: z.number().int().optional().nullable(),
|
||||||
|
mimeType: z.string().optional().nullable(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const newsRouter = router({
|
export const newsRouter = router({
|
||||||
|
|
@ -104,6 +114,16 @@ export const newsRouter = router({
|
||||||
body: input.body,
|
body: input.body,
|
||||||
kategorie: input.kategorie,
|
kategorie: input.kategorie,
|
||||||
publishedAt: input.publishedAt ? new Date(input.publishedAt) : null,
|
publishedAt: input.publishedAt ? new Date(input.publishedAt) : null,
|
||||||
|
...(input.attachments && input.attachments.length > 0 && {
|
||||||
|
attachments: {
|
||||||
|
create: input.attachments.map((a) => ({
|
||||||
|
name: a.name,
|
||||||
|
storagePath: a.storagePath,
|
||||||
|
sizeBytes: a.sizeBytes,
|
||||||
|
mimeType: a.mimeType,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -121,23 +141,44 @@ export const newsRouter = router({
|
||||||
update: adminProcedure
|
update: adminProcedure
|
||||||
.input(z.object({ id: z.string(), data: NewsInput.partial() }))
|
.input(z.object({ id: z.string(), data: NewsInput.partial() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const wasUnpublished = await ctx.prisma.news.findFirst({
|
const existing = await ctx.prisma.news.findFirst({
|
||||||
where: { id: input.id, orgId: ctx.orgId, publishedAt: null },
|
where: { id: input.id, orgId: ctx.orgId },
|
||||||
})
|
})
|
||||||
|
|
||||||
const news = await ctx.prisma.news.updateMany({
|
if (!existing) {
|
||||||
where: { id: input.id, orgId: ctx.orgId },
|
throw new Error('News not found or access denied')
|
||||||
|
}
|
||||||
|
|
||||||
|
const wasUnpublished = !existing.publishedAt
|
||||||
|
|
||||||
|
const { attachments, ...restData } = input.data
|
||||||
|
|
||||||
|
const news = await ctx.prisma.news.update({
|
||||||
|
where: { id: input.id },
|
||||||
data: {
|
data: {
|
||||||
...input.data,
|
...restData,
|
||||||
publishedAt: input.data.publishedAt
|
publishedAt: restData.publishedAt
|
||||||
? new Date(input.data.publishedAt)
|
? new Date(restData.publishedAt)
|
||||||
: undefined,
|
: restData.publishedAt === null
|
||||||
|
? null
|
||||||
|
: undefined,
|
||||||
|
...(attachments && {
|
||||||
|
attachments: {
|
||||||
|
deleteMany: {},
|
||||||
|
create: attachments.map((a) => ({
|
||||||
|
name: a.name,
|
||||||
|
storagePath: a.storagePath,
|
||||||
|
sizeBytes: a.sizeBytes,
|
||||||
|
mimeType: a.mimeType,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Trigger push if just published
|
// Trigger push if just published
|
||||||
if (wasUnpublished && input.data.publishedAt && input.data.title) {
|
if (wasUnpublished && news.publishedAt && news.title) {
|
||||||
sendPushNotifications(ctx.orgId, input.data.title).catch(console.error)
|
sendPushNotifications(ctx.orgId, news.title).catch(console.error)
|
||||||
}
|
}
|
||||||
|
|
||||||
return news
|
return news
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
|
||||||
export const memberProcedure = protectedProcedure.use(async ({ ctx, next }) => {
|
export const memberProcedure = protectedProcedure.use(async ({ ctx, next }) => {
|
||||||
const userRole = await ctx.prisma.userRole.findFirst({
|
const userRole = await ctx.prisma.userRole.findFirst({
|
||||||
where: { userId: ctx.session.user.id },
|
where: { userId: ctx.session.user.id },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
})
|
})
|
||||||
if (!userRole) {
|
if (!userRole) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
|
|
@ -65,6 +66,7 @@ export const memberProcedure = protectedProcedure.use(async ({ ctx, next }) => {
|
||||||
export const adminProcedure = protectedProcedure.use(async ({ ctx, next }) => {
|
export const adminProcedure = protectedProcedure.use(async ({ ctx, next }) => {
|
||||||
const userRole = await ctx.prisma.userRole.findFirst({
|
const userRole = await ctx.prisma.userRole.findFirst({
|
||||||
where: { userId: ctx.session.user.id, role: 'admin' },
|
where: { userId: ctx.session.user.id, role: 'admin' },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
})
|
})
|
||||||
if (!userRole) {
|
if (!userRole) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
|
|
|
||||||
|
|
@ -16,15 +16,16 @@ const config: Config = {
|
||||||
200: '#ffC7c7',
|
200: '#ffC7c7',
|
||||||
300: '#ffa0a0',
|
300: '#ffa0a0',
|
||||||
400: '#ff6b6b',
|
400: '#ff6b6b',
|
||||||
500: '#E63946',
|
500: 'var(--color-brand-primary, #E63946)',
|
||||||
600: '#d42535',
|
600: 'var(--color-brand-hover, #d42535)',
|
||||||
700: '#b21e2c',
|
700: '#b21e2c',
|
||||||
800: '#931d29',
|
800: '#931d29',
|
||||||
900: '#7a1e27',
|
900: '#7a1e27',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
sans: ['var(--font-inter)', 'Inter', 'system-ui', 'sans-serif'],
|
||||||
|
outfit: ['var(--font-outfit)', 'Outfit', 'sans-serif'],
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
blob: 'blob 7s infinite',
|
blob: 'blob 7s infinite',
|
||||||
|
|
|
||||||
|
|
@ -0,0 +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.
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -4,7 +4,9 @@
|
||||||
"Bash(python:*)",
|
"Bash(python:*)",
|
||||||
"Bash(python3:*)",
|
"Bash(python3:*)",
|
||||||
"Bash(node -e:*)",
|
"Bash(node -e:*)",
|
||||||
"Bash(pnpm --filter mobile add:*)"
|
"Bash(pnpm --filter mobile add:*)",
|
||||||
|
"Bash(pnpm db:push:*)",
|
||||||
|
"Bash(pnpm --filter @innungsapp/shared prisma:seed-demo-members:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,10 @@
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"orgSlug": "innung-elektro-stuttgart",
|
||||||
|
"apiUrl": "http://localhost:3000"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,222 @@
|
||||||
import { Tabs, Redirect } from 'expo-router'
|
import { Tabs, Redirect } from 'expo-router'
|
||||||
import { Platform } from 'react-native'
|
import { Platform, View, Text, StyleSheet, TextInput, TouchableOpacity, ActivityIndicator, Alert, ScrollView } from 'react-native'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
import { Ionicons } from '@expo/vector-icons'
|
import { Ionicons } from '@expo/vector-icons'
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||||
import { useAuthStore } from '@/store/auth.store'
|
import { useAuthStore } from '@/store/auth.store'
|
||||||
|
import { trpc } from '@/lib/trpc'
|
||||||
|
import { setupPushNotifications } from '@/lib/notifications'
|
||||||
|
import { authClient } from '@/lib/auth-client'
|
||||||
|
|
||||||
|
function UnreadBadge({ count }: { count: number }) {
|
||||||
|
if (count === 0) return null
|
||||||
|
return (
|
||||||
|
<View style={badge.dot}>
|
||||||
|
<Text style={badge.text}>{count > 9 ? '9+' : count}</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const badge = StyleSheet.create({
|
||||||
|
dot: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: -4,
|
||||||
|
right: -8,
|
||||||
|
minWidth: 17,
|
||||||
|
height: 17,
|
||||||
|
borderRadius: 9,
|
||||||
|
backgroundColor: '#DC2626',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: '#FFFFFF',
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
lineHeight: 13,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function ChatTabIcon({ color, focused }: { color: string; focused: boolean }) {
|
||||||
|
const { data: unreadCount } = trpc.messages.getConversations.useQuery(undefined, {
|
||||||
|
refetchInterval: 15_000,
|
||||||
|
staleTime: 10_000,
|
||||||
|
select: (data) => data.filter((c) => c.hasUnread).length,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Ionicons name={focused ? 'chatbubbles' : 'chatbubbles-outline'} size={23} color={color} />
|
||||||
|
<UnreadBadge count={unreadCount ?? 0} />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ForcePasswordChangeScreen() {
|
||||||
|
const { setSession, signOut } = useAuthStore()
|
||||||
|
const [current, setCurrent] = useState('')
|
||||||
|
const [next, setNext] = useState('')
|
||||||
|
const [confirm, setConfirm] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
setError('')
|
||||||
|
if (!current) { setError('Bitte temporäres Passwort eingeben.'); return }
|
||||||
|
if (next.length < 8) { setError('Das neue Passwort muss mindestens 8 Zeichen haben.'); return }
|
||||||
|
if (next !== confirm) { setError('Die Passwörter stimmen nicht überein.'); return }
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
const result = await authClient.changePassword({ currentPassword: current, newPassword: next })
|
||||||
|
setLoading(false)
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
setError(result.error.message ?? 'Passwort konnte nicht geändert werden.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh session — mustChangePassword is now false
|
||||||
|
const sessionResult = await authClient.getSession()
|
||||||
|
if (sessionResult?.data?.user) {
|
||||||
|
const u = sessionResult.data.user as any
|
||||||
|
await setSession({
|
||||||
|
user: {
|
||||||
|
id: u.id,
|
||||||
|
email: u.email,
|
||||||
|
name: u.name,
|
||||||
|
mustChangePassword: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={fpc.safe}>
|
||||||
|
<ScrollView contentContainerStyle={fpc.content} keyboardShouldPersistTaps="handled">
|
||||||
|
<View style={fpc.card}>
|
||||||
|
<View style={fpc.iconWrap}>
|
||||||
|
<Ionicons name="lock-closed-outline" size={32} color="#003B7E" />
|
||||||
|
</View>
|
||||||
|
<Text style={fpc.title}>Passwort ändern</Text>
|
||||||
|
<Text style={fpc.subtitle}>
|
||||||
|
Ihr Administrator hat ein temporäres Passwort vergeben. Bitte legen Sie jetzt Ihr persönliches Passwort fest.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={fpc.field}>
|
||||||
|
<Text style={fpc.label}>Temporäres Passwort</Text>
|
||||||
|
<TextInput
|
||||||
|
style={fpc.input}
|
||||||
|
value={current}
|
||||||
|
onChangeText={setCurrent}
|
||||||
|
secureTextEntry
|
||||||
|
placeholder="••••••••"
|
||||||
|
placeholderTextColor="#CBD5E1"
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={fpc.field}>
|
||||||
|
<Text style={fpc.label}>Neues Passwort</Text>
|
||||||
|
<TextInput
|
||||||
|
style={fpc.input}
|
||||||
|
value={next}
|
||||||
|
onChangeText={setNext}
|
||||||
|
secureTextEntry
|
||||||
|
placeholder="Mindestens 8 Zeichen"
|
||||||
|
placeholderTextColor="#CBD5E1"
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={fpc.field}>
|
||||||
|
<Text style={fpc.label}>Neues Passwort wiederholen</Text>
|
||||||
|
<TextInput
|
||||||
|
style={fpc.input}
|
||||||
|
value={confirm}
|
||||||
|
onChangeText={setConfirm}
|
||||||
|
secureTextEntry
|
||||||
|
placeholder="Neues Passwort wiederholen"
|
||||||
|
placeholderTextColor="#CBD5E1"
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{!!error && (
|
||||||
|
<View style={fpc.errorBox}>
|
||||||
|
<Text style={fpc.errorText}>{error}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TouchableOpacity style={fpc.btn} onPress={handleSubmit} disabled={loading}>
|
||||||
|
{loading
|
||||||
|
? <ActivityIndicator color="#fff" />
|
||||||
|
: <Text style={fpc.btnText}>Passwort festlegen</Text>
|
||||||
|
}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity style={fpc.logoutBtn} onPress={() => void signOut()}>
|
||||||
|
<Text style={fpc.logoutText}>Abmelden</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fpc = StyleSheet.create({
|
||||||
|
safe: { flex: 1, backgroundColor: '#F8FAFC' },
|
||||||
|
content: { flex: 1, justifyContent: 'center', padding: 24 },
|
||||||
|
card: {
|
||||||
|
backgroundColor: '#FFFFFF', borderRadius: 20,
|
||||||
|
borderWidth: 1, borderColor: '#E2E8F0',
|
||||||
|
padding: 24, gap: 12,
|
||||||
|
},
|
||||||
|
iconWrap: {
|
||||||
|
width: 60, height: 60, borderRadius: 16,
|
||||||
|
backgroundColor: '#EFF6FF', alignItems: 'center', justifyContent: 'center',
|
||||||
|
alignSelf: 'center', marginBottom: 4,
|
||||||
|
},
|
||||||
|
title: { fontSize: 22, fontWeight: '800', color: '#0F172A', textAlign: 'center' },
|
||||||
|
subtitle: { fontSize: 13, color: '#64748B', textAlign: 'center', lineHeight: 19 },
|
||||||
|
field: { gap: 4 },
|
||||||
|
label: { fontSize: 12, fontWeight: '700', color: '#475569', textTransform: 'uppercase', letterSpacing: 0.5 },
|
||||||
|
input: {
|
||||||
|
borderWidth: 1, borderColor: '#E2E8F0', borderRadius: 10,
|
||||||
|
paddingHorizontal: 12, paddingVertical: 11,
|
||||||
|
fontSize: 14, color: '#0F172A', backgroundColor: '#F8FAFC',
|
||||||
|
},
|
||||||
|
errorBox: {
|
||||||
|
backgroundColor: '#FEF2F2', borderWidth: 1,
|
||||||
|
borderColor: '#FECACA', borderRadius: 10,
|
||||||
|
paddingHorizontal: 12, paddingVertical: 10,
|
||||||
|
},
|
||||||
|
errorText: { color: '#B91C1C', fontSize: 13 },
|
||||||
|
btn: {
|
||||||
|
backgroundColor: '#003B7E', borderRadius: 12,
|
||||||
|
paddingVertical: 13, alignItems: 'center', marginTop: 4,
|
||||||
|
},
|
||||||
|
btnText: { color: '#FFFFFF', fontWeight: '700', fontSize: 15 },
|
||||||
|
logoutBtn: { alignItems: 'center', paddingVertical: 8 },
|
||||||
|
logoutText: { color: '#94A3B8', fontSize: 13 },
|
||||||
|
})
|
||||||
|
|
||||||
export default function AppLayout() {
|
export default function AppLayout() {
|
||||||
const session = useAuthStore((s) => s.session)
|
const session = useAuthStore((s) => s.session)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session?.user) return
|
||||||
|
setupPushNotifications().catch(() => {})
|
||||||
|
}, [session?.user?.id])
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return <Redirect href="/(auth)/login" />
|
return <Redirect href="/(auth)/login" />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (session.user.mustChangePassword) {
|
||||||
|
return <ForcePasswordChangeScreen />
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
|
|
@ -67,6 +274,15 @@ export default function AppLayout() {
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="chat/index"
|
||||||
|
options={{
|
||||||
|
title: 'Nachrichten',
|
||||||
|
tabBarIcon: ({ color, focused }) => (
|
||||||
|
<ChatTabIcon color={color} focused={focused} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="profil/index"
|
name="profil/index"
|
||||||
options={{
|
options={{
|
||||||
|
|
@ -82,6 +298,7 @@ export default function AppLayout() {
|
||||||
<Tabs.Screen name="members/[id]" options={{ href: null }} />
|
<Tabs.Screen name="members/[id]" options={{ href: null }} />
|
||||||
<Tabs.Screen name="termine/[id]" options={{ href: null }} />
|
<Tabs.Screen name="termine/[id]" options={{ href: null }} />
|
||||||
<Tabs.Screen name="stellen/[id]" options={{ href: null }} />
|
<Tabs.Screen name="stellen/[id]" options={{ href: null }} />
|
||||||
|
<Tabs.Screen name="chat/[id]" options={{ href: null }} />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,410 @@
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
FlatList,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
ActivityIndicator,
|
||||||
|
StyleSheet,
|
||||||
|
RefreshControl,
|
||||||
|
} from 'react-native'
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||||
|
import { useLocalSearchParams, useRouter } from 'expo-router'
|
||||||
|
import { Ionicons } from '@expo/vector-icons'
|
||||||
|
import { useState, useRef, useMemo, useCallback } from 'react'
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
import { de } from 'date-fns/locale'
|
||||||
|
import { trpc } from '@/lib/trpc'
|
||||||
|
import { useAuthStore } from '@/store/auth.store'
|
||||||
|
|
||||||
|
type Message = {
|
||||||
|
id: string
|
||||||
|
body: string
|
||||||
|
createdAt: Date
|
||||||
|
sender: { id: string; name: string; avatarUrl: string | null }
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageBubble({ msg, isMe }: { msg: Message; isMe: boolean }) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.messageBubbleContainer, isMe ? styles.messageBubbleRight : styles.messageBubbleLeft]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.messageBubble,
|
||||||
|
isMe ? styles.messageBubbleMe : styles.messageBubbleOther
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.messageText, isMe ? styles.messageTextMe : styles.messageTextOther]}>
|
||||||
|
{msg.body}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[styles.messageTime, isMe ? styles.messageTimeMe : styles.messageTimeOther]}
|
||||||
|
>
|
||||||
|
{format(new Date(msg.createdAt), 'HH:mm', { locale: de })}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConversationScreen() {
|
||||||
|
const { id, name } = useLocalSearchParams<{ id: string; name: string }>()
|
||||||
|
const router = useRouter()
|
||||||
|
const session = useAuthStore((s) => s.session)
|
||||||
|
const [text, setText] = useState('')
|
||||||
|
const listRef = useRef<FlatList>(null)
|
||||||
|
// Hide list until inverted FlatList has settled at correct position (prevents initial jump)
|
||||||
|
const [listVisible, setListVisible] = useState(false)
|
||||||
|
const onListLayout = useCallback(() => setListVisible(true), [])
|
||||||
|
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
const onRefresh = useCallback(async () => {
|
||||||
|
setRefreshing(true)
|
||||||
|
try {
|
||||||
|
await refetch()
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const { data, isLoading, refetch, isRefetching } = trpc.messages.getMessages.useQuery(
|
||||||
|
{ conversationId: id },
|
||||||
|
{
|
||||||
|
staleTime: 5_000,
|
||||||
|
refetchInterval: 8_000,
|
||||||
|
// Only re-render when message count or last message ID changes — not on every refetch
|
||||||
|
select: (d) => d,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Stable deps: only recalculate when actual message content changes, not on every refetch
|
||||||
|
const msgCount = data?.messages?.length ?? 0
|
||||||
|
const lastId = data?.messages?.[msgCount - 1]?.id ?? ''
|
||||||
|
const messages = useMemo(() => [...(data?.messages ?? [])].reverse(), [msgCount, lastId])
|
||||||
|
|
||||||
|
// Stable "me" id: derived from real (non-optimistic) messages only
|
||||||
|
const myMemberId = useMemo(
|
||||||
|
() => messages.find((m: Message) => !m.id.startsWith('opt-') && m.sender.name === session?.user?.name)?.sender.id,
|
||||||
|
[messages, session?.user?.name]
|
||||||
|
)
|
||||||
|
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
const sendMutation = trpc.messages.sendMessage.useMutation({
|
||||||
|
onMutate: ({ conversationId, body }) => {
|
||||||
|
// Optimistically insert message immediately — no waiting for server
|
||||||
|
const optimisticMsg: Message = {
|
||||||
|
id: `opt-${Date.now()}`,
|
||||||
|
body,
|
||||||
|
createdAt: new Date(),
|
||||||
|
sender: {
|
||||||
|
id: myMemberId ?? `opt-sender`,
|
||||||
|
name: session?.user?.name ?? '',
|
||||||
|
avatarUrl: null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
utils.messages.getMessages.setData(
|
||||||
|
{ conversationId },
|
||||||
|
(old) => {
|
||||||
|
if (!old) return old
|
||||||
|
// Append at end — messages are stored ascending, reversed only in useMemo for display
|
||||||
|
return { ...old, messages: [...old.messages, optimisticMsg] }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
setText('')
|
||||||
|
return { optimisticId: optimisticMsg.id }
|
||||||
|
},
|
||||||
|
onSuccess: (newMsg, { conversationId }, context) => {
|
||||||
|
// Replace optimistic placeholder with real server message
|
||||||
|
utils.messages.getMessages.setData(
|
||||||
|
{ conversationId },
|
||||||
|
(old) => {
|
||||||
|
if (!old) return old
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
messages: old.messages.map((m) =>
|
||||||
|
m.id === context?.optimisticId ? newMsg : m
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
utils.messages.getConversations.invalidate()
|
||||||
|
},
|
||||||
|
onError: (_, { conversationId }, context) => {
|
||||||
|
// Roll back optimistic message on error
|
||||||
|
utils.messages.getMessages.setData(
|
||||||
|
{ conversationId },
|
||||||
|
(old) => {
|
||||||
|
if (!old) return old
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
messages: old.messages.filter((m) => m.id !== context?.optimisticId),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSend() {
|
||||||
|
const body = text.trim()
|
||||||
|
if (!body) return
|
||||||
|
sendMutation.mutate({ conversationId: id, body })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.safeArea} edges={['top', 'bottom']}>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={styles.keyboardAvoid}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
|
||||||
|
<Ionicons name="chevron-back" size={26} color="#003B7E" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View style={styles.headerInfo}>
|
||||||
|
<Text style={styles.headerName} numberOfLines={1}>
|
||||||
|
{name ?? 'Chat'}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.headerStatus}>Mitglied</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.divider} />
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
<View style={styles.content}>
|
||||||
|
{isLoading ? (
|
||||||
|
<View style={styles.centerCont}>
|
||||||
|
<ActivityIndicator size="large" color="#003B7E" />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
ref={listRef}
|
||||||
|
data={messages}
|
||||||
|
keyExtractor={(m) => m.id}
|
||||||
|
inverted
|
||||||
|
maintainVisibleContentPosition={{ minIndexForVisible: 0, autoscrollToTopThreshold: 10 }}
|
||||||
|
onLayout={onListLayout}
|
||||||
|
style={{ opacity: listVisible ? 1 : 0 }}
|
||||||
|
contentContainerStyle={styles.messageList}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor="#003B7E" />
|
||||||
|
}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View style={[styles.emptyState, { transform: [{ scaleY: -1 }] }]}>
|
||||||
|
<Ionicons name="chatbubble-ellipses-outline" size={48} color="#CBD5E1" />
|
||||||
|
<Text style={styles.emptyText}>
|
||||||
|
Noch keine Nachrichten. Schreib die erste!
|
||||||
|
</Text>
|
||||||
|
<View style={styles.privacyNoteBoxCentered}>
|
||||||
|
<Ionicons name="shield-checkmark-outline" size={16} color="#2563EB" />
|
||||||
|
<Text style={styles.privacyNoteText}>
|
||||||
|
Nachrichten sind nur für euch beide sichtbar. Keine Weitergabe an Dritte (DSGVO-konform).
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<MessageBubble
|
||||||
|
msg={item as Message}
|
||||||
|
isMe={item.sender.id === myMemberId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<View style={styles.inputContainer}>
|
||||||
|
<TextInput
|
||||||
|
style={styles.inputField}
|
||||||
|
placeholder="Nachricht …"
|
||||||
|
placeholderTextColor="#94A3B8"
|
||||||
|
value={text}
|
||||||
|
onChangeText={setText}
|
||||||
|
multiline
|
||||||
|
returnKeyType="default"
|
||||||
|
blurOnSubmit={false}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleSend}
|
||||||
|
disabled={!text.trim() || sendMutation.isPending}
|
||||||
|
style={[styles.sendButton, text.trim() ? styles.sendButtonActive : styles.sendButtonInactive]}
|
||||||
|
>
|
||||||
|
{sendMutation.isPending ? (
|
||||||
|
<ActivityIndicator size="small" color="#fff" />
|
||||||
|
) : (
|
||||||
|
<Ionicons name="send" size={18} color={text.trim() ? '#fff' : '#94A3B8'} />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</SafeAreaView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
safeArea: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
},
|
||||||
|
keyboardAvoid: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#F8FAFC',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
padding: 4,
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
headerInfo: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
headerName: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#0F172A',
|
||||||
|
},
|
||||||
|
headerStatus: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#64748B',
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
divider: {
|
||||||
|
height: 1,
|
||||||
|
backgroundColor: '#E2E8F0',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
centerCont: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
messageList: {
|
||||||
|
paddingTop: 16,
|
||||||
|
paddingBottom: 8,
|
||||||
|
},
|
||||||
|
emptyState: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
paddingVertical: 16,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
color: '#0F172A',
|
||||||
|
marginTop: 12,
|
||||||
|
textAlign: 'left',
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
privacyNoteBoxCentered: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
padding: 0,
|
||||||
|
marginTop: 16,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
privacyNoteText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#0F172A',
|
||||||
|
lineHeight: 20,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
messageBubbleContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
marginBottom: 8,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
messageBubbleRight: {
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
},
|
||||||
|
messageBubbleLeft: {
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
},
|
||||||
|
messageBubble: {
|
||||||
|
maxWidth: '78%',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 10,
|
||||||
|
},
|
||||||
|
messageBubbleMe: {
|
||||||
|
backgroundColor: '#2563EB',
|
||||||
|
borderTopLeftRadius: 16,
|
||||||
|
borderTopRightRadius: 16,
|
||||||
|
borderBottomLeftRadius: 16,
|
||||||
|
borderBottomRightRadius: 4,
|
||||||
|
},
|
||||||
|
messageBubbleOther: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderTopLeftRadius: 16,
|
||||||
|
borderTopRightRadius: 16,
|
||||||
|
borderBottomRightRadius: 16,
|
||||||
|
borderBottomLeftRadius: 4,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#E2E8F0',
|
||||||
|
},
|
||||||
|
messageText: {
|
||||||
|
fontSize: 15,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
messageTextMe: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
messageTextOther: {
|
||||||
|
color: '#0F172A',
|
||||||
|
},
|
||||||
|
messageTime: {
|
||||||
|
fontSize: 10,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
messageTimeMe: {
|
||||||
|
color: '#BFDBFE',
|
||||||
|
textAlign: 'right',
|
||||||
|
},
|
||||||
|
messageTimeOther: {
|
||||||
|
color: '#94A3B8',
|
||||||
|
textAlign: 'left',
|
||||||
|
},
|
||||||
|
inputContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: '#E2E8F0',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
inputField: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#F1F5F9',
|
||||||
|
borderRadius: 20,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingTop: 12,
|
||||||
|
paddingBottom: 12,
|
||||||
|
fontSize: 15,
|
||||||
|
color: '#0F172A',
|
||||||
|
maxHeight: 120,
|
||||||
|
minHeight: 44,
|
||||||
|
},
|
||||||
|
sendButton: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 22,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
sendButtonActive: {
|
||||||
|
backgroundColor: '#2563EB',
|
||||||
|
},
|
||||||
|
sendButtonInactive: {
|
||||||
|
backgroundColor: '#E2E8F0',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,401 @@
|
||||||
|
import { View, Text, SectionList, TouchableOpacity, Animated, ActivityIndicator, StyleSheet, RefreshControl, TextInput } from 'react-native'
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||||
|
import { useRouter } from 'expo-router'
|
||||||
|
import { Ionicons } from '@expo/vector-icons'
|
||||||
|
import { format, isToday, isYesterday } from 'date-fns'
|
||||||
|
import { de } from 'date-fns/locale'
|
||||||
|
import { trpc } from '@/lib/trpc'
|
||||||
|
import { EmptyState } from '@/components/ui/EmptyState'
|
||||||
|
import { Avatar } from '@/components/ui/Avatar'
|
||||||
|
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'
|
||||||
|
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
||||||
|
import { useFocusEffect } from 'expo-router'
|
||||||
|
|
||||||
|
function SkeletonRow() {
|
||||||
|
const anim = useRef(new Animated.Value(0.4)).current
|
||||||
|
useEffect(() => {
|
||||||
|
Animated.loop(
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.timing(anim, { toValue: 1, duration: 800, useNativeDriver: true }),
|
||||||
|
Animated.timing(anim, { toValue: 0.4, duration: 800, useNativeDriver: true }),
|
||||||
|
])
|
||||||
|
).start()
|
||||||
|
}, [])
|
||||||
|
return (
|
||||||
|
<Animated.View style={[skeletonStyles.row, { opacity: anim }]}>
|
||||||
|
<View style={skeletonStyles.avatar} />
|
||||||
|
<View style={skeletonStyles.lines}>
|
||||||
|
<View style={skeletonStyles.lineLong} />
|
||||||
|
<View style={skeletonStyles.lineShort} />
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const skeletonStyles = StyleSheet.create({
|
||||||
|
row: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 20, paddingVertical: 16, backgroundColor: '#FFFFFF', borderBottomWidth: 1, borderBottomColor: '#F1F5F9' },
|
||||||
|
avatar: { width: 48, height: 48, borderRadius: 24, backgroundColor: '#E2E8F0' },
|
||||||
|
lines: { flex: 1, marginLeft: 12, gap: 8 },
|
||||||
|
lineLong: { height: 14, borderRadius: 7, backgroundColor: '#E2E8F0', width: '60%' },
|
||||||
|
lineShort: { height: 12, borderRadius: 6, backgroundColor: '#F1F5F9', width: '80%' },
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatTime(date: Date) {
|
||||||
|
if (isToday(date)) return format(date, 'HH:mm')
|
||||||
|
if (isYesterday(date)) return 'Gestern'
|
||||||
|
return format(date, 'dd.MM.yy', { locale: de })
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChatListScreen() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
|
||||||
|
const [showSkeleton, setShowSkeleton] = useState(true)
|
||||||
|
const { data: chats, isLoading, refetch: refetchChats, isRefetching } = trpc.messages.getConversations.useQuery(undefined, {
|
||||||
|
refetchInterval: 10_000,
|
||||||
|
staleTime: 8_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
setShowSkeleton(true)
|
||||||
|
const t = setTimeout(() => setShowSkeleton(false), 800)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [])
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: members, isFetching: isFetchingMembers } = trpc.members.list.useQuery(
|
||||||
|
{ search: searchQuery },
|
||||||
|
{ enabled: searchQuery.length > 0, staleTime: 30_000 }
|
||||||
|
)
|
||||||
|
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
const refetch = useCallback(async () => {
|
||||||
|
setRefreshing(true)
|
||||||
|
try {
|
||||||
|
await refetchChats()
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false)
|
||||||
|
}
|
||||||
|
}, [refetchChats])
|
||||||
|
|
||||||
|
const filteredChats = (chats || []).filter(c => {
|
||||||
|
if (!searchQuery) return true
|
||||||
|
const q = searchQuery.toLowerCase()
|
||||||
|
return (
|
||||||
|
c.other?.name?.toLowerCase().includes(q) ||
|
||||||
|
c.other?.betrieb?.toLowerCase().includes(q) ||
|
||||||
|
c.lastMessage?.body?.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const sections = []
|
||||||
|
|
||||||
|
if (filteredChats.length > 0 || !searchQuery) {
|
||||||
|
sections.push({
|
||||||
|
title: searchQuery ? 'Bestehende Chats' : '',
|
||||||
|
data: filteredChats,
|
||||||
|
type: 'chat'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchQuery.length > 0) {
|
||||||
|
const chatMemberIds = new Set((chats || []).map(c => c.other?.id).filter(Boolean))
|
||||||
|
const freshMembers = (members || []).filter(m => !chatMemberIds.has(m.id))
|
||||||
|
if (freshMembers.length > 0) {
|
||||||
|
sections.push({
|
||||||
|
title: 'Weitere Mitglieder',
|
||||||
|
data: freshMembers,
|
||||||
|
type: 'member'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderSectionHeader = ({ section }: { section: any }) => {
|
||||||
|
if (!section.title) return null
|
||||||
|
return (
|
||||||
|
<View style={styles.sectionHeader}>
|
||||||
|
<Text style={styles.sectionTitle}>{section.title}</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderItem = ({ item, section }: { item: any, section: any }) => {
|
||||||
|
if (section.type === 'chat') {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() =>
|
||||||
|
router.push({
|
||||||
|
pathname: '/(app)/chat/[id]',
|
||||||
|
params: {
|
||||||
|
id: item.conversationId,
|
||||||
|
name: item.other?.name ?? 'Unbekannt',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
style={styles.chatRow}
|
||||||
|
>
|
||||||
|
<View style={styles.avatarContainer}>
|
||||||
|
<Avatar name={item.other?.name ?? '?'} size={48} />
|
||||||
|
{item.hasUnread && (
|
||||||
|
<View style={styles.unreadDot} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.chatInfo}>
|
||||||
|
<View style={styles.chatHeader}>
|
||||||
|
<Text
|
||||||
|
style={[styles.chatName, item.hasUnread && styles.chatNameUnread]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{item.other?.name ?? 'Unbekannt'}
|
||||||
|
</Text>
|
||||||
|
{item.lastMessage && (
|
||||||
|
<Text style={styles.timeText}>
|
||||||
|
{formatTime(new Date(item.lastMessage.createdAt))}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={[styles.messageText, item.hasUnread && styles.messageTextUnread]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{item.lastMessage?.body ?? 'Noch keine Nachrichten'}
|
||||||
|
</Text>
|
||||||
|
{item.other?.betrieb && (
|
||||||
|
<Text style={styles.companyText} numberOfLines={1}>
|
||||||
|
{item.other.betrieb}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() =>
|
||||||
|
router.push({
|
||||||
|
pathname: '/(app)/members/[id]',
|
||||||
|
params: { id: item.id },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
style={styles.chatRow}
|
||||||
|
>
|
||||||
|
<Avatar name={item.name} size={48} />
|
||||||
|
<View style={styles.chatInfo}>
|
||||||
|
<Text style={styles.chatName} numberOfLines={1}>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
{item.betrieb && (
|
||||||
|
<Text style={styles.companyText} numberOfLines={1}>
|
||||||
|
{item.betrieb}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text style={styles.messageText} numberOfLines={1}>
|
||||||
|
{item.sparte} • {item.ort}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.safeArea} edges={['top']}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<View style={styles.titleRow}>
|
||||||
|
<Text style={styles.screenTitle}>Nachrichten</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => router.push('/(app)/members')}
|
||||||
|
style={styles.newChatBtn}
|
||||||
|
>
|
||||||
|
<Ionicons name="create-outline" size={19} color="#003B7E" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Search Bar */}
|
||||||
|
<View style={styles.searchContainer}>
|
||||||
|
<Ionicons name="search" size={20} color="#94A3B8" />
|
||||||
|
<TextInput
|
||||||
|
style={styles.searchInput}
|
||||||
|
placeholder="Suchen nach Namen, Betrieb oder Nachricht..."
|
||||||
|
placeholderTextColor="#94A3B8"
|
||||||
|
value={searchQuery}
|
||||||
|
onChangeText={setSearchQuery}
|
||||||
|
returnKeyType="search"
|
||||||
|
clearButtonMode="while-editing"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.divider} />
|
||||||
|
|
||||||
|
{showSkeleton ? (
|
||||||
|
<View>
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => <SkeletonRow key={i} />)}
|
||||||
|
</View>
|
||||||
|
) : (!chats || chats.length === 0) && !searchQuery ? (
|
||||||
|
<EmptyState
|
||||||
|
icon="chatbubbles-outline"
|
||||||
|
title="Noch keine Nachrichten"
|
||||||
|
subtitle="Öffne ein Mitgliedsprofil und schreib eine Nachricht — datenschutzkonform ohne private Nummern."
|
||||||
|
/>
|
||||||
|
) : searchQuery.length > 0 && isFetchingMembers && sections.length === 0 ? (
|
||||||
|
<View style={{ alignItems: 'center', paddingTop: 40 }}>
|
||||||
|
<ActivityIndicator color="#003B7E" />
|
||||||
|
</View>
|
||||||
|
) : sections.length === 0 && searchQuery ? (
|
||||||
|
<EmptyState
|
||||||
|
icon="search-outline"
|
||||||
|
title="Keine Ergebnisse"
|
||||||
|
subtitle={`Kein Mitglied gefunden für „${searchQuery}".`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<SectionList
|
||||||
|
sections={sections}
|
||||||
|
keyExtractor={(item, index) => item.conversationId || item.id || String(index)}
|
||||||
|
initialNumToRender={10}
|
||||||
|
maxToRenderPerBatch={10}
|
||||||
|
windowSize={5}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={refetch} tintColor="#003B7E" progressViewOffset={50} />
|
||||||
|
}
|
||||||
|
contentContainerStyle={styles.list}
|
||||||
|
renderItem={renderItem}
|
||||||
|
renderSectionHeader={renderSectionHeader}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SafeAreaView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
safeArea: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#F8FAFC',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 14,
|
||||||
|
paddingBottom: 14,
|
||||||
|
},
|
||||||
|
titleRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
screenTitle: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: '#0F172A',
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
},
|
||||||
|
newChatBtn: {
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 18,
|
||||||
|
backgroundColor: '#EFF6FF',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
searchContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#F1F5F9',
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
height: 44,
|
||||||
|
},
|
||||||
|
searchInput: {
|
||||||
|
flex: 1,
|
||||||
|
marginLeft: 8,
|
||||||
|
fontSize: 15,
|
||||||
|
color: '#0F172A',
|
||||||
|
},
|
||||||
|
divider: {
|
||||||
|
height: 1,
|
||||||
|
backgroundColor: '#E2E8F0',
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
paddingBottom: 30,
|
||||||
|
},
|
||||||
|
sectionHeader: {
|
||||||
|
backgroundColor: '#F8FAFC',
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#F1F5F9',
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#64748B',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
},
|
||||||
|
chatRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 16,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#F1F5F9',
|
||||||
|
},
|
||||||
|
avatarContainer: {
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
unreadDot: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: 6,
|
||||||
|
backgroundColor: '#2563EB',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: '#FFFFFF',
|
||||||
|
},
|
||||||
|
chatInfo: {
|
||||||
|
flex: 1,
|
||||||
|
marginLeft: 12,
|
||||||
|
},
|
||||||
|
chatHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
chatName: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#1E293B',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
chatNameUnread: {
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#0F172A',
|
||||||
|
},
|
||||||
|
timeText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#94A3B8',
|
||||||
|
marginLeft: 8,
|
||||||
|
},
|
||||||
|
messageText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#94A3B8',
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
messageTextUnread: {
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#334155',
|
||||||
|
},
|
||||||
|
companyText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#94A3B8',
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue