Compare commits

..

27 Commits

Author SHA1 Message Date
1de1c9dcde remove migrations 2026-04-11 21:57:48 -05:00
2f94039f02 remove migrations 2026-04-11 21:57:35 -05:00
6ebebfad9a prisma 2026-04-11 21:50:41 -05:00
ed0be3b583 network 2026-04-11 17:31:24 -05:00
666c7724c5 executable + rem whitespace 2026-04-11 17:12:25 -05:00
94206afd49 remove ports, remove volume uploads_data 2026-04-11 17:04:08 -05:00
0084c5f05b log 2026-03-12 14:23:32 +01:00
Timo Knuth
d93f43bf01 Merge branch 'master' of https://gitea.bizmatch.net/tknuth/stadtwerke 2026-03-08 11:28:53 +01:00
Timo Knuth
7b22fdbc22 . 2026-03-08 11:28:22 +01:00
37bc8170db docker fehler seed. 2026-03-07 20:24:38 +01:00
1a69cbe462 docker fehler seed 2026-03-07 19:55:45 +01:00
56ea3348d6 Postgres 2026-03-04 14:13:16 +01:00
Timo Knuth
b7d826e29c feat: Implement initial admin and mobile application UI, including styling, layouts, authentication, and legal page components. 2026-03-03 16:54:11 +01:00
Timo Knuth
59f3efaaed feat: Initialize new admin application with a landing page, cookie consent, and theme switching functionality. 2026-03-02 23:33:11 +01:00
Timo Knuth
873c5e53af feat: Implement Next.js middleware for subdomain-based tenant routing and authentication, create the admin application's main page, and add Google site verification. 2026-03-02 23:01:21 +01:00
9d71c16883 Fehler beheben1 2026-03-02 21:32:22 +01:00
9f5e916e60 Fehler beheben 2026-03-02 18:03:27 +01:00
35c23164bf hoffentlich 2026-02-28 18:45:45 +01:00
Timo Knuth
02bb2ed994 npm run build durch gelaufen 2026-02-28 11:56:31 +01:00
Timo Knuth
aec4b93439 fehler 2 2026-02-28 00:04:13 +01:00
Timo Knuth
166a1b5d06 fehler 2026-02-27 23:45:32 +01:00
Timo Knuth
8999cdbab3 feat: Add initial project setup including superadmin seeding, a Docker entrypoint for the admin app, and a comprehensive README. 2026-02-27 21:33:40 +01:00
Timo Knuth
244da5e69a feat: Implement news section with list and detail views, category filtering, and unread indicators. 2026-02-27 19:36:20 +01:00
Timo Knuth
4863d032d9 feat: Implement comprehensive member management with user accounts, roles, and password handling for admin and mobile applications. 2026-02-27 18:50:17 +01:00
Timo Knuth
253c3c1c6d push 2026-02-27 15:19:24 +01:00
b7f8221095 feat: Set up initial monorepo structure for admin and mobile applications with core configurations and database integration. 2026-02-20 12:58:54 +01:00
5e2d5fb3ae feat: Implement initial with admin and mobile clients, authentication, data models, and lead generation scripts. 2026-02-19 16:18:34 +01:00
182 changed files with 18209 additions and 2982 deletions

307
AGENTS.md Normal file
View File

@@ -0,0 +1,307 @@
# Universal AI Coding Agent Workflow (Codex / Gemini / Claude)
## Workflow Orchestration
### 1. Plan Mode Default
- Enter planning mode for ANY non-trivial task (3+ steps or architecture decisions)
- Analyze the codebase before making changes
- Break problems into clear subtasks
- Produce an implementation plan before writing code
- If assumptions are uncertain, inspect files or run tools first
- Prefer incremental progress over large rewrites
Plan format:
PLAN
1. Understand the task
2. Identify affected files
3. Design the implementation
4. Implement step-by-step
5. Verify results
---
# Multi-Agent Strategy
### 2. Agent Decomposition
Use specialized agents for complex work.
Core roles:
- Orchestrator Agent
- Research Agent
- Implementation Agent
- Test Agent
- Code Review Agent
- Debug Agent
- Documentation Agent
Rules:
- One responsibility per agent
- Prefer parallel execution
- Agents should operate on independent files when possible
- The orchestrator coordinates execution
---
# Agent Responsibilities
### Orchestrator Agent
- analyzes the user request
- creates task list
- assigns tasks to agents
- merges results
### Research Agent
- scans repository
- searches dependencies
- analyzes architecture
- produces context summary
### Implementation Agent
- writes code
- edits files
- follows project conventions
- implements features
### Test Agent
- writes tests
- verifies functionality
- checks edge cases
### Code Review Agent
- reviews diffs
- checks maintainability
- suggests improvements
### Debug Agent
- analyzes logs
- identifies root causes
- implements fixes
### Documentation Agent
- updates docs
- writes README sections
- explains new features
---
# Execution Pipeline
### 3. Execution Phases
PHASE 1 — Discovery
- explore repository
- load relevant files
- understand architecture
PHASE 2 — Planning
- generate implementation plan
- break plan into tasks
PHASE 3 — Task Creation
Create tasks like:
[ ] analyze codebase
[ ] implement feature
[ ] add tests
[ ] review code
[ ] update documentation
PHASE 4 — Implementation
- execute tasks sequentially or in parallel
- commit progress
PHASE 5 — Verification
- run tests
- check logs
- verify feature works
PHASE 6 — Review
- review code quality
- refactor if necessary
PHASE 7 — Documentation
- document changes
---
# Verification System
### 4. Verification Before Done
Never mark a task complete without proof.
Checks:
- code compiles
- feature works
- tests pass
- no new errors introduced
Ask:
"Would a senior engineer approve this implementation?"
---
# Autonomous Debugging
### 5. Autonomous Bug Fixing
When encountering a bug:
1. analyze error message
2. inspect stack trace
3. identify root cause
4. implement fix
5. verify with tests
Rules:
- Never apply random fixes
- Always understand the root cause first
---
# Context Management
### 6. Context Awareness
Before implementing anything:
- load relevant files
- inspect dependencies
- understand architecture
- read configuration files
Always maintain awareness of:
- system architecture
- data flow
- dependencies
---
# Memory System
### 7. Persistent Memory
Store long-term knowledge in:
memory/
- project_summary.md
- architecture.md
- lessons.md
- coding_standards.md
This prevents repeated mistakes.
---
# Learning Loop
### 8. Self-Improvement
After errors or corrections:
Update:
tasks/lessons.md
Include:
- mistake pattern
- root cause
- prevention rule
Example:
Lesson:
Always validate API responses before processing them.
---
# Safety Rules
### 9. Safety
Never perform dangerous actions automatically.
Rules:
- never delete files without confirmation
- avoid modifying production configuration automatically
- create backups before large refactors
- avoid irreversible operations
---
# Iteration Control
### 10. Infinite Loop Protection
If the same error happens more than 3 times:
STOP
- re-evaluate the strategy
- re-plan the solution
- choose a different debugging approach
---
# Core Engineering Principles
### Simplicity First
Prefer the simplest solution that works.
### Root Cause Fixes
Always fix the underlying problem, not symptoms.
### Minimal Impact
Touch the smallest amount of code possible.
### Maintainability
Code should remain readable and maintainable.
---
# Final Rule
Before delivering a solution ask:
Is this solution correct, maintainable, and verifiable?
If not:
Refine it before presenting it.
---
# Recommended File Usage
You can place this workflow in one of the following files:
AGENT_WORKFLOW.md
CLAUDE.md
AGENTS.md
This allows it to be used by:
- Claude Code Agent Teams
- Codex CLI
- Gemini Code Assist
- Cursor Agents

View File

@@ -5,9 +5,9 @@
## 1. Geschäftsmodell-Überblick
**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)
**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 |
|---|---|---|---|
| **Pilot** | 0 € (3 Monate) | bis 50 | Testphase |
| **Starter** | 99 € / Monat | bis 100 | Monatlich kündbar |
| **Standard** | 199 € / Monat | bis 300 | Monatlich kündbar |
| **Pro** | 349 € / Monat | unbegrenzt | Monatlich kündbar |
| **Kreisverband Setup** | 5.000 € (einmalig) | p. Verband | Implementierung |
| **Gilden-Account** | 150300 € / Monat | p. Innung | Jährlich / Monatlich |
| **Starter (Direkt)** | 99 € / Monat | bis 100 | Monatlich kündbar |
| **Standard (Direkt)** | 199 € / Monat | bis 300 | Monatlich kündbar |
| **Pro (Direkt)** | 349 € / Monat | unbegrenzt | Monatlich kündbar |
### 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 |
|---|---|---|---|
| Q1 2026 | 5 (Piloten, kostenlos) | 5 | 0 € |
| Q2 2026 | 5 zahlend | 10 | 750 € |
| Q3 2026 | 10 zahlend | 20 | 2.200 € |
| Q4 2026 | 15 zahlend | 35 | 5.500 € |
| Q1 2027 | 20 zahlend | 55 | 9.500 € |
| Q2 2027 | 30 zahlend | 85 | 16.000 € |
| Segment | Anzahl | Setup Rev. (einmalig) | MRR (recurring) | ARR |
|---|---|---|---|---|
| **Top 10 NRW Kreisverbände** | 10 KV / 311 Gilden | 50.000 € | 61.889 € | 742.668 € |
| **Gesamt NRW Potential** | 40 KV / ~1.000 Gilden | 200.000 € | 199.000 € | 2.388.000 € |
| **Gesamt DE Potential** | 240 KV / ~7.500 Gilden | 1.200.000 € | 1.492.500 € | 17.910.000 € |
**ARR Ende 2026:** ~66.000 €
**ARR Ende 2027:** ~192.000 €
*Annahmen: Durchschnitt €199 MRR pro Gilde/Innung; €5.000 Setup pro Kreisverband.*
### Szenario: Optimistisch (mit HWK-Partner)
---
| Zeitpunkt | Innungen | MRR | ARR |
|---|---|---|---|
| Q4 2026 (HWK-Deal) | 80 | 14.000 € | 168.000 € |
| Q2 2027 | 250 | 47.000 € | 564.000 € |
| Q4 2027 | 500 | 100.000 € | 1.200.000 € |
**Top 3 NRW "Cash-Cow" Targets:**
1. **KH Niederrhein:** 41 Innungen → €5.000 Setup + €8.159 MRR.
2. **KH Gütersloh-Bielefeld:** 43 Innungen → €5.000 Setup + €8.557 MRR.
3. **KH Ruhr:** 39 Innungen → €5.000 Setup + €7.761 MRR.
**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
### Phase 1: Direct Sales (Monat 16)
### Phase 1: Kreisverband Multiplier (NRW)
**Ziel:** 510 Piloten in Baden-Württemberg
**Ziel:** 5 Kreisverbände in NRW als Kunden (ca. 250 angeschlossene Innungen)
**Taktik:**
1. Kaltakquise-Mail an 50 Innungen in BW (SHK, Elektro, Bau, Dachdecker)
2. Kostenloser 3-Monats-Pilot als Türöffner
3. Demo-Call → Figma-Prototype zeigen
4. Pilot live, Feedback sammeln, Testimonials sammeln
1. Kaltakquise-LinkedIn/Mail an 40 Hauptgeschäftsführer (HGF) in NRW
2. White-Label-Demo für den Kreisverband (Multiplier-Effekt)
3. Demo-Call → "InnungsApp NRW Edition" zeigen
4. Pilot mit einem Verband, Onboarding der ersten 1020 Innungen
**Aufwand:** ~10h/Woche Sales, 1 Person
**Erwartete Conversion:**
- 50 angeschriebene Innungen
- 15 Antworten (30 %)
- 40 angeschriebene Kreisverbände
- 12 Antworten (30 %)
- 8 Demo-Calls
- 5 Piloten (10 %)
- 3 zahlende Kunden nach Pilot (60 % Pilot-Conversion)
- 5 Abschlüsse (KV-Setup)
- 250 automatisch erreichte Innungsendnutzer (indirekt)
### Phase 2: Regionale HWK-Partnerschaft (Monat 612)

View File

