feat: Set up initial monorepo structure for admin and mobile applications with core configurations and database integration.

This commit is contained in:
Timo Knuth 2026-02-20 12:58:54 +01:00
parent 5e2d5fb3ae
commit b7f8221095
52 changed files with 2200 additions and 175 deletions

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

@ -71,3 +71,8 @@ npx supabase start
cd apps/mobile
npx expo start
```
pnpm --filter @innungsapp/admin dev
px expo start --clear

View File

@ -135,3 +135,24 @@ eas submit --platform all
## Roadmap
Siehe `innung-app-mvp.md` für die vollständige Roadmap.
## Apps starten (Schnellstart)
Um die Anwendungen lokal zu starten, öffne ein Terminal im Hauptverzeichnis (`innungsapp/`) und nutze folgende Befehle:
**Admin Dashboard starten:**
```bash
pnpm --filter @innungsapp/admin dev
```
Das Dashboard ist im Browser unter [http://localhost:3000](http://localhost:3000) erreichbar.
**Mobile App starten:**
```bash
pnpm --filter @innungsapp/mobile dev
```
Dies startet den Expo-Server. Scanne den QR-Code mit der **Expo Go App** auf deinem Smartphone oder drücke `a` (für den Android Emulator) bzw. `i` (für den iOS Simulator) im Terminal.
**Beides gleichzeitig starten:**
```bash
pnpm dev
```

View File

@ -6,10 +6,11 @@ const UPLOAD_DIR = process.env.UPLOAD_DIR ?? './uploads'
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 filePath = path.join(process.cwd(), UPLOAD_DIR, ...filePathParams)
// Security: prevent path traversal
const resolved = path.resolve(filePath)

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
@ -101,7 +101,7 @@ export default function EinstellungenPage() {
</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

@ -1,11 +1,26 @@
import { Sidebar } from '@/components/layout/Sidebar'
import { Header } from '@/components/layout/Header'
import { auth } from '@/lib/auth'
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
export default function DashboardLayout({
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await auth.api.getSession({ headers: await headers() })
if (!session?.user) {
redirect('/login')
}
// Superadmin Redirect
const superAdminEmail = process.env.SUPERADMIN_EMAIL || 'superadmin@innungsapp.de'
if (session.user.email === superAdminEmail) {
redirect('/superadmin')
}
return (
<div className="flex h-screen bg-gray-50">
<Sidebar />

View File

@ -3,6 +3,7 @@
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'
@ -41,7 +42,7 @@ export default function MitgliedEditPage({
ort: member.ort,
telefon: member.telefon ?? '',
email: member.email,
status: member.status,
status: member.status as 'aktiv' | 'ruhend' | 'ausgetreten',
istAusbildungsbetrieb: member.istAusbildungsbetrieb,
seit: member.seit ?? undefined,
})
@ -57,24 +58,25 @@ export default function MitgliedEditPage({
}
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'
'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-4">
<Link href="/dashboard/mitglieder" className="text-gray-400 hover:text-gray-600">
<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-xl border shadow-sm p-4 flex items-center justify-between">
<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'
? 'Mitglied hat sich eingeloggt'
: 'Noch nicht eingeladen / eingeloggt'}
</p>
</div>
@ -84,12 +86,15 @@ export default function MitgliedEditPage({
disabled={resendMutation.isPending}
className="text-sm text-brand-600 hover:underline disabled:opacity-50"
>
{resendMutation.isPending ? 'Sende...' : resendMutation.isSuccess ? 'Gesendet' : 'Einladung senden'}
{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">
<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>
@ -109,6 +114,13 @@ export default function MitgliedEditPage({
<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} />
@ -117,6 +129,13 @@ export default function MitgliedEditPage({
<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}>
@ -136,9 +155,10 @@ export default function MitgliedEditPage({
</label>
</div>
</div>
</div>
{updateMutation.error && (
<p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg">{updateMutation.error.message}</p>
<p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg">{getTrpcErrorMessage(updateMutation.error)}</p>
)}
<div className="flex gap-3 pt-2 border-t">

View File

@ -7,17 +7,19 @@ import { MEMBER_STATUS_LABELS } from '@innungsapp/shared'
import { format } from 'date-fns'
import { de } from 'date-fns/locale'
const STATUS_COLORS = {
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({
searchParams,
}: {
searchParams: { q?: string; status?: string }
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 session = await auth.api.getSession({ headers: await headers() })
if (!session?.user) redirect('/login')
@ -26,18 +28,15 @@ export default async function MitgliederPage({
})
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' } },
{ name: { contains: search } },
{ betrieb: { contains: search } },
{ ort: { contains: search } },
],
}),
},
@ -60,7 +59,7 @@ export default async function MitgliederPage({
</div>
{/* Filters */}
<div className="bg-white rounded-xl border shadow-sm p-4 flex gap-4">
<div className="bg-white rounded-lg border p-4 flex gap-4">
<form className="flex gap-4 w-full">
<input
name="q"
@ -88,7 +87,7 @@ export default async function MitgliederPage({
</div>
{/* Table */}
<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>
@ -115,16 +114,16 @@ export default async function MitgliederPage({
<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]}`}
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]}
</span>
</td>
<td>
{m.userId ? (
<span className="text-xs text-green-600"> Aktiv</span>
<span className="text-[11px] font-medium text-green-600 bg-green-50 px-2 py-0.5 rounded-full">Aktiv</span>
) : (
<span className="text-xs text-gray-400">Nicht eingeladen</span>
<span className="text-[11px] text-gray-400"></span>
)}
</td>
<td>

View File

