feat: Set up initial monorepo structure for admin and mobile applications with core configurations and database integration.
|
|
@ -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.
|
||||||
|
|
@ -71,3 +71,8 @@ npx supabase start
|
||||||
cd apps/mobile
|
cd apps/mobile
|
||||||
npx expo start
|
npx expo start
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
pnpm --filter @innungsapp/admin dev
|
||||||
|
|
||||||
|
px expo start --clear
|
||||||
|
|
|
||||||
|
|
@ -135,3 +135,24 @@ eas submit --platform all
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
Siehe `innung-app-mvp.md` für die vollständige 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
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,11 @@ const UPLOAD_DIR = process.env.UPLOAD_DIR ?? './uploads'
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: { path: string[] } }
|
{ params }: { params: Promise<{ path: string[] }> }
|
||||||
) {
|
) {
|
||||||
try {
|
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
|
// Security: prevent path traversal
|
||||||
const resolved = path.resolve(filePath)
|
const resolved = path.resolve(filePath)
|
||||||
|
|
|
||||||
|
|
@ -19,14 +19,14 @@ export default function EinstellungenPage() {
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Einstellungen</h1>
|
<h1 className="text-2xl font-bold text-gray-900">Einstellungen</h1>
|
||||||
|
|
||||||
{/* Org Settings */}
|
{/* 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>
|
<h2 className="font-semibold text-gray-900">Innung</h2>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name der Innung</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">Name der Innung</label>
|
||||||
<input
|
<input
|
||||||
defaultValue={org.name}
|
defaultValue={org.name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
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>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -35,7 +35,7 @@ export default function EinstellungenPage() {
|
||||||
type="email"
|
type="email"
|
||||||
defaultValue={org.contactEmail ?? ''}
|
defaultValue={org.contactEmail ?? ''}
|
||||||
onChange={(e) => setContactEmail(e.target.value)}
|
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>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|
@ -51,7 +51,7 @@ export default function EinstellungenPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AVV */}
|
{/* 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>
|
<h2 className="font-semibold text-gray-900">Auftragsverarbeitungsvertrag (AVV)</h2>
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
Der AVV regelt die Verarbeitung personenbezogener Daten im Auftrag Ihrer Innung
|
Der AVV regelt die Verarbeitung personenbezogener Daten im Auftrag Ihrer Innung
|
||||||
|
|
@ -101,7 +101,7 @@ export default function EinstellungenPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Plan Info */}
|
{/* 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>
|
<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">
|
<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}
|
{org.plan}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,26 @@
|
||||||
import { Sidebar } from '@/components/layout/Sidebar'
|
import { Sidebar } from '@/components/layout/Sidebar'
|
||||||
import { Header } from '@/components/layout/Header'
|
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,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
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 (
|
return (
|
||||||
<div className="flex h-screen bg-gray-50">
|
<div className="flex h-screen bg-gray-50">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { use } from 'react'
|
import { use } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { trpc } from '@/lib/trpc-client'
|
import { trpc } from '@/lib/trpc-client'
|
||||||
|
import { getTrpcErrorMessage } from '@/lib/trpc-error'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { SPARTEN, MEMBER_STATUS_LABELS } from '@innungsapp/shared'
|
import { SPARTEN, MEMBER_STATUS_LABELS } from '@innungsapp/shared'
|
||||||
|
|
@ -41,7 +42,7 @@ export default function MitgliedEditPage({
|
||||||
ort: member.ort,
|
ort: member.ort,
|
||||||
telefon: member.telefon ?? '',
|
telefon: member.telefon ?? '',
|
||||||
email: member.email,
|
email: member.email,
|
||||||
status: member.status,
|
status: member.status as 'aktiv' | 'ruhend' | 'ausgetreten',
|
||||||
istAusbildungsbetrieb: member.istAusbildungsbetrieb,
|
istAusbildungsbetrieb: member.istAusbildungsbetrieb,
|
||||||
seit: member.seit ?? undefined,
|
seit: member.seit ?? undefined,
|
||||||
})
|
})
|
||||||
|
|
@ -57,24 +58,25 @@ export default function MitgliedEditPage({
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputClass =
|
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 (
|
return (
|
||||||
<div className="max-w-2xl space-y-6">
|
<div className="max-w-2xl space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-3">
|
||||||
<Link href="/dashboard/mitglieder" className="text-gray-400 hover:text-gray-600">
|
<Link href="/dashboard/mitglieder" className="text-xs text-gray-400 hover:text-gray-600 uppercase tracking-wide">
|
||||||
← Zurück
|
← Zurück
|
||||||
</Link>
|
</Link>
|
||||||
|
<span className="text-gray-200">/</span>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Mitglied bearbeiten</h1>
|
<h1 className="text-2xl font-bold text-gray-900">Mitglied bearbeiten</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Invite Status */}
|
{/* 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>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-700">App-Zugang</p>
|
<p className="text-sm font-medium text-gray-700">App-Zugang</p>
|
||||||
<p className="text-xs text-gray-500 mt-0.5">
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
{member.userId
|
{member.userId
|
||||||
? '✓ Mitglied hat sich eingeloggt'
|
? 'Mitglied hat sich eingeloggt'
|
||||||
: 'Noch nicht eingeladen / eingeloggt'}
|
: 'Noch nicht eingeladen / eingeloggt'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -84,61 +86,79 @@ export default function MitgliedEditPage({
|
||||||
disabled={resendMutation.isPending}
|
disabled={resendMutation.isPending}
|
||||||
className="text-sm text-brand-600 hover:underline disabled:opacity-50"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
{/* Section: Stammdaten */}
|
||||||
<div className="col-span-2">
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Stammdaten</p>
|
||||||
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className={inputClass} />
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
||||||
|
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Betrieb</label>
|
||||||
|
<input value={form.betrieb} onChange={(e) => setForm({ ...form, betrieb: e.target.value })} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Sparte</label>
|
||||||
|
<select value={form.sparte} onChange={(e) => setForm({ ...form, sparte: e.target.value })} className={inputClass}>
|
||||||
|
{SPARTEN.map((s) => <option key={s}>{s}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Ort</label>
|
||||||
|
<input value={form.ort} onChange={(e) => setForm({ ...form, ort: e.target.value })} className={inputClass} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2">
|
</div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Betrieb</label>
|
|
||||||
<input value={form.betrieb} onChange={(e) => setForm({ ...form, betrieb: e.target.value })} className={inputClass} />
|
{/* Section: Kontakt */}
|
||||||
|
<div className="border-t pt-5">
|
||||||
|
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Kontakt</p>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">E-Mail</label>
|
||||||
|
<input type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Telefon</label>
|
||||||
|
<input type="tel" value={form.telefon} onChange={(e) => setForm({ ...form, telefon: e.target.value })} className={inputClass} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Sparte</label>
|
|
||||||
<select value={form.sparte} onChange={(e) => setForm({ ...form, sparte: e.target.value })} className={inputClass}>
|
{/* Section: Status */}
|
||||||
{SPARTEN.map((s) => <option key={s}>{s}</option>)}
|
<div className="border-t pt-5">
|
||||||
</select>
|
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Status</p>
|
||||||
</div>
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Ort</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
||||||
<input value={form.ort} onChange={(e) => setForm({ ...form, ort: e.target.value })} className={inputClass} />
|
<select value={form.status} onChange={(e) => setForm({ ...form, status: e.target.value as typeof form.status })} className={inputClass}>
|
||||||
</div>
|
{(['aktiv', 'ruhend', 'ausgetreten'] as const).map((s) => (
|
||||||
<div>
|
<option key={s} value={s}>{MEMBER_STATUS_LABELS[s]}</option>
|
||||||
<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} />
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Telefon</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">Mitglied seit</label>
|
||||||
<input type="tel" value={form.telefon} onChange={(e) => setForm({ ...form, telefon: e.target.value })} className={inputClass} />
|
<input type="number" value={form.seit ?? ''} onChange={(e) => setForm({ ...form, seit: e.target.value ? Number(e.target.value) : undefined })} className={inputClass} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="col-span-2">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
<select value={form.status} onChange={(e) => setForm({ ...form, status: e.target.value as typeof form.status })} className={inputClass}>
|
<input type="checkbox" checked={form.istAusbildungsbetrieb} onChange={(e) => setForm({ ...form, istAusbildungsbetrieb: e.target.checked })} className="rounded border-gray-300 text-brand-500 focus:ring-brand-500" />
|
||||||
{(['aktiv', 'ruhend', 'ausgetreten'] as const).map((s) => (
|
<span className="text-sm text-gray-700">Ausbildungsbetrieb</span>
|
||||||
<option key={s} value={s}>{MEMBER_STATUS_LABELS[s]}</option>
|
</label>
|
||||||
))}
|
</div>
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Mitglied seit</label>
|
|
||||||
<input type="number" value={form.seit ?? ''} onChange={(e) => setForm({ ...form, seit: e.target.value ? Number(e.target.value) : undefined })} className={inputClass} />
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2">
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input type="checkbox" checked={form.istAusbildungsbetrieb} onChange={(e) => setForm({ ...form, istAusbildungsbetrieb: e.target.checked })} className="rounded border-gray-300 text-brand-500 focus:ring-brand-500" />
|
|
||||||
<span className="text-sm text-gray-700">Ausbildungsbetrieb</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{updateMutation.error && (
|
{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">
|
<div className="flex gap-3 pt-2 border-t">
|
||||||
|
|
|
||||||
|
|
@ -7,17 +7,19 @@ import { MEMBER_STATUS_LABELS } from '@innungsapp/shared'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import { de } from 'date-fns/locale'
|
import { de } from 'date-fns/locale'
|
||||||
|
|
||||||
const STATUS_COLORS = {
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
aktiv: 'bg-green-100 text-green-700',
|
aktiv: 'bg-green-100 text-green-700',
|
||||||
ruhend: 'bg-yellow-100 text-yellow-700',
|
ruhend: 'bg-yellow-100 text-yellow-700',
|
||||||
ausgetreten: 'bg-red-100 text-red-700',
|
ausgetreten: 'bg-red-100 text-red-700',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function MitgliederPage({
|
export default async function MitgliederPage(props: {
|
||||||
searchParams,
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
|
||||||
}: {
|
|
||||||
searchParams: { q?: string; status?: string }
|
|
||||||
}) {
|
}) {
|
||||||
|
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() })
|
const session = await auth.api.getSession({ headers: await headers() })
|
||||||
if (!session?.user) redirect('/login')
|
if (!session?.user) redirect('/login')
|
||||||
|
|
||||||
|
|
@ -26,18 +28,15 @@ export default async function MitgliederPage({
|
||||||
})
|
})
|
||||||
if (!userRole || userRole.role !== 'admin') redirect('/dashboard')
|
if (!userRole || userRole.role !== 'admin') redirect('/dashboard')
|
||||||
|
|
||||||
const search = searchParams.q ?? ''
|
|
||||||
const statusFilter = searchParams.status
|
|
||||||
|
|
||||||
const members = await prisma.member.findMany({
|
const members = await prisma.member.findMany({
|
||||||
where: {
|
where: {
|
||||||
orgId: userRole.orgId,
|
orgId: userRole.orgId,
|
||||||
...(statusFilter && { status: statusFilter as never }),
|
...(statusFilter && { status: statusFilter as never }),
|
||||||
...(search && {
|
...(search && {
|
||||||
OR: [
|
OR: [
|
||||||
{ name: { contains: search, mode: 'insensitive' } },
|
{ name: { contains: search } },
|
||||||
{ betrieb: { contains: search, mode: 'insensitive' } },
|
{ betrieb: { contains: search } },
|
||||||
{ ort: { contains: search, mode: 'insensitive' } },
|
{ ort: { contains: search } },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
@ -60,7 +59,7 @@ export default async function MitgliederPage({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* 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">
|
<form className="flex gap-4 w-full">
|
||||||
<input
|
<input
|
||||||
name="q"
|
name="q"
|
||||||
|
|
@ -88,7 +87,7 @@ export default async function MitgliederPage({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* 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">
|
<table className="w-full data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -115,16 +114,16 @@ export default async function MitgliederPage({
|
||||||
<td>{m.seit ?? '—'}</td>
|
<td>{m.seit ?? '—'}</td>
|
||||||
<td>
|
<td>
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center px-2 py-0.5 rounded-full text-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]}
|
{MEMBER_STATUS_LABELS[m.status]}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{m.userId ? (
|
{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>
|
||||||
<td>
|
<td>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { trpc } from '@/lib/trpc-client'
|
import { trpc } from '@/lib/trpc-client'
|
||||||
|
import { getTrpcErrorMessage } from '@/lib/trpc-error'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
|
|
||||||
|
|
@ -132,7 +133,7 @@ export default function NewsNeuPage() {
|
||||||
|
|
||||||
{createMutation.error && (
|
{createMutation.error && (
|
||||||
<p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg">
|
<p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg">
|
||||||
{createMutation.error.message}
|
{getTrpcErrorMessage(createMutation.error)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ export default async function NewsPage() {
|
||||||
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||||
Entwürfe
|
Entwürfe
|
||||||
</h2>
|
</h2>
|
||||||
<div className="bg-white rounded-xl border shadow-sm overflow-hidden">
|
<div className="bg-white rounded-lg border overflow-hidden">
|
||||||
<table className="w-full data-table">
|
<table className="w-full data-table">
|
||||||
<tbody>
|
<tbody>
|
||||||
{drafts.map((n) => (
|
{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">
|
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||||
Publiziert
|
Publiziert
|
||||||
</h2>
|
</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">
|
<table className="w-full data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ export default async function DashboardPage() {
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* Recent News */}
|
{/* Recent News */}
|
||||||
<div className="bg-white rounded-xl border shadow-sm p-6">
|
<div className="bg-white rounded-lg border p-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="font-semibold text-gray-900">Neueste Beiträge</h2>
|
<h2 className="font-semibold text-gray-900">Neueste Beiträge</h2>
|
||||||
<Link href="/dashboard/news" className="text-sm text-brand-600 hover:underline">
|
<Link href="/dashboard/news" className="text-sm text-brand-600 hover:underline">
|
||||||
|
|
@ -87,7 +87,7 @@ export default async function DashboardPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Upcoming Termine */}
|
{/* 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">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="font-semibold text-gray-900">Nächste Termine</h2>
|
<h2 className="font-semibold text-gray-900">Nächste Termine</h2>
|
||||||
<Link href="/dashboard/termine" className="text-sm text-brand-600 hover:underline">
|
<Link href="/dashboard/termine" className="text-sm text-brand-600 hover:underline">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import { redirect } from 'next/navigation'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import { de } from 'date-fns/locale'
|
import { de } from 'date-fns/locale'
|
||||||
import { DeactivateButton } from './DeactivateButton'
|
import { DeactivateButton } from './DeactivateButton'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
export default async function StellenPage() {
|
export default async function StellenPage() {
|
||||||
const session = await auth.api.getSession({ headers: await headers() })
|
const session = await auth.api.getSession({ headers: await headers() })
|
||||||
|
|
@ -22,14 +23,22 @@ export default async function StellenPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Lehrlingsbörse</h1>
|
<div>
|
||||||
<p className="text-gray-500 mt-1">
|
<h1 className="text-2xl font-bold text-gray-900">Lehrlingsbörse</h1>
|
||||||
{stellen.filter((s) => s.aktiv).length} aktive Angebote
|
<p className="text-gray-500 mt-1">
|
||||||
</p>
|
{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>
|
||||||
|
|
||||||
<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">
|
<table className="w-full data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { trpc } from '@/lib/trpc-client'
|
import { trpc } from '@/lib/trpc-client'
|
||||||
|
import { getTrpcErrorMessage } from '@/lib/trpc-error'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
|
||||||
const TYPEN = [
|
const TYPEN = [
|
||||||
|
|
@ -122,7 +123,7 @@ export default function TerminNeuPage() {
|
||||||
|
|
||||||
{createMutation.error && (
|
{createMutation.error && (
|
||||||
<p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg">
|
<p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg">
|
||||||
{createMutation.error.message}
|
{getTrpcErrorMessage(createMutation.error)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ export default async function TerminePage() {
|
||||||
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||||
Bevorstehend ({upcoming.length})
|
Bevorstehend ({upcoming.length})
|
||||||
</h2>
|
</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">
|
<table className="w-full data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -110,7 +110,7 @@ export default async function TerminePage() {
|
||||||
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||||
Vergangen
|
Vergangen
|
||||||
</h2>
|
</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">
|
<table className="w-full data-table">
|
||||||
<tbody>
|
<tbody>
|
||||||
{past.map((t) => <TerminRow key={t.id} t={t} />)}
|
{past.map((t) => <TerminRow key={t.id} t={t} />)}
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,16 @@
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-gray-50 text-gray-900 antialiased;
|
@apply bg-gray-50 text-gray-900 antialiased;
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@apply border-gray-200;
|
@apply border-gray-200;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4 {
|
||||||
|
font-family: 'Syne', system-ui, sans-serif;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
|
|
@ -22,15 +27,15 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-link-active {
|
.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 {
|
.stat-card {
|
||||||
@apply rounded-xl border bg-white p-6 shadow-sm;
|
@apply rounded-lg border bg-white p-6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.data-table th {
|
.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 {
|
.data-table td {
|
||||||
|
|
@ -42,6 +47,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.data-table tr:hover td {
|
.data-table tr:hover td {
|
||||||
@apply bg-gray-50;
|
@apply bg-gray-50/70;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ import { Providers } from './providers'
|
||||||
const inter = Inter({ subsets: ['latin'] })
|
const inter = Inter({ subsets: ['latin'] })
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'InnungsApp Admin',
|
title: 'InnungsApp PRO | Die moderne Vereinssoftware für das Handwerk',
|
||||||
description: 'Verwaltungsportal für Innungen',
|
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({
|
export default function RootLayout({
|
||||||
|
|
|
||||||
|
|
@ -50,28 +50,29 @@ export default function LoginPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-sm">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="text-center mb-8">
|
<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">
|
<h1
|
||||||
<span className="text-white font-bold text-2xl">I</span>
|
className="text-3xl font-bold text-gray-900 tracking-tight"
|
||||||
</div>
|
style={{ fontFamily: "'Syne', system-ui, sans-serif" }}
|
||||||
<h1 className="text-2xl font-bold text-gray-900">InnungsApp Admin</h1>
|
>
|
||||||
<p className="text-gray-500 mt-1">Verwaltungsportal für Innungen</p>
|
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>
|
||||||
|
|
||||||
<div className="bg-white rounded-2xl shadow-sm border p-8">
|
<div className="bg-white rounded-lg border p-8">
|
||||||
{sent ? (
|
{sent ? (
|
||||||
<div className="text-center">
|
<div className="text-center py-4">
|
||||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-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-8 h-8 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="w-7 h-7 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" />
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">E-Mail gesendet!</h2>
|
<h2 className="text-lg font-semibold text-gray-900 mb-2">E-Mail gesendet</h2>
|
||||||
<p className="text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
Wir haben einen Login-Link an <strong>{email}</strong> gesendet.
|
Login-Link an <strong className="text-gray-700">{email}</strong> gesendet. Bitte prüfen Sie Ihr Postfach.
|
||||||
Bitte überprüfen Sie Ihr Postfach.
|
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSent(false)}
|
onClick={() => setSent(false)}
|
||||||
|
|
@ -82,15 +83,22 @@ export default function LoginPage() {
|
||||||
</div>
|
</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 */}
|
{/* 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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setMode('password')}
|
onClick={() => setMode('password')}
|
||||||
className={`flex-1 py-1.5 text-sm rounded-md font-medium transition-colors ${
|
className={`flex-1 py-1.5 text-sm rounded-md font-medium transition-all ${
|
||||||
mode === 'password' ? 'bg-brand-500 text-white' : 'text-gray-500 hover:text-gray-700'
|
mode === 'password'
|
||||||
|
? 'bg-white shadow-sm text-gray-900 border border-gray-200'
|
||||||
|
: 'text-gray-500 hover:text-gray-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Passwort
|
Passwort
|
||||||
|
|
@ -98,8 +106,10 @@ export default function LoginPage() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setMode('magic')}
|
onClick={() => setMode('magic')}
|
||||||
className={`flex-1 py-1.5 text-sm rounded-md font-medium transition-colors ${
|
className={`flex-1 py-1.5 text-sm rounded-md font-medium transition-all ${
|
||||||
mode === 'magic' ? 'bg-brand-500 text-white' : 'text-gray-500 hover:text-gray-700'
|
mode === 'magic'
|
||||||
|
? 'bg-white shadow-sm text-gray-900 border border-gray-200'
|
||||||
|
: 'text-gray-500 hover:text-gray-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Magic Link
|
Magic Link
|
||||||
|
|
@ -108,7 +118,7 @@ export default function LoginPage() {
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<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
|
E-Mail-Adresse
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
|
|
@ -118,13 +128,13 @@ export default function LoginPage() {
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder="admin@ihre-innung.de"
|
placeholder="admin@ihre-innung.de"
|
||||||
className="w-full px-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>
|
</div>
|
||||||
|
|
||||||
{mode === 'password' && (
|
{mode === 'password' && (
|
||||||
<div>
|
<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
|
Passwort
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
|
|
@ -134,19 +144,19 @@ export default function LoginPage() {
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder="••••••••"
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
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
|
{loading
|
||||||
? 'Bitte warten...'
|
? 'Bitte warten...'
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
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 1–3 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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.' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,27 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { createAuthClient } from 'better-auth/react'
|
import { createAuthClient } from 'better-auth/react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter, usePathname } from 'next/navigation'
|
||||||
|
import { LogOut } from 'lucide-react'
|
||||||
|
|
||||||
const authClient = createAuthClient()
|
const authClient = createAuthClient()
|
||||||
|
|
||||||
|
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() {
|
export function Header() {
|
||||||
const router = useRouter()
|
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() {
|
async function handleSignOut() {
|
||||||
await authClient.signOut()
|
await authClient.signOut()
|
||||||
|
|
@ -15,12 +30,18 @@ export function Header() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="h-14 bg-white border-b flex items-center justify-between px-6 flex-shrink-0">
|
<header className="h-14 bg-white border-b flex items-center justify-between px-6 flex-shrink-0">
|
||||||
<div />
|
<h2
|
||||||
<div className="flex items-center gap-4">
|
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
|
<button
|
||||||
onClick={handleSignOut}
|
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
|
Abmelden
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,15 @@
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { usePathname } from 'next/navigation'
|
import { usePathname } from 'next/navigation'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
|
import { LayoutDashboard, Users, Newspaper, Calendar, GraduationCap, Settings } from 'lucide-react'
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: '/dashboard', label: 'Übersicht', icon: '🏠' },
|
{ href: '/dashboard', label: 'Übersicht', icon: LayoutDashboard },
|
||||||
{ href: '/dashboard/mitglieder', label: 'Mitglieder', icon: '👥' },
|
{ href: '/dashboard/mitglieder', label: 'Mitglieder', icon: Users },
|
||||||
{ href: '/dashboard/news', label: 'News', icon: '📰' },
|
{ href: '/dashboard/news', label: 'News', icon: Newspaper },
|
||||||
{ href: '/dashboard/termine', label: 'Termine', icon: '📅' },
|
{ href: '/dashboard/termine', label: 'Termine', icon: Calendar },
|
||||||
{ href: '/dashboard/stellen', label: 'Lehrlingsbörse', icon: '🎓' },
|
{ href: '/dashboard/stellen', label: 'Lehrlingsbörse', icon: GraduationCap },
|
||||||
{ href: '/dashboard/einstellungen', label: 'Einstellungen', icon: '⚙️' },
|
{ href: '/dashboard/einstellungen', label: 'Einstellungen', icon: Settings },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
|
|
@ -19,22 +20,25 @@ export function Sidebar() {
|
||||||
return (
|
return (
|
||||||
<aside className="w-64 bg-white border-r flex flex-col flex-shrink-0">
|
<aside className="w-64 bg-white border-r flex flex-col flex-shrink-0">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="p-6 border-b">
|
<div className="px-6 py-5 border-b">
|
||||||
<div className="flex items-center gap-3">
|
<Link href="/dashboard">
|
||||||
<div className="w-8 h-8 bg-brand-500 rounded-lg flex items-center justify-center">
|
<span
|
||||||
<span className="text-white font-bold text-sm">I</span>
|
className="text-xl font-bold text-gray-900 tracking-tight"
|
||||||
</div>
|
style={{ fontFamily: "'Syne', system-ui, sans-serif" }}
|
||||||
<span className="font-bold text-gray-900">InnungsApp</span>
|
>
|
||||||
</div>
|
Innungs<span className="text-brand-500">App</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="flex-1 p-4 space-y-1">
|
<nav className="flex-1 p-3 space-y-0.5">
|
||||||
{navItems.map((item) => {
|
{navItems.map((item) => {
|
||||||
const isActive =
|
const isActive =
|
||||||
item.href === '/dashboard'
|
item.href === '/dashboard'
|
||||||
? pathname === '/dashboard'
|
? pathname === '/dashboard'
|
||||||
: pathname.startsWith(item.href)
|
: pathname.startsWith(item.href)
|
||||||
|
const Icon = item.icon
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -42,17 +46,12 @@ export function Sidebar() {
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className={clsx('sidebar-link', isActive && 'sidebar-link-active')}
|
className={clsx('sidebar-link', isActive && 'sidebar-link-active')}
|
||||||
>
|
>
|
||||||
<span>{item.icon}</span>
|
<Icon size={16} className="flex-shrink-0" />
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="p-4 border-t">
|
|
||||||
<p className="text-xs text-gray-400">InnungsApp v0.1.0</p>
|
|
||||||
</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,35 @@
|
||||||
|
import { Users, Newspaper, Calendar, GraduationCap, type LucideIcon } from 'lucide-react'
|
||||||
|
|
||||||
interface Stat {
|
interface Stat {
|
||||||
label: string
|
label: string
|
||||||
value: number
|
value: number
|
||||||
icon: string
|
icon: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ICON_MAP: Record<string, LucideIcon> = {
|
||||||
|
'👥': Users,
|
||||||
|
'📰': Newspaper,
|
||||||
|
'📅': Calendar,
|
||||||
|
'🎓': GraduationCap,
|
||||||
|
}
|
||||||
|
|
||||||
export function StatsCards({ stats }: { stats: Stat[] }) {
|
export function StatsCards({ stats }: { stats: Stat[] }) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
{stats.map((stat) => (
|
{stats.map((stat) => {
|
||||||
<div key={stat.label} className="stat-card">
|
const Icon = ICON_MAP[stat.icon] ?? Users
|
||||||
<div className="text-2xl mb-2">{stat.icon}</div>
|
return (
|
||||||
<div className="text-3xl font-bold text-gray-900">{stat.value}</div>
|
<div key={stat.label} className="stat-card flex flex-col gap-3">
|
||||||
<div className="text-sm text-gray-500 mt-1">{stat.label}</div>
|
<div className="flex items-start justify-between">
|
||||||
</div>
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,9 @@ export const auth = betterAuth({
|
||||||
trustedOrigins: [
|
trustedOrigins: [
|
||||||
process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3032',
|
process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3032',
|
||||||
process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3032',
|
process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3032',
|
||||||
'http://10.36.148.233:3032',
|
'http://192.168.178.115:3032',
|
||||||
'http://localhost:8081', // Expo dev client
|
'http://localhost:8081', // Expo dev client
|
||||||
'http://10.36.148.233:8081',
|
'http://192.168.178.115:8081',
|
||||||
],
|
],
|
||||||
plugins: [
|
plugins: [
|
||||||
magicLink({
|
magicLink({
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -2,9 +2,7 @@ import type { NextConfig } from 'next'
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
transpilePackages: ['@innungsapp/shared'],
|
transpilePackages: ['@innungsapp/shared'],
|
||||||
experimental: {
|
experimental: {},
|
||||||
typedRoutes: true,
|
|
||||||
},
|
|
||||||
// Serve uploaded files
|
// Serve uploaded files
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
return [
|
return [
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@
|
||||||
"@tanstack/react-query": "^5.59.0",
|
"@tanstack/react-query": "^5.59.0",
|
||||||
"better-auth": "^1.2.0",
|
"better-auth": "^1.2.0",
|
||||||
"next": "15.3.4",
|
"next": "15.3.4",
|
||||||
"react": "^18.3.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^18.3.0",
|
"react-dom": "^19.0.0",
|
||||||
"zod": "^3.23.0",
|
"zod": "^3.23.0",
|
||||||
"superjson": "^2.2.1",
|
"superjson": "^2.2.1",
|
||||||
"nodemailer": "^6.9.0",
|
"nodemailer": "^6.9.0",
|
||||||
|
|
@ -33,8 +33,8 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/react": "^18.3.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@types/nodemailer": "^6.4.17",
|
"@types/nodemailer": "^6.4.17",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
"autoprefixer": "^10.4.0",
|
"autoprefixer": "^10.4.0",
|
||||||
|
|
|
||||||
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
|
@ -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())
|
||||||
|
|
@ -37,10 +37,10 @@ export const membersRouter = router({
|
||||||
}),
|
}),
|
||||||
...(input.search && {
|
...(input.search && {
|
||||||
OR: [
|
OR: [
|
||||||
{ name: { contains: input.search, mode: 'insensitive' } },
|
{ name: { contains: input.search } },
|
||||||
{ betrieb: { contains: input.search, mode: 'insensitive' } },
|
{ betrieb: { contains: input.search } },
|
||||||
{ ort: { contains: input.search, mode: 'insensitive' } },
|
{ ort: { contains: input.search } },
|
||||||
{ sparte: { contains: input.search, mode: 'insensitive' } },
|
{ sparte: { contains: input.search } },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,23 @@ export const stellenRouter = router({
|
||||||
return { success: true }
|
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)
|
* Deactivate Stelle (admin moderation)
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { Config } from 'tailwindcss'
|
import type { Config } from 'tailwindcss'
|
||||||
|
import plugin from 'tailwindcss/plugin'
|
||||||
|
|
||||||
const config: Config = {
|
const config: Config = {
|
||||||
content: [
|
content: [
|
||||||
|
|
@ -25,9 +26,38 @@ const config: Config = {
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
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
|
export default config
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(python:*)",
|
||||||
|
"Bash(python3:*)",
|
||||||
|
"Bash(node -e:*)",
|
||||||
|
"Bash(pnpm --filter mobile add:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 70 B |
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 70 B |
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 70 B |
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 70 B |
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 70 B |
|
|
@ -37,11 +37,13 @@
|
||||||
"expo-web-browser": "~15.0.10",
|
"expo-web-browser": "~15.0.10",
|
||||||
"nativewind": "^4.1.0",
|
"nativewind": "^4.1.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
|
"react-dom": "^18.3.0",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-gesture-handler": "~2.28.0",
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
"react-native-reanimated": "~4.1.6",
|
"react-native-reanimated": "~4.1.6",
|
||||||
"react-native-safe-area-context": "5.6.2",
|
"react-native-safe-area-context": "5.6.2",
|
||||||
"react-native-screens": "~4.16.0",
|
"react-native-screens": "~4.16.0",
|
||||||
|
"react-native-web": "^0.21.2",
|
||||||
"superjson": "^2.2.1",
|
"superjson": "^2.2.1",
|
||||||
"tailwind-merge": "^2.5.0",
|
"tailwind-merge": "^2.5.0",
|
||||||
"zod": "^3.23.0",
|
"zod": "^3.23.0",
|
||||||
|
|
|
||||||
|
|
@ -22,5 +22,8 @@
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0",
|
"node": ">=20.0.0",
|
||||||
"pnpm": ">=9.0.0"
|
"pnpm": ">=9.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bcryptjs": "^3.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
})
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -7,6 +7,10 @@ settings:
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
|
dependencies:
|
||||||
|
bcryptjs:
|
||||||
|
specifier: ^3.0.3
|
||||||
|
version: 3.0.3
|
||||||
devDependencies:
|
devDependencies:
|
||||||
turbo:
|
turbo:
|
||||||
specifier: ^2.3.0
|
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)
|
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:
|
expo-router:
|
||||||
specifier: ~6.0.23
|
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:
|
expo-splash-screen:
|
||||||
specifier: ~31.0.13
|
specifier: ~31.0.13
|
||||||
version: 31.0.13(expo@54.0.33)
|
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)
|
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:
|
expo-system-ui:
|
||||||
specifier: ~6.0.9
|
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:
|
expo-web-browser:
|
||||||
specifier: ~15.0.10
|
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))
|
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:
|
react:
|
||||||
specifier: 19.1.0
|
specifier: 19.1.0
|
||||||
version: 19.1.0
|
version: 19.1.0
|
||||||
|
react-dom:
|
||||||
|
specifier: ^18.3.0
|
||||||
|
version: 18.3.1(react@19.1.0)
|
||||||
react-native:
|
react-native:
|
||||||
specifier: 0.81.5
|
specifier: 0.81.5
|
||||||
version: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
|
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:
|
react-native-screens:
|
||||||
specifier: ~4.16.0
|
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)
|
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:
|
superjson:
|
||||||
specifier: ^2.2.1
|
specifier: ^2.2.1
|
||||||
version: 2.2.6
|
version: 2.2.6
|
||||||
|
|
@ -1789,6 +1799,9 @@ packages:
|
||||||
resolution: {integrity: sha512-fB7M1CMOCIUudTRuj7kzxIBTVw2KXnsgbQ6+4cbqSxo8NmRRhA0Ul4ZUzZj3rFd3VznTL4Brmocv1oiN0bWZ8w==}
|
resolution: {integrity: sha512-fB7M1CMOCIUudTRuj7kzxIBTVw2KXnsgbQ6+4cbqSxo8NmRRhA0Ul4ZUzZj3rFd3VznTL4Brmocv1oiN0bWZ8w==}
|
||||||
engines: {node: '>= 20.19.4'}
|
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':
|
'@react-native/normalize-colors@0.81.5':
|
||||||
resolution: {integrity: sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g==}
|
resolution: {integrity: sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g==}
|
||||||
|
|
||||||
|
|
@ -2414,6 +2427,10 @@ packages:
|
||||||
bcp-47-match@2.0.3:
|
bcp-47-match@2.0.3:
|
||||||
resolution: {integrity: sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==}
|
resolution: {integrity: sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==}
|
||||||
|
|
||||||
|
bcryptjs@3.0.3:
|
||||||
|
resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
better-auth@1.4.18:
|
better-auth@1.4.18:
|
||||||
resolution: {integrity: sha512-bnyifLWBPcYVltH3RhS7CM62MoelEqC6Q+GnZwfiDWNfepXoQZBjEvn4urcERC7NTKgKq5zNBM8rvPvRBa6xcg==}
|
resolution: {integrity: sha512-bnyifLWBPcYVltH3RhS7CM62MoelEqC6Q+GnZwfiDWNfepXoQZBjEvn4urcERC7NTKgKq5zNBM8rvPvRBa6xcg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -2716,6 +2733,9 @@ packages:
|
||||||
core-util-is@1.0.3:
|
core-util-is@1.0.3:
|
||||||
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||||
|
|
||||||
|
cross-fetch@3.2.0:
|
||||||
|
resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==}
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
@ -2724,6 +2744,9 @@ packages:
|
||||||
resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==}
|
resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
css-in-js-utils@3.1.0:
|
||||||
|
resolution: {integrity: sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==}
|
||||||
|
|
||||||
css-selector-parser@3.3.0:
|
css-selector-parser@3.3.0:
|
||||||
resolution: {integrity: sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g==}
|
resolution: {integrity: sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g==}
|
||||||
|
|
||||||
|
|
@ -3328,6 +3351,12 @@ packages:
|
||||||
fb-watchman@2.0.2:
|
fb-watchman@2.0.2:
|
||||||
resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==}
|
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:
|
fdir@6.5.0:
|
||||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
@ -3600,6 +3629,9 @@ packages:
|
||||||
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||||
engines: {node: '>= 14'}
|
engines: {node: '>= 14'}
|
||||||
|
|
||||||
|
hyphenate-style-name@1.1.0:
|
||||||
|
resolution: {integrity: sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==}
|
||||||
|
|
||||||
ieee754@1.2.1:
|
ieee754@1.2.1:
|
||||||
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
||||||
|
|
||||||
|
|
@ -3637,6 +3669,9 @@ packages:
|
||||||
inline-style-parser@0.2.7:
|
inline-style-parser@0.2.7:
|
||||||
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
|
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
|
||||||
|
|
||||||
|
inline-style-prefixer@7.0.1:
|
||||||
|
resolution: {integrity: sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw==}
|
||||||
|
|
||||||
internal-slot@1.1.0:
|
internal-slot@1.1.0:
|
||||||
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
@ -4185,6 +4220,9 @@ packages:
|
||||||
memoize-one@5.2.1:
|
memoize-one@5.2.1:
|
||||||
resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==}
|
resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==}
|
||||||
|
|
||||||
|
memoize-one@6.0.0:
|
||||||
|
resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==}
|
||||||
|
|
||||||
merge-options@3.0.4:
|
merge-options@3.0.4:
|
||||||
resolution: {integrity: sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==}
|
resolution: {integrity: sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
@ -4480,6 +4518,15 @@ packages:
|
||||||
resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==}
|
resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==}
|
||||||
engines: {node: '>= 0.4'}
|
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:
|
node-forge@1.3.3:
|
||||||
resolution: {integrity: sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==}
|
resolution: {integrity: sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==}
|
||||||
engines: {node: '>= 6.13.0'}
|
engines: {node: '>= 6.13.0'}
|
||||||
|
|
@ -4766,6 +4813,9 @@ packages:
|
||||||
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
|
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
|
||||||
engines: {node: '>=0.4.0'}
|
engines: {node: '>=0.4.0'}
|
||||||
|
|
||||||
|
promise@7.3.1:
|
||||||
|
resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==}
|
||||||
|
|
||||||
promise@8.3.0:
|
promise@8.3.0:
|
||||||
resolution: {integrity: sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==}
|
resolution: {integrity: sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==}
|
||||||
|
|
||||||
|
|
@ -4888,6 +4938,12 @@ packages:
|
||||||
react: '*'
|
react: '*'
|
||||||
react-native: '*'
|
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:
|
react-native-worklets@0.7.4:
|
||||||
resolution: {integrity: sha512-NYOdM1MwBb3n+AtMqy1tFy3Mn8DliQtd8sbzAVRf9Gc+uvQ0zRfxN7dS8ZzoyX7t6cyQL5THuGhlnX+iFlQTag==}
|
resolution: {integrity: sha512-NYOdM1MwBb3n+AtMqy1tFy3Mn8DliQtd8sbzAVRf9Gc+uvQ0zRfxN7dS8ZzoyX7t6cyQL5THuGhlnX+iFlQTag==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -5187,6 +5243,9 @@ packages:
|
||||||
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
|
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
setimmediate@1.0.5:
|
||||||
|
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
|
||||||
|
|
||||||
setprototypeof@1.2.0:
|
setprototypeof@1.2.0:
|
||||||
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
||||||
|
|
||||||
|
|
@ -5384,6 +5443,9 @@ packages:
|
||||||
babel-plugin-macros:
|
babel-plugin-macros:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
styleq@0.1.3:
|
||||||
|
resolution: {integrity: sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA==}
|
||||||
|
|
||||||
sucrase@3.35.1:
|
sucrase@3.35.1:
|
||||||
resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==}
|
resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==}
|
||||||
engines: {node: '>=16 || 14 >=14.17'}
|
engines: {node: '>=16 || 14 >=14.17'}
|
||||||
|
|
@ -5467,6 +5529,9 @@ packages:
|
||||||
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
|
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
|
||||||
engines: {node: '>=0.6'}
|
engines: {node: '>=0.6'}
|
||||||
|
|
||||||
|
tr46@0.0.3:
|
||||||
|
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
|
||||||
|
|
||||||
trim-lines@3.0.1:
|
trim-lines@3.0.1:
|
||||||
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
|
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
|
||||||
|
|
||||||
|
|
@ -5564,6 +5629,10 @@ packages:
|
||||||
engines: {node: '>=14.17'}
|
engines: {node: '>=14.17'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
ua-parser-js@1.0.41:
|
||||||
|
resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
unbox-primitive@1.1.0:
|
unbox-primitive@1.1.0:
|
||||||
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
|
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
@ -5717,6 +5786,9 @@ packages:
|
||||||
web-namespaces@2.0.1:
|
web-namespaces@2.0.1:
|
||||||
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
|
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
|
||||||
|
|
||||||
|
webidl-conversions@3.0.1:
|
||||||
|
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
|
||||||
|
|
||||||
webidl-conversions@5.0.0:
|
webidl-conversions@5.0.0:
|
||||||
resolution: {integrity: sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==}
|
resolution: {integrity: sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
@ -5728,6 +5800,9 @@ packages:
|
||||||
resolution: {integrity: sha512-HoKuzZrUlgpz35YO27XgD28uh/WJH4B0+3ttFqRo//lmq+9T/mIOJ6kqmINI9HpUpz1imRC/nR/lxKpJiv0uig==}
|
resolution: {integrity: sha512-HoKuzZrUlgpz35YO27XgD28uh/WJH4B0+3ttFqRo//lmq+9T/mIOJ6kqmINI9HpUpz1imRC/nR/lxKpJiv0uig==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
whatwg-url@5.0.0:
|
||||||
|
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
|
||||||
|
|
||||||
which-boxed-primitive@1.1.1:
|
which-boxed-primitive@1.1.1:
|
||||||
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
|
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
@ -6765,7 +6840,7 @@ snapshots:
|
||||||
wrap-ansi: 7.0.0
|
wrap-ansi: 7.0.0
|
||||||
ws: 8.19.0
|
ws: 8.19.0
|
||||||
optionalDependencies:
|
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)
|
react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- bufferutil
|
- bufferutil
|
||||||
|
|
@ -7621,6 +7696,8 @@ snapshots:
|
||||||
|
|
||||||
'@react-native/js-polyfills@0.81.5': {}
|
'@react-native/js-polyfills@0.81.5': {}
|
||||||
|
|
||||||
|
'@react-native/normalize-colors@0.74.89': {}
|
||||||
|
|
||||||
'@react-native/normalize-colors@0.81.5': {}
|
'@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)':
|
'@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: {}
|
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):
|
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:
|
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)
|
'@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: {}
|
core-util-is@1.0.3: {}
|
||||||
|
|
||||||
|
cross-fetch@3.2.0:
|
||||||
|
dependencies:
|
||||||
|
node-fetch: 2.7.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- encoding
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
path-key: 3.1.1
|
path-key: 3.1.1
|
||||||
|
|
@ -8686,6 +8771,10 @@ snapshots:
|
||||||
|
|
||||||
crypto-random-string@2.0.0: {}
|
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: {}
|
css-selector-parser@3.3.0: {}
|
||||||
|
|
||||||
cssesc@3.0.0: {}
|
cssesc@3.0.0: {}
|
||||||
|
|
@ -9323,7 +9412,7 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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:
|
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/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
|
'@expo/schema-utils': 0.1.8
|
||||||
|
|
@ -9359,6 +9448,7 @@ snapshots:
|
||||||
react-dom: 18.3.1(react@19.1.0)
|
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-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-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:
|
transitivePeerDependencies:
|
||||||
- '@react-native-masked-view/masked-view'
|
- '@react-native-masked-view/masked-view'
|
||||||
- '@types/react'
|
- '@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: 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)
|
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:
|
dependencies:
|
||||||
'@react-native/normalize-colors': 0.81.5
|
'@react-native/normalize-colors': 0.81.5
|
||||||
debug: 4.4.3
|
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)
|
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)
|
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:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
|
@ -9467,6 +9559,20 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
bser: 2.1.1
|
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):
|
fdir@6.5.0(picomatch@4.0.3):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
picomatch: 4.0.3
|
picomatch: 4.0.3
|
||||||
|
|
@ -9829,6 +9935,8 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
hyphenate-style-name@1.1.0: {}
|
||||||
|
|
||||||
ieee754@1.2.1: {}
|
ieee754@1.2.1: {}
|
||||||
|
|
||||||
ignore@5.3.2: {}
|
ignore@5.3.2: {}
|
||||||
|
|
@ -9857,6 +9965,10 @@ snapshots:
|
||||||
|
|
||||||
inline-style-parser@0.2.7: {}
|
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:
|
internal-slot@1.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
|
|
@ -10488,6 +10600,8 @@ snapshots:
|
||||||
|
|
||||||
memoize-one@5.2.1: {}
|
memoize-one@5.2.1: {}
|
||||||
|
|
||||||
|
memoize-one@6.0.0: {}
|
||||||
|
|
||||||
merge-options@3.0.4:
|
merge-options@3.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-plain-obj: 2.1.0
|
is-plain-obj: 2.1.0
|
||||||
|
|
@ -10997,6 +11111,10 @@ snapshots:
|
||||||
object.entries: 1.1.9
|
object.entries: 1.1.9
|
||||||
semver: 6.3.1
|
semver: 6.3.1
|
||||||
|
|
||||||
|
node-fetch@2.7.0:
|
||||||
|
dependencies:
|
||||||
|
whatwg-url: 5.0.0
|
||||||
|
|
||||||
node-forge@1.3.3: {}
|
node-forge@1.3.3: {}
|
||||||
|
|
||||||
node-int64@0.4.0: {}
|
node-int64@0.4.0: {}
|
||||||
|
|
@ -11276,6 +11394,10 @@ snapshots:
|
||||||
|
|
||||||
progress@2.0.3: {}
|
progress@2.0.3: {}
|
||||||
|
|
||||||
|
promise@7.3.1:
|
||||||
|
dependencies:
|
||||||
|
asap: 2.0.6
|
||||||
|
|
||||||
promise@8.3.0:
|
promise@8.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
asap: 2.0.6
|
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)
|
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
|
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):
|
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:
|
dependencies:
|
||||||
'@babel/core': 7.29.0
|
'@babel/core': 7.29.0
|
||||||
|
|
@ -11857,6 +11994,8 @@ snapshots:
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
es-object-atoms: 1.1.1
|
es-object-atoms: 1.1.1
|
||||||
|
|
||||||
|
setimmediate@1.0.5: {}
|
||||||
|
|
||||||
setprototypeof@1.2.0: {}
|
setprototypeof@1.2.0: {}
|
||||||
|
|
||||||
sf-symbols-typescript@2.2.0: {}
|
sf-symbols-typescript@2.2.0: {}
|
||||||
|
|
@ -12091,6 +12230,8 @@ snapshots:
|
||||||
client-only: 0.0.1
|
client-only: 0.0.1
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
|
|
||||||
|
styleq@0.1.3: {}
|
||||||
|
|
||||||
sucrase@3.35.1:
|
sucrase@3.35.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/gen-mapping': 0.3.13
|
'@jridgewell/gen-mapping': 0.3.13
|
||||||
|
|
@ -12205,6 +12346,8 @@ snapshots:
|
||||||
|
|
||||||
toidentifier@1.0.1: {}
|
toidentifier@1.0.1: {}
|
||||||
|
|
||||||
|
tr46@0.0.3: {}
|
||||||
|
|
||||||
trim-lines@3.0.1: {}
|
trim-lines@3.0.1: {}
|
||||||
|
|
||||||
trough@2.2.0: {}
|
trough@2.2.0: {}
|
||||||
|
|
@ -12303,6 +12446,8 @@ snapshots:
|
||||||
|
|
||||||
typescript@5.9.3: {}
|
typescript@5.9.3: {}
|
||||||
|
|
||||||
|
ua-parser-js@1.0.41: {}
|
||||||
|
|
||||||
unbox-primitive@1.1.0:
|
unbox-primitive@1.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bound: 1.0.4
|
call-bound: 1.0.4
|
||||||
|
|
@ -12489,6 +12634,8 @@ snapshots:
|
||||||
|
|
||||||
web-namespaces@2.0.1: {}
|
web-namespaces@2.0.1: {}
|
||||||
|
|
||||||
|
webidl-conversions@3.0.1: {}
|
||||||
|
|
||||||
webidl-conversions@5.0.0: {}
|
webidl-conversions@5.0.0: {}
|
||||||
|
|
||||||
whatwg-fetch@3.6.20: {}
|
whatwg-fetch@3.6.20: {}
|
||||||
|
|
@ -12499,6 +12646,11 @@ snapshots:
|
||||||
punycode: 2.3.1
|
punycode: 2.3.1
|
||||||
webidl-conversions: 5.0.0
|
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:
|
which-boxed-primitive@1.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-bigint: 1.1.0
|
is-bigint: 1.1.0
|
||||||
|
|
|
||||||