@@ -4,54 +4,68 @@
## 1. Markt-Entry Strategie
### Fokus: Baden-Württemberg First
### Fokus: North Rhine-Westphalia (NRW) First
**Warum BW als erstes Bundesland?**
- 1.000+ Innungen in BW (1015 % des Gesamtmarkts)
- HWK Baden-Württemberg hat 3 Kammern (Stuttgart, Karlsruhe, Freiburg) mit regionaler Entscheidungshoheit
- Geografisch erreichbar für persönliche Demos
- ZDH-Präsident kommt aus BW (Netzwerkeffekt)
**Warum NRW als erstes Bundesland?**
- Größter Markt in DE (17.5M+ Einwohner, höchste Dichte an Handwerksbetrieben)
- 40+ Kreishandwerkerschaften in NRW (ca. 1/6 des Gesamtmarkts)
- Starke Ballungszentren (Ruhrgebiet, Köln/Düsseldorf) erlauben hohe Lead-Dichte
- Bekannte digitale Pionier-Verbände in der Region (z.B. Köln, Düsseldorf, Münster)
**Zielsegment Phase 1:**
- Handwerk-Innungen mit 50300 Mitgliedern
- Kreisverbände (240 in DE) als Multiplikatoren
- Handwerk-Innungen mit 50300 Mitgliedern via Kreisverbände
- 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 16)
### Ziel
5 zahlende Innungen in BW
5 zahlende Kreisverbände in NRW
### Prospecting
**Lead-Generierung:**
1. Scraping: innungsverzeichnis.de / HWK-Websites → alle BW-Innungen mit Kontaktdaten
2. LinkedIn: Geschäftsführer und Obermeister identifizieren
3. Branchen-Priorisierung: SHK > Elektro > Bau > Dachdecker
1. Scraping: kh-online.de / HWK-Websites → alle NRW Kreishandwerkerschaften
2. LinkedIn: Hauptgeschäftsführer (HGF) identifizieren
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
**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],
ich bin Timo Knuth und entwickle InnungsApp — eine App, die Innungen von
Excel und WhatsApp befreit.
ich bin Timo Knuth und entwickle InnungsApp — eine Plattform, mit der Sie
Ihre [Anzahl] Innungen zentral digitalisieren und von Excel/WhatsApp befreien.
Drei Dinge in 30 Sekunden:
- Mitgliederverzeichnis mit Suche auf dem Smartphone
- Rundschreiben mit Push-Benachrichtigung (Sie sehen, wer es gelesen hat)
- Lehrlingsbörse — Azubis finden, direkt über Ihre Innung
Drei Vorteile für Ihren Kreisverband:
- Zentrale Mitgliederverwaltung für alle angeschlossenen Innungen
- Digitale Rundschreiben mit Push-Benachrichtigung & Lesebestätigung
- 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
Timo Knuth

View File

@@ -0,0 +1,140 @@
# InnungsApp PRO Landingpage Optimierung (SEO, AEO, GEO & CRO)
Basierend auf den Prinzipien modernster **Search Engine Optimization (SEO)**, **Answer Engine Optimization (AEO)**, **Generative Engine Optimization (GEO)** sowie verkaufspsychologischer **Conversion Rate Optimization (CRO)** präsentiere ich hier den Architektur- und Text-Bauplan für die Landingpage der InnungsApp PRO.
---
## 1. DATENBASIERTE AUSGANGSLAGE (Hard Data Insights)
Eine aktuelle Datenanalyse via DataForSEO zeigt ein klares Bild des Suchverhaltens der Zielgruppe:
* **"Innungssoftware"** und **"Innung App"** haben **0** messbares Google-Suchvolumen. Die Zielgruppe sucht *nicht* nach diesem exakten Framing.
* Stattdessen suchen sie aufgabenorientiert:
* **"Vereinssoftware"** & **"Handwerk Software"** haben jeweils starke **1.900 Suchanfragen / Monat** auf Google (und signifikante "AI Search Volume" bei ChatGPT & Co).
* **"Handwerker App"** liegt bei **1.000 Suchanfragen / Monat**.
* Lokale Suchen wie **"Innung München SHK"** haben ca. **1.900 Suchanfragen / Monat** (geringe Konkurrenz).
**Die Strategische Konsequenz:** Wir müssen die InnungsApp PRO als **"spezialisierte Vereinssoftware & Handwerk Software für Innungen"** positionieren, um das Suchvolumen abzugreifen, anstatt auf das Keyword "Innungssoftware" zu hoffen.
---
## 2. SEO & GEO FUNDAMENT (Search & Generative Engine Optimization)
Da AI-Suchmaschinen wie Perplexity, ChatGPT (mit Search) oder Google AI Overviews immer wichtiger werden (über 800 Mio. GPT-Nutzer; 65%+ Zero-Click-Suchen), müssen wir unsere Texte "citable" (zitierfähig) machen. Das bedeutet: Klare Daten, Authorität und strukturierte Aussagen.
### 2.1 Meta-Title & Meta-Description (Auf Suchvolumen optimiert)
Die Meta-Daten sind der erste Touchpoint auf Google. Wir kombinieren Suchvolumen-Keywords (*Vereinssoftware*, *Handwerk Software*, *Innung*) mit einer klaren Lösung.
**Option 1: Fokus "Handwerk & Vereinssoftware" (SEO-Favorit)**
* **Meta-Title:** InnungsApp PRO | Die KI-gestützte Vereinssoftware für das Handwerk
* **Meta-Description:** Zettelwirtschaft war gestern. Reduzieren Sie den Verwaltungsaufwand Ihrer Innung um 10 Std/Woche. Die perfekte Handwerk Software inkl. CRM & App. Starten Sie kostenlos!
**Option 2: Fokus "App & Kommunikation"**
* **Meta-Title:** Handwerker App & Innungs-Verwaltung | InnungsApp PRO
* **Meta-Description:** Erreichen Sie Handwerksbetriebe direkt aufs Smartphone mit 90% Leserate dank DSGVO-konformer Push-Nachrichten in der Vereinssoftware für Innungen.
**Option 3: Fokus "Spezialisierung auf Innungen"**
* **Meta-Title:** Die moderne Software für Kreishandwerkerschaften & Innungen
* **Meta-Description:** Weniger Verwaltung, mehr Leben für Innungsobermeister & Geschäftsführer. Smarte Aktenführung, 1-Klick-RSVP & lokaler Stellenmarkt vereint in einer Plattform.
### 2.2 Logische H1 bis H3 Tag-Struktur
Suchmaschinen und AI-Crawler lieben saubere Hierarchien. Die Struktur ist so gewählt, dass sie als direkte Antwort auf Suchanfragen dient (AEO).
* **H1:** InnungsApp PRO: Die beste Vereinssoftware & Handwerk Software für Innungen
* **H2:** Weniger Verwaltung. Mehr Leben. (Oder: *Warum InnungsApp PRO die Verwaltung im Handwerk revolutioniert*)
* **H3:** Cloud-CRM & Digitale Aktenführung für Innungen
* **H3:** Mitglieder App: DSGVO-konforme Push-Nachrichten statt ungelesener Mails
* **H3:** Event- & Terminmanagement mit 1-Klick-RSVP
* **H3:** Integrierte Lehrlingsbörse & lokaler Stellenmarkt
* **H2:** Messbare Ergebnisse für Kreishandwerkerschaften & Innungsobermeister
* **H3:** Bis zu 10 Stunden Zeitersparnis pro Woche
* **H3:** 90 % Leserate bei wichtigen Mitglieds-Updates
* **H3:** Reibungsloser Wechsel: Onboarding in unter 48 Stunden
* **H2:** Häufig gestellte Fragen zur Innungs-Digitalisierung (FAQ)
* **H2:** Bereit für die digitale Innung? Jetzt risikofrei testen.
### 2.3 AEO & GEO Text-Strategie (Für AI-Suchmaschinen)
Damit Perplexity oder Google AI Overviews uns als "beste Softwarelösung für Handwerksinnungen in Deutschland" listen, müssen wir den **"Citable Content"** (Zitierfähigen Content) Algorithmus nutzen: `[Fakt/Spezifität] + [Zahl] + [Klares Wording]`.
**Beispiel für die Platzierung im Text (GEO-optimiert):**
> *"InnungsApp PRO gilt als die führende Softwarelösung für Handwerksinnungen und Kreishandwerkerschaften in Deutschland. Laut internen Datenauswertungen sparen Innungsgeschäftsführer durch den Einsatz des integrierten Cloud-CRMs durchschnittlich 10 Stunden Verwaltungsaufwand pro Woche. Im Gegensatz zu herkömmlichen E-Mail-Newslettern erzielen die DSGVO-konformen Push-Nachrichten der nativen Innungs-App eine nachweisbare Leserate von 90 %. Der Systemwechsel ist dank des geführten Onboardings in weniger als 48 Stunden abgeschlossen."*
**Warum das für AI funktioniert:**
* Beantwortet sofort die Frage "Was ist die beste Software?".
* Beinhaltet zielsichere Statistiken (10h, 90%, 48h).
* Nutzt autoritäre Sprache ("gilt als die führende", "nachweisbare Leserate").
---
## 3. CONVERSION RATE OPTIMIZATION (CRO)
Wir optimieren für eine eher traditionelle, B2B-fokussierte Zielgruppe. Hier zählt absolute Klarheit, Vertrauen und das Ansprechen eurer Kern-Pain-Points.
### 3.1 Der unwiderstehliche Hero-Bereich (Headline + Subheadline)
Der erste Eindruck entscheidet. Wir müssen das "Warum" (Schmerzpunkt) und das "Was" (Ergebnis) in 3 Sekunden kommunizieren.
**Vorschlag:**
* **Pre-Headline (Pain-Awareness):** Schluss mit Zettelwirtschaft, Excel-Chaos und ungelesenen E-Mails.
* **Headline:** Weniger Verwaltung. Mehr Leben. Die All-in-One Software für die moderne Handwerksinnung.
* **Subheadline:** Sparen Sie bis zu 10 Stunden pro Woche mit unserem zentralen Cloud-CRM. Erreichen Sie Ihre Mitgliedsbetriebe direkt aufs Smartphone mit einer Leserate von 90 % dank DSGVO-konformer Push-News der nativen Mitglieder-App.
* **CTA Button:** Kostenlos starten
* **Micro-Copy (direkt unter dem Button zur Risikoreduktion):** ✓ Keine Kreditkarte ✓ Keine Vertragsbindung ✓ 100% DSGVO-konform (Made in Germany)
### 3.2 Schmerzpunkte (Pain Points) stärker adressieren (Problem-Sektion)
Bevor Features kommen, müssen wir dem Besucher zeigen: *"Wir verstehen dein tägliches Leid."*
Füge eine Sektion direkt nach dem Hero-Bereich ein: **"Kennen Sie diese Herausforderungen im Innungsalltag?"**
1. **Das Erreichbarkeits-Problem:** "Wichtige Rundschreiben und E-Mails landen im Spam oder werden schlichtweg ignoriert. Sie erreichen Ihre Betriebe nicht mehr zuverlässig."
2. **Das Verwaltungs-Chaos:** "Excel-Listen für Veranstaltungen, separate Newsletter-Tools und veraltete Aktenordner fressen Ihre wertvolle Zeit."
3. **Der Nachwuchsmangel:** "Es fehlt eine zentrale, lokale Anlaufstelle, um Auszubildende und Fachkräfte direkt mit Ihren Lehrbetrieben zu vernetzen."
*Dann die Auflösung:* **"Die InnungsApp PRO löst genau diese Probleme in einer einzigen Plattform."**
### 3.3 Reibungsloser "Kostenlos starten" Flow
Um die Einstiegshürde (Cognitive Load) maximal zu senken:
1. **Erwartungsmanagement (3-Schritte-Erklärung):** Zeigt vor/beim Klick, was passiert.
* *1. Account in 60 Sekunden erstellen.*
* *2. Mitglieder-Daten sicher importieren.*
* *3. Erste Push-Nachricht senden.*
2. **Onboarding-Versprechen:** Nutzt die "< 48h"-Metrik prominent in der Nähe des CTAs. ("In unter 48h komplett einsatzbereit unser Team hilft beim Setup.")
3. **Formular-Minimierung:** Wenn sie "Kostenlos starten" klicken, fragt nur das Wichtigste ab (Name, Innung, E-Mail). Alles Weitere passiert im System.
---
## 4. FEHLENDE STRUKTURELEMENTE FÜR MEHR VERTRAUEN
Die Zielgruppe (Innungsobermeister, Geschäftsführer) ist risikoavers. Software-Wechsel werden historisch als anstrengend und gefährlich (Abmahnungen, DSGVO) gesehen. Diese Sektionen müssen zwingend auf die Seite:
### 4.1 Trust-Logos & Social Proof
* **Das Element:** Gleich unter den Hero-Bereich (Hero-Banner) eine Leiste "Bereits erfolgreich im Einsatz bei XX Innungen und Kreishandwerkerschaften".
* **Testimonials:** Echte Zitate mit Gesicht und Titel (z. B. *"Seit wir die InnungsApp nutzen, hat sich unsere Verwaltungszeit halbiert und unsere Event-Rücklaufquote verdoppelt." Max Mustermann, Obermeister Innung XY*). Dieser "Peer-to-Peer"-Trust ist im B2B-Handwerk extrem stark.
### 4.2 Security & DSGVO-Sektion (Trust Signals)
* **Das Element:** Eine dedizierte Sektion für "Sicherheit & Datenschutz".
* **Inhalt:** Hebt das "100 % DSGVO-konformes Hosting in Deutschland" hervor. Nutzt Schloss-Icons, Zertifikate (falls vorhanden, z.B. ISO-Zertifizierung eurer Server) und das Label "Made in Germany".
### 4.3 FAQ (Häufige Fragen & Einwandbehandlung)
* **Das Element:** Ein Akkordeon-Bereich am Ende der Seite (gut für AEO/Featured Snippets!).
* **Fragen, die adressiert werden müssen:**
* *Wie aufwendig ist der Wechsel zu InnungsApp PRO?* (Antwort: < 48h, Import-Service)
* *Sind meine Mitgliedsdaten sicher und DSGVO-konform?*
* *Brauchen unsere Handwerksbetriebe Schulungen für die App?* (Antwort: Nein, so intuitiv wie WhatsApp)
* *Gibt es eine Vertragsbindung für den Testzeitraum?* (Antwort: Nein, läuft automatisch aus/risikofrei)
### 4.4 Feature/Benefit als Gegenüberstellung ("Vorher / Nachher")
* **Das Element:** Eine visuelle Tabelle "Der alte Weg" (Rote X) vs. "Der InnungsApp PRO Weg" (Grüne Haken).
* *Alter Weg:* E-Mails, die keiner liest; Excellisten für Events; Schwarze Bretter für Azubis.
* *InnungsApp PRO:* Push-News mit 90% Leserate; 1-Klick-RSVP; Digitale Lehrlingsbörse.
---
## Zusammenfassung für die Implementierung in Next.js
1. **Meta & Struktur:** Nutzt die `next/head` oder App Router Metadata API für den Title & Description. Verwendet semantische `<article>`, `<section>` und valide `<h1>` bis `<h3>` Tags basierend auf obigem Entwurf.
2. **Schema.org:** Implementiert ein `SoftwareApplication` JSON-LD Script und ein `FAQPage` JSON-LD Script. Das pusht die Klickrate massiv.
3. **Performance:** Da Core Web Vitals (LCP, INP, CLS) SEO-Faktoren sind, nutzt `next/image` für alle Hero-Grafiken und App-Mockups und vermeidet Layout-Shifts beim Laden der Testimonials.

