feat: Implement initial admin and mobile application UI, including styling, layouts, authentication, and legal page components.

This commit is contained in:
Timo Knuth 2026-03-03 16:54:11 +01:00
parent 59f3efaaed
commit b7d826e29c
15 changed files with 265 additions and 33 deletions

View File

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

View File

@ -3,7 +3,7 @@
import { useEffect, useState, type ReactNode } from 'react' import { useEffect, useState, type ReactNode } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { Syne } from 'next/font/google' import { Syne } from 'next/font/google'
import { Moon, Sun } from 'lucide-react' import { Moon, Sun, ArrowLeft } from 'lucide-react'
const syne = Syne({ subsets: ['latin'], weight: ['400', '500', '600', '700', '800'] }) const syne = Syne({ subsets: ['latin'], weight: ['400', '500', '600', '700', '800'] })
@ -141,6 +141,19 @@ export default function LegalPageShell({ title, subtitle, children }: LegalPageS
.theme-btn:hover { color: var(--ink); } .theme-btn:hover { color: var(--ink); }
.back-link {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--ink-muted);
text-decoration: none;
font-size: 0.875rem;
margin-bottom: 24px;
transition: color 0.15s;
}
.back-link:hover { color: var(--ink); }
.main-wrap { .main-wrap {
max-width: 980px; max-width: 980px;
margin: 0 auto; margin: 0 auto;
@ -310,6 +323,10 @@ export default function LegalPageShell({ title, subtitle, children }: LegalPageS
</nav> </nav>
<main className="main-wrap"> <main className="main-wrap">
<Link href="/" className="back-link">
<ArrowLeft size={16} />
Zurück zur Startseite
</Link>
<div className="eyebrow">Rechtliches</div> <div className="eyebrow">Rechtliches</div>
<h1 className="page-title">{title}</h1> <h1 className="page-title">{title}</h1>
<p className="page-subtitle">{subtitle}</p> <p className="page-subtitle">{subtitle}</p>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 407 KiB

View File

@ -21,7 +21,7 @@ export async function generateMetadata(): Promise<Metadata> {
const description = org const description = org
? `Willkommen im offiziellen Portal der ${org.name}.` ? `Willkommen im offiziellen Portal der ${org.name}.`
: 'Digitale Mitgliederverwaltung, Push-News und Lehrlingsboerse fuer Handwerksinnungen.' : 'Digitale Mitgliederverwaltung, Push-News und Lehrlingsboerse fuer Handwerksinnungen.'
const icon = org?.logoUrl || '/favicon.ico' const icon = org?.logoUrl || '/logo.png'
return { return {
title, title,

View File

@ -13,7 +13,7 @@ export default async function LoginPage() {
}) })
} }
const primaryColor = org?.primaryColor || '#E63946' const primaryColor = org?.primaryColor || '#C99738'
const orgName = org?.name || 'InnungsApp' const orgName = org?.name || 'InnungsApp'
const logoUrl = org?.logoUrl || '/logo.png' const logoUrl = org?.logoUrl || '/logo.png'
const secondaryText = org ? `Verwaltungsportal für die ${org.name}` : 'Verwaltungsportal für Innungen' const secondaryText = org ? `Verwaltungsportal für die ${org.name}` : 'Verwaltungsportal für Innungen'

View File

@ -284,12 +284,12 @@ export default function RootPage() {
color: var(--gold); margin-bottom: 32px; font-weight: 500; color: var(--gold); margin-bottom: 32px; font-weight: 500;
} }
.hero-h1 { .hero-h1 {
font-weight: 800; font-size: clamp(1.75rem, 5vw, 7.5rem); font-weight: 800; font-size: clamp(1.5rem, 8vw, 7.5rem);
line-height: 0.92; letter-spacing: -0.04em; line-height: 0.92; letter-spacing: -0.04em;
margin: 0 0 48px; color: var(--ink); margin: 0 0 48px; color: var(--ink);
word-wrap: break-word; }
overflow-wrap: break-word; @media (max-width: 767px) {
hyphens: auto; .hero-h1 { word-wrap: break-word; overflow-wrap: break-word; hyphens: auto; }
} }
.hero-h1 em { color: var(--gold); font-style: normal; } .hero-h1 em { color: var(--gold); font-style: normal; }
@ -376,12 +376,12 @@ export default function RootPage() {
.features-sticky { position: sticky; top: 88px; } .features-sticky { position: sticky; top: 88px; }
} }
.features-h2 { .features-h2 {
font-weight: 800; font-size: clamp(1.5rem, 5vw, 4rem); font-weight: 800; font-size: clamp(1.5rem, 7vw, 4rem);
letter-spacing: -0.04em; line-height: 1.0; letter-spacing: -0.04em; line-height: 1.0;
margin: 24px 0 28px; color: var(--ink); margin: 24px 0 28px; color: var(--ink);
word-wrap: break-word; }
overflow-wrap: break-word; @media (max-width: 767px) {
hyphens: auto; .features-h2 { word-wrap: break-word; overflow-wrap: break-word; hyphens: auto; }
} }
.features-sub { color: var(--ink-muted); font-family: 'Georgia', serif; line-height: 1.65; font-size: 0.9375rem; } .features-sub { color: var(--ink-muted); font-family: 'Georgia', serif; line-height: 1.65; font-size: 0.9375rem; }
@ -468,9 +468,9 @@ export default function RootPage() {
.aeo-inner { max-width: 800px; margin: 0 auto; padding: 0 32px; } .aeo-inner { max-width: 800px; margin: 0 auto; padding: 0 32px; }
.aeo-text { .aeo-text {
color: var(--ink-muted); font-size: 1rem; line-height: 1.8; font-family: 'Georgia', serif; color: var(--ink-muted); font-size: 1rem; line-height: 1.8; font-family: 'Georgia', serif;
word-wrap: break-word; }
overflow-wrap: break-word; @media (max-width: 767px) {
hyphens: auto; .aeo-text { word-wrap: break-word; overflow-wrap: break-word; hyphens: auto; }
} }
.aeo-text p { margin-bottom: 24px; } .aeo-text p { margin-bottom: 24px; }
.aeo-text strong { color: var(--ink); font-weight: 600; } .aeo-text strong { color: var(--ink); font-weight: 600; }
@ -512,11 +512,11 @@ export default function RootPage() {
.cta-inner { flex-direction: row; align-items: flex-end; justify-content: space-between; } .cta-inner { flex-direction: row; align-items: flex-end; justify-content: space-between; }
} }
.cta-h2 { .cta-h2 {
font-weight: 800; font-size: clamp(1.5rem, 5vw, 4.5rem); font-weight: 800; font-size: clamp(1.25rem, 6.5vw, 4.5rem);
letter-spacing: -0.04em; line-height: 1.0; color: var(--ink); letter-spacing: -0.04em; line-height: 1.0; color: var(--ink);
word-wrap: break-word; }
overflow-wrap: break-word; @media (max-width: 767px) {
hyphens: auto; .cta-h2 { word-wrap: break-word; overflow-wrap: break-word; hyphens: auto; }
} }
.cta-h2 em { color: var(--gold); font-style: normal; } .cta-h2 em { color: var(--gold); font-style: normal; }
.cta-right { display: flex; flex-direction: column; gap: 10px; flex-shrink: 0; } .cta-right { display: flex; flex-direction: column; gap: 10px; flex-shrink: 0; }
@ -956,9 +956,13 @@ export default function RootPage() {
{/* AEO / GEO Content Block */} {/* AEO / GEO Content Block */}
<section className="aeo-section"> <section className="aeo-section">
<div className="aeo-inner"> <div className="aeo-inner" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center' }}>
<div className="section-marker">Über unsere Plattform</div> <div className="section-marker" style={{ justifyContent: 'center' }}>Ü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> <h2 className="cta-h2" style={{ marginBottom: '32px', fontSize: 'clamp(1.25rem, 4vw, 2.5rem)', lineHeight: 1.2 }}>
<span style={{ display: 'block' }}>Die smarte Lösung</span>
<span style={{ display: 'block' }}>für moderne</span>
<span style={{ display: 'block' }}>Handwerksorganisationen</span>
</h2>
<div className="aeo-text"> <div className="aeo-text">
<p> <p>
<strong>InnungsApp PRO</strong> ist eine cloudbasierte Verwaltungssoftware, die speziell auf die Bedürfnisse von <strong>Handwerksinnungen in Deutschland</strong> zugeschnitten ist. Die Plattform bündelt Mitgliederverwaltung, Kommunikation und Eventmanagement in einer zentralen Oberfläche. <strong>InnungsApp PRO</strong> ist eine cloudbasierte Verwaltungssoftware, die speziell auf die Bedürfnisse von <strong>Handwerksinnungen in Deutschland</strong> zugeschnitten ist. Die Plattform bündelt Mitgliederverwaltung, Kommunikation und Eventmanagement in einer zentralen Oberfläche.

View File

@ -14,7 +14,7 @@ interface LoginFormProps {
primaryColor?: string primaryColor?: string
} }
export function LoginForm({ primaryColor = '#E63946' }: LoginFormProps) { export function LoginForm({ primaryColor = '#C99738' }: LoginFormProps) {
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

View File

@ -11,16 +11,16 @@ const config: Config = {
extend: { extend: {
colors: { colors: {
brand: { brand: {
50: '#fff1f1', 50: '#fdf8f0',
100: '#ffe1e1', 100: '#f9edd7',
200: '#ffC7c7', 200: '#f1d6b0',
300: '#ffa0a0', 300: '#e6ba83',
400: '#ff6b6b', 400: '#d99d58',
500: 'var(--color-brand-primary, #E63946)', 500: 'var(--color-brand-primary, #C99738)',
600: 'var(--color-brand-hover, #d42535)', 600: 'var(--color-brand-hover, #A6752C)',
700: '#b21e2c', 700: '#8c5d26',
800: '#931d29', 800: '#734c24',
900: '#7a1e27', 900: '#5e3f21',
}, },
}, },
fontFamily: { fontFamily: {

View File

@ -4,6 +4,8 @@ import { Stack, SplashScreen } from 'expo-router'
import { useAuthStore } from '@/store/auth.store' import { useAuthStore } from '@/store/auth.store'
import { TRPCProvider } from '@/lib/trpc' import { TRPCProvider } from '@/lib/trpc'
import { LoadingScreen } from '@/components/ui/LoadingScreen'
SplashScreen.preventAutoHideAsync() SplashScreen.preventAutoHideAsync()
export default function RootLayout() { export default function RootLayout() {
@ -14,7 +16,7 @@ export default function RootLayout() {
initAuth().finally(() => SplashScreen.hideAsync()) initAuth().finally(() => SplashScreen.hideAsync())
}, [initAuth]) }, [initAuth])
if (!isInitialized) return null if (!isInitialized) return <LoadingScreen />
return ( return (
<TRPCProvider> <TRPCProvider>

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 KiB

View File

@ -0,0 +1,140 @@
import React, { useEffect, useRef } from 'react'
import {
View,
Text,
StyleSheet,
ActivityIndicator,
Animated,
ImageBackground,
Dimensions,
} from 'react-native'
const { width, height } = Dimensions.get('window')
export function LoadingScreen() {
const fadeAnim = useRef(new Animated.Value(0)).current
const scaleAnim = useRef(new Animated.Value(0.95)).current
useEffect(() => {
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 1,
duration: 800,
useNativeDriver: true,
}),
Animated.spring(scaleAnim, {
toValue: 1,
friction: 8,
tension: 40,
useNativeDriver: true,
}),
]).start()
}, [])
return (
<View style={styles.container}>
<ImageBackground
source={require('../../assets/loading_bg.png')}
style={styles.backgroundImage}
resizeMode="cover"
>
<Animated.View
style={[
styles.content,
{
opacity: fadeAnim,
transform: [{ scale: scaleAnim }],
},
]}
>
{/* Card with rounded corners and semi-transparent background */}
<View style={styles.glassCard}>
<View style={styles.logoContainer}>
<View style={styles.logoBox}>
<Text style={styles.logoLetter}>I</Text>
</View>
<Text style={styles.appName}>InnungsApp</Text>
</View>
<View style={styles.loaderContainer}>
<ActivityIndicator size="large" color="#003B7E" />
<Text style={styles.loadingText}>Wird geladen...</Text>
</View>
</View>
</Animated.View>
</ImageBackground>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#003B7E',
},
backgroundImage: {
flex: 1,
width: width,
height: height,
justifyContent: 'center',
alignItems: 'center',
},
content: {
width: '80%',
maxWidth: 320,
alignItems: 'center',
},
glassCard: {
width: '100%',
padding: 32,
borderRadius: 32, // Rounded corners as requested
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.4)',
backgroundColor: 'rgba(255, 255, 255, 0.95)', // Solid-ish background for clean look without blur
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.1,
shadowRadius: 20,
elevation: 10,
},
logoContainer: {
alignItems: 'center',
marginBottom: 24,
},
logoBox: {
width: 64,
height: 64,
backgroundColor: '#003B7E',
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 5,
},
logoLetter: {
color: '#FFFFFF',
fontSize: 32,
fontWeight: '900',
},
appName: {
fontSize: 24,
fontWeight: '800',
color: '#0F172A',
letterSpacing: -0.5,
},
loaderContainer: {
alignItems: 'center',
gap: 12,
},
loadingText: {
fontSize: 14,
color: '#64748B',
fontWeight: '600',
marginTop: 8,
},
})

BIN
innungsapp/test.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB