Rebuild as InnungsApp project: replace stadtwerke analysis with full documentation

- PRD: vollständige Produktspezifikation (5 Module, Scope, Akzeptanzkriterien)
- ARCHITECTURE: Tech Stack, Ordnerstruktur, Multi-Tenancy, Push, Kosten
- DATABASE_SCHEMA: Vollständiges SQL-Schema mit RLS Policies und Views
- USER_STORIES: 40+ Stories nach Rolle (Admin, Mitglied, Azubi, Obermeister)
- PERSONAS: 5 detaillierte Nutzerprofile mit Alltag, Zitaten und Erwartungen
- BUSINESS_MODEL: Preistabellen, Unit Economics, Revenue-Projektionen, Distribution
- ROADMAP: 6 Phasen, Sprint-Planung, Meilensteine und KPIs
- COMPETITIVE_ANALYSIS: Wettbewerbsmatrix, USPs, Preispositionierung
- API_DESIGN: Supabase Query Patterns, Edge Functions, Realtime Subscriptions
- ONBOARDING_FLOWS: 7 User Flows von Setup bis Fehlerfall
- GTM_STRATEGY: 3-Phasen-Vertrieb, Outreach-Sequenz, Einwandbehandlung
- AZUBI_MODULE: Video-Feed, 1-Click-Apply, Chat, Berichtsheft, Quiz
- DSGVO_KONZEPT: Rechtsgrundlagen, TOMs, AVV, Minderjährige, Incident Response
- FEATURES_BACKLOG: 72 Features nach MoSCoW + Technische Schulden

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Timo Knuth
2026-02-18 19:03:37 +01:00
parent fc68285cf1
commit fca42db4d2
116 changed files with 9329 additions and 6479 deletions

View File

@@ -0,0 +1,59 @@
{
"expo": {
"name": "InnungsApp",
"slug": "innungsapp",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"scheme": "innungsapp",
"userInterfaceStyle": "automatic",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#E63946"
},
"ios": {
"supportsTablet": false,
"bundleIdentifier": "de.innungsapp.mobile",
"infoPlist": {
"NSCameraUsageDescription": "Für Profilfotos",
"NSCalendarsUsageDescription": "Termine in Ihren Kalender übernehmen"
}
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#E63946"
},
"package": "de.innungsapp.mobile",
"permissions": ["RECEIVE_BOOT_COMPLETED", "SCHEDULE_EXACT_ALARM"]
},
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-router",
"expo-font",
"expo-system-ui",
[
"expo-notifications",
{
"icon": "./assets/notification-icon.png",
"color": "#E63946",
"sounds": []
}
],
[
"expo-calendar",
{
"calendarPermission": "Die App benötigt Zugriff auf Ihren Kalender."
}
]
],
"experiments": {
"typedRoutes": true
}
}
}

View File

@@ -0,0 +1,78 @@
import { Tabs } from 'expo-router'
import { useAuthStore } from '@/store/auth.store'
import { Redirect } from 'expo-router'
import { Platform } from 'react-native'
function TabIcon({ emoji }: { emoji: string }) {
return null // Replaced by tabBarIcon in options
}
export default function AppLayout() {
const session = useAuthStore((s) => s.session)
if (!session) {
return <Redirect href="/(auth)/login" />
}
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: '#E63946',
tabBarInactiveTintColor: '#6b7280',
tabBarStyle: {
borderTopColor: '#e5e7eb',
backgroundColor: 'white',
paddingBottom: Platform.OS === 'ios' ? 8 : 4,
height: Platform.OS === 'ios' ? 82 : 60,
},
headerStyle: { backgroundColor: 'white' },
headerTitleStyle: { fontWeight: '700', color: '#111827' },
headerShadowVisible: false,
}}
>
<Tabs.Screen
name="news"
options={{
title: 'News',
tabBarIcon: ({ color }) => (
/* Replace with actual icons after @expo/vector-icons setup */
<TabIcon emoji="📰" />
),
headerShown: false,
}}
/>
<Tabs.Screen
name="members"
options={{
title: 'Mitglieder',
tabBarIcon: () => <TabIcon emoji="👥" />,
headerShown: false,
}}
/>
<Tabs.Screen
name="termine"
options={{
title: 'Termine',
tabBarIcon: () => <TabIcon emoji="📅" />,
headerShown: false,
}}
/>
<Tabs.Screen
name="stellen"
options={{
title: 'Stellen',
tabBarIcon: () => <TabIcon emoji="🎓" />,
headerShown: false,
}}
/>
<Tabs.Screen
name="profil"
options={{
title: 'Profil',
tabBarIcon: () => <TabIcon emoji="👤" />,
headerShown: false,
}}
/>
</Tabs>
)
}

View File

@@ -0,0 +1,107 @@
import {
View,
Text,
ScrollView,
TouchableOpacity,
Linking,
ActivityIndicator,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { trpc } from '@/lib/trpc'
import { Avatar } from '@/components/ui/Avatar'
export default function MemberDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>()
const router = useRouter()
const { data: member, isLoading } = trpc.members.byId.useQuery({ id })
if (isLoading) {
return (
<SafeAreaView className="flex-1 bg-white items-center justify-center">
<ActivityIndicator size="large" color="#E63946" />
</SafeAreaView>
)
}
if (!member) return null
return (
<SafeAreaView className="flex-1 bg-gray-50" edges={['top']}>
{/* Header */}
<View className="flex-row items-center px-4 py-3 bg-white border-b border-gray-100">
<TouchableOpacity onPress={() => router.back()} className="mr-3">
<Text className="text-brand-500 text-base"> Zurück</Text>
</TouchableOpacity>
</View>
<ScrollView>
{/* Profile Header */}
<View className="bg-white px-6 py-8 items-center border-b border-gray-100">
<Avatar
name={member.name}
imageUrl={member.avatarUrl ?? undefined}
size={80}
/>
<Text className="text-2xl font-bold text-gray-900 mt-4">{member.name}</Text>
<Text className="text-gray-500 mt-1">{member.betrieb}</Text>
{member.istAusbildungsbetrieb && (
<View className="mt-2 bg-green-100 px-3 py-1 rounded-full">
<Text className="text-green-700 text-xs font-medium">
🎓 Ausbildungsbetrieb
</Text>
</View>
)}
</View>
{/* Details */}
<View className="bg-white mx-4 mt-4 rounded-2xl overflow-hidden border border-gray-100">
<InfoRow label="Sparte" value={member.sparte} />
<InfoRow label="Ort" value={member.ort} />
{member.seit && (
<InfoRow label="Mitglied seit" value={String(member.seit)} />
)}
</View>
{/* Contact Buttons */}
<View className="mx-4 mt-4 gap-3">
{member.telefon && (
<TouchableOpacity
onPress={() => Linking.openURL(`tel:${member.telefon}`)}
className="bg-brand-500 rounded-2xl py-4 flex-row items-center justify-center gap-2"
>
<Text className="text-white text-xl">📞</Text>
<Text className="text-white font-semibold text-base">
Anrufen
</Text>
</TouchableOpacity>
)}
<TouchableOpacity
onPress={() =>
Linking.openURL(
`mailto:${member.email}?subject=InnungsApp%20Anfrage`
)
}
className="bg-white border border-gray-200 rounded-2xl py-4 flex-row items-center justify-center gap-2"
>
<Text className="text-gray-900 text-xl"></Text>
<Text className="text-gray-900 font-semibold text-base">
E-Mail senden
</Text>
</TouchableOpacity>
</View>
<View className="h-8" />
</ScrollView>
</SafeAreaView>
)
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<View className="flex-row items-center px-4 py-3 border-b border-gray-50 last:border-0">
<Text className="text-sm text-gray-500 w-32">{label}</Text>
<Text className="text-sm text-gray-900 font-medium flex-1">{value}</Text>
</View>
)
}

