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:
59
innungsapp/apps/mobile/app.json
Normal file
59
innungsapp/apps/mobile/app.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
78
innungsapp/apps/mobile/app/(app)/_layout.tsx
Normal file
78
innungsapp/apps/mobile/app/(app)/_layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
107
innungsapp/apps/mobile/app/(app)/members/[id].tsx
Normal file
107
innungsapp/apps/mobile/app/(app)/members/[id].tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
101
innungsapp/apps/mobile/app/(app)/members/index.tsx
Normal file
101
innungsapp/apps/mobile/app/(app)/members/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
87
innungsapp/apps/mobile/app/(app)/news/[id].tsx
Normal file
87
innungsapp/apps/mobile/app/(app)/news/[id].tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
100
innungsapp/apps/mobile/app/(app)/news/index.tsx
Normal file
100
innungsapp/apps/mobile/app/(app)/news/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
97
innungsapp/apps/mobile/app/(app)/profil/index.tsx
Normal file
97
innungsapp/apps/mobile/app/(app)/profil/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
94
innungsapp/apps/mobile/app/(app)/stellen/[id].tsx
Normal file
94
innungsapp/apps/mobile/app/(app)/stellen/[id].tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
74
innungsapp/apps/mobile/app/(app)/stellen/index.tsx
Normal file
74
innungsapp/apps/mobile/app/(app)/stellen/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
152
innungsapp/apps/mobile/app/(app)/termine/[id].tsx
Normal file
152
innungsapp/apps/mobile/app/(app)/termine/[id].tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
82
innungsapp/apps/mobile/app/(app)/termine/index.tsx
Normal file
82
innungsapp/apps/mobile/app/(app)/termine/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
12
innungsapp/apps/mobile/app/(auth)/_layout.tsx
Normal file
12
innungsapp/apps/mobile/app/(auth)/_layout.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Stack } from 'expo-router'
|
||||
|
||||
export default function AuthLayout() {
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
animation: 'slide_from_right',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
44
innungsapp/apps/mobile/app/(auth)/check-email.tsx
Normal file
44
innungsapp/apps/mobile/app/(auth)/check-email.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
111
innungsapp/apps/mobile/app/(auth)/login.tsx
Normal file
111
innungsapp/apps/mobile/app/(auth)/login.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
37
innungsapp/apps/mobile/app/_layout.tsx
Normal file
37
innungsapp/apps/mobile/app/_layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
7
innungsapp/apps/mobile/app/index.tsx
Normal file
7
innungsapp/apps/mobile/app/index.tsx
Normal 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'} />
|
||||
}
|
||||
62
innungsapp/apps/mobile/app/stellen-public/index.tsx
Normal file
62
innungsapp/apps/mobile/app/stellen-public/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
10
innungsapp/apps/mobile/babel.config.js
Normal file
10
innungsapp/apps/mobile/babel.config.js
Normal 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'],
|
||||
}
|
||||
}
|
||||
48
innungsapp/apps/mobile/components/ui/Button.tsx
Normal file
48
innungsapp/apps/mobile/components/ui/Button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
innungsapp/apps/mobile/components/ui/Card.tsx
Normal file
26
innungsapp/apps/mobile/components/ui/Card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
3
innungsapp/apps/mobile/global.css
Normal file
3
innungsapp/apps/mobile/global.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
21
innungsapp/apps/mobile/hooks/useAuth.ts
Normal file
21
innungsapp/apps/mobile/hooks/useAuth.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
19
innungsapp/apps/mobile/hooks/useMembers.ts
Normal file
19
innungsapp/apps/mobile/hooks/useMembers.ts
Normal 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 })
|
||||
}
|
||||
22
innungsapp/apps/mobile/hooks/useNews.ts
Normal file
22
innungsapp/apps/mobile/hooks/useNews.ts
Normal 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 }
|
||||
}
|
||||
12
innungsapp/apps/mobile/hooks/useStellen.ts
Normal file
12
innungsapp/apps/mobile/hooks/useStellen.ts
Normal 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 })
|
||||
}
|
||||
19
innungsapp/apps/mobile/hooks/useTermine.ts
Normal file
19
innungsapp/apps/mobile/hooks/useTermine.ts
Normal 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()
|
||||
},
|
||||
})
|
||||
}
|
||||
13
innungsapp/apps/mobile/lib/auth-client.ts
Normal file
13
innungsapp/apps/mobile/lib/auth-client.ts
Normal 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()],
|
||||
})
|
||||
43
innungsapp/apps/mobile/lib/notifications.ts
Normal file
43
innungsapp/apps/mobile/lib/notifications.ts
Normal 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
|
||||
})
|
||||
}
|
||||
44
innungsapp/apps/mobile/lib/trpc.ts
Normal file
44
innungsapp/apps/mobile/lib/trpc.ts
Normal 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)
|
||||
)
|
||||
}
|
||||
6
innungsapp/apps/mobile/metro.config.js
Normal file
6
innungsapp/apps/mobile/metro.config.js
Normal 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' })
|
||||
52
innungsapp/apps/mobile/package.json
Normal file
52
innungsapp/apps/mobile/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
58
innungsapp/apps/mobile/store/auth.store.ts
Normal file
58
innungsapp/apps/mobile/store/auth.store.ts
Normal 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 })
|
||||
},
|
||||
}))
|
||||
18
innungsapp/apps/mobile/store/members.store.ts
Normal file
18
innungsapp/apps/mobile/store/members.store.ts
Normal 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 }),
|
||||
}))
|
||||
12
innungsapp/apps/mobile/store/news.store.ts
Normal file
12
innungsapp/apps/mobile/store/news.store.ts
Normal 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]) })),
|
||||
}))
|
||||
20
innungsapp/apps/mobile/tailwind.config.js
Normal file
20
innungsapp/apps/mobile/tailwind.config.js
Normal 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: [],
|
||||
}
|
||||
11
innungsapp/apps/mobile/tsconfig.json
Normal file
11
innungsapp/apps/mobile/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user