feat: Implement mobile application and lead processing utilities.
This commit is contained in:
@@ -1,11 +1,7 @@
|
||||
import { Tabs } from 'expo-router'
|
||||
import { useAuthStore } from '@/store/auth.store'
|
||||
import { Redirect } from 'expo-router'
|
||||
import { Tabs, Redirect } from 'expo-router'
|
||||
import { Platform } from 'react-native'
|
||||
|
||||
function TabIcon({ emoji }: { emoji: string }) {
|
||||
return null // Replaced by tabBarIcon in options
|
||||
}
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
import { useAuthStore } from '@/store/auth.store'
|
||||
|
||||
export default function AppLayout() {
|
||||
const session = useAuthStore((s) => s.session)
|
||||
@@ -17,62 +13,75 @@ export default function AppLayout() {
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: '#E63946',
|
||||
tabBarInactiveTintColor: '#6b7280',
|
||||
tabBarActiveTintColor: '#003B7E',
|
||||
tabBarInactiveTintColor: '#64748B',
|
||||
tabBarStyle: {
|
||||
borderTopColor: '#e5e7eb',
|
||||
backgroundColor: 'white',
|
||||
paddingBottom: Platform.OS === 'ios' ? 8 : 4,
|
||||
height: Platform.OS === 'ios' ? 82 : 60,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#E2E8F0',
|
||||
backgroundColor: '#FFFFFF',
|
||||
height: Platform.OS === 'ios' ? 88 : 64,
|
||||
paddingBottom: Platform.OS === 'ios' ? 28 : 8,
|
||||
paddingTop: 8,
|
||||
},
|
||||
headerStyle: { backgroundColor: 'white' },
|
||||
headerTitleStyle: { fontWeight: '700', color: '#111827' },
|
||||
headerShadowVisible: false,
|
||||
tabBarLabelStyle: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
headerShown: false,
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="news"
|
||||
name="home/index"
|
||||
options={{
|
||||
title: 'News',
|
||||
tabBarIcon: ({ color }) => (
|
||||
/* Replace with actual icons after @expo/vector-icons setup */
|
||||
<TabIcon emoji="📰" />
|
||||
title: 'Start',
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<Ionicons name={focused ? 'home' : 'home-outline'} size={23} color={color} />
|
||||
),
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="members"
|
||||
name="news/index"
|
||||
options={{
|
||||
title: 'Mitglieder',
|
||||
tabBarIcon: () => <TabIcon emoji="👥" />,
|
||||
headerShown: false,
|
||||
title: 'Aktuelles',
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<Ionicons name={focused ? 'newspaper' : 'newspaper-outline'} size={23} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="termine"
|
||||
name="termine/index"
|
||||
options={{
|
||||
title: 'Termine',
|
||||
tabBarIcon: () => <TabIcon emoji="📅" />,
|
||||
headerShown: false,
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<Ionicons name={focused ? 'calendar' : 'calendar-outline'} size={23} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="stellen"
|
||||
name="stellen/index"
|
||||
options={{
|
||||
title: 'Stellen',
|
||||
tabBarIcon: () => <TabIcon emoji="🎓" />,
|
||||
headerShown: false,
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<Ionicons name={focused ? 'briefcase' : 'briefcase-outline'} size={23} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="profil"
|
||||
name="profil/index"
|
||||
options={{
|
||||
title: 'Profil',
|
||||
tabBarIcon: () => <TabIcon emoji="👤" />,
|
||||
headerShown: false,
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<Ionicons name={focused ? 'person' : 'person-outline'} size={23} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<Tabs.Screen name="members/index" options={{ href: null }} />
|
||||
<Tabs.Screen name="news/[id]" options={{ href: null }} />
|
||||
<Tabs.Screen name="members/[id]" options={{ href: null }} />
|
||||
<Tabs.Screen name="termine/[id]" options={{ href: null }} />
|
||||
<Tabs.Screen name="stellen/[id]" options={{ href: null }} />
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
464
innungsapp/apps/mobile/app/(app)/home/index.tsx
Normal file
464
innungsapp/apps/mobile/app/(app)/home/index.tsx
Normal file
@@ -0,0 +1,464 @@
|
||||
import { View, Text, ScrollView, TouchableOpacity, TextInput, StyleSheet, Platform, Image } from 'react-native'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { format } from 'date-fns'
|
||||
import { de } from 'date-fns/locale'
|
||||
import { NEWS_KATEGORIE_LABELS } from '@innungsapp/shared/types'
|
||||
import { useNewsList } from '@/hooks/useNews'
|
||||
import { useTermineListe } from '@/hooks/useTermine'
|
||||
import { useNewsReadStore } from '@/store/news.store'
|
||||
|
||||
// Helper to truncate text
|
||||
function getNewsExcerpt(value: string) {
|
||||
const normalized = value
|
||||
.replace(/[#*_`>-]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
return normalized.length > 85 ? `${normalized.slice(0, 85)}...` : normalized
|
||||
}
|
||||
|
||||
export default function HomeScreen() {
|
||||
const router = useRouter()
|
||||
const { data: newsItems = [] } = useNewsList()
|
||||
const { data: termine = [] } = useTermineListe(true)
|
||||
const readIds = useNewsReadStore((s) => s.readIds)
|
||||
|
||||
const latestNews = newsItems.slice(0, 2)
|
||||
const upcomingEvents = termine.slice(0, 3)
|
||||
const unreadCount = newsItems.filter((item) => !(item.isRead || readIds.has(item.id))).length
|
||||
|
||||
const QUICK_ACTIONS = [
|
||||
{ label: 'Mitglieder', icon: 'people', color: '#003B7E', bg: '#E0F2FE', route: '/(app)/members' },
|
||||
{ label: 'Termine', icon: 'calendar', color: '#B45309', bg: '#FEF3C7', route: '/(app)/termine' },
|
||||
{ label: 'Stellen', icon: 'briefcase', color: '#059669', bg: '#D1FAE5', route: '/(app)/stellen' },
|
||||
{ label: 'Profil', icon: 'person', color: '#4F46E5', bg: '#E0E7FF', route: '/(app)/profil' },
|
||||
]
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Decorative Background Element */}
|
||||
<View style={styles.bgDecoration} />
|
||||
|
||||
<SafeAreaView style={styles.safeArea} edges={['top']}>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Header Section */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerLeft}>
|
||||
<View style={styles.avatar}>
|
||||
<Text style={styles.avatarText}>I</Text>
|
||||
</View>
|
||||
<View>
|
||||
<Text style={styles.greeting}>Willkommen zurück,</Text>
|
||||
<Text style={styles.username}>Demo Admin</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.notificationBtn}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Ionicons name="notifications-outline" size={22} color="#1E293B" />
|
||||
{unreadCount > 0 && (
|
||||
<View style={styles.badge}>
|
||||
<Text style={styles.badgeText}>{unreadCount}</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Search Bar */}
|
||||
<View style={styles.searchContainer}>
|
||||
<Ionicons name="search-outline" size={20} color="#94A3B8" />
|
||||
<TextInput
|
||||
editable={false}
|
||||
placeholder="Suchen..."
|
||||
placeholderTextColor="#94A3B8"
|
||||
style={styles.searchInput}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Quick Actions Grid */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Schnellzugriff</Text>
|
||||
<View style={styles.grid}>
|
||||
{QUICK_ACTIONS.map((action, i) => (
|
||||
<TouchableOpacity
|
||||
key={i}
|
||||
style={styles.gridItem}
|
||||
activeOpacity={0.7}
|
||||
onPress={() => router.push(action.route as never)}
|
||||
>
|
||||
<View style={[styles.gridIcon, { backgroundColor: action.bg }]}>
|
||||
<Ionicons name={action.icon as any} size={24} color={action.color} />
|
||||
</View>
|
||||
<Text style={styles.gridLabel}>{action.label}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* News Section */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionTitle}>Aktuelles</Text>
|
||||
<TouchableOpacity onPress={() => router.push('/(app)/news' as never)}>
|
||||
<Text style={styles.linkText}>Alle anzeigen</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.cardsColumn}>
|
||||
{latestNews.map((item) => (
|
||||
<TouchableOpacity
|
||||
key={item.id}
|
||||
style={styles.newsCard}
|
||||
activeOpacity={0.9}
|
||||
onPress={() => router.push(`/(app)/news/${item.id}` as never)}
|
||||
>
|
||||
<View style={styles.newsHeader}>
|
||||
<View style={styles.categoryBadge}>
|
||||
<Text style={styles.categoryText}>
|
||||
{NEWS_KATEGORIE_LABELS[item.kategorie]}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.dateText}>
|
||||
{item.publishedAt ? format(item.publishedAt, 'dd. MMM', { locale: de }) : 'Entwurf'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.newsTitle} numberOfLines={2}>{item.title}</Text>
|
||||
<Text style={styles.newsBody} numberOfLines={2}>{getNewsExcerpt(item.body)}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Upcoming Events */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionTitle}>Anstehende Termine</Text>
|
||||
<TouchableOpacity onPress={() => router.push('/(app)/termine' as never)}>
|
||||
<Text style={styles.linkText}>Kalender</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.eventsList}>
|
||||
{upcomingEvents.map((event, index) => (
|
||||
<TouchableOpacity
|
||||
key={event.id}
|
||||
style={[styles.eventRow, index !== upcomingEvents.length - 1 && styles.eventBorder]}
|
||||
onPress={() => router.push(`/(app)/termine/${event.id}` as never)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.dateBox}>
|
||||
<Text style={styles.dateMonth}>{format(event.datum, 'MMM', { locale: de })}</Text>
|
||||
<Text style={styles.dateDay}>{format(event.datum, 'dd')}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.eventInfo}>
|
||||
<Text style={styles.eventTitle} numberOfLines={1}>{event.titel}</Text>
|
||||
<Text style={styles.eventMeta} numberOfLines={1}>
|
||||
{event.uhrzeit} • {event.ort}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Ionicons name="chevron-forward" size={16} color="#CBD5E1" />
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F8FAFC', // Slate-50
|
||||
},
|
||||
bgDecoration: {
|
||||
position: 'absolute',
|
||||
top: -100,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 400,
|
||||
backgroundColor: '#003B7E', // Primary brand color
|
||||
opacity: 0.05,
|
||||
transform: [{ scaleX: 1.5 }, { scaleY: 1 }],
|
||||
borderBottomLeftRadius: 200,
|
||||
borderBottomRightRadius: 200,
|
||||
},
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
padding: 20,
|
||||
paddingBottom: 40,
|
||||
gap: 24,
|
||||
},
|
||||
|
||||
// Header
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginTop: 4,
|
||||
},
|
||||
headerLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
avatar: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#003B7E',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#003B7E',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
},
|
||||
avatarText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
},
|
||||
greeting: {
|
||||
fontSize: 13,
|
||||
color: '#64748B',
|
||||
fontWeight: '500',
|
||||
},
|
||||
username: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#0F172A',
|
||||
},
|
||||
notificationBtn: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#FFFFFF',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: '#E2E8F0',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 4,
|
||||
elevation: 1,
|
||||
},
|
||||
badge: {
|
||||
position: 'absolute',
|
||||
top: -2,
|
||||
right: -2,
|
||||
backgroundColor: '#EF4444',
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 1.5,
|
||||
borderColor: '#FFFFFF',
|
||||
},
|
||||
badgeText: {
|
||||
color: '#FFF',
|
||||
fontSize: 9,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
|
||||
// Search
|
||||
searchContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderWidth: 1,
|
||||
borderColor: '#E2E8F0',
|
||||
borderRadius: 16,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
gap: 10,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.03,
|
||||
shadowRadius: 4,
|
||||
elevation: 1,
|
||||
},
|
||||
searchInput: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
color: '#0F172A',
|
||||
},
|
||||
|
||||
// Sections
|
||||
section: {
|
||||
gap: 12,
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#0F172A',
|
||||
},
|
||||
linkText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#003B7E',
|
||||
},
|
||||
|
||||
// Grid
|
||||
grid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
},
|
||||
gridItem: {
|
||||
width: '48%', // Approx half with gap
|
||||
backgroundColor: '#FFFFFF',
|
||||
padding: 16,
|
||||
borderRadius: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E2E8F0',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.02,
|
||||
shadowRadius: 8,
|
||||
elevation: 2,
|
||||
},
|
||||
gridIcon: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
gridLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#334155',
|
||||
},
|
||||
|
||||
// News Cards
|
||||
cardsColumn: {
|
||||
gap: 12,
|
||||
},
|
||||
newsCard: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
padding: 16,
|
||||
borderRadius: 18,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E2E8F0',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.04,
|
||||
shadowRadius: 6,
|
||||
elevation: 2,
|
||||
},
|
||||
newsHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
categoryBadge: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
backgroundColor: '#F1F5F9',
|
||||
borderRadius: 8,
|
||||
},
|
||||
categoryText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
color: '#475569',
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
dateText: {
|
||||
fontSize: 12,
|
||||
color: '#94A3B8',
|
||||
},
|
||||
newsTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#0F172A',
|
||||
marginBottom: 6,
|
||||
lineHeight: 22,
|
||||
},
|
||||
newsBody: {
|
||||
fontSize: 14,
|
||||
color: '#64748B',
|
||||
lineHeight: 20,
|
||||
},
|
||||
|
||||
// Events List
|
||||
eventsList: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 20,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E2E8F0',
|
||||
paddingVertical: 4,
|
||||
},
|
||||
eventRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 12,
|
||||
gap: 14,
|
||||
},
|
||||
eventBorder: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#F1F5F9',
|
||||
},
|
||||
dateBox: {
|
||||
width: 50,
|
||||
height: 50,
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#F8FAFC',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: '#E2E8F0',
|
||||
},
|
||||
dateMonth: {
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
color: '#64748B',
|
||||
marginBottom: -2,
|
||||
},
|
||||
dateDay: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
color: '#0F172A',
|
||||
},
|
||||
eventInfo: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
eventTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
color: '#0F172A',
|
||||
},
|
||||
eventMeta: {
|
||||
fontSize: 12,
|
||||
color: '#64748B',
|
||||
fontWeight: '500',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,107 +1,238 @@
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
Linking,
|
||||
ActivityIndicator,
|
||||
View, Text, ScrollView, TouchableOpacity, Linking, ActivityIndicator, StyleSheet,
|
||||
} from 'react-native'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
import { useMemberDetail } from '@/hooks/useMembers'
|
||||
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 })
|
||||
const { data: member, isLoading } = useMemberDetail(id)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-white items-center justify-center">
|
||||
<ActivityIndicator size="large" color="#E63946" />
|
||||
<SafeAreaView style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#003B7E" />
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
if (!member) return null
|
||||
|
||||
const fields = [
|
||||
member.sparte ? ['SPARTE', member.sparte] : null,
|
||||
member.ort ? ['ORT', member.ort] : null,
|
||||
member.seit ? ['MITGLIED SEIT', String(member.seit)] : null,
|
||||
].filter(Boolean) as [string, string][]
|
||||
|
||||
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>
|
||||
<SafeAreaView style={styles.safeArea} edges={['top']}>
|
||||
{/* Nav */}
|
||||
<View style={styles.navBar}>
|
||||
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn} activeOpacity={0.7}>
|
||||
<Ionicons name="chevron-back" size={20} color="#003B7E" />
|
||||
<Text style={styles.backText}>Zurück</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.divider} />
|
||||
|
||||
<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>
|
||||
<ScrollView showsVerticalScrollIndicator={false}>
|
||||
{/* Hero */}
|
||||
<View style={styles.hero}>
|
||||
<Avatar name={member.name} imageUrl={member.avatarUrl ?? undefined} size={80} shadow />
|
||||
<Text style={styles.heroName}>{member.name}</Text>
|
||||
<Text style={styles.heroCompany}>{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 style={styles.ausbildungPill}>
|
||||
<Ionicons name="school" size={12} color="#15803D" />
|
||||
<Text style={styles.ausbildungText}>Ausbildungsbetrieb</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
{/* 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 style={styles.card}>
|
||||
{fields.map(([label, value], idx) => (
|
||||
<View
|
||||
key={label}
|
||||
style={[styles.fieldRow, idx < fields.length - 1 && styles.fieldRowBorder]}
|
||||
>
|
||||
<Text style={styles.fieldLabel}>{label}</Text>
|
||||
<Text style={styles.fieldValue}>{value}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Contact Buttons */}
|
||||
<View className="mx-4 mt-4 gap-3">
|
||||
{/* Actions */}
|
||||
<View style={styles.actions}>
|
||||
{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"
|
||||
style={styles.btnPrimary}
|
||||
activeOpacity={0.82}
|
||||
>
|
||||
<Text className="text-white text-xl">📞</Text>
|
||||
<Text className="text-white font-semibold text-base">
|
||||
Anrufen
|
||||
</Text>
|
||||
<Ionicons name="call" size={18} color="#FFFFFF" />
|
||||
<Text style={styles.btnPrimaryText}>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"
|
||||
onPress={() => Linking.openURL(`mailto:${member.email}`)}
|
||||
style={styles.btnSecondary}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text className="text-gray-900 text-xl">✉️</Text>
|
||||
<Text className="text-gray-900 font-semibold text-base">
|
||||
E-Mail senden
|
||||
</Text>
|
||||
<Ionicons name="mail-outline" size={18} color="#0F172A" />
|
||||
<Text style={styles.btnSecondaryText}>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>
|
||||
)
|
||||
}
|
||||
const styles = StyleSheet.create({
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F8FAFC',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
backgroundColor: '#FFFFFF',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
navBar: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
backBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
},
|
||||
backText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#003B7E',
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: '#E2E8F0',
|
||||
},
|
||||
hero: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 32,
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
heroName: {
|
||||
fontSize: 21,
|
||||
fontWeight: '800',
|
||||
color: '#0F172A',
|
||||
letterSpacing: -0.4,
|
||||
marginTop: 14,
|
||||
textAlign: 'center',
|
||||
},
|
||||
heroCompany: {
|
||||
fontSize: 14,
|
||||
color: '#475569',
|
||||
marginTop: 3,
|
||||
textAlign: 'center',
|
||||
},
|
||||
ausbildungPill: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
backgroundColor: '#F0FDF4',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 99,
|
||||
marginTop: 10,
|
||||
},
|
||||
ausbildungText: {
|
||||
fontSize: 12,
|
||||
color: '#15803D',
|
||||
fontWeight: '600',
|
||||
},
|
||||
card: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
marginHorizontal: 16,
|
||||
marginTop: 16,
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#1C1917',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 8,
|
||||
elevation: 1,
|
||||
},
|
||||
fieldRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 13,
|
||||
},
|
||||
fieldRowBorder: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#F9F9F9',
|
||||
},
|
||||
fieldLabel: {
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
color: '#64748B',
|
||||
letterSpacing: 0.8,
|
||||
width: 110,
|
||||
},
|
||||
fieldValue: {
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
color: '#0F172A',
|
||||
},
|
||||
actions: {
|
||||
marginHorizontal: 16,
|
||||
marginTop: 16,
|
||||
marginBottom: 32,
|
||||
gap: 10,
|
||||
},
|
||||
btnPrimary: {
|
||||
backgroundColor: '#003B7E',
|
||||
borderRadius: 14,
|
||||
paddingVertical: 15,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
shadowColor: '#003B7E',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.24,
|
||||
shadowRadius: 10,
|
||||
elevation: 5,
|
||||
},
|
||||
btnPrimaryText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
btnSecondary: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 14,
|
||||
paddingVertical: 15,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E2E8F0',
|
||||
},
|
||||
btnSecondaryText: {
|
||||
color: '#0F172A',
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
FlatList,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
RefreshControl,
|
||||
View, Text, FlatList, TextInput, TouchableOpacity, RefreshControl, StyleSheet,
|
||||
} from 'react-native'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
import { useMembersList } from '@/hooks/useMembers'
|
||||
import { useMembersFilterStore } from '@/store/members.store'
|
||||
import { MemberCard } from '@/components/members/MemberCard'
|
||||
import { EmptyState } from '@/components/ui/EmptyState'
|
||||
@@ -20,78 +16,70 @@ export default function MembersScreen() {
|
||||
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',
|
||||
})
|
||||
const { data, isLoading, refetch, isRefetching } = useMembersList()
|
||||
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-gray-50" edges={['top']}>
|
||||
<SafeAreaView style={styles.safeArea} 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>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={styles.screenTitle}>Mitglieder</Text>
|
||||
{data && (
|
||||
<Text style={styles.countText}>{data.length} gesamt</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 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>
|
||||
{/* Search bar */}
|
||||
<View style={styles.searchBar}>
|
||||
<Ionicons name="search-outline" size={16} color="#64748B" />
|
||||
<TextInput
|
||||
className="flex-1 text-sm text-gray-900"
|
||||
placeholder="Name, Betrieb, Ort, Sparte..."
|
||||
placeholderTextColor="#9ca3af"
|
||||
style={styles.searchInput}
|
||||
placeholder="Name, Betrieb, Ort, Sparte ..."
|
||||
placeholderTextColor="#64748B"
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
clearButtonMode="while-editing"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Filter: Ausbildungsbetriebe */}
|
||||
{/* Training filter */}
|
||||
<TouchableOpacity
|
||||
onPress={() => setNurAusbildungsbetriebe(!nurAusbildungsbetriebe)}
|
||||
className="flex-row items-center gap-2 py-1"
|
||||
style={styles.toggleRow}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<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'
|
||||
}`}
|
||||
>
|
||||
<View style={[styles.checkbox, nurAusbildungsbetriebe && styles.checkboxActive]}>
|
||||
{nurAusbildungsbetriebe && (
|
||||
<Text className="text-white text-xs">✓</Text>
|
||||
<Ionicons name="checkmark" size={11} color="#FFFFFF" />
|
||||
)}
|
||||
</View>
|
||||
<Text className="text-sm text-gray-600">Nur Ausbildungsbetriebe</Text>
|
||||
<Text style={styles.toggleLabel}>Nur Ausbildungsbetriebe</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* List */}
|
||||
<View style={styles.divider} />
|
||||
|
||||
{isLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : (
|
||||
<FlatList
|
||||
data={data ?? []}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={{ padding: 12, gap: 8 }}
|
||||
contentContainerStyle={styles.list}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isRefetching}
|
||||
onRefresh={refetch}
|
||||
tintColor="#E63946"
|
||||
/>
|
||||
<RefreshControl refreshing={isRefetching} onRefresh={refetch} tintColor="#003B7E" />
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<MemberCard
|
||||
member={item}
|
||||
onPress={() => router.push(`/(app)/members/${item.id}`)}
|
||||
onPress={() => router.push(`/(app)/members/${item.id}` as never)}
|
||||
/>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<EmptyState
|
||||
icon="👥"
|
||||
icon="people-outline"
|
||||
title="Keine Mitglieder"
|
||||
subtitle={search ? 'Keine Treffer für Ihre Suche' : 'Noch keine Mitglieder'}
|
||||
subtitle={search ? 'Keine Treffer für deine Suche' : 'Noch keine Mitglieder eingetragen'}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -99,3 +87,81 @@ export default function MembersScreen() {
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F8FAFC',
|
||||
},
|
||||
header: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 18,
|
||||
paddingBottom: 14,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'baseline',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 14,
|
||||
},
|
||||
screenTitle: {
|
||||
fontSize: 28,
|
||||
fontWeight: '800',
|
||||
color: '#0F172A',
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
countText: {
|
||||
fontSize: 13,
|
||||
color: '#64748B',
|
||||
fontWeight: '500',
|
||||
},
|
||||
searchBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#F4F4F5',
|
||||
borderRadius: 14,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 11,
|
||||
gap: 8,
|
||||
marginBottom: 10,
|
||||
},
|
||||
searchInput: {
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
color: '#0F172A',
|
||||
},
|
||||
toggleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
paddingTop: 2,
|
||||
},
|
||||
checkbox: {
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 5,
|
||||
borderWidth: 1.5,
|
||||
borderColor: '#D4D4D8',
|
||||
backgroundColor: '#FFFFFF',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
checkboxActive: {
|
||||
backgroundColor: '#003B7E',
|
||||
borderColor: '#003B7E',
|
||||
},
|
||||
toggleLabel: {
|
||||
fontSize: 13,
|
||||
color: '#52525B',
|
||||
fontWeight: '500',
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: '#E2E8F0',
|
||||
},
|
||||
list: {
|
||||
padding: 16,
|
||||
gap: 10,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,87 +1,383 @@
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
View, Text, ScrollView, TouchableOpacity, ActivityIndicator,
|
||||
StyleSheet, Platform,
|
||||
} 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 { Ionicons } from '@expo/vector-icons'
|
||||
import { useNewsDetail } from '@/hooks/useNews'
|
||||
import { AttachmentRow } from '@/components/news/AttachmentRow'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { NEWS_KATEGORIE_LABELS } from '@innungsapp/shared'
|
||||
import { NEWS_KATEGORIE_LABELS } from '@innungsapp/shared/types'
|
||||
import { format } from 'date-fns'
|
||||
import { de } from 'date-fns/locale'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lightweight markdown renderer (headings, bold, bullets, paragraphs)
|
||||
// ---------------------------------------------------------------------------
|
||||
function MarkdownBody({ source }: { source: string }) {
|
||||
const blocks = source.split(/\n\n+/)
|
||||
return (
|
||||
<View style={md.container}>
|
||||
{blocks.map((block, i) => {
|
||||
const trimmed = block.trim()
|
||||
if (!trimmed) return null
|
||||
|
||||
// H2 ##
|
||||
if (trimmed.startsWith('## ')) {
|
||||
return (
|
||||
<Text key={i} style={md.h2}>
|
||||
{trimmed.slice(3)}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
// H3 ###
|
||||
if (trimmed.startsWith('### ')) {
|
||||
return (
|
||||
<Text key={i} style={md.h3}>
|
||||
{trimmed.slice(4)}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
// H1 #
|
||||
if (trimmed.startsWith('# ')) {
|
||||
return (
|
||||
<Text key={i} style={md.h1}>
|
||||
{trimmed.slice(2)}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
// Bullet list
|
||||
if (trimmed.startsWith('- ')) {
|
||||
const items = trimmed.split('\n').filter(Boolean)
|
||||
return (
|
||||
<View key={i} style={md.list}>
|
||||
{items.map((line, j) => {
|
||||
const text = line.replace(/^-\s+/, '')
|
||||
return (
|
||||
<View key={j} style={md.listItem}>
|
||||
<View style={md.bullet} />
|
||||
<Text style={md.listText}>{renderInline(text)}</Text>
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
// Paragraph (with inline bold)
|
||||
return (
|
||||
<Text key={i} style={md.paragraph}>
|
||||
{renderInline(trimmed)}
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
/** Render **bold** inline within a Text node */
|
||||
function renderInline(text: string): React.ReactNode[] {
|
||||
const parts = text.split(/(\*\*.*?\*\*)/)
|
||||
return parts.map((part, i) => {
|
||||
if (part.startsWith('**') && part.endsWith('**')) {
|
||||
return (
|
||||
<Text key={i} style={{ fontWeight: '700', color: '#0F172A' }}>
|
||||
{part.slice(2, -2)}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
return <Text key={i}>{part}</Text>
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Screen
|
||||
// ---------------------------------------------------------------------------
|
||||
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, onOpen } = useNewsDetail(id)
|
||||
|
||||
const { data: news, isLoading } = trpc.news.byId.useQuery({ id })
|
||||
|
||||
useEffect(() => {
|
||||
if (news) {
|
||||
markRead(id)
|
||||
markReadMutation.mutate({ newsId: id })
|
||||
}
|
||||
}, [news?.id])
|
||||
useEffect(() => { if (news) onOpen() }, [news?.id])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-white items-center justify-center">
|
||||
<ActivityIndicator size="large" color="#E63946" />
|
||||
<SafeAreaView style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#003B7E" />
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
if (!news) return null
|
||||
|
||||
const initials = (news.author?.name ?? 'I')
|
||||
.split(' ')
|
||||
.map((n) => n.charAt(0))
|
||||
.slice(0, 2)
|
||||
.join('')
|
||||
|
||||
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>
|
||||
<SafeAreaView style={styles.safeArea} edges={['top']}>
|
||||
{/* Nav bar */}
|
||||
<View style={styles.navBar}>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
style={styles.backBtn}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={22} color="#003B7E" />
|
||||
<Text style={styles.backText}>Neuigkeiten</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} />
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* ── Hero header ────────────────────────────────────────── */}
|
||||
<View style={styles.hero}>
|
||||
<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 style={styles.heroTitle}>{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>
|
||||
{/* Author + date row */}
|
||||
<View style={styles.metaRow}>
|
||||
<View style={styles.avatarCircle}>
|
||||
<Text style={styles.avatarText}>{initials}</Text>
|
||||
</View>
|
||||
<View>
|
||||
<Text style={styles.authorName}>{news.author?.name ?? 'Innung'}</Text>
|
||||
{news.publishedAt && (
|
||||
<Text style={styles.dateText}>
|
||||
{format(new Date(news.publishedAt), 'dd. MMMM yyyy', { locale: de })}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 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>
|
||||
{/* ── Separator ──────────────────────────────────────────── */}
|
||||
<View style={styles.heroSeparator} />
|
||||
|
||||
{/* Attachments */}
|
||||
{/* ── Article body ───────────────────────────────────────── */}
|
||||
<MarkdownBody source={news.body} />
|
||||
|
||||
{/* ── 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 style={styles.attachmentsSection}>
|
||||
<View style={styles.attachmentsHeader}>
|
||||
<Ionicons name="attach" size={14} color="#64748B" />
|
||||
<Text style={styles.attachmentsLabel}>
|
||||
ANHÄNGE ({news.attachments.length})
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.attachmentsCard}>
|
||||
{news.attachments.map((a, idx) => (
|
||||
<View key={a.id}>
|
||||
{idx > 0 && <View style={styles.attachmentsDivider} />}
|
||||
<AttachmentRow attachment={a} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Bottom spacer */}
|
||||
<View style={{ height: 48 }} />
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
const styles = StyleSheet.create({
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
backgroundColor: '#FAFAFA',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
backgroundColor: '#FAFAFA',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
// Nav
|
||||
navBar: {
|
||||
backgroundColor: '#FAFAFA',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: '#E2E8F0',
|
||||
},
|
||||
backBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
backText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: '#003B7E',
|
||||
},
|
||||
// Hero
|
||||
hero: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 22,
|
||||
paddingBottom: 20,
|
||||
gap: 12,
|
||||
},
|
||||
heroTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: '800',
|
||||
color: '#0F172A',
|
||||
letterSpacing: -0.5,
|
||||
lineHeight: 32,
|
||||
marginTop: 4,
|
||||
},
|
||||
metaRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
marginTop: 4,
|
||||
},
|
||||
avatarCircle: {
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 19,
|
||||
backgroundColor: '#003B7E',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
avatarText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
authorName: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#0F172A',
|
||||
lineHeight: 18,
|
||||
},
|
||||
dateText: {
|
||||
fontSize: 12,
|
||||
color: '#94A3B8',
|
||||
marginTop: 1,
|
||||
},
|
||||
heroSeparator: {
|
||||
height: 4,
|
||||
backgroundColor: '#F1F5F9',
|
||||
},
|
||||
// Scroll
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
// Attachments
|
||||
attachmentsSection: {
|
||||
marginHorizontal: 20,
|
||||
marginTop: 28,
|
||||
},
|
||||
attachmentsHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
marginBottom: 10,
|
||||
},
|
||||
attachmentsLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
color: '#64748B',
|
||||
letterSpacing: 0.8,
|
||||
},
|
||||
attachmentsCard: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 16,
|
||||
paddingHorizontal: 16,
|
||||
overflow: 'hidden',
|
||||
...Platform.select({
|
||||
ios: {
|
||||
shadowColor: '#1C1917',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.06,
|
||||
shadowRadius: 10,
|
||||
},
|
||||
android: { elevation: 2 },
|
||||
}),
|
||||
},
|
||||
attachmentsDivider: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: '#F1F5F9',
|
||||
},
|
||||
})
|
||||
|
||||
// Markdown styles
|
||||
const md = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 22,
|
||||
paddingBottom: 8,
|
||||
gap: 14,
|
||||
},
|
||||
h1: {
|
||||
fontSize: 22,
|
||||
fontWeight: '800',
|
||||
color: '#0F172A',
|
||||
letterSpacing: -0.3,
|
||||
lineHeight: 30,
|
||||
},
|
||||
h2: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#0F172A',
|
||||
letterSpacing: -0.2,
|
||||
lineHeight: 26,
|
||||
marginTop: 8,
|
||||
paddingBottom: 6,
|
||||
borderBottomWidth: 2,
|
||||
borderBottomColor: '#EFF6FF',
|
||||
},
|
||||
h3: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#1E293B',
|
||||
lineHeight: 24,
|
||||
marginTop: 4,
|
||||
},
|
||||
paragraph: {
|
||||
fontSize: 16,
|
||||
color: '#334155',
|
||||
lineHeight: 28,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
list: {
|
||||
gap: 8,
|
||||
},
|
||||
listItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: 10,
|
||||
},
|
||||
bullet: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: '#003B7E',
|
||||
marginTop: 10,
|
||||
flexShrink: 0,
|
||||
},
|
||||
listText: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
color: '#334155',
|
||||
lineHeight: 28,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,100 +1,157 @@
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
View, Text, FlatList, TouchableOpacity, RefreshControl, ScrollView, StyleSheet,
|
||||
} 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 { useNewsList } from '@/hooks/useNews'
|
||||
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 = [
|
||||
const FILTERS = [
|
||||
{ value: undefined, label: 'Alle' },
|
||||
{ value: 'Wichtig', label: 'Wichtig' },
|
||||
{ value: 'Pruefung', label: 'Prüfung' },
|
||||
{ value: 'Foerderung', label: 'Förderung' },
|
||||
{ value: 'Pruefung', label: 'Pruefung' },
|
||||
{ value: 'Foerderung', label: 'Foerderung' },
|
||||
{ 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,
|
||||
})
|
||||
const { data, isLoading, refetch, isRefetching } = useNewsList(kategorie)
|
||||
|
||||
const unreadCount = data?.filter((n) => !n.isRead).length ?? 0
|
||||
|
||||
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>
|
||||
<SafeAreaView style={styles.safeArea} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={styles.screenTitle}>Aktuelles</Text>
|
||||
{unreadCount > 0 && (
|
||||
<View style={styles.unreadBadge}>
|
||||
<Text style={styles.unreadBadgeText}>{unreadCount} neu</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.filterScroll}
|
||||
>
|
||||
{FILTERS.map((opt) => {
|
||||
const active = kategorie === opt.value
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={String(opt.value)}
|
||||
onPress={() => setKategorie(opt.value)}
|
||||
style={[styles.chip, active && styles.chipActive]}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<Text style={[styles.chipLabel, active && styles.chipLabelActive]}>
|
||||
{opt.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
})}
|
||||
</ScrollView>
|
||||
</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>
|
||||
<View style={styles.divider} />
|
||||
|
||||
{/* List */}
|
||||
{isLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : (
|
||||
<FlatList
|
||||
data={data ?? []}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={{ padding: 12, gap: 8 }}
|
||||
contentContainerStyle={styles.list}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isRefetching}
|
||||
onRefresh={refetch}
|
||||
tintColor="#E63946"
|
||||
/>
|
||||
<RefreshControl refreshing={isRefetching} onRefresh={refetch} tintColor="#003B7E" />
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<NewsCard
|
||||
news={item}
|
||||
onPress={() => router.push(`/(app)/news/${item.id}`)}
|
||||
onPress={() => router.push(`/(app)/news/${item.id}` as never)}
|
||||
/>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<EmptyState
|
||||
icon="📰"
|
||||
title="Keine News"
|
||||
subtitle="Noch keine Beiträge für diese Kategorie"
|
||||
/>
|
||||
<EmptyState icon="N" title="Keine News" subtitle="Noch keine Beitraege veroeffentlicht." />
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F8FAFC',
|
||||
},
|
||||
header: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 14,
|
||||
paddingBottom: 0,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 14,
|
||||
},
|
||||
screenTitle: {
|
||||
fontSize: 28,
|
||||
fontWeight: '800',
|
||||
color: '#0F172A',
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
unreadBadge: {
|
||||
backgroundColor: '#EFF6FF',
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 99,
|
||||
},
|
||||
unreadBadgeText: {
|
||||
color: '#003B7E',
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
},
|
||||
filterScroll: {
|
||||
paddingBottom: 14,
|
||||
gap: 8,
|
||||
paddingRight: 20,
|
||||
},
|
||||
chip: {
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 7,
|
||||
borderRadius: 99,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E2E8F0',
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
chipActive: {
|
||||
backgroundColor: '#003B7E',
|
||||
borderColor: '#003B7E',
|
||||
},
|
||||
chipLabel: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: '#64748B',
|
||||
},
|
||||
chipLabelActive: {
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: '#E2E8F0',
|
||||
},
|
||||
list: {
|
||||
padding: 16,
|
||||
gap: 10,
|
||||
paddingBottom: 30,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,97 +1,330 @@
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
Linking,
|
||||
ActivityIndicator,
|
||||
} from 'react-native'
|
||||
import { View, Text, ScrollView, TouchableOpacity, Alert, StyleSheet } from 'react-native'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { Avatar } from '@/components/ui/Avatar'
|
||||
import { MOCK_MEMBER_ME } from '@/lib/mock-data'
|
||||
|
||||
type Item = {
|
||||
label: string
|
||||
icon: React.ComponentProps<typeof Ionicons>['name']
|
||||
badge?: string
|
||||
}
|
||||
|
||||
const MENU_ITEMS: Item[] = [
|
||||
{ label: 'Persoenliche Daten', icon: 'person-outline' },
|
||||
{ label: 'Betriebsdaten', icon: 'business-outline', badge: 'Aktiv' },
|
||||
{ label: 'Mitteilungen', icon: 'notifications-outline', badge: '2' },
|
||||
{ label: 'Sicherheit & Login', icon: 'shield-checkmark-outline' },
|
||||
{ label: 'Hilfe & Support', icon: 'help-circle-outline' },
|
||||
]
|
||||
|
||||
export default function ProfilScreen() {
|
||||
const { signOut } = useAuth()
|
||||
const { data: member, isLoading } = trpc.members.me.useQuery()
|
||||
const member = MOCK_MEMBER_ME
|
||||
|
||||
const initials = member.name
|
||||
.split(' ')
|
||||
.slice(0, 2)
|
||||
.map((chunk) => chunk[0]?.toUpperCase() ?? '')
|
||||
.join('')
|
||||
|
||||
const openPlaceholder = () => {
|
||||
Alert.alert('Hinweis', 'Dieser Bereich folgt in einer naechsten Version.')
|
||||
}
|
||||
|
||||
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" />
|
||||
<SafeAreaView style={styles.safeArea} edges={['top']}>
|
||||
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
|
||||
<View style={styles.hero}>
|
||||
<View style={styles.avatarWrap}>
|
||||
<Text style={styles.avatarText}>{initials}</Text>
|
||||
<TouchableOpacity style={styles.settingsBtn} activeOpacity={0.8} onPress={openPlaceholder}>
|
||||
<Ionicons name="settings-outline" size={15} color="#64748B" />
|
||||
</TouchableOpacity>
|
||||
</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>
|
||||
<Text style={styles.name}>{member.name}</Text>
|
||||
<Text style={styles.role}>Innungsgeschaeftsfuehrer</Text>
|
||||
<View style={styles.badgesRow}>
|
||||
<View style={styles.statusBadge}>
|
||||
<Text style={styles.statusBadgeText}>Admin-Status</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 style={[styles.statusBadge, styles.verifyBadge]}>
|
||||
<Text style={[styles.statusBadgeText, styles.verifyBadgeText]}>Verifiziert</Text>
|
||||
</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>
|
||||
</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>
|
||||
<Text style={styles.sectionTitle}>Mein Account</Text>
|
||||
<View style={styles.menuCard}>
|
||||
{MENU_ITEMS.map((item, index) => (
|
||||
<TouchableOpacity
|
||||
key={item.label}
|
||||
style={[styles.menuRow, index < MENU_ITEMS.length - 1 && styles.menuRowBorder]}
|
||||
activeOpacity={0.82}
|
||||
onPress={openPlaceholder}
|
||||
>
|
||||
<View style={styles.menuLeft}>
|
||||
<View style={styles.menuIcon}>
|
||||
<Ionicons name={item.icon} size={18} color="#475569" />
|
||||
</View>
|
||||
<Text style={styles.menuLabel}>{item.label}</Text>
|
||||
</View>
|
||||
<View style={styles.menuRight}>
|
||||
{item.badge ? (
|
||||
<View style={[styles.rowBadge, item.badge === 'Aktiv' ? styles.rowBadgeActive : styles.rowBadgeAlert]}>
|
||||
<Text style={styles.rowBadgeText}>{item.badge}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<Ionicons name="chevron-forward" size={16} color="#94A3B8" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View className="h-8" />
|
||||
<Text style={styles.sectionTitle}>Unterstuetzung</Text>
|
||||
<View style={styles.supportCard}>
|
||||
<Text style={styles.supportTitle}>Probleme oder Fragen?</Text>
|
||||
<Text style={styles.supportText}>
|
||||
Unser Support-Team hilft Ihnen gerne bei technischen Schwierigkeiten weiter.
|
||||
</Text>
|
||||
<TouchableOpacity style={styles.supportBtn} activeOpacity={0.84} onPress={openPlaceholder}>
|
||||
<Text style={styles.supportBtnText}>Support kontaktieren</Text>
|
||||
</TouchableOpacity>
|
||||
<Ionicons name="help-circle-outline" size={70} color="rgba(255,255,255,0.12)" style={styles.supportIcon} />
|
||||
</View>
|
||||
|
||||
<TouchableOpacity style={styles.logoutBtn} activeOpacity={0.84} onPress={() => void signOut()}>
|
||||
<Ionicons name="log-out-outline" size={20} color="#B91C1C" />
|
||||
<Text style={styles.logoutText}>Abmelden</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Text style={styles.footer}>InnungsApp Version 2.4.0</Text>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
const styles = StyleSheet.create({
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F8FAFC',
|
||||
},
|
||||
content: {
|
||||
paddingHorizontal: 18,
|
||||
paddingBottom: 30,
|
||||
gap: 14,
|
||||
},
|
||||
hero: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
alignItems: 'center',
|
||||
paddingTop: 24,
|
||||
paddingBottom: 18,
|
||||
borderRadius: 22,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E2E8F0',
|
||||
marginTop: 8,
|
||||
},
|
||||
avatarWrap: {
|
||||
position: 'relative',
|
||||
},
|
||||
avatarText: {
|
||||
width: 94,
|
||||
height: 94,
|
||||
borderRadius: 47,
|
||||
backgroundColor: '#DBEAFE',
|
||||
borderWidth: 4,
|
||||
borderColor: '#FFFFFF',
|
||||
overflow: 'hidden',
|
||||
textAlign: 'center',
|
||||
textAlignVertical: 'center',
|
||||
color: '#003B7E',
|
||||
fontSize: 34,
|
||||
fontWeight: '800',
|
||||
includeFontPadding: false,
|
||||
},
|
||||
settingsBtn: {
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
bottom: 2,
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 15,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderWidth: 1,
|
||||
borderColor: '#E2E8F0',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
name: {
|
||||
marginTop: 14,
|
||||
fontSize: 24,
|
||||
fontWeight: '800',
|
||||
color: '#0F172A',
|
||||
},
|
||||
role: {
|
||||
marginTop: 2,
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.5,
|
||||
color: '#64748B',
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
badgesRow: {
|
||||
marginTop: 10,
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
statusBadge: {
|
||||
backgroundColor: '#DCFCE7',
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
statusBadgeText: {
|
||||
color: '#166534',
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
},
|
||||
verifyBadge: {
|
||||
backgroundColor: '#DBEAFE',
|
||||
},
|
||||
verifyBadgeText: {
|
||||
color: '#1D4ED8',
|
||||
},
|
||||
sectionTitle: {
|
||||
marginTop: 2,
|
||||
paddingLeft: 2,
|
||||
fontSize: 11,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1.1,
|
||||
color: '#94A3B8',
|
||||
fontWeight: '800',
|
||||
},
|
||||
menuCard: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 18,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E2E8F0',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
menuRow: {
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
menuRowBorder: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#F1F5F9',
|
||||
},
|
||||
menuLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
menuIcon: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 11,
|
||||
backgroundColor: '#F1F5F9',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
menuLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
color: '#1E293B',
|
||||
},
|
||||
menuRight: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 7,
|
||||
},
|
||||
rowBadge: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 999,
|
||||
},
|
||||
rowBadgeActive: {
|
||||
backgroundColor: '#DCFCE7',
|
||||
},
|
||||
rowBadgeAlert: {
|
||||
backgroundColor: '#EF4444',
|
||||
},
|
||||
rowBadgeText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
supportCard: {
|
||||
borderRadius: 18,
|
||||
backgroundColor: '#003B7E',
|
||||
padding: 16,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
},
|
||||
supportTitle: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
marginBottom: 4,
|
||||
maxWidth: 180,
|
||||
},
|
||||
supportText: {
|
||||
color: '#BFDBFE',
|
||||
fontSize: 12,
|
||||
lineHeight: 18,
|
||||
marginBottom: 12,
|
||||
maxWidth: 240,
|
||||
},
|
||||
supportBtn: {
|
||||
alignSelf: 'flex-start',
|
||||
backgroundColor: 'rgba(255,255,255,0.15)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.25)',
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
supportBtnText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.6,
|
||||
},
|
||||
supportIcon: {
|
||||
position: 'absolute',
|
||||
right: -10,
|
||||
bottom: -12,
|
||||
},
|
||||
logoutBtn: {
|
||||
marginTop: 4,
|
||||
backgroundColor: '#FEF2F2',
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: '#FECACA',
|
||||
paddingVertical: 14,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
logoutText: {
|
||||
color: '#B91C1C',
|
||||
fontSize: 14,
|
||||
fontWeight: '800',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
},
|
||||
footer: {
|
||||
textAlign: 'center',
|
||||
marginTop: 4,
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 1,
|
||||
color: '#94A3B8',
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,94 +1,335 @@
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
Linking,
|
||||
ActivityIndicator,
|
||||
View, Text, ScrollView, TouchableOpacity, Linking, ActivityIndicator, StyleSheet, Platform
|
||||
} from 'react-native'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { useLocalSearchParams, useRouter, Stack } from 'expo-router'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
import { useStelleDetail } from '@/hooks/useStellen'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
|
||||
const SPARTE_COLOR: Record<string, string> = {
|
||||
elektro: '#1D4ED8', sanitär: '#0E7490', it: '#7C3AED',
|
||||
info: '#7C3AED', heizung: '#D97706', maler: '#059669',
|
||||
}
|
||||
function getSparteColor(sparte: string): string {
|
||||
const lower = sparte.toLowerCase()
|
||||
for (const [k, v] of Object.entries(SPARTE_COLOR)) {
|
||||
if (lower.includes(k)) return v
|
||||
}
|
||||
return '#003B7E'
|
||||
}
|
||||
|
||||
export default function StelleDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
const { data: stelle, isLoading } = trpc.stellen.byId.useQuery({ id })
|
||||
const { data: stelle, isLoading } = useStelleDetail(id)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-white items-center justify-center">
|
||||
<ActivityIndicator size="large" color="#E63946" />
|
||||
</SafeAreaView>
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#003B7E" />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (!stelle) return null
|
||||
|
||||
const betreffVorlage = `Bewerbung als Auszubildender bei ${stelle.member.betrieb}`
|
||||
const bewerbungsUrl = `mailto:${stelle.kontaktEmail}?subject=${encodeURIComponent(betreffVorlage)}`
|
||||
const color = getSparteColor(stelle.sparte)
|
||||
const initial = stelle.sparte.charAt(0).toUpperCase()
|
||||
const bewerbungsUrl = `mailto:${stelle.kontaktEmail}?subject=${encodeURIComponent(`Bewerbung als Auszubildender bei ${stelle.member.betrieb}`)}`
|
||||
|
||||
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>
|
||||
<>
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
{/* 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>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
|
||||
<Ionicons name="arrow-back" size={24} color="#0F172A" />
|
||||
</TouchableOpacity>
|
||||
<View style={styles.headerSpacer} />
|
||||
<TouchableOpacity style={styles.shareButton}>
|
||||
<Ionicons name="share-outline" size={24} color="#0F172A" />
|
||||
</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>
|
||||
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
|
||||
|
||||
{/* Header Card */}
|
||||
<View style={styles.heroCard}>
|
||||
<View style={[styles.logoBox, { backgroundColor: color + '15' }]}>
|
||||
<Text style={[styles.logoText, { color }]}>{initial}</Text>
|
||||
</View>
|
||||
<Text style={styles.jobTitle}>Auszubildender {stelle.sparte}</Text>
|
||||
<Text style={styles.companyName}>{stelle.member.betrieb}</Text>
|
||||
|
||||
<View style={styles.locationBadge}>
|
||||
<Ionicons name="location-sharp" size={14} color="#64748B" />
|
||||
<Text style={styles.locationText}>
|
||||
{stelle.member.ort} · {stelle.org.name}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Key Facts */}
|
||||
<Text style={styles.sectionHeader}>Eckdaten</Text>
|
||||
<View style={styles.factsContainer}>
|
||||
<View style={styles.factItem}>
|
||||
<View style={styles.factIconBox}>
|
||||
<Ionicons name="people-outline" size={20} color="#003B7E" />
|
||||
</View>
|
||||
<View>
|
||||
<Text style={styles.factLabel}>Anzahl Stellen</Text>
|
||||
<Text style={styles.factValue}>{stelle.stellenAnz}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{stelle.lehrjahr && (
|
||||
<View style={styles.factItem}>
|
||||
<View style={styles.factIconBox}>
|
||||
<Ionicons name="school-outline" size={20} color="#003B7E" />
|
||||
</View>
|
||||
<View>
|
||||
<Text style={styles.factLabel}>Lehrjahr</Text>
|
||||
<Text style={styles.factValue}>{stelle.lehrjahr}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{stelle.verguetung && (
|
||||
<View style={styles.factItem}>
|
||||
<View style={styles.factIconBox}>
|
||||
<Ionicons name="cash-outline" size={20} color="#003B7E" />
|
||||
</View>
|
||||
<View>
|
||||
<Text style={styles.factLabel}>Vergütung</Text>
|
||||
<Text style={styles.factValue}>{stelle.verguetung}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Description */}
|
||||
{stelle.beschreibung && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionHeader}>Beschreibung</Text>
|
||||
<Text style={styles.description}>{stelle.beschreibung}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Contact */}
|
||||
{stelle.kontaktName && (
|
||||
<View style={styles.contactBox}>
|
||||
<Text style={styles.contactTitle}>Ansprechpartner</Text>
|
||||
<View style={styles.contactRow}>
|
||||
<View style={styles.avatarPlaceholder}>
|
||||
<Text style={styles.avatarInitials}>{stelle.kontaktName.charAt(0)}</Text>
|
||||
</View>
|
||||
<View>
|
||||
<Text style={styles.contactName}>{stelle.kontaktName}</Text>
|
||||
<Text style={styles.contactRole}>Recruiting</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
</ScrollView>
|
||||
|
||||
{/* Footer */}
|
||||
<View style={styles.footer}>
|
||||
<Button label="Jetzt bewerben" onPress={() => Linking.openURL(bewerbungsUrl)} />
|
||||
</View>
|
||||
|
||||
</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>
|
||||
)
|
||||
}
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F8FAFC',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#F8FAFC',
|
||||
},
|
||||
backButton: {
|
||||
padding: 8,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#FFFFFF',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
elevation: 1,
|
||||
},
|
||||
headerSpacer: {
|
||||
flex: 1,
|
||||
},
|
||||
shareButton: {
|
||||
padding: 8,
|
||||
},
|
||||
scrollContent: {
|
||||
padding: 24,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
heroCard: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 32,
|
||||
},
|
||||
logoBox: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 24,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
logoText: {
|
||||
fontSize: 32,
|
||||
fontWeight: '800',
|
||||
},
|
||||
jobTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: '800',
|
||||
color: '#0F172A',
|
||||
textAlign: 'center',
|
||||
marginBottom: 8,
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
companyName: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#64748B',
|
||||
marginBottom: 16,
|
||||
},
|
||||
locationBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#F1F5F9',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 99,
|
||||
gap: 6,
|
||||
},
|
||||
locationText: {
|
||||
fontSize: 13,
|
||||
color: '#475569',
|
||||
fontWeight: '500',
|
||||
},
|
||||
sectionHeader: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#0F172A',
|
||||
marginBottom: 16,
|
||||
},
|
||||
factsContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
marginBottom: 32,
|
||||
},
|
||||
factItem: {
|
||||
flex: 1,
|
||||
minWidth: '45%',
|
||||
backgroundColor: '#FFFFFF',
|
||||
padding: 16,
|
||||
borderRadius: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
shadowColor: '#64748B',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.03,
|
||||
shadowRadius: 8,
|
||||
elevation: 1,
|
||||
},
|
||||
factIconBox: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 10,
|
||||
backgroundColor: '#EFF6FF',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
factLabel: {
|
||||
fontSize: 11,
|
||||
color: '#94A3B8',
|
||||
fontWeight: '600',
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: 2,
|
||||
},
|
||||
factValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
color: '#0F172A',
|
||||
},
|
||||
section: {
|
||||
marginBottom: 32,
|
||||
},
|
||||
description: {
|
||||
fontSize: 16,
|
||||
lineHeight: 26,
|
||||
color: '#334155',
|
||||
},
|
||||
contactBox: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
padding: 20,
|
||||
borderRadius: 20,
|
||||
marginBottom: 32,
|
||||
},
|
||||
contactTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
color: '#94A3B8',
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: 16,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
contactRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
avatarPlaceholder: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
backgroundColor: '#F1F5F9',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
avatarInitials: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#64748B',
|
||||
},
|
||||
contactName: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#0F172A',
|
||||
},
|
||||
contactRole: {
|
||||
fontSize: 13,
|
||||
color: '#64748B',
|
||||
},
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: '#FFFFFF',
|
||||
padding: 20,
|
||||
paddingBottom: Platform.OS === 'ios' ? 32 : 20,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#F1F5F9',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,70 +1,60 @@
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
RefreshControl,
|
||||
} from 'react-native'
|
||||
import { View, Text, FlatList, RefreshControl, StyleSheet } from 'react-native'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { useStellenListe } from '@/hooks/useStellen'
|
||||
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({})
|
||||
const { data, isLoading, refetch, isRefetching } = useStellenListe()
|
||||
|
||||
const totalStellen = data?.reduce((sum, s) => sum + s.stellenAnz, 0) ?? 0
|
||||
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-gray-50" edges={['top']}>
|
||||
<SafeAreaView style={styles.safeArea} 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 style={styles.header}>
|
||||
<View style={styles.titleRow}>
|
||||
<View style={styles.titleBlock}>
|
||||
<Text style={styles.screenTitle}>Lehrlingsbörse</Text>
|
||||
<Text style={styles.subtitle}>Ausbildungsplätze in deiner Innung</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>
|
||||
|
||||
{/* Counter */}
|
||||
{totalStellen > 0 && (
|
||||
<View style={styles.counter}>
|
||||
<Text style={styles.counterNumber}>{totalStellen}</Text>
|
||||
<Text style={styles.counterLabel}>Stellen</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
{isLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : (
|
||||
<FlatList
|
||||
data={data ?? []}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={{ padding: 12, gap: 8 }}
|
||||
contentContainerStyle={styles.list}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isRefetching}
|
||||
onRefresh={refetch}
|
||||
tintColor="#E63946"
|
||||
/>
|
||||
<RefreshControl refreshing={isRefetching} onRefresh={refetch} tintColor="#003B7E" />
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<StelleCard
|
||||
stelle={item}
|
||||
onPress={() => router.push(`/(app)/stellen/${item.id}`)}
|
||||
onPress={() => router.push(`/(app)/stellen/${item.id}` as never)}
|
||||
/>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<EmptyState
|
||||
icon="🎓"
|
||||
icon="school-outline"
|
||||
title="Keine Angebote"
|
||||
subtitle="Aktuell sind keine Ausbildungsplätze verfügbar"
|
||||
subtitle="Aktuell keine Ausbildungsplätze verfügbar"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -72,3 +62,69 @@ export default function StellenScreen() {
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F8FAFC',
|
||||
},
|
||||
header: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 18,
|
||||
paddingBottom: 16,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
titleBlock: {
|
||||
flex: 1,
|
||||
},
|
||||
screenTitle: {
|
||||
fontSize: 26,
|
||||
fontWeight: '800',
|
||||
color: '#0F172A',
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 13,
|
||||
color: '#64748B',
|
||||
marginTop: 2,
|
||||
},
|
||||
counter: {
|
||||
backgroundColor: '#003B7E',
|
||||
borderRadius: 14,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
alignItems: 'center',
|
||||
minWidth: 56,
|
||||
shadowColor: '#003B7E',
|
||||
shadowOffset: { width: 0, height: 3 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
},
|
||||
counterNumber: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 22,
|
||||
fontWeight: '800',
|
||||
lineHeight: 24,
|
||||
},
|
||||
counterLabel: {
|
||||
color: 'rgba(255,255,255,0.75)',
|
||||
fontSize: 9,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: '#E2E8F0',
|
||||
},
|
||||
list: {
|
||||
padding: 16,
|
||||
gap: 10,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,152 +1,379 @@
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
Linking,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
View, Text, ScrollView, TouchableOpacity, Linking, ActivityIndicator, Alert, StyleSheet, Platform,
|
||||
} from 'react-native'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { useLocalSearchParams, useRouter, Stack } from 'expo-router'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
import { useTerminDetail, useToggleAnmeldung } from '@/hooks/useTermine'
|
||||
import { AnmeldeButton } from '@/components/termine/AnmeldeButton'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { TERMIN_TYP_LABELS } from '@innungsapp/shared'
|
||||
import { TERMIN_TYP_LABELS } from '@innungsapp/shared/types'
|
||||
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.')
|
||||
}
|
||||
const { data: termin, isLoading } = useTerminDetail(id)
|
||||
const { mutate, isPending } = useToggleAnmeldung()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-white items-center justify-center">
|
||||
<ActivityIndicator size="large" color="#E63946" />
|
||||
</SafeAreaView>
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#003B7E" />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (!termin) return null
|
||||
|
||||
const datumFormatted = format(new Date(termin.datum), 'EEEE, dd. MMMM yyyy', { locale: de })
|
||||
const datum = new Date(termin.datum)
|
||||
const isPast = datum < new Date()
|
||||
|
||||
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>
|
||||
<>
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
|
||||
<Ionicons name="arrow-back" size={24} color="#0F172A" />
|
||||
</TouchableOpacity>
|
||||
<View style={styles.headerSpacer} />
|
||||
<TouchableOpacity style={styles.shareButton}>
|
||||
<Ionicons name="share-outline" size={24} color="#0F172A" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View className="h-8" />
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
|
||||
{/* Date Badge */}
|
||||
<View style={styles.dateline}>
|
||||
<View style={styles.calendarIcon}>
|
||||
<Ionicons name="calendar" size={18} color="#003B7E" />
|
||||
</View>
|
||||
<Text style={styles.dateText}>
|
||||
{format(datum, 'EEEE, d. MMMM yyyy', { locale: de })}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.title}>{termin.titel}</Text>
|
||||
|
||||
<View style={styles.badgesRow}>
|
||||
<Badge label={TERMIN_TYP_LABELS[termin.typ]} typ={termin.typ} />
|
||||
{termin.isAngemeldet && (
|
||||
<View style={styles.registeredBadge}>
|
||||
<Ionicons name="checkmark-circle" size={14} color="#059669" />
|
||||
<Text style={styles.registeredText}>Angemeldet</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Info Card */}
|
||||
<View style={styles.card}>
|
||||
{/* Time */}
|
||||
{termin.uhrzeit && (
|
||||
<View style={styles.infoRow}>
|
||||
<View style={styles.iconBox}>
|
||||
<Ionicons name="time-outline" size={22} color="#64748B" />
|
||||
</View>
|
||||
<View style={styles.infoContent}>
|
||||
<Text style={styles.infoLabel}>Uhrzeit</Text>
|
||||
<Text style={styles.infoValue}>
|
||||
{termin.uhrzeit}
|
||||
{termin.endeUhrzeit ? ` – ${termin.endeUhrzeit}` : ''} Uhr
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{termin.uhrzeit && termin.ort && <View style={styles.divider} />}
|
||||
|
||||
{/* Location */}
|
||||
{termin.ort && (
|
||||
<TouchableOpacity
|
||||
style={styles.infoRow}
|
||||
onPress={() =>
|
||||
termin.adresse &&
|
||||
Linking.openURL(`https://maps.google.com/?q=${encodeURIComponent(termin.adresse)}`)
|
||||
}
|
||||
activeOpacity={termin.adresse ? 0.7 : 1}
|
||||
>
|
||||
<View style={styles.iconBox}>
|
||||
<Ionicons name="location-outline" size={22} color="#64748B" />
|
||||
</View>
|
||||
<View style={styles.infoContent}>
|
||||
<Text style={styles.infoLabel}>Ort</Text>
|
||||
<Text style={styles.infoValue}>{termin.ort}</Text>
|
||||
{termin.adresse && (
|
||||
<Text style={styles.infoSub}>{termin.adresse}</Text>
|
||||
)}
|
||||
</View>
|
||||
{termin.adresse && (
|
||||
<Ionicons name="chevron-forward" size={20} color="#CBD5E1" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{(termin.uhrzeit || termin.ort) && (termin.teilnehmerAnzahl !== undefined) && <View style={styles.divider} />}
|
||||
|
||||
{/* Participants */}
|
||||
<View style={styles.infoRow}>
|
||||
<View style={styles.iconBox}>
|
||||
<Ionicons name="people-outline" size={22} color="#64748B" />
|
||||
</View>
|
||||
<View style={styles.infoContent}>
|
||||
<Text style={styles.infoLabel}>Teilnehmer</Text>
|
||||
<Text style={styles.infoValue}>
|
||||
{termin.teilnehmerAnzahl} Anmeldungen
|
||||
</Text>
|
||||
{termin.maxTeilnehmer && (
|
||||
<Text style={styles.infoSub}>{termin.maxTeilnehmer} Plätze verfügbar</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Description */}
|
||||
{termin.beschreibung && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Beschreibung</Text>
|
||||
<Text style={styles.description}>{termin.beschreibung}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
</ScrollView>
|
||||
|
||||
{/* Bottom Action Bar – only for upcoming events */}
|
||||
{isPast ? (
|
||||
<View style={styles.pastBar}>
|
||||
<Ionicons name="time-outline" size={16} color="#94A3B8" />
|
||||
<Text style={styles.pastBarText}>Vergangener Termin – keine Anmeldung möglich</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.actionBar}>
|
||||
<View style={styles.actionButtonContainer}>
|
||||
<AnmeldeButton
|
||||
terminId={id}
|
||||
isAngemeldet={termin.isAngemeldet}
|
||||
onToggle={() => mutate({ terminId: id })}
|
||||
isLoading={isPending}
|
||||
/>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={styles.calendarButton}
|
||||
onPress={() => Alert.alert('Kalender', 'Funktion folgt in Kürze')}
|
||||
>
|
||||
<Ionicons name="calendar-outline" size={24} color="#0F172A" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
</SafeAreaView>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F8FAFC',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#F8FAFC',
|
||||
},
|
||||
backButton: {
|
||||
padding: 8,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#FFFFFF',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
elevation: 1,
|
||||
},
|
||||
headerSpacer: {
|
||||
flex: 1,
|
||||
},
|
||||
shareButton: {
|
||||
padding: 8,
|
||||
},
|
||||
scrollContent: {
|
||||
padding: 24,
|
||||
paddingBottom: 100,
|
||||
},
|
||||
dateline: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
gap: 8,
|
||||
},
|
||||
calendarIcon: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#EFF6FF',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
dateText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: '#003B7E',
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: '800',
|
||||
color: '#0F172A',
|
||||
lineHeight: 34,
|
||||
marginBottom: 16,
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
badgesRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
marginBottom: 32,
|
||||
},
|
||||
registeredBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
backgroundColor: '#ECFDF5',
|
||||
borderRadius: 6,
|
||||
borderWidth: 1,
|
||||
borderColor: '#A7F3D0',
|
||||
},
|
||||
registeredText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: '#047857',
|
||||
},
|
||||
card: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 20,
|
||||
padding: 20,
|
||||
shadowColor: '#64748B',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.04,
|
||||
shadowRadius: 12,
|
||||
elevation: 2,
|
||||
marginBottom: 32,
|
||||
},
|
||||
infoRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: 16,
|
||||
},
|
||||
iconBox: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
backgroundColor: '#F1F5F9',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
infoContent: {
|
||||
flex: 1,
|
||||
paddingTop: 2,
|
||||
},
|
||||
infoLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: '#94A3B8',
|
||||
marginBottom: 2,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
infoValue: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#0F172A',
|
||||
lineHeight: 22,
|
||||
},
|
||||
infoSub: {
|
||||
fontSize: 14,
|
||||
color: '#64748B',
|
||||
marginTop: 2,
|
||||
lineHeight: 20,
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: '#F1F5F9',
|
||||
marginVertical: 16,
|
||||
marginLeft: 56,
|
||||
},
|
||||
section: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#0F172A',
|
||||
marginBottom: 12,
|
||||
},
|
||||
description: {
|
||||
fontSize: 16,
|
||||
lineHeight: 26,
|
||||
color: '#334155',
|
||||
},
|
||||
actionBar: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: '#FFFFFF',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
paddingBottom: Platform.OS === 'ios' ? 32 : 16,
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#F1F5F9',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: -4 },
|
||||
shadowOpacity: 0.02,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
},
|
||||
pastBar: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: '#F8FAFC',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 14,
|
||||
paddingBottom: Platform.OS === 'ios' ? 28 : 14,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#E2E8F0',
|
||||
},
|
||||
pastBarText: {
|
||||
fontSize: 13,
|
||||
color: '#94A3B8',
|
||||
fontWeight: '500',
|
||||
},
|
||||
actionButtonContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
calendarButton: {
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#F1F5F9',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,78 +1,78 @@
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
RefreshControl,
|
||||
View, Text, FlatList, TouchableOpacity, RefreshControl, StyleSheet,
|
||||
} 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 { useTermineListe, useToggleAnmeldung } from '@/hooks/useTermine'
|
||||
import { TerminCard } from '@/components/termine/TerminCard'
|
||||
import { EmptyState } from '@/components/ui/EmptyState'
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'
|
||||
|
||||
type Tab = 'kommend' | 'vergangen'
|
||||
|
||||
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',
|
||||
})
|
||||
const [tab, setTab] = useState<Tab>('kommend')
|
||||
const { data, isLoading, refetch, isRefetching } = useTermineListe(tab === 'kommend')
|
||||
const { mutate, isPending } = useToggleAnmeldung()
|
||||
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-gray-50" edges={['top']}>
|
||||
<SafeAreaView style={styles.safeArea} 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'
|
||||
}`}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.screenTitle}>Termine</Text>
|
||||
|
||||
{/* Segment control */}
|
||||
<View style={styles.segmentContainer}>
|
||||
{(['kommend', 'vergangen'] as Tab[]).map((t) => {
|
||||
const active = tab === t
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={t}
|
||||
onPress={() => setTab(t)}
|
||||
style={[styles.segment, active && styles.segmentActive]}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{t === 'kommend' ? 'Bevorstehend' : 'Vergangen'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
<Text style={[styles.segmentLabel, active && styles.segmentLabelActive]}>
|
||||
{t === 'kommend' ? 'Bevorstehend' : 'Vergangen'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
{isLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : (
|
||||
<FlatList
|
||||
data={data ?? []}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={{ padding: 12, gap: 8 }}
|
||||
contentContainerStyle={styles.list}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isRefetching}
|
||||
onRefresh={refetch}
|
||||
tintColor="#E63946"
|
||||
/>
|
||||
<RefreshControl refreshing={isRefetching} onRefresh={refetch} tintColor="#003B7E" />
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<TerminCard
|
||||
termin={item}
|
||||
onPress={() => router.push(`/(app)/termine/${item.id}`)}
|
||||
isPast={tab === 'vergangen'}
|
||||
onPress={() => router.push(`/(app)/termine/${item.id}` as never)}
|
||||
onToggleAnmeldung={() => mutate({ terminId: item.id })}
|
||||
isToggling={isPending}
|
||||
/>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<EmptyState
|
||||
icon="📅"
|
||||
title={tab === 'kommend' ? 'Keine Termine' : 'Keine vergangenen Termine'}
|
||||
subtitle="Es sind aktuell keine Termine eingetragen"
|
||||
icon="calendar-outline"
|
||||
title="Keine Termine"
|
||||
subtitle={
|
||||
tab === 'kommend'
|
||||
? 'Aktuell keine bevorstehenden Termine'
|
||||
: 'Keine vergangenen Termine'
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -80,3 +80,61 @@ export default function TermineScreen() {
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F8FAFC',
|
||||
},
|
||||
header: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 18,
|
||||
paddingBottom: 14,
|
||||
},
|
||||
screenTitle: {
|
||||
fontSize: 28,
|
||||
fontWeight: '800',
|
||||
color: '#0F172A',
|
||||
letterSpacing: -0.5,
|
||||
marginBottom: 14,
|
||||
},
|
||||
segmentContainer: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#F4F4F5',
|
||||
borderRadius: 14,
|
||||
padding: 3,
|
||||
gap: 3,
|
||||
},
|
||||
segment: {
|
||||
flex: 1,
|
||||
paddingVertical: 9,
|
||||
borderRadius: 11,
|
||||
alignItems: 'center',
|
||||
},
|
||||
segmentActive: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
segmentLabel: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
color: '#64748B',
|
||||
},
|
||||
segmentLabelActive: {
|
||||
color: '#0F172A',
|
||||
fontWeight: '600',
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: '#E2E8F0',
|
||||
},
|
||||
list: {
|
||||
padding: 16,
|
||||
gap: 10,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,44 +1,106 @@
|
||||
import { View, Text, TouchableOpacity } from 'react-native'
|
||||
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'
|
||||
import { useRouter, useLocalSearchParams } from 'expo-router'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
|
||||
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>
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<View style={styles.content}>
|
||||
<View style={styles.iconBox}>
|
||||
<Ionicons name="checkmark-circle" size={46} color="#15803D" />
|
||||
</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 style={styles.title}>E-Mail pruefen</Text>
|
||||
<Text style={styles.body}>Wir haben einen Login-Link gesendet an:</Text>
|
||||
|
||||
<View style={styles.emailBox}>
|
||||
<Text style={styles.emailText} numberOfLines={1}>{email}</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.hint}>
|
||||
Bitte oeffnen Sie den Link in Ihrer E-Mail, um sich anzumelden.
|
||||
</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>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
style={styles.backBtn}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={16} color="#003B7E" />
|
||||
<Text style={styles.backText}>Andere E-Mail verwenden</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 28,
|
||||
},
|
||||
iconBox: {
|
||||
width: 88,
|
||||
height: 88,
|
||||
backgroundColor: '#DCFCE7',
|
||||
borderRadius: 44,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: '800',
|
||||
color: '#0F172A',
|
||||
textAlign: 'center',
|
||||
marginBottom: 10,
|
||||
},
|
||||
body: {
|
||||
fontSize: 14,
|
||||
color: '#64748B',
|
||||
textAlign: 'center',
|
||||
marginBottom: 6,
|
||||
},
|
||||
emailBox: {
|
||||
backgroundColor: '#F8FAFC',
|
||||
borderWidth: 1,
|
||||
borderColor: '#E2E8F0',
|
||||
borderRadius: 14,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
marginBottom: 16,
|
||||
},
|
||||
emailText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
color: '#0F172A',
|
||||
},
|
||||
hint: {
|
||||
fontSize: 13,
|
||||
color: '#64748B',
|
||||
textAlign: 'center',
|
||||
lineHeight: 19,
|
||||
paddingHorizontal: 8,
|
||||
},
|
||||
backBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
marginTop: 28,
|
||||
},
|
||||
backText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
color: '#003B7E',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ActivityIndicator,
|
||||
View, Text, TextInput, TouchableOpacity,
|
||||
KeyboardAvoidingView, Platform, ActivityIndicator, StyleSheet,
|
||||
} 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'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
import { authClient } from '@/lib/auth-client'
|
||||
|
||||
export default function LoginScreen() {
|
||||
const router = useRouter()
|
||||
@@ -18,16 +14,16 @@ export default function LoginScreen() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const canSubmit = email.trim().length > 0 && !loading
|
||||
|
||||
async function handleSendLink() {
|
||||
if (!email.trim()) return
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
const result = await authClient.signIn.magicLink({
|
||||
email: email.trim().toLowerCase(),
|
||||
callbackURL: '/news',
|
||||
callbackURL: '/home',
|
||||
})
|
||||
|
||||
setLoading(false)
|
||||
if (result.error) {
|
||||
setError(result.error.message ?? 'Ein Fehler ist aufgetreten.')
|
||||
@@ -37,33 +33,28 @@ export default function LoginScreen() {
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-white">
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
className="flex-1"
|
||||
style={styles.keyboardView}
|
||||
>
|
||||
<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 style={styles.content}>
|
||||
<View style={styles.logoSection}>
|
||||
<View style={styles.logoBox}>
|
||||
<Text style={styles.logoLetter}>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>
|
||||
<Text style={styles.appName}>InnungsApp</Text>
|
||||
<Text style={styles.tagline}>Ihre Kreishandwerkerschaft digital</Text>
|
||||
</View>
|
||||
|
||||
{/* Form */}
|
||||
<View className="space-y-4">
|
||||
<View>
|
||||
<Text className="text-sm font-medium text-gray-700 mb-2">
|
||||
E-Mail-Adresse
|
||||
</Text>
|
||||
<View style={styles.form}>
|
||||
<Text style={styles.inputLabel}>Email-Adresse</Text>
|
||||
<View style={styles.inputWrap}>
|
||||
<Ionicons name="mail-outline" size={18} color="#94A3B8" />
|
||||
<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"
|
||||
style={styles.input}
|
||||
placeholder="beispiel@handwerk.de"
|
||||
placeholderTextColor="#94A3B8"
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
@@ -75,37 +66,141 @@ export default function LoginScreen() {
|
||||
</View>
|
||||
|
||||
{error ? (
|
||||
<View className="bg-red-50 rounded-xl px-4 py-3">
|
||||
<Text className="text-red-600 text-sm">{error}</Text>
|
||||
<View style={styles.errorBox}>
|
||||
<Text style={styles.errorText}>{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'
|
||||
}`}
|
||||
disabled={!canSubmit}
|
||||
style={[styles.submitBtn, !canSubmit && styles.submitBtnDisabled]}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="white" />
|
||||
<ActivityIndicator color="#FFFFFF" />
|
||||
) : (
|
||||
<Text
|
||||
className={`font-semibold text-base ${
|
||||
loading || !email.trim() ? 'text-gray-400' : 'text-white'
|
||||
}`}
|
||||
>
|
||||
Magic Link senden
|
||||
</Text>
|
||||
<View style={styles.submitContent}>
|
||||
<Text style={styles.submitLabel}>Login-Link senden</Text>
|
||||
<Ionicons name="arrow-forward" size={16} color="#FFFFFF" />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<Text className="text-center text-sm text-gray-400">
|
||||
Kein Passwort nötig — Zugang nur per Admin-Einladung
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.hint}>
|
||||
Noch kein Zugang? Kontaktieren Sie Ihre Innungsgeschaeftsstelle.
|
||||
</Text>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
keyboardView: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
logoSection: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 40,
|
||||
},
|
||||
logoBox: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
backgroundColor: '#003B7E',
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
logoLetter: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 30,
|
||||
fontWeight: '900',
|
||||
},
|
||||
appName: {
|
||||
fontSize: 30,
|
||||
fontWeight: '800',
|
||||
color: '#0F172A',
|
||||
letterSpacing: -0.6,
|
||||
marginBottom: 4,
|
||||
},
|
||||
tagline: {
|
||||
fontSize: 14,
|
||||
color: '#64748B',
|
||||
textAlign: 'center',
|
||||
},
|
||||
form: {
|
||||
gap: 12,
|
||||
},
|
||||
inputLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
color: '#334155',
|
||||
},
|
||||
inputWrap: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#F8FAFC',
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E2E8F0',
|
||||
paddingHorizontal: 12,
|
||||
gap: 8,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
paddingVertical: 13,
|
||||
color: '#0F172A',
|
||||
fontSize: 15,
|
||||
},
|
||||
errorBox: {
|
||||
backgroundColor: '#FEF2F2',
|
||||
borderWidth: 1,
|
||||
borderColor: '#FECACA',
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
errorText: {
|
||||
color: '#B91C1C',
|
||||
fontSize: 13,
|
||||
},
|
||||
submitBtn: {
|
||||
backgroundColor: '#003B7E',
|
||||
borderRadius: 14,
|
||||
paddingVertical: 14,
|
||||
alignItems: 'center',
|
||||
marginTop: 4,
|
||||
},
|
||||
submitBtnDisabled: {
|
||||
backgroundColor: '#CBD5E1',
|
||||
},
|
||||
submitContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
submitLabel: {
|
||||
color: '#FFFFFF',
|
||||
fontWeight: '700',
|
||||
fontSize: 15,
|
||||
},
|
||||
hint: {
|
||||
marginTop: 24,
|
||||
textAlign: 'center',
|
||||
color: '#64748B',
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
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 { Stack, SplashScreen } from 'expo-router'
|
||||
import { useAuthStore } from '@/store/auth.store'
|
||||
import { setupPushNotifications } from '@/lib/notifications'
|
||||
|
||||
SplashScreen.preventAutoHideAsync()
|
||||
|
||||
@@ -18,20 +13,14 @@ export default function RootLayout() {
|
||||
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>
|
||||
<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/index" />
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,5 +3,8 @@ import { useAuthStore } from '@/store/auth.store'
|
||||
|
||||
export default function Index() {
|
||||
const session = useAuthStore((s) => s.session)
|
||||
return <Redirect href={session ? '/(app)/news' : '/(auth)/login'} />
|
||||
if (session) {
|
||||
return <Redirect href={'/(app)/home' as never} />
|
||||
}
|
||||
return <Redirect href="/(auth)/login" />
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user