View File

@@ -0,0 +1,101 @@
import {
View,
Text,
FlatList,
TextInput,
TouchableOpacity,
RefreshControl,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { trpc } from '@/lib/trpc'
import { useMembersFilterStore } from '@/store/members.store'
import { MemberCard } from '@/components/members/MemberCard'
import { EmptyState } from '@/components/ui/EmptyState'
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'
export default function MembersScreen() {
const router = useRouter()
const search = useMembersFilterStore((s) => s.search)
const nurAusbildungsbetriebe = useMembersFilterStore((s) => s.nurAusbildungsbetriebe)
const setSearch = useMembersFilterStore((s) => s.setSearch)
const setNurAusbildungsbetriebe = useMembersFilterStore((s) => s.setNurAusbildungsbetriebe)
const { data, isLoading, refetch, isRefetching } = trpc.members.list.useQuery({
search: search || undefined,
ausbildungsbetrieb: nurAusbildungsbetriebe || undefined,
status: 'aktiv',
})
return (
<SafeAreaView className="flex-1 bg-gray-50" edges={['top']}>
{/* Header */}
<View className="bg-white px-4 pt-3 pb-2 border-b border-gray-100">
<Text className="text-xl font-bold text-gray-900 mb-3">Mitglieder</Text>
{/* Search */}
<View className="flex-row items-center bg-gray-100 rounded-xl px-3 py-2 mb-2">
<Text className="text-gray-400 mr-2">🔍</Text>
<TextInput
className="flex-1 text-sm text-gray-900"
placeholder="Name, Betrieb, Ort, Sparte..."
placeholderTextColor="#9ca3af"
value={search}
onChangeText={setSearch}
clearButtonMode="while-editing"
/>
</View>
{/* Filter: Ausbildungsbetriebe */}
<TouchableOpacity
onPress={() => setNurAusbildungsbetriebe(!nurAusbildungsbetriebe)}
className="flex-row items-center gap-2 py-1"
>
<View
className={`w-5 h-5 rounded border-2 items-center justify-center ${
nurAusbildungsbetriebe
? 'bg-brand-500 border-brand-500'
: 'border-gray-300 bg-white'
}`}
>
{nurAusbildungsbetriebe && (
<Text className="text-white text-xs"></Text>
)}
</View>
<Text className="text-sm text-gray-600">Nur Ausbildungsbetriebe</Text>
</TouchableOpacity>
</View>
{/* List */}
{isLoading ? (
<LoadingSpinner />
) : (
<FlatList
data={data ?? []}
keyExtractor={(item) => item.id}
contentContainerStyle={{ padding: 12, gap: 8 }}
refreshControl={
<RefreshControl
refreshing={isRefetching}
onRefresh={refetch}
tintColor="#E63946"
/>
}
renderItem={({ item }) => (
<MemberCard
member={item}
onPress={() => router.push(`/(app)/members/${item.id}`)}
/>
)}
ListEmptyComponent={
<EmptyState
icon="👥"
title="Keine Mitglieder"
subtitle={search ? 'Keine Treffer für Ihre Suche' : 'Noch keine Mitglieder'}
/>
}
/>
)}
</SafeAreaView>
)
}

View File

@@ -0,0 +1,87 @@
import {
View,
Text,
ScrollView,
TouchableOpacity,
ActivityIndicator,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { useEffect } from 'react'
import { trpc } from '@/lib/trpc'
import { useNewsReadStore } from '@/store/news.store'
import { AttachmentRow } from '@/components/news/AttachmentRow'
import { Badge } from '@/components/ui/Badge'
import { NEWS_KATEGORIE_LABELS } from '@innungsapp/shared'
import { format } from 'date-fns'
import { de } from 'date-fns/locale'
export default function NewsDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>()
const router = useRouter()
const markRead = useNewsReadStore((s) => s.markRead)
const markReadMutation = trpc.news.markRead.useMutation()
const { data: news, isLoading } = trpc.news.byId.useQuery({ id })
useEffect(() => {
if (news) {
markRead(id)
markReadMutation.mutate({ newsId: id })
}
}, [news?.id])
if (isLoading) {
return (
<SafeAreaView className="flex-1 bg-white items-center justify-center">
<ActivityIndicator size="large" color="#E63946" />
</SafeAreaView>
)
}
if (!news) return null
return (
<SafeAreaView className="flex-1 bg-white" edges={['top']}>
{/* Header */}
<View className="flex-row items-center px-4 py-3 border-b border-gray-100">
<TouchableOpacity onPress={() => router.back()} className="mr-3">
<Text className="text-brand-500 text-base"> Zurück</Text>
</TouchableOpacity>
<Text className="font-semibold text-gray-900 flex-1" numberOfLines={1}>
{news.title}
</Text>
</View>
<ScrollView contentContainerStyle={{ padding: 16 }}>
<Badge label={NEWS_KATEGORIE_LABELS[news.kategorie]} kategorie={news.kategorie} />
<Text className="text-2xl font-bold text-gray-900 mt-3 mb-2">
{news.title}
</Text>
<Text className="text-sm text-gray-500 mb-6">
{news.author?.name ?? 'InnungsApp'} ·{' '}
{news.publishedAt
? format(new Date(news.publishedAt), 'dd. MMMM yyyy', { locale: de })
: ''}
</Text>
{/* Simple Markdown renderer — plain text for MVP */}
<Text className="text-base text-gray-700 leading-7">
{news.body.replace(/^#+\s/gm, '').replace(/\*\*(.*?)\*\*/g, '$1')}
</Text>
{/* Attachments */}
{news.attachments.length > 0 && (
<View className="mt-8 border-t border-gray-100 pt-4">
<Text className="font-semibold text-gray-900 mb-3">Anhänge</Text>
{news.attachments.map((a) => (
<AttachmentRow key={a.id} attachment={a} />
))}
</View>
)}
</ScrollView>
</SafeAreaView>
)
}

View File

@@ -0,0 +1,100 @@
import {
View,
Text,
FlatList,
TouchableOpacity,
RefreshControl,
ScrollView,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useState } from 'react'
import { trpc } from '@/lib/trpc'
import { useRouter } from 'expo-router'
import { NewsCard } from '@/components/news/NewsCard'
import { EmptyState } from '@/components/ui/EmptyState'
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'
import { NEWS_KATEGORIE_LABELS } from '@innungsapp/shared'
const FILTER_OPTIONS = [
{ value: undefined, label: 'Alle' },
{ value: 'Wichtig', label: 'Wichtig' },
{ value: 'Pruefung', label: 'Prüfung' },
{ value: 'Foerderung', label: 'Förderung' },
{ value: 'Veranstaltung', label: 'Veranstaltung' },
]
export default function NewsScreen() {
const router = useRouter()
const [kategorie, setKategorie] = useState<string | undefined>(undefined)
const { data, isLoading, refetch, isRefetching } = trpc.news.list.useQuery({
kategorie: kategorie as never,
})
return (
<SafeAreaView className="flex-1 bg-gray-50" edges={['top']}>
{/* Header */}
<View className="bg-white px-4 py-3 border-b border-gray-100">
<Text className="text-xl font-bold text-gray-900">News</Text>
</View>
{/* Kategorie Filter */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
className="bg-white border-b border-gray-100"
contentContainerStyle={{ paddingHorizontal: 12, paddingVertical: 10, gap: 8 }}
>
{FILTER_OPTIONS.map((opt) => (
<TouchableOpacity
key={String(opt.value)}
onPress={() => setKategorie(opt.value)}
className={`px-4 py-1.5 rounded-full border ${
kategorie === opt.value
? 'bg-brand-500 border-brand-500'
: 'bg-white border-gray-200'
}`}
>
<Text
className={`text-sm font-medium ${
kategorie === opt.value ? 'text-white' : 'text-gray-600'
}`}
>
{opt.label}
</Text>
</TouchableOpacity>
))}
</ScrollView>
{/* List */}
{isLoading ? (
<LoadingSpinner />
) : (
<FlatList
data={data ?? []}
keyExtractor={(item) => item.id}
contentContainerStyle={{ padding: 12, gap: 8 }}
refreshControl={
<RefreshControl
refreshing={isRefetching}
onRefresh={refetch}
tintColor="#E63946"
/>
}
renderItem={({ item }) => (
<NewsCard
news={item}
onPress={() => router.push(`/(app)/news/${item.id}`)}
/>
)}
ListEmptyComponent={
<EmptyState
icon="📰"
title="Keine News"
subtitle="Noch keine Beiträge für diese Kategorie"
/>
}
/>
)}
</SafeAreaView>
)
}

View File

@@ -0,0 +1,97 @@
import {
View,
Text,
ScrollView,
TouchableOpacity,
Linking,
ActivityIndicator,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { trpc } from '@/lib/trpc'
import { useAuth } from '@/hooks/useAuth'
import { Avatar } from '@/components/ui/Avatar'
export default function ProfilScreen() {
const { signOut } = useAuth()
const { data: member, isLoading } = trpc.members.me.useQuery()
return (
<SafeAreaView className="flex-1 bg-gray-50" edges={['top']}>
<View className="bg-white px-4 py-3 border-b border-gray-100">
<Text className="text-xl font-bold text-gray-900">Mein Profil</Text>
</View>
<ScrollView>
{isLoading ? (
<View className="py-16 items-center">
<ActivityIndicator color="#E63946" />
</View>
) : member ? (
<>
{/* Profile */}
<View className="bg-white px-4 py-8 items-center border-b border-gray-100">
<Avatar name={member.name} size={72} />
<Text className="text-xl font-bold text-gray-900 mt-4">{member.name}</Text>
<Text className="text-gray-500 mt-1">{member.betrieb}</Text>
<Text className="text-gray-400 text-sm mt-0.5">{member.org.name}</Text>
</View>
{/* Member Details */}
<View className="bg-white mx-4 mt-4 rounded-2xl overflow-hidden border border-gray-100">
<InfoRow label="E-Mail" value={member.email} />
{member.telefon && <InfoRow label="Telefon" value={member.telefon} />}
<InfoRow label="Sparte" value={member.sparte} />
<InfoRow label="Ort" value={member.ort} />
{member.seit && <InfoRow label="Mitglied seit" value={String(member.seit)} />}
</View>
<View className="mx-4 mt-2">
<Text className="text-xs text-gray-400 px-1">
Änderungen an Ihren Daten nehmen Sie über die Innungsgeschäftsstelle vor.
</Text>
</View>
</>
) : null}
{/* Links */}
<View className="bg-white mx-4 mt-4 rounded-2xl overflow-hidden border border-gray-100">
<TouchableOpacity
onPress={() => Linking.openURL('https://innungsapp.de/datenschutz')}
className="flex-row items-center justify-between px-4 py-3.5 border-b border-gray-50"
>
<Text className="text-gray-700">Datenschutzerklärung</Text>
<Text className="text-gray-400"></Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => Linking.openURL('https://innungsapp.de/impressum')}
className="flex-row items-center justify-between px-4 py-3.5"
>
<Text className="text-gray-700">Impressum</Text>
<Text className="text-gray-400"></Text>
</TouchableOpacity>
</View>
{/* Logout */}
<View className="mx-4 mt-4">
<TouchableOpacity
onPress={signOut}
className="bg-red-50 border border-red-200 rounded-2xl py-4 items-center"
>
<Text className="text-red-600 font-semibold">Abmelden</Text>
</TouchableOpacity>
</View>
<View className="h-8" />
</ScrollView>
</SafeAreaView>
)
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<View className="flex-row items-center px-4 py-3 border-b border-gray-50 last:border-0">
<Text className="text-sm text-gray-500 w-28">{label}</Text>
<Text className="text-sm text-gray-900 flex-1">{value}</Text>
</View>
)
}

View File

@@ -0,0 +1,94 @@
import {
View,
Text,
ScrollView,
TouchableOpacity,
Linking,
ActivityIndicator,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { trpc } from '@/lib/trpc'
export default function StelleDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>()
const router = useRouter()
const { data: stelle, isLoading } = trpc.stellen.byId.useQuery({ id })
if (isLoading) {
return (
<SafeAreaView className="flex-1 bg-white items-center justify-center">
<ActivityIndicator size="large" color="#E63946" />
</SafeAreaView>
)
}
if (!stelle) return null
const betreffVorlage = `Bewerbung als Auszubildender bei ${stelle.member.betrieb}`
const bewerbungsUrl = `mailto:${stelle.kontaktEmail}?subject=${encodeURIComponent(betreffVorlage)}`
return (
<SafeAreaView className="flex-1 bg-gray-50" edges={['top']}>
<View className="flex-row items-center px-4 py-3 bg-white border-b border-gray-100">
<TouchableOpacity onPress={() => router.back()} className="mr-3">
<Text className="text-brand-500 text-base"> Zurück</Text>
</TouchableOpacity>
</View>
<ScrollView>
{/* Header */}
<View className="bg-white px-4 py-6 border-b border-gray-100">
<View className="bg-brand-50 rounded-xl w-16 h-16 items-center justify-center mb-4">
<Text className="text-3xl">🎓</Text>
</View>
<Text className="text-2xl font-bold text-gray-900">{stelle.member.betrieb}</Text>
<Text className="text-gray-500 mt-1">{stelle.member.ort}</Text>
<Text className="text-gray-500">{stelle.org.name}</Text>
</View>
{/* Details */}
<View className="bg-white mx-4 mt-4 rounded-2xl overflow-hidden border border-gray-100">
<DetailRow label="Sparte" value={stelle.sparte} />
<DetailRow label="Anzahl Stellen" value={String(stelle.stellenAnz)} />
{stelle.lehrjahr && <DetailRow label="Lehrjahr" value={stelle.lehrjahr} />}
{stelle.verguetung && <DetailRow label="Vergütung" value={stelle.verguetung} />}
</View>
{stelle.beschreibung && (
<View className="bg-white mx-4 mt-4 rounded-2xl p-4 border border-gray-100">
<Text className="font-semibold text-gray-900 mb-2">Über die Stelle</Text>
<Text className="text-gray-600 leading-6">{stelle.beschreibung}</Text>
</View>
)}
{/* CTA */}
<View className="mx-4 mt-6">
<TouchableOpacity
onPress={() => Linking.openURL(bewerbungsUrl)}
className="bg-brand-500 rounded-2xl py-4 flex-row items-center justify-center gap-2"
>
<Text className="text-white text-xl"></Text>
<Text className="text-white font-semibold text-base">Jetzt bewerben</Text>
</TouchableOpacity>
{stelle.kontaktName && (
<Text className="text-center text-sm text-gray-400 mt-3">
Ansprechperson: {stelle.kontaktName}
</Text>
)}
</View>
<View className="h-8" />
</ScrollView>
</SafeAreaView>
)
}
function DetailRow({ label, value }: { label: string; value: string }) {
return (
<View className="flex-row items-center px-4 py-3 border-b border-gray-50 last:border-0">
<Text className="text-sm text-gray-500 w-32">{label}</Text>
<Text className="text-sm text-gray-900 font-medium flex-1">{value}</Text>
</View>
)
}

View File

@@ -0,0 +1,74 @@
import {
View,
Text,
FlatList,
TouchableOpacity,
RefreshControl,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { trpc } from '@/lib/trpc'
import { StelleCard } from '@/components/stellen/StelleCard'
import { EmptyState } from '@/components/ui/EmptyState'
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'
import { useAuth } from '@/hooks/useAuth'
export default function StellenScreen() {
const router = useRouter()
const { isAuthenticated } = useAuth()
const { data, isLoading, refetch, isRefetching } = trpc.stellen.listPublic.useQuery({})
return (
<SafeAreaView className="flex-1 bg-gray-50" edges={['top']}>
{/* Header */}
<View className="bg-white px-4 pt-3 pb-3 border-b border-gray-100">
<View className="flex-row items-center justify-between">
<View>
<Text className="text-xl font-bold text-gray-900">Lehrlingsbörse</Text>
<Text className="text-sm text-gray-500 mt-0.5">
{data?.length ?? 0} Angebote
</Text>
</View>
{isAuthenticated && (
<TouchableOpacity
onPress={() => router.push('/(app)/stellen/neu')}
className="bg-brand-500 px-4 py-2 rounded-xl"
>
<Text className="text-white text-sm font-medium">+ Stelle anbieten</Text>
</TouchableOpacity>
)}
</View>
</View>
{isLoading ? (
<LoadingSpinner />
) : (
<FlatList
data={data ?? []}
keyExtractor={(item) => item.id}
contentContainerStyle={{ padding: 12, gap: 8 }}
refreshControl={
<RefreshControl
refreshing={isRefetching}
onRefresh={refetch}
tintColor="#E63946"
/>
}
renderItem={({ item }) => (
<StelleCard
stelle={item}
onPress={() => router.push(`/(app)/stellen/${item.id}`)}
/>
)}
ListEmptyComponent={
<EmptyState
icon="🎓"
title="Keine Angebote"
subtitle="Aktuell sind keine Ausbildungsplätze verfügbar"
/>
}
/>
)}
</SafeAreaView>
)
}

View File

@@ -0,0 +1,152 @@
import {
View,
Text,
ScrollView,
TouchableOpacity,
Linking,
ActivityIndicator,
Alert,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { trpc } from '@/lib/trpc'
import { AnmeldeButton } from '@/components/termine/AnmeldeButton'
import { Badge } from '@/components/ui/Badge'
import { TERMIN_TYP_LABELS } from '@innungsapp/shared'
import { format } from 'date-fns'
import { de } from 'date-fns/locale'
import * as Calendar from 'expo-calendar'
export default function TerminDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>()
const router = useRouter()
const { data: termin, isLoading } = trpc.termine.byId.useQuery({ id })
const toggleMutation = trpc.termine.toggleAnmeldung.useMutation({
onSuccess: () => {
// Invalidate queries
},
})
async function addToCalendar() {
if (!termin) return
const { status } = await Calendar.requestCalendarPermissionsAsync()
if (status !== 'granted') {
Alert.alert('Keine Berechtigung', 'Bitte erlauben Sie den Kalender-Zugriff in den Einstellungen.')
return
}
const calendars = await Calendar.getCalendarsAsync(Calendar.EntityTypes.EVENT)
const defaultCal = calendars.find((c) => c.isPrimary) ?? calendars[0]
if (!defaultCal) {
Alert.alert('Kein Kalender', 'Es wurde kein Kalender gefunden.')
return
}
const startDate = new Date(termin.datum)
if (termin.uhrzeit) {
const [h, m] = termin.uhrzeit.split(':').map(Number)
startDate.setHours(h, m)
}
await Calendar.createEventAsync(defaultCal.id, {
title: termin.titel,
startDate,
endDate: startDate,
location: termin.adresse ?? termin.ort ?? undefined,
notes: termin.beschreibung ?? undefined,
})
Alert.alert('Termin gespeichert', 'Der Termin wurde in Ihren Kalender eingetragen.')
}
if (isLoading) {
return (
<SafeAreaView className="flex-1 bg-white items-center justify-center">
<ActivityIndicator size="large" color="#E63946" />
</SafeAreaView>
)
}
if (!termin) return null
const datumFormatted = format(new Date(termin.datum), 'EEEE, dd. MMMM yyyy', { locale: de })
return (
<SafeAreaView className="flex-1 bg-gray-50" edges={['top']}>
<View className="flex-row items-center px-4 py-3 bg-white border-b border-gray-100">
<TouchableOpacity onPress={() => router.back()} className="mr-3">
<Text className="text-brand-500 text-base"> Zurück</Text>
</TouchableOpacity>
</View>
<ScrollView>
<View className="bg-white px-4 py-6 border-b border-gray-100">
<Badge label={TERMIN_TYP_LABELS[termin.typ]} typ={termin.typ} />
<Text className="text-2xl font-bold text-gray-900 mt-3">{termin.titel}</Text>
<Text className="text-gray-500 mt-2 capitalize">{datumFormatted}</Text>
{termin.uhrzeit && (
<Text className="text-gray-500">
{termin.uhrzeit}{termin.endeUhrzeit ? ` ${termin.endeUhrzeit}` : ''} Uhr
</Text>
)}
</View>
<View className="bg-white mx-4 mt-4 rounded-2xl overflow-hidden border border-gray-100">
{termin.ort && (
<TouchableOpacity
onPress={() =>
termin.adresse &&
Linking.openURL(
`https://maps.google.com/?q=${encodeURIComponent(termin.adresse)}`
)
}
className="flex-row items-center px-4 py-3 border-b border-gray-50"
>
<Text className="text-2xl mr-3">📍</Text>
<View>
<Text className="font-medium text-gray-900">{termin.ort}</Text>
{termin.adresse && (
<Text className="text-sm text-brand-500 mt-0.5">{termin.adresse}</Text>
)}
</View>
</TouchableOpacity>
)}
<View className="flex-row items-center px-4 py-3">
<Text className="text-2xl mr-3">👥</Text>
<Text className="text-gray-700">
{termin.teilnehmerAnzahl} Anmeldungen
{termin.maxTeilnehmer ? ` / ${termin.maxTeilnehmer} Plätze` : ''}
</Text>
</View>
</View>
{termin.beschreibung && (
<View className="bg-white mx-4 mt-4 rounded-2xl p-4 border border-gray-100">
<Text className="font-semibold text-gray-900 mb-2">Beschreibung</Text>
<Text className="text-gray-600 leading-6">{termin.beschreibung}</Text>
</View>
)}
{/* Actions */}
<View className="mx-4 mt-4 gap-3">
<AnmeldeButton
terminId={id}
isAngemeldet={termin.isAngemeldet}
onToggle={() => toggleMutation.mutate({ terminId: id })}
isLoading={toggleMutation.isPending}
/>
<TouchableOpacity
onPress={addToCalendar}
className="bg-white border border-gray-200 rounded-2xl py-4 flex-row items-center justify-center gap-2"
>
<Text className="text-gray-900 text-xl">📅</Text>
<Text className="text-gray-900 font-semibold">Zum Kalender hinzufügen</Text>
</TouchableOpacity>
</View>
<View className="h-8" />
</ScrollView>
</SafeAreaView>
)
}

View File

@@ -0,0 +1,82 @@
import {
View,
Text,
FlatList,
TouchableOpacity,
RefreshControl,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useState } from 'react'
import { useRouter } from 'expo-router'
import { trpc } from '@/lib/trpc'
import { TerminCard } from '@/components/termine/TerminCard'
import { EmptyState } from '@/components/ui/EmptyState'
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'
export default function TermineScreen() {
const router = useRouter()
const [tab, setTab] = useState<'kommend' | 'vergangen'>('kommend')
const { data, isLoading, refetch, isRefetching } = trpc.termine.list.useQuery({
upcoming: tab === 'kommend',
})
return (
<SafeAreaView className="flex-1 bg-gray-50" edges={['top']}>
{/* Header */}
<View className="bg-white px-4 py-3 border-b border-gray-100">
<Text className="text-xl font-bold text-gray-900 mb-3">Termine</Text>
{/* Tabs */}
<View className="flex-row gap-1 bg-gray-100 p-1 rounded-xl">
{(['kommend', 'vergangen'] as const).map((t) => (
<TouchableOpacity
key={t}
onPress={() => setTab(t)}
className={`flex-1 py-2 rounded-lg items-center ${
tab === t ? 'bg-white shadow-sm' : ''
}`}
>
<Text
className={`text-sm font-medium ${
tab === t ? 'text-gray-900' : 'text-gray-500'
}`}
>
{t === 'kommend' ? 'Bevorstehend' : 'Vergangen'}
</Text>
</TouchableOpacity>
))}
</View>
</View>
{isLoading ? (
<LoadingSpinner />
) : (
<FlatList
data={data ?? []}
keyExtractor={(item) => item.id}
contentContainerStyle={{ padding: 12, gap: 8 }}
refreshControl={
<RefreshControl
refreshing={isRefetching}
onRefresh={refetch}
tintColor="#E63946"
/>
}
renderItem={({ item }) => (
<TerminCard
termin={item}
onPress={() => router.push(`/(app)/termine/${item.id}`)}
/>
)}
ListEmptyComponent={
<EmptyState
icon="📅"
title={tab === 'kommend' ? 'Keine Termine' : 'Keine vergangenen Termine'}
subtitle="Es sind aktuell keine Termine eingetragen"
/>
}
/>
)}
</SafeAreaView>
)
}

View File

@@ -0,0 +1,12 @@
import { Stack } from 'expo-router'
export default function AuthLayout() {
return (
<Stack
screenOptions={{
headerShown: false,
animation: 'slide_from_right',
}}
/>
)
}

View File

@@ -0,0 +1,44 @@
import { View, Text, TouchableOpacity } from 'react-native'
import { useRouter, useLocalSearchParams } from 'expo-router'
import { SafeAreaView } from 'react-native-safe-area-context'
export default function CheckEmailScreen() {
const router = useRouter()
const { email } = useLocalSearchParams<{ email: string }>()
return (
<SafeAreaView className="flex-1 bg-white">
<View className="flex-1 justify-center items-center px-6">
{/* Envelope Illustration */}
<View className="w-24 h-24 bg-brand-50 rounded-full items-center justify-center mb-6">
<Text className="text-5xl">📧</Text>
</View>
<Text className="text-2xl font-bold text-gray-900 text-center mb-3">
Schau in dein Postfach
</Text>
<Text className="text-gray-500 text-center leading-6 mb-2">
Wir haben einen Login-Link an
</Text>
<Text className="font-semibold text-gray-900 text-center mb-6">
{email}
</Text>
<Text className="text-gray-500 text-center leading-6">
Klicken Sie auf den Link in der E-Mail, um sich einzuloggen.
Der Link ist 24 Stunden gültig.
</Text>
<View className="mt-10 space-y-3 w-full">
<TouchableOpacity
onPress={() => router.back()}
className="py-3 items-center"
>
<Text className="text-brand-500 font-medium">
Andere E-Mail verwenden
</Text>
</TouchableOpacity>
</View>
</View>
</SafeAreaView>
)
}

View File

@@ -0,0 +1,111 @@
import {
View,
Text,
TextInput,
TouchableOpacity,
KeyboardAvoidingView,
Platform,
ActivityIndicator,
} from 'react-native'
import { useState } from 'react'
import { useRouter } from 'expo-router'
import { authClient } from '@/lib/auth-client'
import { SafeAreaView } from 'react-native-safe-area-context'
export default function LoginScreen() {
const router = useRouter()
const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
async function handleSendLink() {
if (!email.trim()) return
setLoading(true)
setError('')
const result = await authClient.signIn.magicLink({
email: email.trim().toLowerCase(),
callbackURL: '/news',
})
setLoading(false)
if (result.error) {
setError(result.error.message ?? 'Ein Fehler ist aufgetreten.')
} else {
router.push({ pathname: '/(auth)/check-email', params: { email } })
}
}
return (
<SafeAreaView className="flex-1 bg-white">
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
className="flex-1"
>
<View className="flex-1 justify-center px-6">
{/* Logo */}
<View className="items-center mb-10">
<View className="w-20 h-20 bg-brand-500 rounded-2xl items-center justify-center mb-4">
<Text className="text-white font-bold text-3xl">I</Text>
</View>
<Text className="text-2xl font-bold text-gray-900">InnungsApp</Text>
<Text className="text-gray-500 mt-1 text-center">
Die digitale Plattform Ihrer Innung
</Text>
</View>
{/* Form */}
<View className="space-y-4">
<View>
<Text className="text-sm font-medium text-gray-700 mb-2">
E-Mail-Adresse
</Text>
<TextInput
className="border border-gray-300 rounded-xl px-4 py-3.5 text-base text-gray-900 bg-gray-50"
placeholder="ihre@email.de"
placeholderTextColor="#9ca3af"
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
value={email}
onChangeText={setEmail}
onSubmitEditing={handleSendLink}
returnKeyType="go"
/>
</View>
{error ? (
<View className="bg-red-50 rounded-xl px-4 py-3">
<Text className="text-red-600 text-sm">{error}</Text>
</View>
) : null}
<TouchableOpacity
onPress={handleSendLink}
disabled={loading || !email.trim()}
className={`rounded-xl py-4 items-center ${
loading || !email.trim() ? 'bg-gray-200' : 'bg-brand-500'
}`}
>
{loading ? (
<ActivityIndicator color="white" />
) : (
<Text
className={`font-semibold text-base ${
loading || !email.trim() ? 'text-gray-400' : 'text-white'
}`}
>
Magic Link senden
</Text>
)}
</TouchableOpacity>
<Text className="text-center text-sm text-gray-400">
Kein Passwort nötig Zugang nur per Admin-Einladung
</Text>
</View>
</View>
</KeyboardAvoidingView>
</SafeAreaView>
)
}

View File

@@ -0,0 +1,37 @@
import '../global.css'
import { useEffect } from 'react'
import { Stack } from 'expo-router'
import { SplashScreen } from 'expo-router'
import { QueryClientProvider } from '@tanstack/react-query'
import { queryClient } from '@/lib/trpc'
import { TRPCProvider } from '@/lib/trpc'
import { useAuthStore } from '@/store/auth.store'
import { setupPushNotifications } from '@/lib/notifications'
SplashScreen.preventAutoHideAsync()
export default function RootLayout() {
const initAuth = useAuthStore((s) => s.initialize)
const isInitialized = useAuthStore((s) => s.isInitialized)
useEffect(() => {
initAuth().finally(() => SplashScreen.hideAsync())
}, [initAuth])
useEffect(() => {
setupPushNotifications().catch(console.error)
}, [])
if (!isInitialized) return null
return (
<TRPCProvider>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen name="(auth)" options={{ animation: 'fade' }} />
<Stack.Screen name="(app)" options={{ animation: 'fade' }} />
<Stack.Screen name="stellen-public" />
</Stack>
</TRPCProvider>
)
}

View File

@@ -0,0 +1,7 @@
import { Redirect } from 'expo-router'
import { useAuthStore } from '@/store/auth.store'
export default function Index() {
const session = useAuthStore((s) => s.session)
return <Redirect href={session ? '/(app)/news' : '/(auth)/login'} />
}

View File

@@ -0,0 +1,62 @@
/**
* Public Lehrlingsbörse — accessible without login
* Can be embedded in a WebView or shared as a link
*/
import {
View,
Text,
FlatList,
RefreshControl,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { trpc } from '@/lib/trpc'
import { StelleCard } from '@/components/stellen/StelleCard'
import { EmptyState } from '@/components/ui/EmptyState'
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'
export default function StellenPublicScreen() {
const router = useRouter()
const { data, isLoading, refetch, isRefetching } = trpc.stellen.listPublic.useQuery({})
return (
<SafeAreaView className="flex-1 bg-gray-50">
<View className="bg-brand-500 px-4 py-4">
<Text className="text-white text-xl font-bold">Lehrlingsbörse</Text>
<Text className="text-white/80 text-sm mt-0.5">
Aktuelle Ausbildungsplätze
</Text>
</View>
{isLoading ? (
<LoadingSpinner />
) : (
<FlatList
data={data ?? []}
keyExtractor={(item) => item.id}
contentContainerStyle={{ padding: 12, gap: 8 }}
refreshControl={
<RefreshControl
refreshing={isRefetching}
onRefresh={refetch}
tintColor="#E63946"
/>
}
renderItem={({ item }) => (
<StelleCard
stelle={item}
onPress={() => router.push(`/(app)/stellen/${item.id}`)}
/>
)}
ListEmptyComponent={
<EmptyState
icon="🎓"
title="Keine Angebote"
subtitle="Aktuell sind keine Ausbildungsplätze verfügbar"
/>
}
/>
)}
</SafeAreaView>
)
}

View File

@@ -0,0 +1,10 @@
module.exports = function (api) {
api.cache(true)
return {
presets: [
['babel-preset-expo', { jsxImportSource: 'nativewind' }],
'nativewind/babel',
],
plugins: ['react-native-reanimated/plugin'],
}
}

View File

@@ -0,0 +1,48 @@
import { TouchableOpacity, Text, ActivityIndicator } from 'react-native'
interface ButtonProps {
label: string
onPress: () => void
variant?: 'primary' | 'secondary' | 'ghost'
loading?: boolean
disabled?: boolean
icon?: string
}
export function Button({
label,
onPress,
variant = 'primary',
loading,
disabled,
icon,
}: ButtonProps) {
const base = 'rounded-2xl py-4 flex-row items-center justify-center gap-2'
const variants = {
primary: `${base} bg-brand-500`,
secondary: `${base} bg-white border border-gray-200`,
ghost: `${base}`,
}
const textVariants = {
primary: 'text-white font-semibold text-base',
secondary: 'text-gray-900 font-semibold text-base',
ghost: 'text-brand-500 font-semibold text-base',
}
return (
<TouchableOpacity
onPress={onPress}
disabled={disabled || loading}
className={`${variants[variant]} ${disabled || loading ? 'opacity-50' : ''}`}
>
{loading ? (
<ActivityIndicator color={variant === 'primary' ? 'white' : '#E63946'} />
) : (
<>
{icon && <Text className="text-xl">{icon}</Text>}
<Text className={textVariants[variant]}>{label}</Text>
</>
)}
</TouchableOpacity>
)
}

View File

@@ -0,0 +1,26 @@
import { View, TouchableOpacity } from 'react-native'
interface CardProps {
children: React.ReactNode
onPress?: () => void
className?: string
}
export function Card({ children, onPress, className = '' }: CardProps) {
if (onPress) {
return (
<TouchableOpacity
onPress={onPress}
className={`bg-white rounded-2xl border border-gray-100 p-4 ${className}`}
activeOpacity={0.7}
>
{children}
</TouchableOpacity>
)
}
return (
<View className={`bg-white rounded-2xl border border-gray-100 p-4 ${className}`}>
{children}
</View>
)
}

View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1,21 @@
import { useAuthStore } from '@/store/auth.store'
import { useRouter } from 'expo-router'
export function useAuth() {
const { session, orgId, role, signOut } = useAuthStore()
const router = useRouter()
async function handleSignOut() {
await signOut()
router.replace('/(auth)/login')
}
return {
session,
orgId,
role,
isAuthenticated: !!session,
isAdmin: role === 'admin',
signOut: handleSignOut,
}
}

View File

@@ -0,0 +1,19 @@
import { trpc } from '@/lib/trpc'
import { useMembersFilterStore } from '@/store/members.store'
export function useMembersList() {
const search = useMembersFilterStore((s) => s.search)
const nurAusbildungsbetriebe = useMembersFilterStore(
(s) => s.nurAusbildungsbetriebe
)
return trpc.members.list.useQuery({
search: search || undefined,
ausbildungsbetrieb: nurAusbildungsbetriebe || undefined,
status: 'aktiv',
})
}
export function useMemberDetail(id: string) {
return trpc.members.byId.useQuery({ id })
}

View File

@@ -0,0 +1,22 @@
import { trpc } from '@/lib/trpc'
import { useNewsReadStore } from '@/store/news.store'
export function useNewsList(kategorie?: string) {
return trpc.news.list.useQuery({
kategorie: kategorie as never,
})
}
export function useNewsDetail(id: string) {
const markRead = useNewsReadStore((s) => s.markRead)
const markReadMutation = trpc.news.markRead.useMutation()
const query = trpc.news.byId.useQuery({ id })
function onOpen() {
markRead(id)
markReadMutation.mutate({ newsId: id })
}
return { ...query, onOpen }
}

View File

@@ -0,0 +1,12 @@
import { trpc } from '@/lib/trpc'
export function useStellenListe(opts?: { sparte?: string; lehrjahr?: string }) {
return trpc.stellen.listPublic.useQuery({
sparte: opts?.sparte,
lehrjahr: opts?.lehrjahr,
})
}
export function useStelleDetail(id: string) {
return trpc.stellen.byId.useQuery({ id })
}

View File

@@ -0,0 +1,19 @@
import { trpc } from '@/lib/trpc'
export function useTermineListe(upcoming = true) {
return trpc.termine.list.useQuery({ upcoming })
}
export function useTerminDetail(id: string) {
return trpc.termine.byId.useQuery({ id })
}
export function useToggleAnmeldung() {
const utils = trpc.useUtils()
return trpc.termine.toggleAnmeldung.useMutation({
onSuccess: () => {
utils.termine.list.invalidate()
utils.termine.byId.invalidate()
},
})
}

View File

@@ -0,0 +1,13 @@
import { createAuthClient } from 'better-auth/react'
import { magicLinkClient } from 'better-auth/client/plugins'
import Constants from 'expo-constants'
const apiUrl =
Constants.expoConfig?.extra?.apiUrl ??
process.env.EXPO_PUBLIC_API_URL ??
'http://localhost:3000'
export const authClient = createAuthClient({
baseURL: apiUrl,
plugins: [magicLinkClient()],
})

View File

@@ -0,0 +1,43 @@
import * as Notifications from 'expo-notifications'
import { Platform } from 'react-native'
import { trpc } from './trpc'
import { queryClient } from './trpc'
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),
})
export async function setupPushNotifications() {
if (Platform.OS === 'web') return
const { status: existingStatus } = await Notifications.getPermissionsAsync()
let finalStatus = existingStatus
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync()
finalStatus = status
}
if (finalStatus !== 'granted') return
const token = await Notifications.getExpoPushTokenAsync({
projectId: process.env.EXPO_PUBLIC_PROJECT_ID,
})
// Store push token on the server
// We call the tRPC mutation to save the token
const caller = trpc.createClient as never
// Simple fetch to avoid circular deps:
const apiUrl = process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3000'
await fetch(`${apiUrl}/api/push-token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: token.data }),
}).catch(() => {
// Silently fail — push is optional
})
}

View File

@@ -0,0 +1,44 @@
import { createTRPCReact } from '@trpc/react-query'
import { httpBatchLink } from '@trpc/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import superjson from 'superjson'
import { createElement, type ReactNode } from 'react'
import AsyncStorage from '@react-native-async-storage/async-storage'
import type { AppRouter } from '@innungsapp/admin'
export const trpc = createTRPCReact<AppRouter>()
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000,
retry: 1,
},
},
})
function getApiUrl() {
return process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3000'
}
const trpcClient = trpc.createClient({
links: [
httpBatchLink({
url: `${getApiUrl()}/api/trpc`,
transformer: superjson,
async headers() {
// Include session cookie for auth
const token = await AsyncStorage.getItem('better-auth-session')
return token ? { cookie: `better-auth.session_token=${token}` } : {}
},
}),
],
})
export function TRPCProvider({ children }: { children: ReactNode }) {
return createElement(
trpc.Provider,
{ client: trpcClient, queryClient },
createElement(QueryClientProvider, { client: queryClient }, children)
)
}

View File

@@ -0,0 +1,6 @@
const { getDefaultConfig } = require('expo/metro-config')
const { withNativeWind } = require('nativewind/metro')
const config = getDefaultConfig(__dirname)
module.exports = withNativeWind(config, { input: './global.css' })

View File

@@ -0,0 +1,52 @@
{
"name": "@innungsapp/mobile",
"version": "0.1.0",
"private": true,
"main": "expo-router/entry",
"scripts": {
"dev": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"lint": "expo lint",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@expo/vector-icons": "^14.0.0",
"@react-native-async-storage/async-storage": "^2.1.0",
"@tanstack/react-query": "^5.59.0",
"@trpc/client": "^11.0.0",
"@trpc/react-query": "^11.0.0",
"better-auth": "^1.2.0",
"expo": "~52.0.0",
"expo-calendar": "~13.0.0",
"expo-constants": "~17.0.0",
"expo-dev-client": "~5.0.0",
"expo-document-picker": "~13.0.0",
"expo-font": "~13.0.0",
"expo-haptics": "~14.0.0",
"expo-linking": "~7.0.0",
"expo-notifications": "~0.29.0",
"expo-router": "~4.0.0",
"expo-splash-screen": "~0.29.0",
"expo-status-bar": "~2.0.0",
"expo-system-ui": "~4.0.0",
"expo-web-browser": "~14.0.0",
"nativewind": "^4.1.0",
"react": "18.3.1",
"react-native": "0.76.1",
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.0.0",
"react-native-reanimated": "~3.16.0",
"react-native-gesture-handler": "~2.21.0",
"superjson": "^2.2.1",
"zustand": "^5.0.0",
"date-fns": "^3.6.0",
"zod": "^3.23.0"
},
"devDependencies": {
"@babel/core": "^7.25.0",
"@types/react": "~18.3.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.6.0"
}
}

View File

@@ -0,0 +1,58 @@
import { create } from 'zustand'
import { authClient } from '@/lib/auth-client'
import AsyncStorage from '@react-native-async-storage/async-storage'
interface Session {
user: {
id: string
email: string
name: string
}
token?: string
}
interface AuthState {
session: Session | null
orgId: string | null
role: 'admin' | 'member' | null
isInitialized: boolean
initialize: () => Promise<void>
setSession: (session: Session | null) => void
signOut: () => Promise<void>
}
export const useAuthStore = create<AuthState>((set) => ({
session: null,
orgId: null,
role: null,
isInitialized: false,
initialize: async () => {
try {
const { data } = await authClient.getSession()
if (data?.session && data?.user) {
set({
session: { user: data.user },
isInitialized: true,
})
// Store token for API requests
if (data.session.token) {
await AsyncStorage.setItem('better-auth-session', data.session.token)
}
} else {
await AsyncStorage.removeItem('better-auth-session')
set({ session: null, orgId: null, role: null, isInitialized: true })
}
} catch {
set({ session: null, isInitialized: true })
}
},
setSession: (session) => set({ session }),
signOut: async () => {
await authClient.signOut()
await AsyncStorage.removeItem('better-auth-session')
set({ session: null, orgId: null, role: null })
},
}))

View File

@@ -0,0 +1,18 @@
import { create } from 'zustand'
interface MembersFilterState {
search: string
nurAusbildungsbetriebe: boolean
setSearch: (s: string) => void
setNurAusbildungsbetriebe: (b: boolean) => void
reset: () => void
}
export const useMembersFilterStore = create<MembersFilterState>((set) => ({
search: '',
nurAusbildungsbetriebe: false,
setSearch: (search) => set({ search }),
setNurAusbildungsbetriebe: (nurAusbildungsbetriebe) =>
set({ nurAusbildungsbetriebe }),
reset: () => set({ search: '', nurAusbildungsbetriebe: false }),
}))

View File

@@ -0,0 +1,12 @@
import { create } from 'zustand'
interface NewsReadState {
readIds: Set<string>
markRead: (newsId: string) => void
}
export const useNewsReadStore = create<NewsReadState>((set) => ({
readIds: new Set(),
markRead: (newsId) =>
set((s) => ({ readIds: new Set([...s.readIds, newsId]) })),
}))

View File

@@ -0,0 +1,20 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./app/**/*.{js,jsx,ts,tsx}', './components/**/*.{js,jsx,ts,tsx}'],
presets: [require('nativewind/preset')],
theme: {
extend: {
colors: {
brand: {
50: '#fff1f1',
100: '#ffe1e1',
400: '#ff6b6b',
500: '#E63946',
600: '#d42535',
700: '#b21e2c',
},
},
},
},
plugins: [],
}

View File

@@ -0,0 +1,11 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
},
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.d.ts", "expo-env.d.ts"]
}