View File

@@ -5,6 +5,14 @@
---
## Technisches Setup
Die aktuelle und verbindliche Anleitung fuer Start, Docker-Deployment, Migrationen und Seeding liegt in:
- `innungsapp/README.md`
Dieses Root-README ist eine Produkt- und Strategieuebersicht.
## Was ist InnungsApp?
InnungsApp ist eine mobile-first SaaS-Plattform, die Innungen und Kreishandwerkerschaften digitalisiert. Sie löst zwei akute Probleme gleichzeitig:
@@ -56,7 +64,7 @@ InnungsApp ist eine mobile-first SaaS-Plattform, die Innungen und Kreishandwerke
- **Hosting:** Vercel
- **Analytics:** PostHog
## Quickstart
## Quickstart (Legacy, nicht der aktuelle Betriebsweg)
```bash
# Repo klonen
@@ -71,3 +79,12 @@ npx supabase start
cd apps/mobile
npx expo start
```
pnpm --filter @innungsapp/admin dev -- --port 3032
npx expo start --clear
Demo: admin@demo.de / demo1234
Hinweis: Fuer den aktuellen lokalen/produktiven Betrieb bitte `innungsapp/README.md` verwenden.

35
dsvgo+.md Normal file
View File

@@ -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.

239
email.md Normal file
View File

@@ -0,0 +1,239 @@
# InnungsApp Outreach Emails
## Allgemeine Verband-Varianten
### Variante 1: Standardisierung / Kontrolle
**Betreff:** Ihre Innungen digital einheitlich organisieren
**Betreff:** Digitale Infrastruktur fuer Ihre angeschlossenen Innungen
Hallo Herr/Frau [Nachname],
viele Kreishandwerksverbaende koordinieren heute 20 oder mehr Innungen, ohne ein gemeinsames System fuer Kommunikation, Termine und Mitgliedsinfos.
Das Ergebnis ist meist:
- Excel-Listen
- Rundmails ohne Rueckmeldung
- WhatsApp als inoffizieller Kanal
- kein einheitlicher Standard ueber alle Innungen hinweg
Genau dafuer haben wir `InnungsApp` gebaut: eine Verbandsloesung, mit der Sie Kommunikation und Organisation ueber angeschlossene Innungen hinweg standardisieren koennen.
Der Einstieg ist einfach:
- Verband-Setup
- Start mit 3 Pilot-Innungen
- danach schrittweiser Rollout auf weitere Innungen
Haetten Sie naechste Woche 20 Minuten fuer einen kurzen Austausch?
Viele Gruesse
[Name]
### Variante 2: DSGVO / WhatsApp-Risiko
**Betreff:** WhatsApp und Excel sind kein System fuer einen Verband
**Betreff:** DSGVO-sichere Kommunikation fuer Ihre Innungen
Hallo Herr/Frau [Nachname],
bei vielen Kreishandwerksverbaenden laeuft die Kommunikation mit angeschlossenen Innungen noch ueber Rundmails, Excel und teils WhatsApp-Strukturen.
Fuer einzelne Faelle funktioniert das irgendwie. Auf Verbandsebene ist es meist:
- schwer steuerbar
- nicht einheitlich
- kaum auswertbar
- DSGVO-seitig unnoetig riskant
`InnungsApp` hilft Kreishandwerksverbaenden, genau das zentraler und professioneller aufzusetzen, ohne jede Innung einzeln mit Inselloesungen arbeiten zu lassen.
Unser Modell:
- Setup auf Verbandsebene
- Einfuehrung mit 3 Innungen
- danach Rollout im Verband
Wenn das grundsaetzlich relevant klingt, zeige ich Ihnen das gern in 20 Minuten.
Beste Gruesse
[Name]
### Variante 3: Geschaeftsfuehrer-Hook / Fuehrungsaufgabe
**Betreff:** Wie steuern Sie heute die Digitalisierung Ihrer Innungen?
**Betreff:** Ein Standard statt 20 Einzelloesungen
Hallo Herr/Frau [Nachname],
eine Frage aus echtem Interesse:
Wie stellen Sie heute sicher, dass Ihre angeschlossenen Innungen bei Kommunikation, Terminen und Mitgliederorganisation nicht alle unterschiedlich arbeiten?
Genau dort sehen wir bei vielen Kreishandwerksverbaenden einen Engpass:
kein gemeinsamer Standard, hoher Koordinationsaufwand und wenig Transparenz.
`InnungsApp` ist dafuer als Verbandsloesung gedacht:
- zentral aufgesetzt
- fuer erste 3 Innungen eingefuehrt
- dann auf weitere Innungen ausrollbar
Wenn das Thema bei Ihnen aktuell oder perspektivisch relevant ist, schicke ich gern eine kurze Uebersicht oder zeige es in einer 20-Minuten-Demo.
Viele Gruesse
[Name]
### Variante 4: Rollout mit wenig Risiko
**Betreff:** Verbandsweite Digitalisierung ohne Big-Bang-Einfuehrung
**Betreff:** Erst 3 Innungen, dann Verbands-Rollout
Hallo Herr/Frau [Nachname],
oft ist nicht die Idee das Problem, sondern das Einfuehrungsrisiko:
"Nutzen die Innungen das wirklich?"
"Muss das erst durch Vorstand und Gremien?"
"Wie startet man so etwas praktisch?"
Deshalb haben wir den Einstieg fuer Kreishandwerksverbaende bewusst schlank gedacht:
- Verband-Setup
- Start mit 3 Innungen
- klarer Rollout-Plan fuer weitere Innungen
`InnungsApp` buendelt Kommunikation, Termine und Mitgliederinfos in einer gemeinsamen Struktur statt in vielen Einzelprozessen.
Waere ein kurzer Termin sinnvoll, damit ich Ihnen den Ablauf einmal kompakt zeige?
Beste Gruesse
[Name]
### Variante 5: Outcome / Entlastung
**Betreff:** Weniger Koordinationsaufwand fuer Ihre Innungen
**Betreff:** Kommunikation und Termine nicht mehr ueber Excel + Rundmail
Hallo Herr/Frau [Nachname],
wir sprechen gerade mit Kreishandwerksverbaenden, die ihre angeschlossenen Innungen organisatorisch entlasten wollen.
Das Muster ist oft gleich:
- Mitgliederinfos liegen verteilt
- Rundschreiben werden verschickt, aber nicht sauber nachverfolgt
- Termine und Rueckmeldungen laufen uneinheitlich
Mit `InnungsApp` koennen Verbaende dafuer einen gemeinsamen digitalen Standard schaffen, statt jede Innung einzeln improvisieren zu lassen.
Der Einstieg erfolgt nicht als harter Komplett-Rollout, sondern strukturiert:
- Setup auf Verbandsebene
- Einfuehrung in 3 Innungen
- anschliessende Ausweitung
Falls das bei Ihnen in den naechsten Monaten ein Thema ist, koennen wir gern 20 Minuten sprechen.
Viele Gruesse
[Name]
### Variante 6: Sehr kurz / direkt
**Betreff:** Loesung fuer Kreishandwerksverbaende
**Betreff:** 20 Minuten zu einem Verbands-Rollout?
Hallo Herr/Frau [Nachname],
wir bauen `InnungsApp` fuer Kreishandwerksverbaende, die Kommunikation und Organisation ueber mehrere Innungen hinweg einheitlicher aufsetzen wollen.
Statt Excel, Rundmail und Inselloesungen:
- Verband-Setup
- Start mit 3 Innungen
- danach Rollout
Ist das ein Thema, das bei Ihnen aktuell relevant ist?
Viele Gruesse
[Name]
## HGF / Geschaeftsfuehrer-Versionen
### HGF Version 1: Haerter / direkter
**Betreff:** Viele Verbaende arbeiten noch ohne gemeinsamen digitalen Standard
**Betreff:** Excel, Rundmail, WhatsApp: kein belastbares System fuer einen Verband
Hallo Herr/Frau [Nachname],
viele Kreishandwerksverbaende steuern ihre angeschlossenen Innungen noch ohne einheitliches digitales System.
Das fuehrt fast immer zu denselben Problemen:
- jede Innung arbeitet anders
- Kommunikation laeuft ueber Rundmails statt ueber einen steuerbaren Kanal
- Informationen sind verteilt statt zentral
- der Verband hat wenig Transparenz und wenig Standardisierung
Genau dafuer haben wir `InnungsApp` entwickelt.
Nicht als Einzelloesung fuer eine Innung, sondern als Struktur auf Verbandsebene:
- ein gemeinsamer Rahmen fuer Kommunikation, Termine und Mitgliederinformationen
- Start mit 3 Innungen
- danach geordneter Rollout auf weitere Innungen
Wenn das Thema bei Ihnen relevant ist, lohnt sich ein kurzer Austausch.
Haetten Sie naechste oder uebernaechste Woche 20 Minuten?
Viele Gruesse
[Name]
### HGF Version 2: Waermer / eleganter
**Betreff:** Digitale Struktur fuer Ihre angeschlossenen Innungen
**Betreff:** Ein einheitlicher Rahmen fuer Kommunikation und Organisation im Verband
Hallo Herr/Frau [Nachname],
ich beschaeftige mich aktuell intensiv mit der Frage, wie Kreishandwerksverbaende ihre angeschlossenen Innungen digital besser unterstuetzen und gleichzeitig organisatorisch entlasten koennen.
In vielen Gespraechen zeigt sich ein aehnliches Bild:
- Kommunikation laeuft ueber verschiedene Kanaele nebeneinander
- Ablaeufe unterscheiden sich stark zwischen den Innungen
- es fehlt ein gemeinsamer, professioneller Standard auf Verbandsebene
Mit `InnungsApp` haben wir eine Loesung entwickelt, die genau an diesem Punkt ansetzt:
- Kommunikation, Termine und Mitgliederinformationen in einer gemeinsamen Struktur
- Einfuehrung nicht als grosser Komplettwechsel, sondern kontrolliert
- Start mit 3 Innungen, danach schrittweise Ausweitung
Fuer Geschaeftsfuehrer ist vor allem interessant, dass dadurch nicht nur Prozesse digitaler werden, sondern auch Steuerbarkeit und Aussenwirkung des Verbands verbessert werden.
Wenn Sie moechten, zeige ich Ihnen das gern in einem kompakten Termin.
Viele Gruesse
[Name]
### HGF Version 3: Sehr kurz unter 120 Woertern
**Betreff:** Digitaler Standard fuer Ihre Innungen
**Betreff:** 20 Minuten zu einem Verbands-Rollout?
Hallo Herr/Frau [Nachname],
viele Kreishandwerksverbaende arbeiten bei Kommunikation und Organisation ihrer Innungen noch mit einem Mix aus Rundmail, Excel und Einzelloesungen.
`InnungsApp` ist dafuer als Verbandsloesung gedacht:
- gemeinsamer Standard fuer Kommunikation, Termine und Mitgliederinfos
- Start mit 3 Innungen
- danach Rollout auf weitere angeschlossene Innungen
Der Nutzen fuer den Verband:
- mehr Standardisierung
- mehr Steuerbarkeit
- weniger Inselloesungen
Falls das grundsaetzlich relevant ist, zeige ich Ihnen das gern in 20 Minuten.
Viele Gruesse
[Name]
## Einsatzempfehlung
- Erstkontakt: `Variante 6` oder `HGF Version 3`
- Etwas haerterer Erstkontakt: `HGF Version 1`
- Konservativer oder waermerer Ton: `HGF Version 2`
- Wenn DSGVO im Fokus steht: `Variante 2`
- Wenn Einfuehrungsangst dominiert: `Variante 4`