@ -0,0 +1,170 @@
'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')
useEffect(() => {
if (news) {
setTitle(news.title)
setBody(news.body)
setKategorie(news.kategorie)
}
}, [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>
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,
},
})
}
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>
{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

@ -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'
import dynamic from 'next/dynamic'
@ -132,7 +133,7 @@ export default function NewsNeuPage() {
{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

@ -52,7 +52,7 @@ 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) => (
@ -83,7 +83,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>

View File

@ -59,7 +59,7 @@ export default async function DashboardPage() {
<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="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">
@ -87,7 +87,7 @@ export default async function DashboardPage() {
</div>
{/* Upcoming Termine */}
<div className="bg-white rounded-xl border shadow-sm p-6">
<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">

View File

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

View File

@ -5,6 +5,7 @@ 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() })
@ -22,14 +23,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

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

@ -84,7 +84,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 +110,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

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

@ -6,8 +6,8 @@ import { Providers } from './providers'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'InnungsApp Admin',
description: 'Verwaltungsportal für Innungen',
title: 'InnungsApp PRO | Die moderne Vereinssoftware für das Handwerk',
description: 'Zettelwirtschaft war gestern. Reduzieren Sie den Verwaltungsaufwand Ihrer Innung um 10 Std/Woche. Die perfekte Handwerk Software inkl. CRM & App. Kontaktieren Sie uns für eine Beratung!',
}
export default function RootLayout({

View File

@ -50,28 +50,29 @@ export default function LoginPage() {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="w-full max-w-sm">
{/* 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>
<h1
className="text-3xl font-bold text-gray-900 tracking-tight"
style={{ fontFamily: "'Syne', system-ui, sans-serif" }}
>
Innungs<span className="text-brand-500">App</span>
</h1>
<p className="text-sm text-gray-500 mt-1">Verwaltungsportal für Innungen</p>
</div>
<div className="bg-white rounded-2xl shadow-sm border p-8">
<div className="bg-white rounded-lg 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" />
<div className="text-center py-4">
<div className="w-14 h-14 bg-green-50 border border-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-7 h-7 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</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.
<h2 className="text-lg font-semibold text-gray-900 mb-2">E-Mail gesendet</h2>
<p className="text-sm text-gray-500">
Login-Link an <strong className="text-gray-700">{email}</strong> gesendet. Bitte prüfen Sie Ihr Postfach.
</p>
<button
onClick={() => setSent(false)}
@ -82,15 +83,22 @@ export default function LoginPage() {
</div>
) : (
<>
<h2 className="text-xl font-semibold text-gray-900 mb-6">Anmelden</h2>
<h2
className="text-lg font-semibold text-gray-900 mb-5"
style={{ fontFamily: "'Syne', system-ui, sans-serif" }}
>
Anmelden
</h2>
{/* Mode toggle */}
<div className="flex rounded-lg border border-gray-200 p-1 mb-6">
<div className="flex rounded-lg border border-gray-200 p-0.5 mb-5 bg-gray-50">
<button
type="button"
onClick={() => setMode('password')}
className={`flex-1 py-1.5 text-sm rounded-md font-medium transition-colors ${
mode === 'password' ? 'bg-brand-500 text-white' : 'text-gray-500 hover:text-gray-700'
className={`flex-1 py-1.5 text-sm rounded-md font-medium transition-all ${
mode === 'password'
? 'bg-white shadow-sm text-gray-900 border border-gray-200'
: 'text-gray-500 hover:text-gray-700'
}`}
>
Passwort
@ -98,8 +106,10 @@ export default function LoginPage() {
<button
type="button"
onClick={() => setMode('magic')}
className={`flex-1 py-1.5 text-sm rounded-md font-medium transition-colors ${
mode === 'magic' ? 'bg-brand-500 text-white' : 'text-gray-500 hover:text-gray-700'
className={`flex-1 py-1.5 text-sm rounded-md font-medium transition-all ${
mode === 'magic'
? 'bg-white shadow-sm text-gray-900 border border-gray-200'
: 'text-gray-500 hover:text-gray-700'
}`}
>
Magic Link
@ -108,7 +118,7 @@ export default function LoginPage() {
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
<label htmlFor="email" className="block text-xs font-medium text-gray-600 mb-1 uppercase tracking-wide">
E-Mail-Adresse
</label>
<input
@ -118,13 +128,13 @@ export default function LoginPage() {
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"
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
/>
</div>
{mode === 'password' && (
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
<label htmlFor="password" className="block text-xs font-medium text-gray-600 mb-1 uppercase tracking-wide">
Passwort
</label>
<input
@ -134,19 +144,19 @@ export default function LoginPage() {
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
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"
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-4 py-2 rounded-lg">{error}</p>
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg">{error}</p>
)}
<button
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"
className="w-full bg-brand-500 text-white py-2.5 px-4 rounded-lg text-sm font-medium hover:bg-brand-600 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
>
{loading
? 'Bitte warten...'

View File

@ -1,5 +1,755 @@
import { redirect } from 'next/navigation'
"use client";
import { useState, useEffect } from 'react'
import Link from 'next/link'
import Image from 'next/image'
import { Syne } from 'next/font/google'
import { ArrowRight, ArrowUpRight, Sun, Moon } from 'lucide-react'
const syne = Syne({ subsets: ['latin'], weight: ['400', '500', '600', '700', '800'] })
const FEATURES = [
{
num: '01',
title: 'Mitgliederverwaltung',
desc: 'Digitales CRM für alle Mitgliedsdaten. Kontakte, Statushistorie und Dokumentenablage — sicher in der Cloud, immer aktuell.',
tags: ['Cloud CRM', 'Aktenführung', 'Einladungsmanagement'],
},
{
num: '02',
title: 'Push-News & Kommunikation',
desc: 'Statt ungeöffneter E-Mails: direkte Push-Benachrichtigungen mit extrem hoher Erreichbarkeit. Sofort, zuverlässig, DSGVO-konform.',
tags: ['Push-Notifications', 'News-Feed', 'Direktkommunikation'],
},
{
num: '03',
title: 'Event- und Terminmanagement',
desc: 'Veranstaltungen anlegen, kommunizieren und RSVPs digital einsammeln. Keine manuellen Listen, kein Nachfragen — alles läuft automatisch.',
tags: ['Terminübersicht', '1-Klick RSVP', 'Kalender-Export'],
},
{
num: '04',
title: 'Lehrlingsbörse & Stellenmarkt',
desc: 'Nachwuchs finden, bevor andere ihn sehen. Integrierter Marktplatz für Ausbildungsstellen und Fachkräfte direkt in der App.',
tags: ['Lehrlingsbörse', 'Stellenmarkt', 'Lokale Reichweite'],
},
]
const STATS = [
{ num: '10 Std.', label: 'Zeitersparnis pro Woche' },
{ num: 'Echtzeit', label: 'Kommunikation' },
{ num: 'Cloud', label: 'Sicher & Überall' },
{ num: '100 %', label: 'DSGVO-konform' },
]
export default function RootPage() {
redirect('/dashboard')
const [theme, setTheme] = useState('theme-light');
const [mounted, setMounted] = useState(false);
const [openFaq, setOpenFaq] = useState<number | null>(null);
useEffect(() => {
setMounted(true);
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);
};
const toggleFaq = (index: number) => {
setOpenFaq(openFaq === index ? null : index);
}
const schemaOrg = {
"@context": "https://schema.org",
"@graph": [
{
"@type": "SoftwareApplication",
"name": "InnungsApp PRO",
"applicationCategory": "BusinessApplication",
"operatingSystem": "Web, iOS, Android",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "EUR"
},
"description": "Zettelwirtschaft war gestern. Reduzieren Sie den Verwaltungsaufwand Ihrer Innung um 10 Std/Woche. Die perfekte Handwerk Software inkl. CRM & App."
},
{
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "Wie lange dauert der Wechsel zur InnungsApp PRO?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Unser Onboarding-Team unterstützt Sie beim gesamten Prozess und richtet Ihre Umgebung schnellstmöglich ein. Sie können zeitnah starten."
}
},
{
"@type": "Question",
"name": "Ist die Plattform DSGVO-konform?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Ja. 100% Hosting in Deutschland und streng nach DSGVO-Standards entwickelt."
}
},
{
"@type": "Question",
"name": "Brauchen meine Mitglieder eine Schulung?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Nein, die App ist so intuitiv wie WhatsApp Ihre Mitglieder können sie ohne Einarbeitung direkt nutzen."
}
}
]
}
]
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaOrg) }}
/>
<style>{`
.theme-light {
--bg: #FAFAFA;
--nav-bg: rgba(250, 250, 250, 0.85);
--ink: #111111;
--ink-muted: rgba(17, 17, 17, 0.6);
--ink-faint: rgba(17, 17, 17, 0.1);
--gold: #C9973A;
--gold-light: #B8862D;
--gold-faint: rgba(201, 151, 58, 0.08); /* Subtle gold background for badges/blobs */
--card-bg: rgba(255, 255, 255, 0.6);
--glass-border: rgba(17, 17, 17, 0.05);
}
.theme-dark {
--bg: #0C0B09;
--nav-bg: rgba(12, 11, 9, 0.85);
--ink: #EAE6DA;
--ink-muted: rgba(234,230,218,0.45);
--ink-faint: rgba(234,230,218,0.12);
--gold: #C9973A;
--gold-light: #DFB25C;
--gold-faint: rgba(201, 151, 58, 0.15);
--card-bg: rgba(20, 19, 17, 0.4);
--glass-border: rgba(234,230,218,0.05);
}
body { background: var(--bg); }
.page {
background: var(--bg); color: var(--ink); min-height: 100vh;
background-image:
radial-gradient(circle at 15% 50%, var(--gold-faint), transparent 25%),
radial-gradient(circle at 85% 30%, var(--gold-faint), transparent 25%);
transition: background 0.3s, color 0.3s;
}
/* Nav */
.nav {
position: fixed; top: 0; left: 0; right: 0; z-index: 50;
background: var(--nav-bg);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-bottom: 1px solid var(--ink-faint);
height: 64px;
display: flex; align-items: center;
}
.nav-inner {
max-width: 1280px; margin: 0 auto; padding: 0 32px;
display: flex; align-items: center; justify-content: space-between;
width: 100%;
}
.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: 32px; }
.nav-link {
font-size: 0.875rem; color: var(--ink-muted);
text-decoration: none; transition: color 0.15s;
font-weight: 400;
}
.nav-link:hover { color: var(--ink); }
.btn-primary {
background: var(--gold); color: var(--bg);
font-weight: 600; font-size: 0.875rem;
padding: 10px 22px;
display: inline-flex; align-items: center; gap: 6px;
text-decoration: none;
transition: background 0.15s, transform 0.1s;
letter-spacing: -0.01em;
}
.btn-primary:hover { background: var(--gold-light); }
/* Hero */
.hero {
max-width: 1280px; margin: 0 auto; padding: 160px 32px 96px;
}
.eyebrow {
font-size: 0.75rem; letter-spacing: 0.14em; text-transform: uppercase;
color: var(--gold); margin-bottom: 32px; font-weight: 500;
}
.hero-h1 {
font-weight: 800; font-size: clamp(3.25rem, 8vw, 7.5rem);
line-height: 0.92; letter-spacing: -0.04em;
margin: 0 0 48px; color: var(--ink);
}
.hero-h1 em { color: var(--gold); font-style: normal; }
.hero-body {
display: flex; flex-direction: column; gap: 40px;
}
@media (min-width: 640px) {
.hero-body { flex-direction: row; align-items: flex-start; gap: 64px; }
}
.hero-desc {
max-width: 440px; color: var(--ink-muted);
font-size: 1.0625rem; line-height: 1.7;
font-family: 'Georgia', serif;
}
.hero-image-wrapper {
flex: 1;
position: relative;
display: flex;
justify-content: center;
align-items: center;
border-radius: 24px;
overflow: hidden;
box-shadow: 0 24px 80px rgba(0,0,0,0.15);
border: 1px solid var(--glass-border);
aspect-ratio: 16/10;
background: var(--card-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.hero-image-wrapper img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.hero-cta { display: flex; flex-direction: column; gap: 12px; padding-top: 4px; flex-shrink: 0; }
.btn-ghost {
border: 1px solid var(--ink-faint); color: var(--ink);
padding: 10px 22px; font-size: 0.875rem; font-weight: 500;
display: inline-flex; align-items: center; gap: 6px;
text-decoration: none; transition: border-color 0.15s, background 0.15s;
letter-spacing: -0.01em;
}
.btn-ghost:hover {
border-color: var(--ink-muted);
background: var(--ink-faint);
}
/* Stats */
.stats {
margin-top: 80px;
border-top: 1px solid var(--ink-faint);
display: grid; grid-template-columns: repeat(2, 1fr);
justify-items: center;
}
@media (min-width: 768px) { .stats { grid-template-columns: repeat(4, 1fr); } }
.stat {
padding: 28px 24px;
border-right: 1px solid var(--ink-faint);
text-align: center;
width: 100%;
}
.stat:last-child { border-right: none; }
.stat-num {
font-weight: 800; font-size: 2.25rem;
color: var(--gold); letter-spacing: -0.03em; line-height: 1;
margin-bottom: 6px;
}
.stat-label { font-size: 0.8125rem; color: var(--ink-muted); font-family: 'Georgia', serif; }
/* Features */
.features { border-top: 1px solid var(--ink-faint); padding: 96px 0; }
.features-inner {
max-width: 1280px; margin: 0 auto; padding: 0 32px;
display: grid; grid-template-columns: 1fr;
gap: 64px;
}
@media (min-width: 1024px) {
.features-inner { grid-template-columns: 5fr 7fr; gap: 96px; }
}
.features-sticky { }
@media (min-width: 1024px) {
.features-sticky { position: sticky; top: 88px; }
}
.features-h2 {
font-weight: 800; font-size: clamp(2.5rem, 5vw, 4rem);
letter-spacing: -0.04em; line-height: 1.0;
margin: 24px 0 28px; color: var(--ink);
}
.features-sub { color: var(--ink-muted); font-family: 'Georgia', serif; line-height: 1.65; font-size: 0.9375rem; }
.feature-list { display: flex; flex-direction: column; }
.feature-item {
padding: 36px 32px;
border: 1px solid var(--glass-border);
background: var(--card-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-radius: 24px;
display: flex; gap: 28px; align-items: flex-start;
cursor: default;
margin-bottom: 24px;
transition: transform 0.3s, border-color 0.3s, box-shadow 0.3s;
}
.feature-item:hover {
transform: translateY(-4px);
border-color: var(--ink-faint);
box-shadow: 0 12px 40px rgba(0,0,0,0.05);
}
.feature-num {
font-weight: 800; font-size: 3.5rem; line-height: 1;
color: var(--ink-faint); letter-spacing: -0.04em;
flex-shrink: 0; padding-top: 2px;
transition: color 0.2s, transform 0.2s;
user-select: none;
min-width: 72px;
}
.feature-item:hover .feature-num { color: var(--gold); transform: translateX(-4px); }
.feature-title {
font-weight: 700; font-size: 1.25rem;
letter-spacing: -0.02em; margin-bottom: 10px; color: var(--ink);
}
.feature-desc { color: var(--ink-muted); font-family: 'Georgia', serif; line-height: 1.65; font-size: 0.9375rem; margin-bottom: 16px; }
.feature-tags { display: flex; flex-wrap: wrap; gap: 8px; }
.feature-tag {
font-size: 0.6875rem; letter-spacing: 0.06em; text-transform: uppercase;
border: 1px solid var(--ink-faint);
color: var(--ink-muted);
padding: 4px 10px;
font-weight: 500;
}
/* Challenges */
.challenges-section { padding: 96px 0; border-top: 1px solid var(--ink-faint); }
.challenges-inner { max-width: 1280px; margin: 0 auto; padding: 0 32px; }
.challenges-grid { display: grid; grid-template-columns: 1fr; gap: 32px; margin-top: 48px; }
@media (min-width: 768px) { .challenges-grid { grid-template-columns: repeat(3, 1fr); } }
.challenge-card {
padding: 32px;
background: var(--card-bg);
border: 1px solid rgba(255, 100, 100, 0.15); /* Subtly negative */
border-radius: 24px;
}
.challenge-title { font-weight: 700; font-size: 1.125rem; margin-bottom: 12px; color: var(--ink); }
.challenge-desc { color: var(--ink-muted); font-size: 0.9375rem; line-height: 1.6; }
/* Trust & Social Proof */
.trust-banner {
padding: 64px 32px;
text-align: center;
background: var(--ink-faint);
}
.trust-text { font-size: 1.125rem; color: var(--ink); font-style: italic; max-width: 600px; margin: 0 auto 24px; font-family: 'Georgia', serif;}
.trust-author { font-size: 0.875rem; color: var(--ink-muted); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }
/* Comparison */
.comparison-section { padding: 96px 0; border-top: 1px solid var(--ink-faint); }
.comparison-inner { max-width: 1280px; margin: 0 auto; padding: 0 32px; }
.comparison-grid { display: grid; grid-template-columns: 1fr; gap: 32px; margin-top: 48px; }
@media (min-width: 768px) { .comparison-grid { grid-template-columns: 1fr 1fr; } }
.comp-card { padding: 40px; border-radius: 24px; }
.comp-old { background: rgba(220, 50, 50, 0.03); border: 1px solid rgba(220, 50, 50, 0.1); }
.comp-new { background: var(--gold-faint); border: 1px solid var(--gold); }
.comp-title { font-weight: 800; font-size: 1.5rem; margin-bottom: 24px; }
.comp-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 16px; }
.comp-item { display: flex; gap: 12px; font-size: 0.9375rem; color: var(--ink); }
.comp-icon-red { color: #dc3232; font-weight: bold; }
.comp-icon-green { color: var(--gold); font-weight: bold; }
/* AEO / SEO Block */
.aeo-section { padding: 96px 0; border-top: 1px solid var(--ink-faint); }
.aeo-inner { max-width: 800px; margin: 0 auto; padding: 0 32px; }
.aeo-text { color: var(--ink-muted); font-size: 1rem; line-height: 1.8; font-family: 'Georgia', serif; }
.aeo-text p { margin-bottom: 24px; }
.aeo-text strong { color: var(--ink); font-weight: 600; }
/* FAQ */
.faq-section { padding: 96px 0; border-top: 1px solid var(--ink-faint); }
.faq-inner { max-width: 800px; margin: 0 auto; padding: 0 32px; }
.faq-item { border-bottom: 1px solid var(--ink-faint); }
.faq-question {
width: 100%; text-align: left; padding: 24px 0;
background: none; border: none; color: var(--ink);
font-weight: 700; font-size: 1.125rem;
display: flex; justify-content: space-between; align-items: center;
cursor: pointer; font-family: inherit;
}
.faq-icon {
flex-shrink: 0; width: 24px; height: 24px;
border: 1px solid var(--ink-faint); border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 1rem; font-weight: 400; color: var(--gold);
transition: background 0.15s;
}
.faq-answer {
padding-bottom: 24px; color: var(--ink-muted);
font-size: 0.9375rem; line-height: 1.6;
overflow: hidden;
transition: max-height 0.3s ease;
}
/* CTA */
.cta-section { border-top: 1px solid var(--ink-faint); padding: 96px 0 120px; }
.cta-inner {
max-width: 1280px; margin: 0 auto; padding: 0 32px;
display: flex; flex-direction: column; gap: 48px;
align-items: flex-start;
}
@media (min-width: 768px) {
.cta-inner { flex-direction: row; align-items: flex-end; justify-content: space-between; }
}
.cta-h2 {
font-weight: 800; font-size: clamp(2.25rem, 5vw, 4.5rem);
letter-spacing: -0.04em; line-height: 1.0; color: var(--ink);
}
.cta-h2 em { color: var(--gold); font-style: normal; }
.cta-right { display: flex; flex-direction: column; gap: 10px; flex-shrink: 0; }
.cta-note { font-size: 0.75rem; color: var(--ink-muted); text-align: center; font-family: 'Georgia', serif; }
.btn-primary-lg {
background: var(--gold); color: var(--bg);
font-weight: 700; font-size: 1rem;
padding: 14px 28px;
display: inline-flex; align-items: center; gap: 8px;
text-decoration: none;
transition: background 0.15s;
letter-spacing: -0.02em;
white-space: nowrap;
}
.btn-primary-lg:hover { background: var(--gold-light); }
/* Footer */
.footer {
border-top: 1px solid var(--ink-faint);
padding: 36px 0;
}
.footer-inner {
max-width: 1280px; margin: 0 auto; padding: 0 32px;
display: flex; flex-direction: column; align-items: center; gap: 20px;
}
@media (min-width: 768px) {
.footer-inner { flex-direction: row; justify-content: space-between; }
}
.footer-logo { font-weight: 800; font-size: 1.0625rem; letter-spacing: -0.02em; }
.footer-copy { 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); }
/* Horizontal rule decoration */
.section-marker {
font-size: 0.6875rem; letter-spacing: 0.14em; text-transform: uppercase;
color: var(--gold); font-weight: 500; margin-bottom: 20px;
}
@media (max-width: 639px) {
.nav-links .nav-link { display: none; }
.stat { border-right: none; padding-right: 0; }
.stat:nth-child(odd) { border-right: 1px solid var(--ink-faint); padding-right: 16px; }
}
`}</style>
<div className={`page ${syne.className} ${theme}`}>
{/* Nav */}
<nav className="nav">
<div className="nav-inner">
<div className="logo">
Innungs<span className="logo-accent">App</span> <span className="logo-pro">PRO</span>
</div>
<div className="nav-links">
<a href="#leistungen" className="nav-link">Leistungen</a>
<button
onClick={toggleTheme}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '4px', display: 'flex', alignItems: 'center', color: 'var(--ink-muted)' }}
aria-label="Toggle theme"
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>
<Link href="/login" className="btn-primary">
Team kontaktieren <ArrowRight size={13} />
</Link>
</div>
</div>
</nav>
<main>
{/* Hero */}
<section className="hero">
<div className="eyebrow">Für Handwerksinnungen in Deutschland</div>
<h1 className="hero-h1">
Weniger Verwaltung.<br />
<em>Mehr Leben.</em><br />
<span style={{ fontSize: 'clamp(1.5rem, 3vw, 2.5rem)', fontWeight: 600, color: 'var(--ink-muted)', letterSpacing: '-0.02em', display: 'block', marginTop: '16px' }}>Die All-in-One Software für die moderne Handwerksinnung.</span>
</h1>
<div className="hero-body">
<div style={{ flex: 1 }}>
<p className="hero-desc" style={{ fontWeight: 600, color: 'var(--ink)', marginBottom: '16px' }}>
Schluss mit Zettelwirtschaft, Excel-Chaos und ungelesenen E-Mails.
</p>
<p className="hero-desc">
Ihre Mitgliederverwaltung, Push-News und Lehrstellenbörse gebündelt in einer sicheren Cloud-Plattform, die Ihre Prozesse automatisiert und den Innungsalltag vereinfacht.
</p>
<div className="hero-cta" style={{ marginTop: '32px', maxWidth: '300px' }}>
<Link href="/login" className="btn-primary-lg" style={{ justifyContent: 'center' }}>
Team kontaktieren <ArrowRight size={16} />
</Link>
<div style={{ fontSize: '0.75rem', color: 'var(--ink-muted)', textAlign: 'center', marginTop: '8px' }}>
Individuelle Beratung. Keine Vertragsbindung.
</div>
</div>
</div>
<div className="hero-image-wrapper">
<Image
src="/mobile-mockup.png"
alt="InnungsApp PRO Mobile Interface"
width={400}
height={800}
priority
style={{
width: '100%',
height: 'auto',
objectFit: 'contain'
}}
/>
</div>
</div>
{/* Stats */}
<div className="stats">
{STATS.map((s) => (
<div key={s.label} className="stat">
<div className="stat-num">{s.num}</div>
<div className="stat-label">{s.label}</div>
</div>
))}
</div>
</section>
{/* Challenges / Pain Points */}
<section className="challenges-section">
<div className="challenges-inner">
<div className="section-marker">Herausforderungen</div>
<h2 className="cta-h2">Kennen Sie diese Probleme<br />im <em>Innungsalltag?</em></h2>
<div className="challenges-grid">
<div className="challenge-card">
<h3 className="challenge-title">Geringe Erreichbarkeit</h3>
<p className="challenge-desc">Wichtige E-Mails landen im Spam-Ordner oder werden überlesen. Informationen kommen bei den Mitgliedern nicht rechtzeitig an.</p>
</div>
<div className="challenge-card">
<h3 className="challenge-title">Zettelwirtschaft</h3>
<p className="challenge-desc">Mitgliedsdaten sind über verschiedene Excel-Listen, Ordner und lokale Festplatten verstreut. Die Datenpflege kostet massiv Zeit.</p>
</div>
<div className="challenge-card">
<h3 className="challenge-title">Nachwuchsmangel</h3>
<p className="challenge-desc">Es wird immer schwieriger, junge Talente für das Handwerk zu begeistern und freie Ausbildungsplätze erfolgreich zu besetzen.</p>
</div>
</div>
</div>
</section>
{/* Features */}
<section id="leistungen" className="features">
<div className="features-inner">
<div className="features-sticky">
<div className="section-marker">Leistungen</div>
<h2 className="features-h2">
Alles,<br />was Ihre<br />Innung<br />braucht.
</h2>
<p className="features-sub">
Kein Sammelsurium aus Insellösungen. Eine durchdachte Plattform für die moderne Handwerksinnung von der Verwaltung bis zur Nachwuchsgewinnung.
</p>
</div>
<div className="feature-list">
{FEATURES.map((f) => (
<div key={f.num} className="feature-item">
<div className="feature-num">{f.num}</div>
<div>
<h3 className="feature-title">{f.title}</h3>
<p className="feature-desc">{f.desc}</p>
<div className="feature-tags">
{f.tags.map((t) => (
<span key={t} className="feature-tag">{t}</span>
))}
</div>
</div>
</div>
))}
</div>
</div>
</section>
{/* Gegenüberstellung / Comparison */}
<section className="comparison-section">
<div className="comparison-inner">
<div className="section-marker" style={{ textAlign: 'center' }}>Der Vergleich</div>
<h2 className="cta-h2" style={{ textAlign: 'center' }}>Warum <em>InnungsApp PRO?</em></h2>
<div className="comparison-grid">
<div className="comp-card comp-old">
<h3 className="comp-title" style={{ color: '#dc3232' }}>Der alte Weg</h3>
<ul className="comp-list">
<li className="comp-item"><span className="comp-icon-red"></span> Verteilte Excel-Listen und Aktenordner</li>
<li className="comp-item"><span className="comp-icon-red"></span> Wichtige E-Mails verstauben ungelesen</li>
<li className="comp-item"><span className="comp-icon-red"></span> Manueller Abgleich von Zu- und Absagen</li>
<li className="comp-item"><span className="comp-icon-red"></span> Keine Sichtbarkeit für junge Talente</li>
<li className="comp-item"><span className="comp-icon-red"></span> Hoher Frust bei der Verwaltung</li>
</ul>
</div>
<div className="comp-card comp-new">
<h3 className="comp-title" style={{ color: 'var(--gold)' }}>Der InnungsApp Weg</h3>
<ul className="comp-list">
<li className="comp-item"><span className="comp-icon-green"></span> Digitales Cloud-CRM, überall abrufbar</li>
<li className="comp-item"><span className="comp-icon-green"></span> Push-Nachrichten in Echtzeit</li>
<li className="comp-item"><span className="comp-icon-green"></span> Automatisierte 1-Klick Event-Zusagen</li>
<li className="comp-item"><span className="comp-icon-green"></span> Integrierte, lokale Lehrlingsbörse</li>
<li className="comp-item"><span className="comp-icon-green"></span> 10 Stunden Zeitersparnis pro Woche</li>
</ul>
</div>
</div>
</div>
</section>
{/* AEO / GEO Content Block */}
<section className="aeo-section">
<div className="aeo-inner">
<div className="section-marker">Über unsere Plattform</div>
<h2 className="cta-h2" style={{ marginBottom: '32px', fontSize: 'clamp(2rem, 4vw, 3rem)' }}>Die smarte Lösung für moderne Handwerksorganisationen</h2>
<div className="aeo-text">
<p>
<strong>InnungsApp PRO</strong> ist die führende cloudbasierte Verwaltungssoftware, die speziell auf die Bedürfnisse von <strong>Handwerksinnungen in Deutschland</strong> zugeschnitten ist. Mit einem tiefen Verständnis für die administrativen Herausforderungen des Handwerks bietet unsere Software eine sichere, DSGVO-konforme Umgebung zur zentralen Verwaltung von Mitgliedsdaten.
</p>
<p>
Im Durchschnitt reduzieren Innungen ihren administrativen Aufwand nach der Einführung um <strong>bis zu 10 Stunden pro Woche</strong>. Statt ineffizienter E-Mail-Verteiler nutzt die Plattform moderne Push-Technologie, welche für höchste Erreichbarkeit sorgt. Die Integration einer eigenen Lehrlingsbörse löst zudem aktiv das Problem des Nachwuchsmangels, indem sie Betriebe und junge Talente lokal vernetzt.
</p>
<p>
Alle Serverstandorte befinden sich in Deutschland, wodurch höchste Datenschutzstandards garantiert werden. Egal ob Eventmanagement, Kommunikation oder Mitgliederverwaltung die InnungsApp PRO bündelt alle essenziellen Funktionen in einer einzigen, intuitiven Oberfläche.
</p>
</div>
</div>
</section>
{/* FAQ Section */}
<section className="faq-section">
<div className="faq-inner">
<div className="section-marker">FAQ</div>
<h2 className="cta-h2" style={{ marginBottom: '48px' }}>Häufige <em>Fragen</em></h2>
{[
{
q: 'Wie schnell sind wir startklar?',
a: 'In der Regel innerhalb von 13 Werktagen. Unser Onboarding-Team importiert Ihre bestehenden Mitgliederdaten (aus Excel oder CSV), richtet Ihre Innung ein und begleitet Sie beim ersten Login. Sie müssen nichts technisch einrichten.',
},
{
q: 'Ist die Plattform DSGVO-konform?',
a: 'Ja, vollständig. Alle Daten werden ausschließlich auf zertifizierten Servern in Deutschland gespeichert. Wir schließen mit Ihnen einen Auftragsverarbeitungsvertrag (AVV) ab und haben die Software von Anfang an nach dem Prinzip "Privacy by Design" entwickelt. Kein Datentransfer in Drittländer.',
},
{
q: 'Können meine Mitglieder die App sofort benutzen?',
a: 'Ja — ohne App-Store-Konto und ohne Schulung. Mitglieder erhalten eine E-Mail-Einladung, klicken den Link und sind direkt drin. Die Oberfläche ist so einfach wie WhatsApp. Wer will, kann zusätzlich die native iOS/Android App installieren.',
},
{
q: 'Was kostet InnungsApp PRO und gibt es Vertragsbindung?',
a: 'Die Kosten richten sich nach der Größe Ihrer Innung (Anzahl Mitglieder). Es gibt keine langfristige Vertragsbindung — Sie können monatlich kündigen. Kontaktieren Sie uns für ein individuelles Angebot. Pilotinnungen profitieren von besonders günstigen Konditionen.',
},
{
q: 'Können mehrere Innungen oder ein Verband die Plattform nutzen?',
a: 'Ja. Die Plattform ist mandantenfähig — jede Innung hat ihren eigenen, vollständig getrennten Bereich. Verbände können mehrere angeschlossene Innungen zentral verwalten. Daten und Kommunikation bleiben dabei immer sauber getrennt.',
},
{
q: 'Wie funktionieren Push-Benachrichtigungen?',
a: 'Wenn Sie als Admin eine News veröffentlichen, erhalten alle aktiven Mitglieder sofort eine Push-Nachricht auf ihr Smartphone — auch wenn die App im Hintergrund läuft. Die Öffnungsrate liegt typischerweise bei über 80 %, verglichen mit unter 20 % bei E-Mail-Newslettern.',
},
].map((faq, i) => (
<div key={i} className="faq-item" style={i === 5 ? { borderBottom: 'none' } : {}}>
<button className="faq-question" onClick={() => toggleFaq(i)}>
<span>{faq.q}</span>
<span className="faq-icon">{openFaq === i ? '' : '+'}</span>
</button>
<div
className="faq-answer"
style={{ display: openFaq === i ? 'block' : 'none' }}
>
{faq.a}
</div>
</div>
))}
</div>
</section>
{/* CTA */}
<section className="cta-section">
<div className="cta-inner">
<div>
<div className="section-marker" style={{ marginBottom: '28px' }}>Bereit loszulegen?</div>
<h2 className="cta-h2">
Ihre Innung.<br />
Ihre App.<br />
<em>Ihre Zukunft.</em>
</h2>
</div>
<div className="cta-right">
<Link href="/login" className="btn-primary-lg">
Jetzt Team kontaktieren <ArrowUpRight size={18} />
</Link>
<p className="cta-note">Persönliche Beratung. Keine Vertragsbindung.</p>
</div>
</div>
</section>
</main>
{/* Footer */}
<footer className="footer">
<div className="footer-inner">
<div className="footer-logo" style={{ display: 'flex', alignItems: 'center' }}>
Innungs<span className="logo-accent">App</span> <span className="logo-pro" style={{ fontSize: '0.6rem', padding: '1px 4px', marginLeft: '6px' }}>PRO</span>
</div>
<p className="footer-copy">
© {new Date().getFullYear()} InnungsApp SaaS. Alle Rechte vorbehalten.
</p>
<div className="footer-links">
<Link href="#" className="footer-link">Impressum</Link>
<Link href="#" className="footer-link">Datenschutz</Link>
</div>
</div>
</footer>
</div>
</>
)
}