44
innungsapp/.dockerignore Normal file
View File

@@ -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

View File

@@ -1,7 +1,10 @@
# =============================================
# DATABASE
# DATABASE (PostgreSQL)
# =============================================
DATABASE_URL="postgresql://postgres:password@localhost:5432/innungsapp"
POSTGRES_DB="innungsapp"
POSTGRES_USER="innungsapp"
POSTGRES_PASSWORD="innungsapp"
DATABASE_URL="postgresql://innungsapp:innungsapp@localhost:5432/innungsapp?schema=public"
# =============================================
# BETTER-AUTH
@@ -23,6 +26,14 @@ SMTP_PASS=""
# ADMIN APP (Next.js)
# =============================================
NEXT_PUBLIC_APP_URL="http://localhost:3000"
NEXT_PUBLIC_POSTHOG_KEY=""
NEXT_PUBLIC_POSTHOG_HOST="https://us.i.posthog.com"
# =============================================
# SUPERADMIN SEED
# =============================================
SUPERADMIN_EMAIL="superadmin@innungsapp.de"
SUPERADMIN_PASSWORD="change-me-strong-password"
# =============================================
# MOBILE APP (Expo)

View File

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

View File

@@ -21,9 +21,6 @@ out
# Uploads (local file storage)
apps/admin/uploads/
# Prisma
packages/shared/prisma/migrations/
# Expo
apps/mobile/.expo
apps/mobile/android

View File

@@ -106,6 +106,74 @@ Required in `apps/admin/.env` (see `.env.example`):
- `EXPO_PUBLIC_API_URL` — Mobile points to admin API
- `UPLOAD_DIR` / `UPLOAD_MAX_SIZE_MB` — File storage
## Planned: SQLite → PostgreSQL Migration
The current schema uses **SQLite** (`packages/shared/prisma/schema.prisma`). The migration target is **PostgreSQL** (production-grade, enables JSONB and native arrays).
### What changes in `schema.prisma`
```prisma
datasource db {
provider = "postgresql" // was: "sqlite"
url = env("DATABASE_URL")
}
```
### Fields to convert to `@db.JsonB`
These fields are currently stored as JSON-encoded `String?` in SQLite and must become proper JSONB columns in PostgreSQL:
| Model | Field | Prisma annotation |
|---|---|---|
| `Organization` | `landingPageFeatures` | `@db.JsonB` |
| `Organization` | `landingPageFooter` | `@db.JsonB` |
Example after migration:
```prisma
landingPageFeatures Json? @map("landing_page_features") @db.JsonB
landingPageFooter Json? @map("landing_page_footer") @db.JsonB
```
### Fields to convert to native PostgreSQL arrays
`Organization.sparten` is stored as `String?` (comma-separated or JSON) in SQLite. In PostgreSQL it becomes:
```prisma
sparten String[] @default([])
```
### Migration steps
1. Provision a PostgreSQL instance (Supabase, Neon, or self-hosted via Docker).
2. Set `DATABASE_URL` to a `postgresql://` connection string.
3. Update `schema.prisma`: change `provider`, add `@db.JsonB` and `String[]` types.
4. Run `pnpm db:generate` to regenerate the Prisma client.
5. Create a fresh migration: `pnpm db:migrate` (this creates `packages/shared/prisma/migrations/…`).
6. All code that currently parses `landingPageFeatures` / `landingPageFooter` as `JSON.parse(string)` must switch to reading them directly as objects (Prisma returns them as `unknown` / `JsonValue`).
### Docker Compose (local PostgreSQL)
Add a `postgres` service to `docker-compose.yml`:
```yaml
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: innungsapp
POSTGRES_USER: innungsapp
POSTGRES_PASSWORD: secret
volumes:
- pg_data:/var/lib/postgresql/data
ports:
- "5432:5432"
volumes:
pg_data:
```
Then set `DATABASE_URL=postgresql://innungsapp:secret@localhost:5432/innungsapp`.
## Key Conventions
- **Styling**: Tailwind CSS in admin; NativeWind v4 (Tailwind syntax) in mobile
@@ -114,3 +182,4 @@ Required in `apps/admin/.env` (see `.env.example`):
- **Icons**: `lucide-react` (admin), `@expo/vector-icons` (mobile)
- **Schema changes**: Always run `pnpm db:generate` after editing `packages/shared/prisma/schema.prisma`
- **tRPC client (mobile)**: configured in `apps/mobile/lib/trpc.ts`, uses `superjson` transformer
- **Enum fields**: Stored as `String` in SQLite (enforced via Zod); after PostgreSQL migration, consider converting to native `enum` types

View File

@@ -1,124 +1,294 @@
# InnungsApp
Die digitale Plattform für Innungen — News, Mitgliederverzeichnis, Termine und Lehrlingsbörse.
Digitale Plattform fuer Innungen mit Admin-Dashboard (Next.js) und Mobile App (Expo).
## Stack
| Schicht | Technologie |
| Layer | Technology |
|---|---|
| **Monorepo** | pnpm Workspaces + Turborepo |
| **Mobile App** | Expo (React Native) + Expo Router |
| **Admin Dashboard** | Next.js 15 (App Router) |
| **API** | tRPC v11 |
| **Auth** | better-auth (Magic Links) |
| **Datenbank** | PostgreSQL + Prisma ORM |
| **Styling Mobile** | NativeWind v4 (Tailwind CSS) |
| **Styling Admin** | Tailwind CSS |
| **State Management** | Zustand (Mobile) + React Query (beide Apps) |
| Monorepo | pnpm Workspaces + Turborepo |
| Admin Dashboard | Next.js 15 (App Router) |
| Mobile App | Expo + React Native |
| API | tRPC v11 |
| Auth | better-auth (magic links + credential login) |
| Database | PostgreSQL + Prisma ORM (`jsonb` fuer Landing-Page-Felder) |
| Styling | Tailwind CSS (admin), NativeWind (mobile) |
## Projekt-Struktur
## Projektstruktur
```
```text
innungsapp/
├── apps/
│ ├── mobile/ # Expo React Native App (iOS + Android)
└── admin/ # Next.js Admin Dashboard
├── packages/
└── shared/ # TypeScript-Typen + Prisma Client
└── ...
|-- apps/
| |-- admin/
| `-- mobile/
|-- packages/
| `-- shared/
| `-- prisma/
|-- docker-compose.yml
`-- README.md
```
## Setup
## Local Setup
Port-Hinweis:
- Ohne Docker (lokales `pnpm dev`): App typischerweise auf `http://localhost:3000`
- Mit Docker Compose: App auf `http://localhost:3010` (Container-intern weiter `3000`)
### Voraussetzungen
- Node.js >= 20
- pnpm >= 9
- PostgreSQL-Datenbank
- SMTP-Server (für Magic Links)
- SMTP-Zugang (fuer Einladungen und Magic Links)
### 1. Abhängigkeiten installieren
### 1. Abhaengigkeiten installieren
```bash
pnpm install
```
### 2. Umgebungsvariablen
### 2. Umgebungsvariablen setzen (Admin lokal)
```bash
cp .env.example apps/admin/.env.local
# .env.local befüllen (DATABASE_URL, BETTER_AUTH_SECRET, SMTP_*)
cp .env.example .env
```
### 3. Datenbank einrichten
Danach `.env` anpassen (mindestens `DATABASE_URL`, `BETTER_AUTH_SECRET`, SMTP-Werte).
### 3. DB vorbereiten (lokal)
Lokale PostgreSQL-DB starten (nur falls noch nicht aktiv):
```bash
docker compose up -d postgres
```
Prisma vorbereiten:
```bash
# Prisma Client generieren
pnpm db:generate
pnpm db:push
```
# Migrationen anwenden
pnpm db:migrate
Optional Demo-Daten:
# Demo-Daten einspielen (optional)
```bash
pnpm db:seed
pnpm db:seed-superadmin
```
### 4. Entwicklung starten
```bash
# Admin Dashboard (http://localhost:3000)
pnpm --filter @innungsapp/admin dev
# Mobile App (Expo DevTools)
pnpm --filter @innungsapp/mobile dev
```
Oder alles parallel:
Oder parallel:
```bash
pnpm dev
```
## Datenbank-Schema
## Production Deployment (Docker, Admin)
Das Schema befindet sich in `packages/shared/prisma/schema.prisma`.
Dieser Abschnitt ist der verbindliche Weg fuer den Productive-Server.
Wichtige Tabellen:
- `organizations` — Innungen (Multi-Tenancy)
- `members` — Mitglieder (verknüpft mit Auth-User nach Einladung)
- `user_roles` — Berechtigungen (admin | member)
- `news`, `news_reads`, `news_attachments` — News-System
- `termine`, `termin_anmeldungen` — Terminverwaltung
- `stellen` — Lehrlingsbörse (öffentlich lesbar)
### Voraussetzungen
## Auth-Flow
- Linux Server mit Docker + Docker Compose
- DNS-Eintrag auf den Server
- SMTP-Zugangsdaten
- Reverse Proxy (z. B. Nginx) fuer HTTPS
1. **Admin einrichten:** Seed-Daten oder manuell in der DB
2. **Mitglied einladen:** Admin erstellt Mitglied → "Einladung senden" → Magic Link per E-Mail
3. **Mitglied loggt ein:** Magic Link → Session → App-Zugang
## API (tRPC)
Alle API-Endpunkte sind typsicher über tRPC definiert:
- `organizations.*` — Org-Einstellungen, Stats, AVV
- `members.*` — CRUD, Einladungen
- `news.*` — CRUD, Lesestatus, Push-Benachrichtigungen
- `termine.*` — CRUD, Anmeldungen
- `stellen.*` — Public + Auth-geschützte Endpunkte
## Deployment
### Admin (Vercel)
### 1. Repository klonen
```bash
# Umgebungsvariablen in Vercel setzen:
# DATABASE_URL, BETTER_AUTH_SECRET, BETTER_AUTH_URL, SMTP_*
# Deploy
vercel --cwd apps/admin
git clone <repo-url>
cd innungsapp
```
### Mobile (EAS Build)
### 2. Production-Env anlegen
```bash
cp .env.production.example .env
```
Pflichtwerte in `.env`:
- `DATABASE_URL` (PostgreSQL DSN, z. B. `postgresql://innungsapp:...@postgres:5432/innungsapp?schema=public`)
- `POSTGRES_DB`
- `POSTGRES_USER`
- `POSTGRES_PASSWORD`
- `BETTER_AUTH_SECRET` (mindestens 32 Zeichen)
- `BETTER_AUTH_URL` (z. B. `https://app.deine-innung.de`)
- `NEXT_PUBLIC_APP_URL` (gleich wie `BETTER_AUTH_URL`)
- `EMAIL_FROM`
- `SMTP_HOST`
- `SMTP_PORT`
- `SMTP_SECURE`
- `SMTP_USER`
- `SMTP_PASS`
- `SUPERADMIN_EMAIL`
- `SUPERADMIN_PASSWORD`
### 3. Container bauen und starten
```bash
docker compose up -d --build
```
Hinweis zum DB-Start:
- Wenn Prisma-Migrationen vorhanden sind, wird `prisma migrate deploy` ausgefuehrt.
- Wenn keine Migrationen vorhanden sind, wird einmalig `prisma db push` ausgefuehrt.
### 4. Healthcheck und Logs pruefen
```bash
docker compose logs -f admin
curl -fsS http://localhost:3010/api/health
```
Erwartet: JSON mit `"status":"ok"`, z. B.
```json
{"status":"ok","timestamp":"2026-03-04T12:34:56.789Z"}
```
### 5. Superadmin anlegen (nur beim ersten Start)
```bash
docker compose exec -w /app admin node packages/shared/prisma/seed-superadmin.js
```
Login-Daten kommen aus `.env`:
- E-Mail: `SUPERADMIN_EMAIL`
- Passwort: `SUPERADMIN_PASSWORD`
Hinweis:
- In `NODE_ENV=production` bricht der Seed ab, wenn `SUPERADMIN_PASSWORD` fehlt.
- In Entwicklung wird ohne `SUPERADMIN_PASSWORD` als Fallback `demo1234` genutzt.
- Der Seed ist idempotent (`upsert`) und kann bei Bedarf erneut ausgefuehrt werden.
### 6. HTTPS (Reverse Proxy)
Nginx sollte auf `localhost:3010` weiterleiten und TLS terminieren.
Beispiel:
```nginx
server {
listen 80;
server_name app.deine-innung.de;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name app.deine-innung.de;
ssl_certificate /etc/letsencrypt/live/app.deine-innung.de/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.deine-innung.de/privkey.pem;
location / {
proxy_pass http://localhost:3010;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
### 7. Updates einspielen
```bash
git pull
docker compose up -d --build
docker compose logs -f admin
```
### 8. Backup und Restore (Docker Volumes)
Vorher die exakten Volumenamen pruefen:
```bash
docker volume ls | grep pg_data
docker volume ls | grep uploads_data
```
Backup:
```bash
mkdir -p backups
docker run --rm \
-v innungsapp_pg_data:/volume \
-v "$(pwd)/backups:/backup" \
alpine sh -c "tar czf /backup/pg_data_$(date +%F_%H%M).tar.gz -C /volume ."
```
Restore (nur bei gestoppter App):
```bash
docker compose down
docker run --rm \
-v innungsapp_pg_data:/volume \
-v "$(pwd)/backups:/backup" \
alpine sh -c "rm -rf /volume/* && tar xzf /backup/<backup-file>.tar.gz -C /volume"
docker compose up -d
```
### 9. Verifizierte Kommandos (Stand 4. Maerz 2026)
Die folgenden Befehle wurden in dieser Umgebung erfolgreich ausgefuehrt:
```bash
# 1) Postgres starten (falls noch nicht aktiv)
docker compose up -d postgres
# 2) Prisma Client generieren
(cd packages/shared && npx prisma generate)
# 3) Initiale PostgreSQL-Migration erstellen (einmalig)
(cd packages/shared && \
DATABASE_URL="postgresql://innungsapp:innungsapp@localhost:5432/innungsapp?schema=public" \
npx prisma migrate dev --name init_postgres --schema=prisma/schema.prisma --create-only)
# 4) Migration anwenden
(cd packages/shared && \
DATABASE_URL="postgresql://innungsapp:innungsapp@localhost:5432/innungsapp?schema=public" \
npx prisma migrate deploy --schema=prisma/schema.prisma)
# 5) Gesamtes Setup bauen und starten
docker compose up -d --build
# 6) Superadmin seeden (mit ENV-Werten)
docker compose exec -e SUPERADMIN_EMAIL=superadmin@innungsapp.de \
-e SUPERADMIN_PASSWORD='demo1234' \
-w /app admin node packages/shared/prisma/seed-superadmin.js
# 7) Laufzeitstatus pruefen
docker compose ps
docker compose logs --tail 80 admin
curl -fsS http://localhost:3010/api/health
```
Optionale SQL-Verifikation (wurde ebenfalls erfolgreich getestet):
```bash
# JSONB-Spalten pruefen
docker compose exec -T postgres psql -U innungsapp -d innungsapp -c \
"SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'organizations' AND column_name IN ('landing_page_features','landing_page_footer') ORDER BY column_name;"
# Seeded Superadmin pruefen
docker compose exec -T postgres psql -U innungsapp -d innungsapp -c \
"SELECT u.email, u.role, u.email_verified, a.provider_id, (a.password IS NOT NULL) AS has_password FROM \"user\" u LEFT JOIN account a ON a.user_id = u.id AND a.provider_id = 'credential' WHERE u.email = 'superadmin@innungsapp.de';"
```
## Mobile Release (EAS)
```bash
cd apps/mobile
@@ -126,12 +296,32 @@ eas build --platform all --profile production
eas submit --platform all
```
## DSGVO / AVV
Wichtig:
- AVV-Akzeptanz in Admin → Einstellungen (Pflichtfeld vor Go-Live)
- Alle personenbezogenen Daten in EU-Region (Datenbankserver in Deutschland empfohlen)
- Keine Daten an Dritte außer Expo Push API (anonymisierte Token)
- In `apps/mobile/eas.json` sind Submit-Placeholders vorhanden und muessen ersetzt werden.
- Fuer Production darf keine API-URL auf `localhost` zeigen.
## Roadmap
## Troubleshooting
Siehe `innung-app-mvp.md` für die vollständige Roadmap.
### `migrate deploy` oder `db push` fehlschlaegt
- `DATABASE_URL` pruefen
- `postgres` Container Healthcheck pruefen (`docker compose ps`)
- Logs: `docker compose logs -f admin`
### Healthcheck liefert Fehler
- Containerstatus: `docker compose ps`
- App-Logs lesen
- Reverse Proxy testweise umgehen und direkt `http://localhost:3010/api/health` pruefen
### Login funktioniert nicht nach Seed
- Seed-Command erneut ausfuehren
- In DB pruefen, ob `user` und `account` Eintraege fuer `superadmin@innungsapp.de` existieren
## Weiterfuehrende Doku
- Produkt-Roadmap: `../ROADMAP.md`
- Architektur: `../ARCHITECTURE.md`
- API Design: `../API_DESIGN.md`