View File

@ -0,0 +1,81 @@
'use client'
import { useActionState } from 'react'
import { createOrganization } from './actions'
const initialState = {
success: false,
error: '',
}
export function CreateOrgForm() {
const [state, formAction, isPending] = useActionState(createOrganization, initialState)
return (
<div className="bg-white p-6 rounded-xl border shadow-sm">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Neue Innung anlegen</h2>
{state.success && (
<div className="mb-4 p-3 bg-green-50 text-green-700 rounded-lg text-sm">
Innung wurde erfolgreich angelegt!
</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-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Name der Innung
</label>
<input
type="text"
name="name"
required
placeholder="z.B. Tischler-Innung Berlin"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Kurzbezeichnung (Slug)
</label>
<p className="text-xs text-gray-500 mb-2">Für interne Zuordnung (nur Kleinbuchstaben, ohne Leerzeichen).</p>
<input
type="text"
name="slug"
required
placeholder="tischler-berlin"
pattern="^[a-z0-9-]+$"
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">
Kontakt E-Mail (Optional)
</label>
<input
type="email"
name="contactEmail"
placeholder="info@tischler-berlin.de"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
/>
</div>
<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 angelegt...' : 'Innung anlegen'}
</button>
</form>
</div>
)
}

View File

@ -0,0 +1,49 @@
'use server'
import { prisma } from '@innungsapp/shared'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'
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('Ungültige E-Mail Adresse').optional().or(z.literal('')),
})
export async function createOrganization(prevState: any, formData: FormData) {
try {
const rawData = {
name: formData.get('name') as string,
slug: (formData.get('slug') as string).toLowerCase(),
contactEmail: formData.get('contactEmail') as string,
}
const validatedData = createOrgSchema.parse(rawData)
// Check if slug exists
const existingOrg = await prisma.organization.findUnique({
where: { slug: validatedData.slug }
})
if (existingOrg) {
return { success: false, error: 'Diese Kurzbezeichnung (Slug) existiert bereits.' }
}
await prisma.organization.create({
data: {
name: validatedData.name,
slug: validatedData.slug,
contactEmail: validatedData.contactEmail || null,
plan: 'pilot',
}
})
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.' }
}
}

View File

@ -0,0 +1,45 @@
import { auth } from '@/lib/auth'
import { headers } from 'next/headers'
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 headers() })
if (!session?.user) {
redirect('/login')
}
const superAdminEmail = process.env.SUPERADMIN_EMAIL || 'superadmin@innungsapp.de'
if (session.user.email !== superAdminEmail) {
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">
<span
className="font-bold text-base tracking-tight"
style={{ fontFamily: "'Syne', system-ui, sans-serif" }}
>
Super Admin
</span>
<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,84 @@
import { prisma } from '@innungsapp/shared'
import { CreateOrgForm } from './CreateOrgForm'
import { format } from 'date-fns'
import { de } from 'date-fns/locale'
export default async function SuperAdminPage() {
const organizations = await prisma.organization.findMany({
orderBy: { createdAt: 'desc' },
include: {
_count: {
select: {
members: true,
userRoles: true,
},
},
},
})
return (
<div className="space-y-8">
<div>
<h1 className="text-2xl font-bold text-gray-900">Innungs-Verwaltung (Multi-Tenant)</h1>
<p className="text-gray-500 mt-1">Hierüber werden alle Mandanten der Lösung verwaltet.</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Form: Create new org */}
<div className="lg:col-span-1">
<CreateOrgForm />
</div>
{/* List of orgs */}
<div className="lg:col-span-2">
<div className="bg-white rounded-lg border overflow-hidden">
<div className="p-6 border-b">
<h2 className="text-lg font-semibold text-gray-900">Aktive Innungen ({organizations.length})</h2>
</div>
<div className="divide-y">
{organizations.length === 0 ? (
<div className="p-8 text-center text-gray-500">
Bisher keine Innungen angelegt.
</div>
) : (
organizations.map((org) => (
<div key={org.id} className="p-5 hover:bg-gray-50 border-l-[3px] border-transparent hover:border-brand-500 transition-all">
<div className="flex justify-between items-start">
<div>
<h3 className="font-bold text-gray-900 text-lg">{org.name}</h3>
<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>{org.contactEmail || 'Keine E-Mail'}</span>
</div>
</div>
<span className="bg-blue-100 text-blue-800 text-xs font-semibold px-2.5 py-0.5 rounded">
{org.plan}
</span>
</div>
<div className="mt-4 flex flex-wrap gap-4 text-sm">
<div className="bg-gray-50 px-3 py-2 rounded-lg border inline-block">
<span className="text-gray-500 block text-xs uppercase tracking-wider font-semibold">Mitglieder</span>
<span className="font-bold text-gray-900">{org._count.members}</span>
</div>
<div className="bg-gray-50 px-3 py-2 rounded-lg border inline-block">
<span className="text-gray-500 block text-xs uppercase tracking-wider font-semibold">Admins</span>
<span className="font-bold text-gray-900">{org._count.userRoles}</span>
</div>
<div className="bg-gray-50 px-3 py-2 rounded-lg border inline-block">
<span className="text-gray-500 block text-xs uppercase tracking-wider font-semibold">Erstellt am</span>
<span className="font-bold text-gray-900">{format(org.createdAt, 'dd.MM.yyyy', { locale: de })}</span>
</div>
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -1,12 +1,27 @@
'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 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 +30,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,14 +3,15 @@
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() {
@ -19,22 +20,25 @@ export function Sidebar() {
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">
<Link href="/dashboard">
<span
className="text-xl font-bold text-gray-900 tracking-tight"
style={{ fontFamily: "'Syne', system-ui, sans-serif" }}
>
Innungs<span className="text-brand-500">App</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 +46,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>
)
}

View File

@ -17,9 +17,9 @@ export const auth = betterAuth({
trustedOrigins: [
process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3032',
process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3032',
'http://10.36.148.233:3032',
'http://192.168.178.115:3032',
'http://localhost:8081', // Expo dev client
'http://10.36.148.233:8081',
'http://192.168.178.115:8081',
],
plugins: [
magicLink({

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

@ -2,9 +2,7 @@ import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
transpilePackages: ['@innungsapp/shared'],
experimental: {
typedRoutes: true,
},
experimental: {},
// Serve uploaded files
async rewrites() {
return [

View File

@ -20,8 +20,8 @@
"@tanstack/react-query": "^5.59.0",
"better-auth": "^1.2.0",
"next": "15.3.4",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"zod": "^3.23.0",
"superjson": "^2.2.1",
"nodemailer": "^6.9.0",
@ -33,8 +33,8 @@
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/nodemailer": "^6.4.17",
"tailwindcss": "^3.4.0",
"autoprefixer": "^10.4.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

View File

@ -0,0 +1,34 @@
import { auth } from './lib/auth'
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function seed() {
console.log('Cleaning up existing superadmin if exists...')
// Delete the existing user to avoid constraints
await prisma.user.deleteMany({
where: { email: 'superadmin@innungsapp.de' },
})
console.log('Seeding superadmin via better-auth API...')
try {
const user = await auth.api.signUpEmail({
body: {
email: 'superadmin@innungsapp.de',
password: 'demo1234',
name: 'Super Admin',
}
})
// Check if better-auth actually uses the Prisma DB directly here
// It should, as auth is configured with the prisma instance
console.log('Superadmin created successfully! ID:', user.user.id)
} catch (err: any) {
console.error('Error creating superadmin:', err.message || err)
}
}
seed()
.catch(console.error)
.finally(() => prisma.$disconnect())

View File

@ -37,10 +37,10 @@ export const membersRouter = router({
}),
...(input.search && {
OR: [
{ name: { contains: input.search, mode: 'insensitive' } },
{ betrieb: { contains: input.search, mode: 'insensitive' } },
{ ort: { contains: input.search, mode: 'insensitive' } },
{ sparte: { contains: input.search, mode: 'insensitive' } },
{ name: { contains: input.search } },
{ betrieb: { contains: input.search } },
{ ort: { contains: input.search } },
{ sparte: { contains: input.search } },
],
}),
},

View File

@ -128,6 +128,23 @@ export const stellenRouter = router({
return { success: true }
}),
/**
* Create Stelle on behalf of a member (admin only)
*/
createForMember: adminProcedure
.input(StelleInput.extend({ memberId: z.string() }))
.mutation(async ({ ctx, input }) => {
const { memberId, ...data } = input
// Verify member belongs to this org
await ctx.prisma.member.findFirstOrThrow({
where: { id: memberId, orgId: ctx.orgId },
})
const stelle = await ctx.prisma.stelle.create({
data: { orgId: ctx.orgId, memberId, ...data },
})
return stelle
}),
/**
* Deactivate Stelle (admin moderation)
*/

View File

@ -1,4 +1,5 @@
import type { Config } from 'tailwindcss'
import plugin from 'tailwindcss/plugin'
const config: Config = {
content: [
@ -25,9 +26,38 @@ const config: Config = {
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
},
animation: {
blob: 'blob 7s infinite',
'fade-in-up': 'fadeInUp 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards',
},
keyframes: {
blob: {
'0%': { transform: 'translate(0px, 0px) scale(1)' },
'33%': { transform: 'translate(30px, -50px) scale(1.1)' },
'66%': { transform: 'translate(-20px, 20px) scale(0.9)' },
'100%': { transform: 'translate(0px, 0px) scale(1)' },
},
fadeInUp: {
'0%': { opacity: '0', transform: 'translateY(20px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
},
plugins: [],
},
},
plugins: [
plugin(function ({ matchUtilities, theme }) {
matchUtilities(
{
'animation-delay': (value) => {
return {
'animation-delay': value,
}
},
},
{ values: theme('transitionDelay') }
)
}),
],
}
export default config

View File

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"Bash(python:*)",
"Bash(python3:*)",
"Bash(node -e:*)",
"Bash(pnpm --filter mobile add:*)"
]
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 70 B

View File

@ -37,11 +37,13 @@
"expo-web-browser": "~15.0.10",
"nativewind": "^4.1.0",
"react": "19.1.0",
"react-dom": "^18.3.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "~4.1.6",
"react-native-safe-area-context": "5.6.2",
"react-native-screens": "~4.16.0",
"react-native-web": "^0.21.2",
"superjson": "^2.2.1",
"tailwind-merge": "^2.5.0",
"zod": "^3.23.0",

View File

@ -22,5 +22,8 @@
"engines": {
"node": ">=20.0.0",
"pnpm": ">=9.0.0"
},
"dependencies": {
"bcryptjs": "^3.0.3"
}
}

Binary file not shown.

View File

@ -0,0 +1,50 @@
import { PrismaClient } from '@prisma/client'
import bcrypt from 'bcryptjs'
const prisma = new PrismaClient()
async function main() {
console.log('Seeding superadmin with fresh bcryptjs hash...')
// Generate hash using bcryptjs, rounds=10
const salt = bcrypt.genSaltSync(10)
const hash = bcrypt.hashSync('demo1234', salt)
console.log('Generated hash:', hash)
const superAdminUser = await prisma.user.upsert({
where: { email: 'superadmin@innungsapp.de' },
update: {},
create: {
id: 'superadmin-user-id',
name: 'Super Admin',
email: 'superadmin@innungsapp.de',
emailVerified: true,
},
})
await prisma.account.upsert({
where: { id: 'superadmin-account-id' },
update: {
password: hash
},
create: {
id: 'superadmin-account-id',
accountId: superAdminUser.id,
providerId: 'credential',
userId: superAdminUser.id,
password: hash,
},
})
console.log('Superadmin updated! Email: superadmin@innungsapp.de, Password: demo1234')
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})

View File

@ -0,0 +1,14 @@
import { auth } from '../apps/admin/lib/auth'
async function main() {
const password = 'demo1234'
// Actually better-auth 1.x exports a util or hashes internally.
// An easy way to fix a user is to just update their password using the auth instance
// but since we want to strictly seed the DB, let's just generate it using the bcrypt package directly.
// For now let's just use the `better-auth` API directly if possible?
// Wait, let's look at how better auth handles hashing.
}
main()

View File

@ -7,6 +7,10 @@ settings:
importers:
.:
dependencies:
bcryptjs:
specifier: ^3.0.3
version: 3.0.3
devDependencies:
turbo:
specifier: ^2.3.0
@ -164,7 +168,7 @@ importers:
version: 0.32.16(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
expo-router:
specifier: ~6.0.23
version: 6.0.23(@expo/metro-runtime@6.1.2)(@types/react-dom@18.3.7(@types/react@19.1.17))(@types/react@19.1.17)(expo-constants@18.0.13)(expo-linking@8.0.11)(expo@54.0.33)(react-dom@18.3.1(react@19.1.0))(react-native-gesture-handler@2.28.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-reanimated@4.1.6(@babel/core@7.29.0)(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
version: 6.0.23(ksghc7kmk7b34van6p2il22s6a)
expo-splash-screen:
specifier: ~31.0.13
version: 31.0.13(expo@54.0.33)
@ -173,7 +177,7 @@ importers:
version: 3.0.9(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
expo-system-ui:
specifier: ~6.0.9
version: 6.0.9(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))
version: 6.0.9(expo@54.0.33)(react-native-web@0.21.2(react-dom@18.3.1(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))
expo-web-browser:
specifier: ~15.0.10
version: 15.0.10(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))
@ -183,6 +187,9 @@ importers:
react:
specifier: 19.1.0
version: 19.1.0
react-dom:
specifier: ^18.3.0
version: 18.3.1(react@19.1.0)
react-native:
specifier: 0.81.5
version: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
@ -198,6 +205,9 @@ importers:
react-native-screens:
specifier: ~4.16.0
version: 4.16.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
react-native-web:
specifier: ^0.21.2
version: 0.21.2(react-dom@18.3.1(react@19.1.0))(react@19.1.0)
superjson:
specifier: ^2.2.1
version: 2.2.6
@ -1789,6 +1799,9 @@ packages:
resolution: {integrity: sha512-fB7M1CMOCIUudTRuj7kzxIBTVw2KXnsgbQ6+4cbqSxo8NmRRhA0Ul4ZUzZj3rFd3VznTL4Brmocv1oiN0bWZ8w==}
engines: {node: '>= 20.19.4'}
'@react-native/normalize-colors@0.74.89':
resolution: {integrity: sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg==}
'@react-native/normalize-colors@0.81.5':
resolution: {integrity: sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g==}
@ -2414,6 +2427,10 @@ packages:
bcp-47-match@2.0.3:
resolution: {integrity: sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==}
bcryptjs@3.0.3:
resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==}
hasBin: true
better-auth@1.4.18:
resolution: {integrity: sha512-bnyifLWBPcYVltH3RhS7CM62MoelEqC6Q+GnZwfiDWNfepXoQZBjEvn4urcERC7NTKgKq5zNBM8rvPvRBa6xcg==}
peerDependencies:
@ -2716,6 +2733,9 @@ packages:
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
cross-fetch@3.2.0:
resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@ -2724,6 +2744,9 @@ packages:
resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==}
engines: {node: '>=8'}
css-in-js-utils@3.1.0:
resolution: {integrity: sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==}
css-selector-parser@3.3.0:
resolution: {integrity: sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g==}
@ -3328,6 +3351,12 @@ packages:
fb-watchman@2.0.2:
resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==}
fbjs-css-vars@1.0.2:
resolution: {integrity: sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==}
fbjs@3.0.5:
resolution: {integrity: sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@ -3600,6 +3629,9 @@ packages:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
hyphenate-style-name@1.1.0:
resolution: {integrity: sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==}
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
@ -3637,6 +3669,9 @@ packages:
inline-style-parser@0.2.7:
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
inline-style-prefixer@7.0.1:
resolution: {integrity: sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw==}
internal-slot@1.1.0:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
@ -4185,6 +4220,9 @@ packages:
memoize-one@5.2.1:
resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==}
memoize-one@6.0.0:
resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==}
merge-options@3.0.4:
resolution: {integrity: sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==}
engines: {node: '>=10'}
@ -4480,6 +4518,15 @@ packages:
resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==}
engines: {node: '>= 0.4'}
node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
peerDependencies:
encoding: ^0.1.0
peerDependenciesMeta:
encoding:
optional: true
node-forge@1.3.3:
resolution: {integrity: sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==}
engines: {node: '>= 6.13.0'}
@ -4766,6 +4813,9 @@ packages:
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
engines: {node: '>=0.4.0'}
promise@7.3.1:
resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==}
promise@8.3.0:
resolution: {integrity: sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==}
@ -4888,6 +4938,12 @@ packages:
react: '*'
react-native: '*'
react-native-web@0.21.2:
resolution: {integrity: sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==}
peerDependencies:
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
react-native-worklets@0.7.4:
resolution: {integrity: sha512-NYOdM1MwBb3n+AtMqy1tFy3Mn8DliQtd8sbzAVRf9Gc+uvQ0zRfxN7dS8ZzoyX7t6cyQL5THuGhlnX+iFlQTag==}
peerDependencies:
@ -5187,6 +5243,9 @@ packages:
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
engines: {node: '>= 0.4'}
setimmediate@1.0.5:
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
@ -5384,6 +5443,9 @@ packages:
babel-plugin-macros:
optional: true
styleq@0.1.3:
resolution: {integrity: sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA==}
sucrase@3.35.1:
resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==}
engines: {node: '>=16 || 14 >=14.17'}
@ -5467,6 +5529,9 @@ packages:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
trim-lines@3.0.1:
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
@ -5564,6 +5629,10 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
ua-parser-js@1.0.41:
resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==}
hasBin: true
unbox-primitive@1.1.0:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'}
@ -5717,6 +5786,9 @@ packages:
web-namespaces@2.0.1:
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
webidl-conversions@5.0.0:
resolution: {integrity: sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==}
engines: {node: '>=8'}
@ -5728,6 +5800,9 @@ packages:
resolution: {integrity: sha512-HoKuzZrUlgpz35YO27XgD28uh/WJH4B0+3ttFqRo//lmq+9T/mIOJ6kqmINI9HpUpz1imRC/nR/lxKpJiv0uig==}
engines: {node: '>=10'}
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'}
@ -6765,7 +6840,7 @@ snapshots:
wrap-ansi: 7.0.0
ws: 8.19.0
optionalDependencies:
expo-router: 6.0.23(@expo/metro-runtime@6.1.2)(@types/react-dom@18.3.7(@types/react@19.1.17))(@types/react@19.1.17)(expo-constants@18.0.13)(expo-linking@8.0.11)(expo@54.0.33)(react-dom@18.3.1(react@19.1.0))(react-native-gesture-handler@2.28.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-reanimated@4.1.6(@babel/core@7.29.0)(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
expo-router: 6.0.23(ksghc7kmk7b34van6p2il22s6a)
react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
transitivePeerDependencies:
- bufferutil
@ -7621,6 +7696,8 @@ snapshots:
'@react-native/js-polyfills@0.81.5': {}
'@react-native/normalize-colors@0.74.89': {}
'@react-native/normalize-colors@0.81.5': {}
'@react-native/virtualized-lists@0.81.5(@types/react@19.1.17)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
@ -8382,6 +8459,8 @@ snapshots:
bcp-47-match@2.0.3: {}
bcryptjs@3.0.3: {}
better-auth@1.4.18(@prisma/client@5.22.0(prisma@5.22.0))(next@15.3.4(babel-plugin-react-compiler@1.0.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(prisma@5.22.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
@ -8678,6 +8757,12 @@ snapshots:
core-util-is@1.0.3: {}
cross-fetch@3.2.0:
dependencies:
node-fetch: 2.7.0
transitivePeerDependencies:
- encoding
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@ -8686,6 +8771,10 @@ snapshots:
crypto-random-string@2.0.0: {}
css-in-js-utils@3.1.0:
dependencies:
hyphenate-style-name: 1.1.0
css-selector-parser@3.3.0: {}
cssesc@3.0.0: {}
@ -9323,7 +9412,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
expo-router@6.0.23(@expo/metro-runtime@6.1.2)(@types/react-dom@18.3.7(@types/react@19.1.17))(@types/react@19.1.17)(expo-constants@18.0.13)(expo-linking@8.0.11)(expo@54.0.33)(react-dom@18.3.1(react@19.1.0))(react-native-gesture-handler@2.28.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-reanimated@4.1.6(@babel/core@7.29.0)(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
expo-router@6.0.23(ksghc7kmk7b34van6p2il22s6a):
dependencies:
'@expo/metro-runtime': 6.1.2(expo@54.0.33)(react-dom@18.3.1(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
'@expo/schema-utils': 0.1.8
@ -9359,6 +9448,7 @@ snapshots:
react-dom: 18.3.1(react@19.1.0)
react-native-gesture-handler: 2.28.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
react-native-reanimated: 4.1.6(@babel/core@7.29.0)(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
react-native-web: 0.21.2(react-dom@18.3.1(react@19.1.0))(react@19.1.0)
transitivePeerDependencies:
- '@react-native-masked-view/masked-view'
- '@types/react'
@ -9380,12 +9470,14 @@ snapshots:
react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
react-native-is-edge-to-edge: 1.2.1(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
expo-system-ui@6.0.9(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)):
expo-system-ui@6.0.9(expo@54.0.33)(react-native-web@0.21.2(react-dom@18.3.1(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)):
dependencies:
'@react-native/normalize-colors': 0.81.5
debug: 4.4.3
expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
optionalDependencies:
react-native-web: 0.21.2(react-dom@18.3.1(react@19.1.0))(react@19.1.0)
transitivePeerDependencies:
- supports-color
@ -9467,6 +9559,20 @@ snapshots:
dependencies:
bser: 2.1.1
fbjs-css-vars@1.0.2: {}
fbjs@3.0.5:
dependencies:
cross-fetch: 3.2.0
fbjs-css-vars: 1.0.2
loose-envify: 1.4.0
object-assign: 4.1.1
promise: 7.3.1
setimmediate: 1.0.5
ua-parser-js: 1.0.41
transitivePeerDependencies:
- encoding
fdir@6.5.0(picomatch@4.0.3):
optionalDependencies:
picomatch: 4.0.3
@ -9829,6 +9935,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
hyphenate-style-name@1.1.0: {}
ieee754@1.2.1: {}
ignore@5.3.2: {}
@ -9857,6 +9965,10 @@ snapshots:
inline-style-parser@0.2.7: {}
inline-style-prefixer@7.0.1:
dependencies:
css-in-js-utils: 3.1.0
internal-slot@1.1.0:
dependencies:
es-errors: 1.3.0
@ -10488,6 +10600,8 @@ snapshots:
memoize-one@5.2.1: {}
memoize-one@6.0.0: {}
merge-options@3.0.4:
dependencies:
is-plain-obj: 2.1.0
@ -10997,6 +11111,10 @@ snapshots:
object.entries: 1.1.9
semver: 6.3.1
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
node-forge@1.3.3: {}
node-int64@0.4.0: {}
@ -11276,6 +11394,10 @@ snapshots:
progress@2.0.3: {}
promise@7.3.1:
dependencies:
asap: 2.0.6
promise@8.3.0:
dependencies:
asap: 2.0.6
@ -11422,6 +11544,21 @@ snapshots:
react-native-is-edge-to-edge: 1.2.1(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
warn-once: 0.1.1
react-native-web@0.21.2(react-dom@18.3.1(react@19.1.0))(react@19.1.0):
dependencies:
'@babel/runtime': 7.28.6
'@react-native/normalize-colors': 0.74.89
fbjs: 3.0.5
inline-style-prefixer: 7.0.1
memoize-one: 6.0.0
nullthrows: 1.1.1
postcss-value-parser: 4.2.0
react: 19.1.0
react-dom: 18.3.1(react@19.1.0)
styleq: 0.1.3
transitivePeerDependencies:
- encoding
react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
dependencies:
'@babel/core': 7.29.0
@ -11857,6 +11994,8 @@ snapshots:
es-errors: 1.3.0
es-object-atoms: 1.1.1
setimmediate@1.0.5: {}
setprototypeof@1.2.0: {}
sf-symbols-typescript@2.2.0: {}
@ -12091,6 +12230,8 @@ snapshots:
client-only: 0.0.1
react: 18.3.1
styleq@0.1.3: {}
sucrase@3.35.1:
dependencies:
'@jridgewell/gen-mapping': 0.3.13
@ -12205,6 +12346,8 @@ snapshots:
toidentifier@1.0.1: {}
tr46@0.0.3: {}
trim-lines@3.0.1: {}
trough@2.2.0: {}
@ -12303,6 +12446,8 @@ snapshots:
typescript@5.9.3: {}
ua-parser-js@1.0.41: {}
unbox-primitive@1.1.0:
dependencies:
call-bound: 1.0.4
@ -12489,6 +12634,8 @@ snapshots:
web-namespaces@2.0.1: {}
webidl-conversions@3.0.1: {}
webidl-conversions@5.0.0: {}
whatwg-fetch@3.6.20: {}
@ -12499,6 +12646,11 @@ snapshots:
punycode: 2.3.1
webidl-conversions: 5.0.0
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1
which-boxed-primitive@1.1.1:
dependencies:
is-bigint: 1.1.0