View File

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

View File

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

View File

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

View File

@@ -19,14 +19,14 @@ export default function EinstellungenPage() {
<h1 className="text-2xl font-bold text-gray-900">Einstellungen</h1>
{/* Org Settings */}
<div className="bg-white rounded-xl border shadow-sm p-6 space-y-4">
<div className="bg-white rounded-lg border p-6 space-y-4">
<h2 className="font-semibold text-gray-900">Innung</h2>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name der Innung</label>
<input
defaultValue={org.name}
onChange={(e) => setName(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"
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
/>
</div>
<div>
@@ -35,7 +35,7 @@ export default function EinstellungenPage() {
type="email"
defaultValue={org.contactEmail ?? ''}
onChange={(e) => setContactEmail(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"
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
/>
</div>
<button
@@ -51,7 +51,7 @@ export default function EinstellungenPage() {
</div>
{/* AVV */}
<div className="bg-white rounded-xl border shadow-sm p-6 space-y-4">
<div className="bg-white rounded-lg border p-6 space-y-4">
<h2 className="font-semibold text-gray-900">Auftragsverarbeitungsvertrag (AVV)</h2>
<p className="text-sm text-gray-600">
Der AVV regelt die Verarbeitung personenbezogener Daten im Auftrag Ihrer Innung
@@ -100,8 +100,35 @@ export default function EinstellungenPage() {
)}
</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 */}
<div className="bg-white rounded-xl border shadow-sm p-6">
<div className="bg-white rounded-lg border p-6">
<h2 className="font-semibold text-gray-900 mb-2">Plan</h2>
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-brand-100 text-brand-700 capitalize">
{org.plan}

View File

@@ -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 overflow-y-auto 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>
)
}

View File

@@ -0,0 +1,272 @@
'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}>
<option value=""> Bitte wählen </option>
{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>
)
}

View File

@@ -8,37 +8,31 @@ import { SPARTEN } from '@innungsapp/shared'
export default function MitgliedNeuPage() {
const router = useRouter()
const [sendInvite, setSendInvite] = useState(true)
const [form, setForm] = useState({
name: '',
betrieb: '',
sparte: 'Elektrotechnik',
sparte: '',
ort: '',
telefon: '',
email: '',
status: 'aktiv' as const,
istAusbildungsbetrieb: false,
seit: new Date().getFullYear(),
role: 'member' as 'member' | 'admin',
password: '',
})
const createMutation = trpc.members.create.useMutation({
onSuccess: () => router.push('/dashboard/mitglieder'),
})
const inviteMutation = trpc.members.invite.useMutation({
onSuccess: () => router.push('/dashboard/mitglieder'),
})
const isPending = createMutation.isPending || inviteMutation.isPending
const error = createMutation.error ?? inviteMutation.error
const isPending = createMutation.isPending
const error = createMutation.error
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (sendInvite) {
inviteMutation.mutate(form)
} else {
createMutation.mutate(form)
}
}
return (
<div className="max-w-2xl space-y-6">
@@ -61,28 +55,27 @@ export default function MitgliedNeuPage() {
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">Betrieb *</label>
<label className="block text-sm font-medium text-gray-700 mb-1">Betrieb</label>
<input
required
value={form.betrieb}
onChange={(e) => setForm({ ...form, betrieb: 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>
<label className="block text-sm font-medium text-gray-700 mb-1">Sparte *</label>
<label className="block text-sm font-medium text-gray-700 mb-1">Sparte</label>
<select
value={form.sparte}
onChange={(e) => setForm({ ...form, sparte: 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"
>
<option value=""> Bitte wählen </option>
{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>
<label className="block text-sm font-medium text-gray-700 mb-1">Ort</label>
<input
required
value={form.ort}
onChange={(e) => setForm({ ...form, ort: 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"
@@ -130,6 +123,27 @@ export default function MitgliedNeuPage() {
<option value="ausgetreten">Ausgetreten</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 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">
<label className="flex items-center gap-2 cursor-pointer">
<input
@@ -143,23 +157,6 @@ export default function MitgliedNeuPage() {
</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 && (
<p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg">
{error.message}
@@ -172,7 +169,7 @@ export default function MitgliedNeuPage() {
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"
>
{isPending ? 'Wird gespeichert...' : sendInvite ? 'Speichern & Einladung senden' : 'Speichern'}
{isPending ? 'Wird gespeichert...' : 'Speichern'}
</button>
<Link
href="/dashboard/mitglieder"

View File

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

View File

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

View File

@@ -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>
)
}

View File

@@ -1,5 +1,5 @@
import { prisma } from '@innungsapp/shared'
import { auth } from '@/lib/auth'
import { auth, getSanitizedHeaders } from '@/lib/auth'
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import Link from 'next/link'
@@ -16,7 +16,8 @@ const KATEGORIE_COLORS: Record<string, string> = {
}
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')
const userRole = await prisma.userRole.findFirst({
where: { userId: session.user.id, role: 'admin' },
@@ -29,8 +30,8 @@ export default async function NewsPage() {
orderBy: [{ publishedAt: 'desc' }, { createdAt: 'desc' }],
})
const published = news.filter((n) => n.publishedAt)
const drafts = news.filter((n) => !n.publishedAt)
const published = news.filter((n: typeof news[number]) => n.publishedAt)
const drafts = news.filter((n: typeof news[number]) => !n.publishedAt)
return (
<div className="space-y-6">
@@ -52,10 +53,10 @@ export default async function NewsPage() {
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
Entwürfe
</h2>
<div className="bg-white rounded-xl border shadow-sm overflow-hidden">
<div className="bg-white rounded-lg border overflow-hidden">
<table className="w-full data-table">
<tbody>
{drafts.map((n) => (
{drafts.map((n: typeof drafts[number]) => (
<tr key={n.id}>
<td className="w-full">
<p className="font-medium text-gray-900">{n.title}</p>
@@ -83,7 +84,7 @@ export default async function NewsPage() {
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
Publiziert
</h2>
<div className="bg-white rounded-xl border shadow-sm overflow-hidden">
<div className="bg-white rounded-lg border overflow-hidden">
<table className="w-full data-table">
<thead>
<tr>
@@ -95,7 +96,7 @@ export default async function NewsPage() {
</tr>
</thead>
<tbody>
{published.map((n) => (
{published.map((n: typeof published[number]) => (
<tr key={n.id}>
<td className="font-medium text-gray-900">{n.title}</td>
<td>

View File

@@ -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: typeof recentNews[number]) => (
<div key={n.id} className="flex items-start gap-3 py-2 border-b last:border-0">
<div className="flex-1 min-w-0">
<p className="font-medium text-sm text-gray-900 truncate">{n.title}</p>
<p className="text-xs text-gray-500 mt-0.5">
{n.publishedAt
? format(n.publishedAt, 'dd. MMM yyyy', { locale: de })
: 'Entwurf'}{' '}
· {n.author?.name ?? 'Unbekannt'}
</p>
</div>
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full whitespace-nowrap">
{NEWS_KATEGORIE_LABELS[n.kategorie]}
</span>
</div>
))}
</div>
</div>
{/* Upcoming Termine */}
<div className="bg-white rounded-lg border p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold text-gray-900">Nächste Termine</h2>
<Link href="/dashboard/termine" className="text-sm text-brand-600 hover:underline">
Alle anzeigen
</Link>
</div>
<div className="space-y-3">
{nextTermine.length === 0 && (
<p className="text-sm text-gray-500">Keine bevorstehenden Termine</p>
)}
{nextTermine.map((t: typeof nextTermine[number]) => (
<div key={t.id} className="flex items-start gap-3 py-2 border-b last:border-0">
<div className="text-center min-w-[40px]">
<p className="text-lg font-bold text-brand-500 leading-none">
{format(t.datum, 'dd', { locale: de })}
</p>
<p className="text-xs text-gray-500 uppercase">
{format(t.datum, 'MMM', { locale: de })}
</p>
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm text-gray-900 truncate">{t.titel}</p>
<p className="text-xs text-gray-500">{t.ort ?? 'Kein Ort angegeben'}</p>
</div>
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full whitespace-nowrap">
{TERMIN_TYP_LABELS[t.typ]}
</span>
</div>
))}
</div>
</div>
</div>
</div>
)
}

View File

@@ -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: NonNullable<typeof members>[number]) => m.id === e.target.value)
setForm({ ...form, memberId: e.target.value, sparte: selected?.sparte ?? form.sparte })
}}
className={inputClass}
>
<option value="">Mitglied auswählen...</option>
{members?.map((m: NonNullable<typeof members>[number]) => (
<option key={m.id} value={m.id}>
{m.betrieb} {m.name}
</option>
))}
</select>
</div>
</div>
{/* Stellendetails */}
<div className="border-t pt-5">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Stellendetails</p>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Sparte *</label>
<input
required
value={form.sparte}
onChange={(e) => setForm({ ...form, sparte: e.target.value })}
placeholder="z.B. Elektrotechnik"
className={inputClass}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Anzahl Stellen</label>
<input
type="number"
min={1}
value={form.stellenAnz}
onChange={(e) => setForm({ ...form, stellenAnz: Number(e.target.value) })}
className={inputClass}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Lehrjahr</label>
<input
value={form.lehrjahr}
onChange={(e) => setForm({ ...form, lehrjahr: e.target.value })}
placeholder="z.B. 1. Lehrjahr"
className={inputClass}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Vergütung</label>
<input
value={form.verguetung}
onChange={(e) => setForm({ ...form, verguetung: e.target.value })}
placeholder="z.B. 650 € / Monat"
className={inputClass}
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
<textarea
rows={3}
value={form.beschreibung}
onChange={(e) => setForm({ ...form, beschreibung: e.target.value })}
placeholder="Aufgaben, Anforderungen, ..."
className={inputClass}
/>
</div>
</div>
</div>
{/* Kontakt */}
<div className="border-t pt-5">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Kontakt</p>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Kontakt-E-Mail *</label>
<input
type="email"
required
value={form.kontaktEmail}
onChange={(e) => setForm({ ...form, kontaktEmail: e.target.value })}
placeholder="bewerbung@betrieb.de"
className={inputClass}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Ansprechpartner</label>
<input
value={form.kontaktName}
onChange={(e) => setForm({ ...form, kontaktName: e.target.value })}
placeholder="Max Mustermann"
className={inputClass}
/>
</div>
</div>
</div>
{createMutation.error && (
<p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg">
{getTrpcErrorMessage(createMutation.error)}
</p>
)}
<div className="flex gap-3 pt-2 border-t">
<button
type="submit"
disabled={createMutation.isPending || !form.memberId}
className="bg-brand-500 text-white px-6 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 disabled:opacity-60 transition-colors"
>
{createMutation.isPending ? 'Wird gespeichert...' : 'Stelle anlegen'}
</button>
<Link href="/dashboard/stellen" className="px-6 py-2 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-100 transition-colors">
Abbrechen
</Link>
</div>
</form>
</div>
<div className="lg:col-span-1 sticky top-6">
<AIGenerator type="stelle" onApply={(text) => setForm({ ...form, beschreibung: (form.beschreibung || '') + (form.beschreibung?.trim() ? '\n\n' : '') + text })} />
</div>
</div>
</div>
)
}

View File

@@ -1,13 +1,15 @@
import { prisma } from '@innungsapp/shared'
import { auth } from '@/lib/auth'
import { auth, getSanitizedHeaders } from '@/lib/auth'
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { format } from 'date-fns'
import { de } from 'date-fns/locale'
import { DeactivateButton } from './DeactivateButton'
import Link from 'next/link'
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')
const userRole = await prisma.userRole.findFirst({
where: { userId: session.user.id, role: 'admin' },
@@ -22,14 +24,22 @@ export default async function StellenPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Lehrlingsbörse</h1>
<p className="text-gray-500 mt-1">
{stellen.filter((s) => s.aktiv).length} aktive Angebote
</p>
</div>
<Link
href="/dashboard/stellen/neu"
className="bg-brand-500 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 transition-colors"
>
+ Stelle anlegen
</Link>
</div>
<div className="bg-white rounded-xl border shadow-sm overflow-hidden">
<div className="bg-white rounded-lg border overflow-hidden">
<table className="w-full data-table">
<thead>
<tr>

View File

@@ -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>
)
}

View File

@@ -3,6 +3,7 @@
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'
const TYPEN = [
@@ -122,7 +123,7 @@ export default function TerminNeuPage() {
{createMutation.error && (
<p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg">
{createMutation.error.message}
{getTrpcErrorMessage(createMutation.error)}
</p>
)}

View File

@@ -1,5 +1,5 @@
import { prisma } from '@innungsapp/shared'
import { auth } from '@/lib/auth'
import { auth, getSanitizedHeaders } from '@/lib/auth'
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import Link from 'next/link'
@@ -16,7 +16,8 @@ const TYP_COLORS: Record<string, string> = {
}
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')
const userRole = await prisma.userRole.findFirst({
where: { userId: session.user.id, role: 'admin' },
@@ -84,7 +85,7 @@ export default async function TerminePage() {
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
Bevorstehend ({upcoming.length})
</h2>
<div className="bg-white rounded-xl border shadow-sm overflow-hidden">
<div className="bg-white rounded-lg border overflow-hidden">
<table className="w-full data-table">
<thead>
<tr>
@@ -110,7 +111,7 @@ export default async function TerminePage() {
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
Vergangen
</h2>
<div className="bg-white rounded-xl border shadow-sm overflow-hidden opacity-70">
<div className="bg-white rounded-lg border overflow-hidden opacity-70">
<table className="w-full data-table">
<tbody>
{past.map((t) => <TerminRow key={t.id} t={t} />)}

View File

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

View File

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

View File

@@ -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 }
)
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
import { NextResponse } from 'next/server'
export async function GET() {
return NextResponse.json({ status: 'ok', timestamp: new Date().toISOString() })
}

View File

@@ -1,9 +1,9 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { auth, getSanitizedHeaders } from '@/lib/auth'
import { prisma } from '@innungsapp/shared'
export async function POST(req: NextRequest) {
const session = await auth.api.getSession({ headers: req.headers })
const session = await auth.api.getSession({ headers: await getSanitizedHeaders(req.headers) })
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

View File

@@ -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 })
}

View File

@@ -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 })
}

View File

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

View File

@@ -2,14 +2,21 @@ import { NextRequest, NextResponse } from 'next/server'
import { writeFile, mkdir } from 'fs/promises'
import path from 'path'
import { randomUUID } from 'crypto'
import { auth } from '@/lib/auth'
import { auth, getSanitizedHeaders } from '@/lib/auth'
const UPLOAD_DIR = process.env.UPLOAD_DIR ?? './uploads'
const UPLOAD_DIR = process.env.UPLOAD_DIR ?? (process.env.NODE_ENV === 'production' ? '/app/uploads' : './uploads')
const MAX_SIZE_BYTES = Number(process.env.UPLOAD_MAX_SIZE_MB ?? 10) * 1024 * 1024
function getUploadRoot() {
if (path.isAbsolute(UPLOAD_DIR)) {
return UPLOAD_DIR
}
return path.resolve(process.cwd(), UPLOAD_DIR)
}
export async function POST(req: NextRequest) {
// Auth check
const session = await auth.api.getSession({ headers: req.headers })
const session = await auth.api.getSession({ headers: await getSanitizedHeaders(req.headers) })
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
@@ -39,7 +46,7 @@ export async function POST(req: NextRequest) {
const ext = path.extname(file.name)
const fileName = `${randomUUID()}${ext}`
const uploadPath = path.join(process.cwd(), UPLOAD_DIR)
const uploadPath = getUploadRoot()
await mkdir(uploadPath, { recursive: true })
const buffer = Buffer.from(await file.arrayBuffer())

View File

@@ -2,19 +2,28 @@ import { NextRequest, NextResponse } from 'next/server'
import { readFile } from 'fs/promises'
import path from 'path'
const UPLOAD_DIR = process.env.UPLOAD_DIR ?? './uploads'
const UPLOAD_DIR = process.env.UPLOAD_DIR ?? (process.env.NODE_ENV === 'production' ? '/app/uploads' : './uploads')
function getUploadRoot() {
if (path.isAbsolute(UPLOAD_DIR)) {
return UPLOAD_DIR
}
return path.resolve(process.cwd(), UPLOAD_DIR)
}
export async function GET(
req: NextRequest,
{ params }: { params: { path: string[] } }
{ params }: { params: Promise<{ path: string[] }> }
) {
try {
const filePath = path.join(process.cwd(), UPLOAD_DIR, ...params.path)
const { path: filePathParams } = await params
const uploadRoot = getUploadRoot()
const filePath = path.join(uploadRoot, ...filePathParams)
// Security: prevent path traversal
const resolved = path.resolve(filePath)
const uploadDir = path.resolve(path.join(process.cwd(), UPLOAD_DIR))
if (!resolved.startsWith(uploadDir)) {
const uploadDir = path.resolve(uploadRoot)
if (!resolved.startsWith(uploadDir + path.sep) && resolved !== uploadDir) {
return new NextResponse('Forbidden', { status: 403 })
}

View File

@@ -0,0 +1,354 @@
'use client'
import { useEffect, useState, type ReactNode } from 'react'
import Link from 'next/link'
import { Syne } from 'next/font/google'
import { Moon, Sun, ArrowLeft } 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); }
.back-link {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--ink-muted);
text-decoration: none;
font-size: 0.875rem;
margin-bottom: 24px;
transition: color 0.15s;
}
.back-link: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">
<Link href="/" className="back-link">
<ArrowLeft size={16} />
Zurück zur Startseite
</Link>
<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>
</>
)
}

View File

@@ -1,18 +0,0 @@
import { Sidebar } from '@/components/layout/Sidebar'
import { Header } from '@/components/layout/Header'
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
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>
)
}

View File

@@ -1,155 +0,0 @@
'use client'
import { use } from 'react'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc-client'
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,
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-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/mitglieder" className="text-gray-400 hover:text-gray-600">
Zurück
</Link>
<h1 className="text-2xl font-bold text-gray-900">Mitglied bearbeiten</h1>
</div>
{/* Invite Status */}
<div className="bg-white rounded-xl border shadow-sm 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-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">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>
<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>
<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>
{updateMutation.error && (
<p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg">{updateMutation.error.message}</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>
)
}

View File

@@ -1,150 +0,0 @@
import { prisma } from '@innungsapp/shared'
import { auth } from '@/lib/auth'
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import Link from 'next/link'
import { MEMBER_STATUS_LABELS } from '@innungsapp/shared'
import { format } from 'date-fns'
import { de } from 'date-fns/locale'
const STATUS_COLORS = {
aktiv: 'bg-green-100 text-green-700',
ruhend: 'bg-yellow-100 text-yellow-700',
ausgetreten: 'bg-red-100 text-red-700',
}
export default async function MitgliederPage({
searchParams,
}: {
searchParams: { q?: string; status?: string }
}) {
const session = await auth.api.getSession({ headers: await headers() })
if (!session?.user) redirect('/login')
const userRole = await prisma.userRole.findFirst({
where: { userId: session.user.id },
})
if (!userRole || userRole.role !== 'admin') redirect('/dashboard')
const search = searchParams.q ?? ''
const statusFilter = searchParams.status
const members = await prisma.member.findMany({
where: {
orgId: userRole.orgId,
...(statusFilter && { status: statusFilter as never }),
...(search && {
OR: [
{ name: { contains: search, mode: 'insensitive' } },
{ betrieb: { contains: search, mode: 'insensitive' } },
{ ort: { contains: search, mode: 'insensitive' } },
],
}),
},
orderBy: { name: 'asc' },
})
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Mitglieder</h1>
<p className="text-gray-500 mt-1">{members.length} Einträge</p>
</div>
<Link
href="/dashboard/mitglieder/neu"
className="bg-brand-500 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 transition-colors"
>
+ Mitglied anlegen
</Link>
</div>
{/* Filters */}
<div className="bg-white rounded-xl border shadow-sm p-4 flex gap-4">
<form className="flex gap-4 w-full">
<input
name="q"
defaultValue={search}
placeholder="Name, Betrieb, Ort suchen..."
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
<select
name="status"
defaultValue={statusFilter ?? ''}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
>
<option value="">Alle Status</option>
<option value="aktiv">Aktiv</option>
<option value="ruhend">Ruhend</option>
<option value="ausgetreten">Ausgetreten</option>
</select>
<button
type="submit"
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg text-sm hover:bg-gray-200 transition-colors"
>
Suchen
</button>
</form>
</div>
{/* Table */}
<div className="bg-white rounded-xl border shadow-sm overflow-hidden">
<table className="w-full data-table">
<thead>
<tr>
<th>Name / Betrieb</th>
<th>Sparte</th>
<th>Ort</th>
<th>Mitglied seit</th>
<th>Status</th>
<th>Eingeladen</th>
<th></th>
</tr>
</thead>
<tbody>
{members.map((m) => (
<tr key={m.id}>
<td>
<div>
<p className="font-medium text-gray-900">{m.name}</p>
<p className="text-xs text-gray-500">{m.betrieb}</p>
</div>
</td>
<td>{m.sparte}</td>
<td>{m.ort}</td>
<td>{m.seit ?? '—'}</td>
<td>
<span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_COLORS[m.status]}`}
>
{MEMBER_STATUS_LABELS[m.status]}
</span>
</td>
<td>
{m.userId ? (
<span className="text-xs text-green-600"> Aktiv</span>
) : (
<span className="text-xs text-gray-400">Nicht eingeladen</span>
)}
</td>
<td>
<Link
href={`/dashboard/mitglieder/${m.id}`}
className="text-sm text-brand-600 hover:underline"
>
Bearbeiten
</Link>
</td>
</tr>
))}
</tbody>
</table>
{members.length === 0 && (
<div className="text-center py-12 text-gray-500">
Keine Mitglieder gefunden
</div>
)}
</div>
</div>
)
}

View File

@@ -1,158 +0,0 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc-client'
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">
{createMutation.error.message}
</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>
)
}

View File

@@ -1,124 +1,106 @@
import { auth, getSanitizedHeaders } from '@/lib/auth'
import { prisma } from '@innungsapp/shared'
import { auth } 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'
import { redirect } from 'next/navigation'
export default async function DashboardPage() {
const session = await auth.api.getSession({ headers: await headers() })
if (!session?.user) redirect('/login')
export default async function GlobalDashboardRedirect() {
const headerList = await headers()
const host = headerList.get('host') || ''
const session = await auth.api.getSession({ headers: await getSanitizedHeaders(headerList) })
const userRole = await prisma.userRole.findFirst({
where: { userId: session.user.id },
include: { org: true },
if (!session?.user) {
redirect('/login')
}
// Superadmin logic
const superAdminEmail = process.env.SUPERADMIN_EMAIL || 'superadmin@innungsapp.de'
const isSuperAdmin = session.user.email === superAdminEmail || session.user.role === 'admin'
if (isSuperAdmin) {
redirect('/superadmin')
}
const userRoles = await prisma.userRole.findMany({
where: { userId: session.user.id, role: 'admin' },
include: {
org: {
select: { id: true, name: true, slug: true },
},
},
orderBy: { createdAt: 'asc' },
})
if (!userRole) redirect('/login')
const orgId = userRole.orgId
const now = new Date()
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
if (userRoles.length === 1) {
const slug = userRoles[0].org.slug
const protocol = host.includes('localhost') ? 'http' : 'https'
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,
}),
])
// Construct the subdomain URL
let newHost = host
if (host.includes('localhost')) {
const port = host.includes(':') ? `:${host.split(':')[1]}` : ''
newHost = `${slug}.localhost${port}`
} else {
// Assumes domain.tld
const parts = host.split('.')
if (parts.length === 2) {
newHost = `${slug}.${host}`
} else if (parts.length > 2) {
newHost = `${slug}.${parts.slice(-2).join('.')}`
}
}
redirect(`${protocol}://${newHost}/dashboard`)
}
const getOrgUrl = (slug: string, currentHost: string) => {
const protocol = currentHost.includes('localhost') ? 'http' : 'https'
let newHost = currentHost
if (currentHost.includes('localhost')) {
const port = currentHost.includes(':') ? `:${currentHost.split(':')[1]}` : ''
newHost = `${slug}.localhost${port}`
} else {
const parts = currentHost.split('.')
newHost = parts.length >= 2 ? `${slug}.${parts.slice(-2).join('.')}` : `${slug}.${currentHost}`
}
return `${protocol}://${newHost}/dashboard`
}
return (
<div className="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>
<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>
<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-xl border shadow-sm 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
{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>
<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-xl border shadow-sm 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>
) : (
<p className="text-gray-500 mb-6 text-sm">
Ihr Konto hat aktuell keinen Admin-Zugriff auf eine Innung.
</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>
<form action={async () => {
'use server'
const { auth, getSanitizedHeaders } = await import('@/lib/auth')
await auth.api.signOut({ headers: await getSanitizedHeaders() })
redirect('/login')
}}>
<button type="submit" className="text-sm font-medium text-brand-600 hover:text-brand-700">
Abmelden
</button>
</form>
</div>
</div>
)

View File

@@ -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}</>
}

View File

@@ -0,0 +1,217 @@
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 USA-Region betrieben. Die Daten werden gemäß EU-Standardvertragsklauseln (SCC) mit angemessenen Schutzmassnahmen uebermittelt.
</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>
)
}

View File

@@ -9,11 +9,16 @@
body {
@apply bg-gray-50 text-gray-900 antialiased;
font-family: system-ui, -apple-system, sans-serif;
}
* {
@apply border-gray-200;
}
h1, h2, h3, h4 {
font-family: 'Syne', system-ui, sans-serif;
}
}
@layer components {
@@ -22,15 +27,15 @@
}
.sidebar-link-active {
@apply bg-brand-50 text-brand-600;
@apply bg-brand-50 text-brand-600 border-l-[3px] border-brand-500 rounded-l-none;
}
.stat-card {
@apply rounded-xl border bg-white p-6 shadow-sm;
@apply rounded-lg border bg-white p-6;
}
.data-table th {
@apply bg-gray-50 px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-500;
@apply bg-gray-50 px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-500 border-b-2 border-gray-200;
}
.data-table td {
@@ -42,6 +47,6 @@
}
.data-table tr:hover td {
@apply bg-gray-50;
@apply bg-gray-50/70;
}
}

View File

@@ -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}</>
}

View File

@@ -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 &sect; 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 &sect; 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 &sect; 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>
)
}

View File

@@ -1,15 +1,50 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import { Inter, Outfit } from 'next/font/google'
import './globals.css'
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 = {
title: 'InnungsApp Admin',
description: 'Verwaltungsportal für Innungen',
export async function generateMetadata(): Promise<Metadata> {
const slug = await getTenantSlug()
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 || '/logo.png'
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({
children,
}: {
@@ -17,7 +52,7 @@ export default function RootLayout({
}) {
return (
<html lang="de">
<body className={inter.className}>
<body className={`${inter.variable} ${outfit.variable} font-sans bg-gray-50`}>
<Providers>{children}</Providers>
</body>
</html>

View File

@@ -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}</>
}

View File

@@ -1,118 +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'
import { createAuthClient } from 'better-auth/react'
import { magicLinkClient } from 'better-auth/client/plugins'
export default async function LoginPage() {
const slug = await getTenantSlug()
const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000',
plugins: [magicLinkClient()],
})
export default function LoginPage() {
const [email, setEmail] = 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('')
const result = await authClient.signIn.magicLink({
email,
callbackURL: '/dashboard',
let org = null
if (slug) {
org = await prisma.organization.findUnique({
where: { slug }
})
}
setLoading(false)
if (result.error) {
setError(result.error.message ?? 'Ein Fehler ist aufgetreten.')
} else {
setSent(true)
}
}
const primaryColor = org?.primaryColor || '#C99738'
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 (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<div className="w-full max-w-md">
{/* Logo */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 bg-brand-500 rounded-2xl mb-4">
<span className="text-white font-bold text-2xl">I</span>
</div>
<h1 className="text-2xl font-bold text-gray-900">InnungsApp Admin</h1>
<p className="text-gray-500 mt-1">Verwaltungsportal für Innungen</p>
<div className="min-h-screen bg-gray-50 flex flex-col">
<main className="flex-1 flex items-center justify-center p-4">
<div className="w-full max-w-sm">
<div className="mb-4">
<Link
href={slug ? `/${slug}` : "/"}
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"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-5 w-5"
aria-hidden="true"
>
<path d="M3 11.5 12 4l9 7.5" />
<path d="M5.5 10.5V20h13V10.5" />
<path d="M10 20v-5h4v5" />
</svg>
</Link>
</div>
<div className="bg-white rounded-2xl shadow-sm border p-8">
{sent ? (
<div className="text-center">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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" />
</svg>
</div>
<h2 className="text-xl font-semibold text-gray-900 mb-2">
E-Mail gesendet!
</h2>
<p className="text-gray-500">
Wir haben einen Login-Link an <strong>{email}</strong> gesendet.
Bitte überprüfen Sie Ihr Postfach.
</p>
<button
onClick={() => setSent(false)}
className="mt-6 text-brand-600 text-sm hover:underline"
>
Andere E-Mail verwenden
</button>
</div>
) : (
<>
<h2 className="text-xl font-semibold text-gray-900 mb-6">
Anmelden
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 mb-1"
>
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-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
<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>
{error && (
<p className="text-sm text-red-600 bg-red-50 px-4 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 font-medium hover:bg-brand-600 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
<h1
className="text-3xl font-bold text-gray-900 tracking-tight"
style={{ fontFamily: "'Syne', system-ui, sans-serif" }}
>
{loading ? 'Wird gesendet...' : 'Magic Link senden'}
</button>
</form>
<p className="mt-4 text-center text-sm text-gray-500">
Kein Passwort nötig Sie erhalten einen Link per E-Mail.
</p>
{org ? (
<>
<span style={{ color: primaryColor }}>{org.name.split(' ')[0]}</span>
{org.name.includes(' ') ? ` ${org.name.split(' ').slice(1).join(' ')}` : ''}
</>
) : (
<>
Innungs<span className="text-brand-500">App</span>
</>
)}
</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>
</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>
)
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,5 +1,7 @@
'use client'
import '../instrumentation-client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { httpBatchLink } from '@trpc/client'
import { useState } from 'react'

View File

@@ -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>
)
}

View File

@@ -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',
}
}

View File

@@ -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,
},
]
}

View File

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

View File

@@ -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>
)
}

View File

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

View File

@@ -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>
)
}

View File

@@ -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 } },
{ slug: { contains: q } },
]
} : {},
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>
)
}

View File

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

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

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

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

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

View File

@@ -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>
)
}

View File

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

View File

@@ -1,12 +1,32 @@
'use client'
import { createAuthClient } from 'better-auth/react'
import { useRouter } from 'next/navigation'
import { useRouter, usePathname } from 'next/navigation'
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:3010'),
})
const PAGE_TITLES: Record<string, string> = {
'/dashboard': 'Übersicht',
'/dashboard/mitglieder': 'Mitglieder',
'/dashboard/news': 'News',
'/dashboard/termine': 'Termine',
'/dashboard/stellen': 'Lehrlingsbörse',
'/dashboard/einstellungen': 'Einstellungen',
}
export function Header() {
const router = useRouter()
const pathname = usePathname()
const title = Object.entries(PAGE_TITLES)
.sort((a, b) => b[0].length - a[0].length)
.find(([path]) => pathname === path || pathname.startsWith(path + '/'))?.[1] ?? 'Dashboard'
async function handleSignOut() {
await authClient.signOut()
@@ -15,12 +35,18 @@ export function Header() {
return (
<header className="h-14 bg-white border-b flex items-center justify-between px-6 flex-shrink-0">
<div />
<div className="flex items-center gap-4">
<h2
className="text-sm font-semibold text-gray-700 tracking-tight"
style={{ fontFamily: "'Syne', system-ui, sans-serif" }}
>
{title}
</h2>
<div className="flex items-center gap-3">
<button
onClick={handleSignOut}
className="text-sm text-gray-600 hover:text-gray-900 transition-colors"
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 transition-colors"
>
<LogOut size={14} />
Abmelden
</button>
</div>

View File

@@ -3,38 +3,46 @@
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { clsx } from 'clsx'
import { LayoutDashboard, Users, Newspaper, Calendar, GraduationCap, Settings } from 'lucide-react'
const navItems = [
{ href: '/dashboard', label: 'Übersicht', icon: '🏠' },
{ href: '/dashboard/mitglieder', label: 'Mitglieder', icon: '👥' },
{ href: '/dashboard/news', label: 'News', icon: '📰' },
{ href: '/dashboard/termine', label: 'Termine', icon: '📅' },
{ href: '/dashboard/stellen', label: 'Lehrlingsbörse', icon: '🎓' },
{ href: '/dashboard/einstellungen', label: 'Einstellungen', icon: '⚙️' },
{ href: '/dashboard', label: 'Übersicht', icon: LayoutDashboard },
{ href: '/dashboard/mitglieder', label: 'Mitglieder', icon: Users },
{ href: '/dashboard/news', label: 'News', icon: Newspaper },
{ href: '/dashboard/termine', label: 'Termine', icon: Calendar },
{ href: '/dashboard/stellen', label: 'Lehrlingsbörse', icon: GraduationCap },
{ href: '/dashboard/einstellungen', label: 'Einstellungen', icon: Settings },
]
export function Sidebar() {
export function Sidebar({ orgName, logoUrl }: { orgName?: string; logoUrl?: string | null }) {
const pathname = usePathname()
return (
<aside className="w-64 bg-white border-r flex flex-col flex-shrink-0">
{/* Logo */}
<div className="p-6 border-b">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-brand-500 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-sm">I</span>
</div>
<span className="font-bold text-gray-900">InnungsApp</span>
</div>
<div className="px-6 py-5 border-b flex items-center gap-3">
<Link href="/dashboard" className="flex items-center gap-3 w-full">
{logoUrl ? (
<img src={logoUrl} alt={orgName || 'Logo'} className="h-8 max-w-[120px] object-contain" />
) : (
<span
className="text-xl font-bold text-gray-900 tracking-tight leading-tight line-clamp-2"
style={{ fontFamily: "'Syne', system-ui, sans-serif" }}
>
{orgName || <span>Innungs<span className="text-brand-500">App</span></span>}
</span>
)}
</Link>
</div>
{/* Navigation */}
<nav className="flex-1 p-4 space-y-1">
<nav className="flex-1 p-3 space-y-0.5">
{navItems.map((item) => {
const isActive =
item.href === '/dashboard'
? pathname === '/dashboard'
: pathname.startsWith(item.href)
const Icon = item.icon
return (
<Link
@@ -42,17 +50,12 @@ export function Sidebar() {
href={item.href}
className={clsx('sidebar-link', isActive && 'sidebar-link-active')}
>
<span>{item.icon}</span>
<Icon size={16} className="flex-shrink-0" />
<span>{item.label}</span>
</Link>
)
})}
</nav>
{/* Footer */}
<div className="p-4 border-t">
<p className="text-xs text-gray-400">InnungsApp v0.1.0</p>
</div>
</aside>
)
}

View File

@@ -1,19 +1,35 @@
import { Users, Newspaper, Calendar, GraduationCap, type LucideIcon } from 'lucide-react'
interface Stat {
label: string
value: number
icon: string
}
const ICON_MAP: Record<string, LucideIcon> = {
'👥': Users,
'📰': Newspaper,
'📅': Calendar,
'🎓': GraduationCap,
}
export function StatsCards({ stats }: { stats: Stat[] }) {
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{stats.map((stat) => (
<div key={stat.label} className="stat-card">
<div className="text-2xl mb-2">{stat.icon}</div>
<div className="text-3xl font-bold text-gray-900">{stat.value}</div>
<div className="text-sm text-gray-500 mt-1">{stat.label}</div>
{stats.map((stat) => {
const Icon = ICON_MAP[stat.icon] ?? Users
return (
<div key={stat.label} className="stat-card flex flex-col gap-3">
<div className="flex items-start justify-between">
<div>
<div className="text-4xl font-bold text-gray-900 leading-none">{stat.value}</div>
<div className="text-xs text-gray-500 mt-2 uppercase tracking-wide font-medium">{stat.label}</div>
</div>
))}
<Icon size={18} className="text-gray-300 flex-shrink-0 mt-0.5" />
</div>
</div>
)
})}
</div>
)
}

Binary file not shown.

View File

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

View File

@@ -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)
}

View File

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

View File

@@ -1,7 +1,10 @@
import nodemailer from 'nodemailer'
const SMTP_HOST = (process.env.SMTP_HOST ?? '').trim()
const SMTP_HOST_IS_PLACEHOLDER = SMTP_HOST === '' || SMTP_HOST.toLowerCase() === 'smtp.example.com'
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
host: SMTP_HOST,
port: Number(process.env.SMTP_PORT) || 587,
secure: process.env.SMTP_SECURE === 'true',
auth:
@@ -10,6 +13,16 @@ const transporter = nodemailer.createTransport({
: undefined,
})
async function sendMailOrSkip(mailOptions: any, emailType: string) {
if (SMTP_HOST_IS_PLACEHOLDER) {
const target = typeof mailOptions?.to === 'string' ? mailOptions.to : 'unknown-recipient'
console.warn(`[email] SMTP not configured. Skipping ${emailType} email to ${target}.`)
return
}
await transporter.sendMail(mailOptions)
}
export async function sendMagicLinkEmail({
to,
magicUrl,
@@ -17,7 +30,7 @@ export async function sendMagicLinkEmail({
to: string
magicUrl: string
}) {
await transporter.sendMail({
await sendMailOrSkip({
from: process.env.EMAIL_FROM ?? 'noreply@innungsapp.de',
to,
subject: 'Ihr Login-Link für InnungsApp',
@@ -44,7 +57,7 @@ export async function sendMagicLinkEmail({
</div>
</div>
`,
})
}, 'magic link')
}
export async function sendInviteEmail({
@@ -61,7 +74,7 @@ export async function sendInviteEmail({
// Generate magic link for the invite
const signInUrl = `${apiUrl}/login?email=${encodeURIComponent(to)}&invited=true`
await transporter.sendMail({
await sendMailOrSkip({
from: process.env.EMAIL_FROM ?? 'noreply@innungsapp.de',
to,
subject: `Einladung zur InnungsApp — ${orgName}`,
@@ -86,5 +99,53 @@ export async function sendInviteEmail({
</div>
</div>
`,
})
}, 'invite')
}
export async function sendAdminCredentialsEmail({
to,
adminName,
orgName,
password,
loginUrl,
}: {
to: string
adminName: string
orgName: string
password: string
loginUrl: string
}) {
await sendMailOrSkip({
from: process.env.EMAIL_FROM ?? 'noreply@innungsapp.de',
to,
subject: `Admin-Zugang für — ${orgName}`,
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: #111827; padding: 24px; border-radius: 8px 8px 0 0;">
<h1 style="color: white; margin: 0; font-size: 24px;">InnungsApp Admin</h1>
</div>
<div style="background: #fff; padding: 32px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 8px 8px;">
<h2 style="color: #111827; margin-top: 0;">Hallo ${adminName},</h2>
<p style="color: #4b5563;">
Sie wurden als Administrator für die <strong>${orgName}</strong> in der InnungsApp freigeschaltet.
</p>
<div style="background: #f9fafb; padding: 20px; border-radius: 8px; margin: 24px 0;">
<p style="margin-top: 0; font-size: 14px; color: #6b7280; text-transform: uppercase; letter-spacing: 0.05em;">Ihre Zugangsdaten</p>
<p style="margin: 8px 0; font-size: 16px; color: #111827;"><strong>E-Mail:</strong> ${to}</p>
<p style="margin: 8px 0; font-size: 16px; color: #111827;"><strong>Passwort:</strong> <code style="background: #eee; padding: 2px 4px; rounded: 4px;">${password}</code></p>
</div>
<p style="color: #4b5563;">Klicken Sie auf den Button, um sich im Verwaltungsportal anzumelden. Sie werden aufgefordert, Ihr Passwort nach dem ersten Login zu ändern.</p>
<a href="${loginUrl}/login?email=${encodeURIComponent(to)}"
style="display: inline-block; background: #111827; color: white; padding: 12px 24px;
border-radius: 6px; text-decoration: none; font-weight: bold; margin: 16px 0;">
Zum Admin-Portal
</a>
<hr style="border-color: #e5e7eb; margin: 24px 0;" />
<p style="color: #9ca3af; font-size: 12px; margin: 0;">
InnungsApp · Administrative Portal
</p>
</div>
</div>
`,
}, 'admin credentials')
}

View File

@@ -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
}

View File

@@ -0,0 +1,90 @@
/**
* Converts a tRPC/Zod error into a user-friendly German string.
*/
export function getTrpcErrorMessage(error: { message: string } | null | undefined): string {
if (!error) return ''
try {
const issues = JSON.parse(error.message) as Array<{
code: string
path: string[]
message: string
minimum?: number
maximum?: number
}>
if (Array.isArray(issues) && issues.length > 0) {
return issues
.map((issue) => {
const field = fieldLabel(issue.path.join('.'))
const msg = zodMessageDe(issue)
return field ? `${field} ${msg}` : msg
})
.join(' · ')
}
} catch {
// Not JSON — fall through to generic messages below
}
return genericMessageDe(error.message)
}
function zodMessageDe(issue: {
code: string
minimum?: number
maximum?: number
message?: string
}): string {
switch (issue.code) {
case 'too_small':
if (issue.minimum === 1) return 'ist ein Pflichtfeld.'
return `muss mindestens ${issue.minimum} Zeichen lang sein.`
case 'too_big':
return `darf maximal ${issue.maximum} Zeichen lang sein.`
case 'invalid_string':
return 'hat ein ungültiges Format.'
case 'invalid_type':
return 'ist ein Pflichtfeld.'
case 'invalid_enum_value':
return 'enthält einen ungültigen Wert.'
default:
return 'ist ungültig.'
}
}
function genericMessageDe(message: string): string {
const lower = message.toLowerCase()
if (lower.includes('unauthorized') || lower.includes('not authenticated'))
return 'Sie sind nicht angemeldet.'
if (lower.includes('forbidden') || lower.includes('not allowed'))
return 'Sie haben keine Berechtigung für diese Aktion.'
if (lower.includes('not found'))
return 'Der Eintrag wurde nicht gefunden.'
if (lower.includes('email'))
return 'Die E-Mail-Adresse ist ungültig.'
return 'Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.'
}
const FIELD_LABELS: Record<string, string> = {
sparte: 'Sparte',
stellenAnz: 'Anzahl Stellen',
kontaktEmail: 'Kontakt-E-Mail',
kontaktName: 'Ansprechpartner',
verguetung: 'Vergütung',
lehrjahr: 'Lehrjahr',
beschreibung: 'Beschreibung',
memberId: 'Betrieb',
title: 'Titel',
body: 'Inhalt',
kategorie: 'Kategorie',
name: 'Name',
email: 'E-Mail',
betrieb: 'Betrieb',
ort: 'Ort',
telefon: 'Telefon',
datum: 'Datum',
titel: 'Titel',
maxTeilnehmer: 'Max. Teilnehmer',
typ: 'Typ',
}
function fieldLabel(field: string): string {
return FIELD_LABELS[field] ?? field
}

View File

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

5
innungsapp/apps/admin/next-env.d.ts vendored Normal file
View File

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

View File

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

View File

@@ -13,34 +13,37 @@
"type-check": "tsc --noEmit"
},
"dependencies": {
"@google/genai": "^1.42.0",
"@innungsapp/shared": "workspace:*",
"@tanstack/react-query": "^5.59.0",
"@trpc/client": "^11.0.0",
"@trpc/react-query": "^11.0.0",
"@trpc/server": "^11.0.0",
"@tanstack/react-query": "^5.59.0",
"better-auth": "^1.2.0",
"next": "^15.0.0",
"react": "^18.3.0",
"react-dom": "^18.3.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",
"lucide-react": "^0.460.0",
"better-auth": "^1.2.0",
"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",
"sharp": "^0.33.0",
"superjson": "^2.2.1",
"tailwind-merge": "^2.5.0",
"zod": "^3.23.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@types/nodemailer": "^6.4.17",
"tailwindcss": "^3.4.0",
"@types/react": "19.0.0",
"@types/react-dom": "19.0.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"typescript": "^5.6.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: 55 KiB

View File

@@ -0,0 +1,7 @@
<!DOCTYPE html>
<html>
<head>
<meta name="google-site-verification" content="googleccd5315437d68a49" />
</head>
<body></body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Some files were not shown because too many files have changed in this diff Show More