This commit is contained in:
Timo Knuth
2026-02-27 15:19:24 +01:00
parent b7f8221095
commit 253c3c1c6d
134 changed files with 11188 additions and 1871 deletions

View File

@@ -4,7 +4,9 @@
"Bash(python:*)",
"Bash(python3:*)",
"Bash(node -e:*)",
"Bash(pnpm --filter mobile add:*)"
"Bash(pnpm --filter mobile add:*)",
"Bash(pnpm db:push:*)",
"Bash(pnpm --filter @innungsapp/shared prisma:seed-demo-members:*)"
]
}
}

View File

@@ -58,6 +58,10 @@
],
"experiments": {
"typedRoutes": true
},
"extra": {
"orgSlug": "innung-elektro-stuttgart",
"apiUrl": "http://localhost:3000"
}
}
}
}

View File

@@ -1,15 +1,222 @@
import { Tabs, Redirect } from 'expo-router'
import { Platform } from 'react-native'
import { Platform, View, Text, StyleSheet, TextInput, TouchableOpacity, ActivityIndicator, Alert, ScrollView } from 'react-native'
import { useEffect, useState } from 'react'
import { Ionicons } from '@expo/vector-icons'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useAuthStore } from '@/store/auth.store'
import { trpc } from '@/lib/trpc'
import { setupPushNotifications } from '@/lib/notifications'
import { authClient } from '@/lib/auth-client'
function UnreadBadge({ count }: { count: number }) {
if (count === 0) return null
return (
<View style={badge.dot}>
<Text style={badge.text}>{count > 9 ? '9+' : count}</Text>
</View>
)
}
const badge = StyleSheet.create({
dot: {
position: 'absolute',
top: -4,
right: -8,
minWidth: 17,
height: 17,
borderRadius: 9,
backgroundColor: '#DC2626',
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 4,
borderWidth: 2,
borderColor: '#FFFFFF',
},
text: {
fontSize: 10,
fontWeight: '700',
color: '#FFFFFF',
lineHeight: 13,
},
})
function ChatTabIcon({ color, focused }: { color: string; focused: boolean }) {
const { data: unreadCount } = trpc.messages.getConversations.useQuery(undefined, {
refetchInterval: 15_000,
staleTime: 10_000,
select: (data) => data.filter((c) => c.hasUnread).length,
})
return (
<View>
<Ionicons name={focused ? 'chatbubbles' : 'chatbubbles-outline'} size={23} color={color} />
<UnreadBadge count={unreadCount ?? 0} />
</View>
)
}
function ForcePasswordChangeScreen() {
const { setSession, signOut } = useAuthStore()
const [current, setCurrent] = useState('')
const [next, setNext] = useState('')
const [confirm, setConfirm] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
async function handleSubmit() {
setError('')
if (!current) { setError('Bitte temporäres Passwort eingeben.'); return }
if (next.length < 8) { setError('Das neue Passwort muss mindestens 8 Zeichen haben.'); return }
if (next !== confirm) { setError('Die Passwörter stimmen nicht überein.'); return }
setLoading(true)
const result = await authClient.changePassword({ currentPassword: current, newPassword: next })
setLoading(false)
if (result.error) {
setError(result.error.message ?? 'Passwort konnte nicht geändert werden.')
return
}
// Refresh session — mustChangePassword is now false
const sessionResult = await authClient.getSession()
if (sessionResult?.data?.user) {
const u = sessionResult.data.user as any
await setSession({
user: {
id: u.id,
email: u.email,
name: u.name,
mustChangePassword: false,
},
})
}
}
return (
<SafeAreaView style={fpc.safe}>
<ScrollView contentContainerStyle={fpc.content} keyboardShouldPersistTaps="handled">
<View style={fpc.card}>
<View style={fpc.iconWrap}>
<Ionicons name="lock-closed-outline" size={32} color="#003B7E" />
</View>
<Text style={fpc.title}>Passwort ändern</Text>
<Text style={fpc.subtitle}>
Ihr Administrator hat ein temporäres Passwort vergeben. Bitte legen Sie jetzt Ihr persönliches Passwort fest.
</Text>
<View style={fpc.field}>
<Text style={fpc.label}>Temporäres Passwort</Text>
<TextInput
style={fpc.input}
value={current}
onChangeText={setCurrent}
secureTextEntry
placeholder="••••••••"
placeholderTextColor="#CBD5E1"
autoCapitalize="none"
/>
</View>
<View style={fpc.field}>
<Text style={fpc.label}>Neues Passwort</Text>
<TextInput
style={fpc.input}
value={next}
onChangeText={setNext}
secureTextEntry
placeholder="Mindestens 8 Zeichen"
placeholderTextColor="#CBD5E1"
autoCapitalize="none"
/>
</View>
<View style={fpc.field}>
<Text style={fpc.label}>Neues Passwort wiederholen</Text>
<TextInput
style={fpc.input}
value={confirm}
onChangeText={setConfirm}
secureTextEntry
placeholder="Neues Passwort wiederholen"
placeholderTextColor="#CBD5E1"
autoCapitalize="none"
/>
</View>
{!!error && (
<View style={fpc.errorBox}>
<Text style={fpc.errorText}>{error}</Text>
</View>
)}
<TouchableOpacity style={fpc.btn} onPress={handleSubmit} disabled={loading}>
{loading
? <ActivityIndicator color="#fff" />
: <Text style={fpc.btnText}>Passwort festlegen</Text>
}
</TouchableOpacity>
<TouchableOpacity style={fpc.logoutBtn} onPress={() => void signOut()}>
<Text style={fpc.logoutText}>Abmelden</Text>
</TouchableOpacity>
</View>
</ScrollView>
</SafeAreaView>
)
}
const fpc = StyleSheet.create({
safe: { flex: 1, backgroundColor: '#F8FAFC' },
content: { flex: 1, justifyContent: 'center', padding: 24 },
card: {
backgroundColor: '#FFFFFF', borderRadius: 20,
borderWidth: 1, borderColor: '#E2E8F0',
padding: 24, gap: 12,
},
iconWrap: {
width: 60, height: 60, borderRadius: 16,
backgroundColor: '#EFF6FF', alignItems: 'center', justifyContent: 'center',
alignSelf: 'center', marginBottom: 4,
},
title: { fontSize: 22, fontWeight: '800', color: '#0F172A', textAlign: 'center' },
subtitle: { fontSize: 13, color: '#64748B', textAlign: 'center', lineHeight: 19 },
field: { gap: 4 },
label: { fontSize: 12, fontWeight: '700', color: '#475569', textTransform: 'uppercase', letterSpacing: 0.5 },
input: {
borderWidth: 1, borderColor: '#E2E8F0', borderRadius: 10,
paddingHorizontal: 12, paddingVertical: 11,
fontSize: 14, color: '#0F172A', backgroundColor: '#F8FAFC',
},
errorBox: {
backgroundColor: '#FEF2F2', borderWidth: 1,
borderColor: '#FECACA', borderRadius: 10,
paddingHorizontal: 12, paddingVertical: 10,
},
errorText: { color: '#B91C1C', fontSize: 13 },
btn: {
backgroundColor: '#003B7E', borderRadius: 12,
paddingVertical: 13, alignItems: 'center', marginTop: 4,
},
btnText: { color: '#FFFFFF', fontWeight: '700', fontSize: 15 },
logoutBtn: { alignItems: 'center', paddingVertical: 8 },
logoutText: { color: '#94A3B8', fontSize: 13 },
})
export default function AppLayout() {
const session = useAuthStore((s) => s.session)
useEffect(() => {
if (!session?.user) return
setupPushNotifications().catch(() => {})
}, [session?.user?.id])
if (!session) {
return <Redirect href="/(auth)/login" />
}
if (session.user.mustChangePassword) {
return <ForcePasswordChangeScreen />
}
return (
<Tabs
screenOptions={{
@@ -67,6 +274,15 @@ export default function AppLayout() {
),
}}
/>
<Tabs.Screen
name="chat/index"
options={{
title: 'Nachrichten',
tabBarIcon: ({ color, focused }) => (
<ChatTabIcon color={color} focused={focused} />
),
}}
/>
<Tabs.Screen
name="profil/index"
options={{
@@ -82,6 +298,7 @@ export default function AppLayout() {
<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.Screen name="chat/[id]" options={{ href: null }} />
</Tabs>
)
}

View File

@@ -0,0 +1,410 @@
import {
View,
Text,
FlatList,
TextInput,
TouchableOpacity,
KeyboardAvoidingView,
Platform,
ActivityIndicator,
StyleSheet,
RefreshControl,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { Ionicons } from '@expo/vector-icons'
import { useState, useRef, useMemo, useCallback } from 'react'
import { format } from 'date-fns'
import { de } from 'date-fns/locale'
import { trpc } from '@/lib/trpc'
import { useAuthStore } from '@/store/auth.store'
type Message = {
id: string
body: string
createdAt: Date
sender: { id: string; name: string; avatarUrl: string | null }
}
function MessageBubble({ msg, isMe }: { msg: Message; isMe: boolean }) {
return (
<View style={[styles.messageBubbleContainer, isMe ? styles.messageBubbleRight : styles.messageBubbleLeft]}>
<View
style={[
styles.messageBubble,
isMe ? styles.messageBubbleMe : styles.messageBubbleOther
]}
>
<Text style={[styles.messageText, isMe ? styles.messageTextMe : styles.messageTextOther]}>
{msg.body}
</Text>
<Text
style={[styles.messageTime, isMe ? styles.messageTimeMe : styles.messageTimeOther]}
>
{format(new Date(msg.createdAt), 'HH:mm', { locale: de })}
</Text>
</View>
</View>
)
}
export default function ConversationScreen() {
const { id, name } = useLocalSearchParams<{ id: string; name: string }>()
const router = useRouter()
const session = useAuthStore((s) => s.session)
const [text, setText] = useState('')
const listRef = useRef<FlatList>(null)
// Hide list until inverted FlatList has settled at correct position (prevents initial jump)
const [listVisible, setListVisible] = useState(false)
const onListLayout = useCallback(() => setListVisible(true), [])
const [refreshing, setRefreshing] = useState(false)
const onRefresh = useCallback(async () => {
setRefreshing(true)
try {
await refetch()
} finally {
setRefreshing(false)
}
}, [])
const { data, isLoading, refetch, isRefetching } = trpc.messages.getMessages.useQuery(
{ conversationId: id },
{
staleTime: 5_000,
refetchInterval: 8_000,
// Only re-render when message count or last message ID changes — not on every refetch
select: (d) => d,
}
)
// Stable deps: only recalculate when actual message content changes, not on every refetch
const msgCount = data?.messages?.length ?? 0
const lastId = data?.messages?.[msgCount - 1]?.id ?? ''
const messages = useMemo(() => [...(data?.messages ?? [])].reverse(), [msgCount, lastId])
// Stable "me" id: derived from real (non-optimistic) messages only
const myMemberId = useMemo(
() => messages.find((m: Message) => !m.id.startsWith('opt-') && m.sender.name === session?.user?.name)?.sender.id,
[messages, session?.user?.name]
)
const utils = trpc.useUtils()
const sendMutation = trpc.messages.sendMessage.useMutation({
onMutate: ({ conversationId, body }) => {
// Optimistically insert message immediately — no waiting for server
const optimisticMsg: Message = {
id: `opt-${Date.now()}`,
body,
createdAt: new Date(),
sender: {
id: myMemberId ?? `opt-sender`,
name: session?.user?.name ?? '',
avatarUrl: null,
},
}
utils.messages.getMessages.setData(
{ conversationId },
(old) => {
if (!old) return old
// Append at end — messages are stored ascending, reversed only in useMemo for display
return { ...old, messages: [...old.messages, optimisticMsg] }
}
)
setText('')
return { optimisticId: optimisticMsg.id }
},
onSuccess: (newMsg, { conversationId }, context) => {
// Replace optimistic placeholder with real server message
utils.messages.getMessages.setData(
{ conversationId },
(old) => {
if (!old) return old
return {
...old,
messages: old.messages.map((m) =>
m.id === context?.optimisticId ? newMsg : m
),
}
}
)
utils.messages.getConversations.invalidate()
},
onError: (_, { conversationId }, context) => {
// Roll back optimistic message on error
utils.messages.getMessages.setData(
{ conversationId },
(old) => {
if (!old) return old
return {
...old,
messages: old.messages.filter((m) => m.id !== context?.optimisticId),
}
}
)
},
})
function handleSend() {
const body = text.trim()
if (!body) return
sendMutation.mutate({ conversationId: id, body })
}
return (
<SafeAreaView style={styles.safeArea} edges={['top', 'bottom']}>
<KeyboardAvoidingView
style={styles.keyboardAvoid}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
<Ionicons name="chevron-back" size={26} color="#003B7E" />
</TouchableOpacity>
<View style={styles.headerInfo}>
<Text style={styles.headerName} numberOfLines={1}>
{name ?? 'Chat'}
</Text>
<Text style={styles.headerStatus}>Mitglied</Text>
</View>
</View>
<View style={styles.divider} />
{/* Messages */}
<View style={styles.content}>
{isLoading ? (
<View style={styles.centerCont}>
<ActivityIndicator size="large" color="#003B7E" />
</View>
) : (
<FlatList
ref={listRef}
data={messages}
keyExtractor={(m) => m.id}
inverted
maintainVisibleContentPosition={{ minIndexForVisible: 0, autoscrollToTopThreshold: 10 }}
onLayout={onListLayout}
style={{ opacity: listVisible ? 1 : 0 }}
contentContainerStyle={styles.messageList}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor="#003B7E" />
}
ListEmptyComponent={
<View style={[styles.emptyState, { transform: [{ scaleY: -1 }] }]}>
<Ionicons name="chatbubble-ellipses-outline" size={48} color="#CBD5E1" />
<Text style={styles.emptyText}>
Noch keine Nachrichten. Schreib die erste!
</Text>
<View style={styles.privacyNoteBoxCentered}>
<Ionicons name="shield-checkmark-outline" size={16} color="#2563EB" />
<Text style={styles.privacyNoteText}>
Nachrichten sind nur für euch beide sichtbar. Keine Weitergabe an Dritte (DSGVO-konform).
</Text>
</View>
</View>
}
renderItem={({ item }) => (
<MessageBubble
msg={item as Message}
isMe={item.sender.id === myMemberId}
/>
)}
/>
)}
</View>
{/* Input */}
<View style={styles.inputContainer}>
<TextInput
style={styles.inputField}
placeholder="Nachricht …"
placeholderTextColor="#94A3B8"
value={text}
onChangeText={setText}
multiline
returnKeyType="default"
blurOnSubmit={false}
/>
<TouchableOpacity
onPress={handleSend}
disabled={!text.trim() || sendMutation.isPending}
style={[styles.sendButton, text.trim() ? styles.sendButtonActive : styles.sendButtonInactive]}
>
{sendMutation.isPending ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Ionicons name="send" size={18} color={text.trim() ? '#fff' : '#94A3B8'} />
)}
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: '#FFFFFF',
},
keyboardAvoid: {
flex: 1,
backgroundColor: '#F8FAFC',
},
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
backgroundColor: '#FFFFFF',
},
backButton: {
padding: 4,
marginRight: 8,
},
headerInfo: {
flex: 1,
},
headerName: {
fontSize: 18,
fontWeight: '700',
color: '#0F172A',
},
headerStatus: {
fontSize: 12,
color: '#64748B',
marginTop: 2,
},
divider: {
height: 1,
backgroundColor: '#E2E8F0',
},
content: {
flex: 1,
},
centerCont: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
messageList: {
paddingTop: 16,
paddingBottom: 8,
},
emptyState: {
flex: 1,
alignItems: 'flex-start',
justifyContent: 'flex-start',
paddingVertical: 16,
paddingHorizontal: 16,
},
emptyText: {
color: '#0F172A',
marginTop: 12,
textAlign: 'left',
fontSize: 16,
},
privacyNoteBoxCentered: {
flexDirection: 'row',
alignItems: 'flex-start',
padding: 0,
marginTop: 16,
gap: 8,
},
privacyNoteText: {
fontSize: 14,
color: '#0F172A',
lineHeight: 20,
flex: 1,
},
messageBubbleContainer: {
flexDirection: 'row',
marginBottom: 8,
paddingHorizontal: 16,
},
messageBubbleRight: {
justifyContent: 'flex-end',
},
messageBubbleLeft: {
justifyContent: 'flex-start',
},
messageBubble: {
maxWidth: '78%',
paddingHorizontal: 16,
paddingVertical: 10,
},
messageBubbleMe: {
backgroundColor: '#2563EB',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
borderBottomLeftRadius: 16,
borderBottomRightRadius: 4,
},
messageBubbleOther: {
backgroundColor: '#FFFFFF',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
borderBottomRightRadius: 16,
borderBottomLeftRadius: 4,
borderWidth: 1,
borderColor: '#E2E8F0',
},
messageText: {
fontSize: 15,
lineHeight: 20,
},
messageTextMe: {
color: '#FFFFFF',
},
messageTextOther: {
color: '#0F172A',
},
messageTime: {
fontSize: 10,
marginTop: 4,
},
messageTimeMe: {
color: '#BFDBFE',
textAlign: 'right',
},
messageTimeOther: {
color: '#94A3B8',
textAlign: 'left',
},
inputContainer: {
flexDirection: 'row',
alignItems: 'flex-end',
paddingHorizontal: 16,
paddingVertical: 12,
borderTopWidth: 1,
borderTopColor: '#E2E8F0',
backgroundColor: '#FFFFFF',
gap: 8,
},
inputField: {
flex: 1,
backgroundColor: '#F1F5F9',
borderRadius: 20,
paddingHorizontal: 16,
paddingTop: 12,
paddingBottom: 12,
fontSize: 15,
color: '#0F172A',
maxHeight: 120,
minHeight: 44,
},
sendButton: {
width: 44,
height: 44,
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
},
sendButtonActive: {
backgroundColor: '#2563EB',
},
sendButtonInactive: {
backgroundColor: '#E2E8F0',
},
})

View File

@@ -0,0 +1,401 @@
import { View, Text, SectionList, TouchableOpacity, Animated, ActivityIndicator, StyleSheet, RefreshControl, TextInput } from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { Ionicons } from '@expo/vector-icons'
import { format, isToday, isYesterday } from 'date-fns'
import { de } from 'date-fns/locale'
import { trpc } from '@/lib/trpc'
import { EmptyState } from '@/components/ui/EmptyState'
import { Avatar } from '@/components/ui/Avatar'
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
import { useFocusEffect } from 'expo-router'
function SkeletonRow() {
const anim = useRef(new Animated.Value(0.4)).current
useEffect(() => {
Animated.loop(
Animated.sequence([
Animated.timing(anim, { toValue: 1, duration: 800, useNativeDriver: true }),
Animated.timing(anim, { toValue: 0.4, duration: 800, useNativeDriver: true }),
])
).start()
}, [])
return (
<Animated.View style={[skeletonStyles.row, { opacity: anim }]}>
<View style={skeletonStyles.avatar} />
<View style={skeletonStyles.lines}>
<View style={skeletonStyles.lineLong} />
<View style={skeletonStyles.lineShort} />
</View>
</Animated.View>
)
}
const skeletonStyles = StyleSheet.create({
row: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 20, paddingVertical: 16, backgroundColor: '#FFFFFF', borderBottomWidth: 1, borderBottomColor: '#F1F5F9' },
avatar: { width: 48, height: 48, borderRadius: 24, backgroundColor: '#E2E8F0' },
lines: { flex: 1, marginLeft: 12, gap: 8 },
lineLong: { height: 14, borderRadius: 7, backgroundColor: '#E2E8F0', width: '60%' },
lineShort: { height: 12, borderRadius: 6, backgroundColor: '#F1F5F9', width: '80%' },
})
function formatTime(date: Date) {
if (isToday(date)) return format(date, 'HH:mm')
if (isYesterday(date)) return 'Gestern'
return format(date, 'dd.MM.yy', { locale: de })
}
export default function ChatListScreen() {
const router = useRouter()
const [searchQuery, setSearchQuery] = useState('')
const [showSkeleton, setShowSkeleton] = useState(true)
const { data: chats, isLoading, refetch: refetchChats, isRefetching } = trpc.messages.getConversations.useQuery(undefined, {
refetchInterval: 10_000,
staleTime: 8_000,
})
useFocusEffect(
useCallback(() => {
setShowSkeleton(true)
const t = setTimeout(() => setShowSkeleton(false), 800)
return () => clearTimeout(t)
}, [])
)
const { data: members, isFetching: isFetchingMembers } = trpc.members.list.useQuery(
{ search: searchQuery },
{ enabled: searchQuery.length > 0, staleTime: 30_000 }
)
const [refreshing, setRefreshing] = useState(false)
const refetch = useCallback(async () => {
setRefreshing(true)
try {
await refetchChats()
} finally {
setRefreshing(false)
}
}, [refetchChats])
const filteredChats = (chats || []).filter(c => {
if (!searchQuery) return true
const q = searchQuery.toLowerCase()
return (
c.other?.name?.toLowerCase().includes(q) ||
c.other?.betrieb?.toLowerCase().includes(q) ||
c.lastMessage?.body?.toLowerCase().includes(q)
)
})
const sections = []
if (filteredChats.length > 0 || !searchQuery) {
sections.push({
title: searchQuery ? 'Bestehende Chats' : '',
data: filteredChats,
type: 'chat'
})
}
if (searchQuery.length > 0) {
const chatMemberIds = new Set((chats || []).map(c => c.other?.id).filter(Boolean))
const freshMembers = (members || []).filter(m => !chatMemberIds.has(m.id))
if (freshMembers.length > 0) {
sections.push({
title: 'Weitere Mitglieder',
data: freshMembers,
type: 'member'
})
}
}
const renderSectionHeader = ({ section }: { section: any }) => {
if (!section.title) return null
return (
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{section.title}</Text>
</View>
)
}
const renderItem = ({ item, section }: { item: any, section: any }) => {
if (section.type === 'chat') {
return (
<TouchableOpacity
onPress={() =>
router.push({
pathname: '/(app)/chat/[id]',
params: {
id: item.conversationId,
name: item.other?.name ?? 'Unbekannt',
},
})
}
activeOpacity={0.7}
style={styles.chatRow}
>
<View style={styles.avatarContainer}>
<Avatar name={item.other?.name ?? '?'} size={48} />
{item.hasUnread && (
<View style={styles.unreadDot} />
)}
</View>
<View style={styles.chatInfo}>
<View style={styles.chatHeader}>
<Text
style={[styles.chatName, item.hasUnread && styles.chatNameUnread]}
numberOfLines={1}
>
{item.other?.name ?? 'Unbekannt'}
</Text>
{item.lastMessage && (
<Text style={styles.timeText}>
{formatTime(new Date(item.lastMessage.createdAt))}
</Text>
)}
</View>
<Text
style={[styles.messageText, item.hasUnread && styles.messageTextUnread]}
numberOfLines={1}
>
{item.lastMessage?.body ?? 'Noch keine Nachrichten'}
</Text>
{item.other?.betrieb && (
<Text style={styles.companyText} numberOfLines={1}>
{item.other.betrieb}
</Text>
)}
</View>
</TouchableOpacity>
)
} else {
return (
<TouchableOpacity
onPress={() =>
router.push({
pathname: '/(app)/members/[id]',
params: { id: item.id },
})
}
activeOpacity={0.7}
style={styles.chatRow}
>
<Avatar name={item.name} size={48} />
<View style={styles.chatInfo}>
<Text style={styles.chatName} numberOfLines={1}>
{item.name}
</Text>
{item.betrieb && (
<Text style={styles.companyText} numberOfLines={1}>
{item.betrieb}
</Text>
)}
<Text style={styles.messageText} numberOfLines={1}>
{item.sparte} {item.ort}
</Text>
</View>
</TouchableOpacity>
)
}
}
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
{/* Header */}
<View style={styles.header}>
<View style={styles.titleRow}>
<Text style={styles.screenTitle}>Nachrichten</Text>
<TouchableOpacity
onPress={() => router.push('/(app)/members')}
style={styles.newChatBtn}
>
<Ionicons name="create-outline" size={19} color="#003B7E" />
</TouchableOpacity>
</View>
{/* Search Bar */}
<View style={styles.searchContainer}>
<Ionicons name="search" size={20} color="#94A3B8" />
<TextInput
style={styles.searchInput}
placeholder="Suchen nach Namen, Betrieb oder Nachricht..."
placeholderTextColor="#94A3B8"
value={searchQuery}
onChangeText={setSearchQuery}
returnKeyType="search"
clearButtonMode="while-editing"
/>
</View>
</View>
<View style={styles.divider} />
{showSkeleton ? (
<View>
{[1, 2, 3, 4, 5].map((i) => <SkeletonRow key={i} />)}
</View>
) : (!chats || chats.length === 0) && !searchQuery ? (
<EmptyState
icon="chatbubbles-outline"
title="Noch keine Nachrichten"
subtitle="Öffne ein Mitgliedsprofil und schreib eine Nachricht — datenschutzkonform ohne private Nummern."
/>
) : searchQuery.length > 0 && isFetchingMembers && sections.length === 0 ? (
<View style={{ alignItems: 'center', paddingTop: 40 }}>
<ActivityIndicator color="#003B7E" />
</View>
) : sections.length === 0 && searchQuery ? (
<EmptyState
icon="search-outline"
title="Keine Ergebnisse"
subtitle={`Kein Mitglied gefunden für „${searchQuery}".`}
/>
) : (
<SectionList
sections={sections}
keyExtractor={(item, index) => item.conversationId || item.id || String(index)}
initialNumToRender={10}
maxToRenderPerBatch={10}
windowSize={5}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={refetch} tintColor="#003B7E" progressViewOffset={50} />
}
contentContainerStyle={styles.list}
renderItem={renderItem}
renderSectionHeader={renderSectionHeader}
/>
)}
</SafeAreaView>
)
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: '#F8FAFC',
},
header: {
backgroundColor: '#FFFFFF',
paddingHorizontal: 20,
paddingTop: 14,
paddingBottom: 14,
},
titleRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 16,
},
screenTitle: {
fontSize: 28,
fontWeight: '800',
color: '#0F172A',
letterSpacing: -0.5,
},
newChatBtn: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: '#EFF6FF',
alignItems: 'center',
justifyContent: 'center',
},
searchContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#F1F5F9',
borderRadius: 12,
paddingHorizontal: 12,
height: 44,
},
searchInput: {
flex: 1,
marginLeft: 8,
fontSize: 15,
color: '#0F172A',
},
divider: {
height: 1,
backgroundColor: '#E2E8F0',
},
list: {
paddingBottom: 30,
},
sectionHeader: {
backgroundColor: '#F8FAFC',
paddingHorizontal: 20,
paddingVertical: 8,
borderBottomWidth: 1,
borderBottomColor: '#F1F5F9',
},
sectionTitle: {
fontSize: 13,
fontWeight: '700',
color: '#64748B',
textTransform: 'uppercase',
},
chatRow: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 20,
paddingVertical: 16,
backgroundColor: '#FFFFFF',
borderBottomWidth: 1,
borderBottomColor: '#F1F5F9',
},
avatarContainer: {
position: 'relative',
},
unreadDot: {
position: 'absolute',
top: 0,
right: 0,
width: 12,
height: 12,
borderRadius: 6,
backgroundColor: '#2563EB',
borderWidth: 2,
borderColor: '#FFFFFF',
},
chatInfo: {
flex: 1,
marginLeft: 12,
},
chatHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
chatName: {
fontSize: 16,
fontWeight: '600',
color: '#1E293B',
flex: 1,
},
chatNameUnread: {
fontWeight: '700',
color: '#0F172A',
},
timeText: {
fontSize: 12,
color: '#94A3B8',
marginLeft: 8,
},
messageText: {
fontSize: 14,
color: '#94A3B8',
marginTop: 2,
},
messageTextUnread: {
fontWeight: '500',
color: '#334155',
},
companyText: {
fontSize: 12,
color: '#94A3B8',
marginTop: 2,
},
})

View File

@@ -1,4 +1,5 @@
import { View, Text, ScrollView, TouchableOpacity, TextInput, StyleSheet, Platform, Image } from 'react-native'
import React, { useEffect, useRef } from 'react'
import { View, Text, ScrollView, TouchableOpacity, TextInput, StyleSheet, Platform, Image, Animated } from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { Ionicons } from '@expo/vector-icons'
import { useRouter } from 'expo-router'
@@ -7,8 +8,8 @@ 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'
import { trpc } from '@/lib/trpc'
import { getOrgSlug } from '@/lib/org-config'
// Helper to truncate text
function getNewsExcerpt(value: string) {
@@ -19,17 +20,45 @@ function getNewsExcerpt(value: string) {
return normalized.length > 85 ? `${normalized.slice(0, 85)}...` : normalized
}
const AVATAR_PALETTES = [
{ bg: '#003B7E' }, { bg: '#1D4ED8' }, { bg: '#059669' },
{ bg: '#4338CA' }, { bg: '#B45309' }, { bg: '#0F766E' },
]
function Skeleton({ style }: { style?: any }) {
const anim = useRef(new Animated.Value(0.4)).current
useEffect(() => {
Animated.loop(
Animated.sequence([
Animated.timing(anim, { toValue: 1, duration: 1000, useNativeDriver: true }),
Animated.timing(anim, { toValue: 0.4, duration: 1000, useNativeDriver: true }),
])
).start()
}, [anim])
return <Animated.View style={[{ backgroundColor: '#E2E8F0', borderRadius: 8 }, style, { opacity: anim }]} />
}
function getInitials(name: string) {
return name.split(' ').slice(0, 2).map((w) => w[0]?.toUpperCase() ?? '').join('')
}
function getAvatarBg(name: string) {
return AVATAR_PALETTES[name.charCodeAt(0) % AVATAR_PALETTES.length].bg
}
export default function HomeScreen() {
const router = useRouter()
const { data: newsItems = [] } = useNewsList()
const { data: termine = [] } = useTermineListe(true)
const readIds = useNewsReadStore((s) => s.readIds)
const { data: newsItems = [], isLoading: newsLoading } = useNewsList()
const { data: termine = [], isLoading: termineLoading } = useTermineListe(true)
const { data: me } = trpc.members.me.useQuery()
const { data: unread } = trpc.messages.unreadCount.useQuery(undefined, { refetchInterval: 15_000 })
const { data: org } = trpc.organizations.bySlug.useQuery({ slug: getOrgSlug() })
const userName = me?.name ?? ''
const orgName = org?.name ?? 'InnungsApp'
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 unreadCount = unread?.count ?? 0
const QUICK_ACTIONS = [
{ label: 'Mitglieder', icon: 'people-circle', color: '#2563EB', bg: '#DBEAFE', route: '/(app)/members' },
@@ -51,11 +80,16 @@ export default function HomeScreen() {
{/* Header Section */}
<View style={styles.header}>
<View style={styles.headerLeft}>
<View style={styles.avatar}>
<Text style={styles.avatarText}>I</Text>
</View>
<TouchableOpacity
onPress={() => router.push('/(app)/profil' as never)}
activeOpacity={0.85}
>
<View style={[styles.avatar, { backgroundColor: userName ? getAvatarBg(userName) : '#003B7E' }]}>
<Text style={styles.avatarText}>{userName ? getInitials(userName) : '?'}</Text>
</View>
</TouchableOpacity>
<View>
<Text style={styles.greeting}>Willkommen zurück,</Text>
<Text style={styles.greeting}>{orgName}</Text>
<Text style={styles.username}>{userName}</Text>
</View>
</View>
@@ -63,8 +97,9 @@ export default function HomeScreen() {
<TouchableOpacity
style={styles.notificationBtn}
activeOpacity={0.8}
onPress={() => router.push('/(app)/chat' as never)}
>
<Ionicons name="notifications-outline" size={22} color="#1E293B" />
<Ionicons name="chatbubbles-outline" size={22} color="#1E293B" />
{unreadCount > 0 && (
<View style={styles.badge}>
<Text style={styles.badgeText}>{unreadCount}</Text>
@@ -114,28 +149,37 @@ export default function HomeScreen() {
</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]}
{newsLoading ? (
<>
<Skeleton style={[styles.newsCard, { height: 130 }]} />
<Skeleton style={[styles.newsCard, { height: 130 }]} />
</>
) : latestNews.length > 0 ? (
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.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>
))}
<Text style={styles.newsTitle} numberOfLines={2}>{item.title}</Text>
<Text style={styles.newsBody} numberOfLines={2}>{getNewsExcerpt(item.body)}</Text>
</TouchableOpacity>
))
) : (
<Text style={styles.emptyText}>Keine aktuellen Neuigkeiten</Text>
)}
</View>
</View>
@@ -149,28 +193,58 @@ export default function HomeScreen() {
</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>
{termineLoading ? (
<>
<View style={[styles.eventRow, styles.eventBorder]}>
<Skeleton style={styles.dateBox} />
<View style={styles.eventInfo}>
<Skeleton style={{ width: '70%', height: 16, marginBottom: 6 }} />
<Skeleton style={{ width: '40%', height: 12 }} />
</View>
</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 style={[styles.eventRow, styles.eventBorder]}>
<Skeleton style={styles.dateBox} />
<View style={styles.eventInfo}>
<Skeleton style={{ width: '60%', height: 16, marginBottom: 6 }} />
<Skeleton style={{ width: '50%', height: 12 }} />
</View>
</View>
<View style={styles.eventRow}>
<Skeleton style={styles.dateBox} />
<View style={styles.eventInfo}>
<Skeleton style={{ width: '80%', height: 16, marginBottom: 6 }} />
<Skeleton style={{ width: '30%', height: 12 }} />
</View>
</View>
</>
) : upcomingEvents.length > 0 ? (
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>
<Ionicons name="chevron-forward" size={16} color="#CBD5E1" />
</TouchableOpacity>
))}
<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 style={{ padding: 16 }}>
<Text style={styles.emptyText}>Keine anstehenden Termine</Text>
</View>
)}
</View>
</View>
@@ -221,20 +295,21 @@ const styles = StyleSheet.create({
avatar: {
width: 44,
height: 44,
borderRadius: 14,
backgroundColor: '#003B7E',
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#003B7E',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 4,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.15,
shadowRadius: 6,
elevation: 3,
},
avatarText: {
color: '#FFFFFF',
fontSize: 20,
fontWeight: '700',
fontSize: 16,
fontWeight: '800',
lineHeight: 20,
includeFontPadding: false,
},
greeting: {
fontSize: 13,
@@ -463,4 +538,10 @@ const styles = StyleSheet.create({
color: '#64748B',
fontWeight: '500',
},
emptyText: {
fontSize: 14,
color: '#94A3B8',
textAlign: 'center',
paddingVertical: 12,
},
})

View File

@@ -6,11 +6,20 @@ import { useLocalSearchParams, useRouter } from 'expo-router'
import { Ionicons } from '@expo/vector-icons'
import { useMemberDetail } from '@/hooks/useMembers'
import { Avatar } from '@/components/ui/Avatar'
import { trpc } from '@/lib/trpc'
export default function MemberDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>()
const router = useRouter()
const { data: member, isLoading } = useMemberDetail(id)
const getOrCreate = trpc.messages.getOrCreate.useMutation({
onSuccess: ({ conversationId }) => {
router.push({
pathname: '/(app)/chat/[id]',
params: { id: conversationId, name: member?.name ?? '' },
})
},
})
if (isLoading) {
return (
@@ -87,6 +96,15 @@ export default function MemberDetailScreen() {
<Ionicons name="mail-outline" size={18} color="#0F172A" />
<Text style={styles.btnSecondaryText}>E-Mail senden</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => getOrCreate.mutate({ otherMemberId: id })}
style={[styles.btnSecondary, getOrCreate.isPending && { opacity: 0.6 }]}
activeOpacity={0.8}
disabled={getOrCreate.isPending}
>
<Ionicons name="chatbubble-outline" size={18} color="#0F172A" />
<Text style={styles.btnSecondaryText}>Nachricht schreiben</Text>
</TouchableOpacity>
</View>
</ScrollView>
</SafeAreaView>

View File

@@ -66,8 +66,11 @@ export default function MembersScreen() {
data={data ?? []}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
initialNumToRender={10}
maxToRenderPerBatch={10}
windowSize={5}
refreshControl={
<RefreshControl refreshing={isRefetching} onRefresh={refetch} tintColor="#003B7E" />
<RefreshControl refreshing={isRefetching} onRefresh={refetch} tintColor="#003B7E" progressViewOffset={50} />
}
renderItem={({ item }) => (
<MemberCard

View File

@@ -1,14 +1,43 @@
import {
View, Text, FlatList, TouchableOpacity, RefreshControl, ScrollView, StyleSheet,
View, Text, FlatList, TouchableOpacity, RefreshControl, ScrollView, StyleSheet, Animated,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useState } from 'react'
import { useState, useEffect, useRef, useCallback } from 'react'
import { useRouter } from 'expo-router'
import { useFocusEffect } 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'
function SkeletonCard() {
const anim = useRef(new Animated.Value(0.4)).current
useEffect(() => {
Animated.loop(
Animated.sequence([
Animated.timing(anim, { toValue: 1, duration: 800, useNativeDriver: true }),
Animated.timing(anim, { toValue: 0.4, duration: 800, useNativeDriver: true }),
])
).start()
}, [])
return (
<Animated.View style={[skeletonStyles.card, { opacity: anim }]}>
<View style={skeletonStyles.badge} />
<View style={skeletonStyles.titleLine} />
<View style={skeletonStyles.titleLineShort} />
<View style={skeletonStyles.metaLine} />
</Animated.View>
)
}
const skeletonStyles = StyleSheet.create({
card: { backgroundColor: '#FFFFFF', borderRadius: 12, padding: 16, marginBottom: 10, marginHorizontal: 16 },
badge: { height: 20, width: 80, borderRadius: 10, backgroundColor: '#E2E8F0', marginBottom: 12 },
titleLine: { height: 16, borderRadius: 8, backgroundColor: '#E2E8F0', width: '85%', marginBottom: 8 },
titleLineShort: { height: 16, borderRadius: 8, backgroundColor: '#E2E8F0', width: '55%', marginBottom: 12 },
metaLine: { height: 12, borderRadius: 6, backgroundColor: '#F1F5F9', width: '40%' },
})
const FILTERS = [
{ value: undefined, label: 'Alle' },
{ value: 'Wichtig', label: 'Wichtig' },
@@ -20,8 +49,17 @@ const FILTERS = [
export default function NewsScreen() {
const router = useRouter()
const [kategorie, setKategorie] = useState<string | undefined>(undefined)
const [showSkeleton, setShowSkeleton] = useState(true)
const { data, isLoading, refetch, isRefetching } = useNewsList(kategorie)
useFocusEffect(
useCallback(() => {
setShowSkeleton(true)
const t = setTimeout(() => setShowSkeleton(false), 800)
return () => clearTimeout(t)
}, [])
)
const unreadCount = data?.filter((n) => !n.isRead).length ?? 0
return (
@@ -61,15 +99,20 @@ export default function NewsScreen() {
<View style={styles.divider} />
{isLoading ? (
<LoadingSpinner />
{showSkeleton ? (
<View style={{ paddingTop: 16 }}>
{[1,2,3,4].map((i) => <SkeletonCard key={i} />)}
</View>
) : (
<FlatList
data={data ?? []}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
initialNumToRender={10}
maxToRenderPerBatch={10}
windowSize={5}
refreshControl={
<RefreshControl refreshing={isRefetching} onRefresh={refetch} tintColor="#003B7E" />
<RefreshControl refreshing={isRefetching} onRefresh={refetch} tintColor="#003B7E" progressViewOffset={50} />
}
renderItem={({ item }) => (
<NewsCard
@@ -78,7 +121,7 @@ export default function NewsScreen() {
/>
)}
ListEmptyComponent={
<EmptyState icon="N" title="Keine News" subtitle="Noch keine Beitraege veroeffentlicht." />
<EmptyState icon="newspaper-outline" title="Keine News" subtitle="Noch keine Beitraege veroeffentlicht." />
}
/>
)}

View File

@@ -1,54 +1,140 @@
import { View, Text, ScrollView, TouchableOpacity, Alert, StyleSheet } from 'react-native'
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, TextInput, ActivityIndicator, Alert } from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { Ionicons } from '@expo/vector-icons'
import { useState } from 'react'
import { useRouter } from 'expo-router'
import { useAuth } from '@/hooks/useAuth'
import { trpc } from '@/lib/trpc'
import { authClient } from '@/lib/auth-client'
type Item = {
label: string
icon: React.ComponentProps<typeof Ionicons>['name']
badge?: string
const AVATAR_PALETTES = [
{ bg: '#003B7E', text: '#FFFFFF' },
{ bg: '#1D4ED8', text: '#FFFFFF' },
{ bg: '#059669', text: '#FFFFFF' },
{ bg: '#4338CA', text: '#FFFFFF' },
{ bg: '#B45309', text: '#FFFFFF' },
{ bg: '#0F766E', text: '#FFFFFF' },
]
function getInitials(name: string) {
return name.split(' ').slice(0, 2).map((w) => w[0]?.toUpperCase() ?? '').join('')
}
function getPalette(name: string) {
if (!name) return AVATAR_PALETTES[0]
return AVATAR_PALETTES[name.charCodeAt(0) % AVATAR_PALETTES.length]
}
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' },
]
function InfoRow({ label, value }: { label: string; value?: string | null }) {
if (!value) return null
return (
<View style={styles.infoRow}>
<Text style={styles.infoLabel}>{label}</Text>
<Text style={styles.infoValue}>{value}</Text>
</View>
)
}
export default function ProfilScreen() {
const { signOut } = useAuth()
const router = useRouter()
const utils = trpc.useUtils()
const { data: me } = trpc.members.me.useQuery()
const name = me?.name ?? ''
const { data: unread } = trpc.messages.unreadCount.useQuery(undefined, { refetchInterval: 15_000 })
const [showPersonal, setShowPersonal] = useState(false)
const [showBetrieb, setShowBetrieb] = useState(false)
const [showSicherheit, setShowSicherheit] = useState(false)
const [showMitteilungen, setShowMitteilungen] = useState(false)
const initials = name
.split(' ')
.slice(0, 2)
.map((chunk) => chunk[0]?.toUpperCase() ?? '')
.join('')
const [isEditing, setIsEditing] = useState(false)
const [isEditingBetrieb, setIsEditingBetrieb] = useState(false)
const [isChangingPassword, setIsChangingPassword] = useState(false)
const [passwordForm, setPasswordForm] = useState({ current: '', next: '', confirm: '' })
const [passwordLoading, setPasswordLoading] = useState(false)
const [passwordError, setPasswordError] = useState('')
const [editForm, setEditForm] = useState({ name: '', email: '', telefon: '', ort: '' })
const [editBetriebForm, setEditBetriebForm] = useState({ betrieb: '', sparte: '', istAusbildungsbetrieb: false })
const openPlaceholder = () => {
Alert.alert('Hinweis', 'Dieser Bereich folgt in einer naechsten Version.')
const { mutate: updateMe, isPending: isUpdating } = trpc.members.updateMe.useMutation({
onSuccess: () => {
utils.members.me.invalidate()
setIsEditing(false)
Alert.alert('Erfolg', 'Profil wurde aktualisiert.')
},
onError: () => {
Alert.alert('Fehler', 'Profil konnte nicht aktualisiert werden.')
},
})
const handleEditPersonal = () => {
if (!me) return
setEditForm({
name: me.name || '',
email: me.email || '',
telefon: me.telefon || '',
ort: me.ort || '',
})
setIsEditing(true)
setShowPersonal(true)
}
const handleEditBetrieb = () => {
if (!me) return
setEditBetriebForm({
betrieb: me.betrieb || '',
sparte: me.sparte || '',
istAusbildungsbetrieb: me.istAusbildungsbetrieb ?? false,
})
setIsEditingBetrieb(true)
setShowBetrieb(true)
}
const handleSavePersonal = () => {
updateMe({
name: editForm.name,
email: editForm.email,
telefon: editForm.telefon,
ort: editForm.ort,
})
}
const handleSaveBetrieb = () => {
updateMe({
betrieb: editBetriebForm.betrieb,
sparte: editBetriebForm.sparte,
istAusbildungsbetrieb: editBetriebForm.istAusbildungsbetrieb,
})
setIsEditingBetrieb(false)
}
const name = me?.name ?? ''
const initials = getInitials(name)
const palette = getPalette(name)
const role = me?.org?.name ? 'Mitglied' : 'Mitglied'
const unreadCount = unread?.count ?? 0
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
{/* HERO */}
<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>
<Text style={styles.name}>{name}</Text>
<Text style={styles.role}>Innungsgeschaeftsfuehrer</Text>
<View style={styles.badgesRow}>
<View style={styles.statusBadge}>
<Text style={styles.statusBadgeText}>Admin-Status</Text>
{/* Avatar — View+Text statt nur Text, damit Initials wirklich mittig */}
<View style={[styles.avatarCircle, { backgroundColor: palette.bg }]}>
<Text style={[styles.avatarText, { color: palette.text }]}>{initials}</Text>
</View>
<View style={styles.settingsBtn}>
<Ionicons name="settings-outline" size={15} color="#64748B" />
</View>
</View>
<Text style={styles.name}>{name || ''}</Text>
<Text style={styles.role}>{me?.org?.name ?? 'Innung'}</Text>
<View style={styles.badgesRow}>
{me?.status === 'aktiv' && (
<View style={styles.statusBadge}>
<Text style={styles.statusBadgeText}>Aktiv</Text>
</View>
)}
<View style={[styles.statusBadge, styles.verifyBadge]}>
<Text style={[styles.statusBadgeText, styles.verifyBadgeText]}>Verifiziert</Text>
</View>
@@ -57,43 +143,332 @@ export default function ProfilScreen() {
<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>
<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>
{/* PERSÖNLICHE DATEN */}
<TouchableOpacity
style={styles.menuRow}
activeOpacity={0.82}
onPress={() => {
if (showPersonal) {
setShowPersonal(false)
setIsEditing(false)
} else {
setShowPersonal(true)
}
}}
>
<View style={styles.menuLeft}>
<View style={styles.menuIcon}>
<Ionicons name="person-outline" size={18} color="#475569" />
</View>
<Text style={styles.menuLabel}>Persönliche Daten</Text>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
{showPersonal && !isEditing && (
<TouchableOpacity onPress={handleEditPersonal} style={{ marginRight: 12, padding: 4 }}>
<Ionicons name="create-outline" size={18} color="#2563EB" />
</TouchableOpacity>
)}
<Ionicons name={showPersonal ? 'chevron-up' : 'chevron-down'} size={16} color="#94A3B8" />
</View>
</TouchableOpacity>
<Ionicons name="help-circle-outline" size={70} color="rgba(255,255,255,0.12)" style={styles.supportIcon} />
{showPersonal && (
<View style={styles.expandedCard}>
{isEditing ? (
<>
<View style={styles.infoRowEdit}>
<Text style={styles.infoLabel}>Name</Text>
<TextInput style={styles.input} value={editForm.name} onChangeText={t => setEditForm(f => ({ ...f, name: t }))} />
</View>
<View style={styles.infoRowEdit}>
<Text style={styles.infoLabel}>E-Mail</Text>
<TextInput style={styles.input} value={editForm.email} keyboardType="email-address" autoCapitalize="none" onChangeText={t => setEditForm(f => ({ ...f, email: t }))} />
</View>
<View style={styles.infoRowEdit}>
<Text style={styles.infoLabel}>Telefon</Text>
<TextInput style={styles.input} value={editForm.telefon} keyboardType="phone-pad" onChangeText={t => setEditForm(f => ({ ...f, telefon: t }))} />
</View>
<View style={styles.infoRowEdit}>
<Text style={styles.infoLabel}>Ort</Text>
<TextInput style={styles.input} value={editForm.ort} onChangeText={t => setEditForm(f => ({ ...f, ort: t }))} />
</View>
<View style={styles.editActionRow}>
<TouchableOpacity style={styles.cancelBtn} onPress={() => setIsEditing(false)}>
<Text style={styles.cancelBtnText}>Abbrechen</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.saveBtn} onPress={handleSavePersonal} disabled={isUpdating}>
{isUpdating ? <ActivityIndicator color="#fff" size="small" /> : <Text style={styles.saveBtnText}>Speichern</Text>}
</TouchableOpacity>
</View>
</>
) : (
<>
<InfoRow label="Name" value={me?.name} />
<InfoRow label="E-Mail" value={me?.email} />
<InfoRow label="Telefon" value={me?.telefon} />
<InfoRow label="Ort" value={me?.ort} />
<InfoRow label="Mitglied seit" value={me?.seit ? String(me.seit) : null} />
{!me?.name && (
<Text style={styles.emptyHint}>Keine Daten vorhanden.</Text>
)}
</>
)}
</View>
)}
<View style={styles.menuRowBorderOnly} />
{/* BETRIEBSDATEN */}
<TouchableOpacity
style={styles.menuRow}
activeOpacity={0.82}
onPress={() => {
if (showBetrieb) {
setShowBetrieb(false)
setIsEditingBetrieb(false)
} else {
setShowBetrieb(true)
}
}}
>
<View style={styles.menuLeft}>
<View style={styles.menuIcon}>
<Ionicons name="business-outline" size={18} color="#475569" />
</View>
<Text style={styles.menuLabel}>Betriebsdaten</Text>
</View>
<View style={styles.menuRight}>
{showBetrieb && !isEditingBetrieb && (
<TouchableOpacity onPress={handleEditBetrieb} style={{ marginRight: 12, padding: 4 }}>
<Ionicons name="create-outline" size={18} color="#2563EB" />
</TouchableOpacity>
)}
<Ionicons name={showBetrieb ? 'chevron-up' : 'chevron-down'} size={16} color="#94A3B8" />
</View>
</TouchableOpacity>
{showBetrieb && (
<View style={styles.expandedCard}>
{isEditingBetrieb ? (
<>
<View style={styles.infoRowEdit}>
<Text style={styles.infoLabel}>Betrieb</Text>
<TextInput style={styles.input} value={editBetriebForm.betrieb} onChangeText={t => setEditBetriebForm(f => ({ ...f, betrieb: t }))} />
</View>
<View style={styles.infoRowEdit}>
<Text style={styles.infoLabel}>Sparte</Text>
<TextInput style={styles.input} value={editBetriebForm.sparte} onChangeText={t => setEditBetriebForm(f => ({ ...f, sparte: t }))} />
</View>
<TouchableOpacity
style={[styles.infoRowEdit, { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 10 }]}
onPress={() => setEditBetriebForm(f => ({ ...f, istAusbildungsbetrieb: !f.istAusbildungsbetrieb }))}
>
<Text style={styles.infoLabel}>Ausbildungsbetrieb</Text>
<Ionicons name={editBetriebForm.istAusbildungsbetrieb ? 'checkbox' : 'square-outline'} size={20} color="#2563EB" />
</TouchableOpacity>
<View style={styles.editActionRow}>
<TouchableOpacity style={styles.cancelBtn} onPress={() => setIsEditingBetrieb(false)}>
<Text style={styles.cancelBtnText}>Abbrechen</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.saveBtn} onPress={handleSaveBetrieb} disabled={isUpdating}>
{isUpdating ? <ActivityIndicator color="#fff" size="small" /> : <Text style={styles.saveBtnText}>Speichern</Text>}
</TouchableOpacity>
</View>
</>
) : (
<>
<InfoRow label="Betrieb" value={me?.betrieb} />
<InfoRow label="Sparte" value={me?.sparte} />
<InfoRow label="Ausbildung" value={me?.istAusbildungsbetrieb ? 'Ja' : 'Nein'} />
{!me?.betrieb && !me?.sparte && (
<Text style={styles.emptyHint}>Keine Betriebsdaten vorhanden.</Text>
)}
</>
)}
</View>
)}
{!showBetrieb && (me?.betrieb || me?.sparte) && (
<View style={styles.subInfo}>
{me?.betrieb && <Text style={styles.subInfoText}>{me.betrieb}</Text>}
{me?.sparte && <Text style={styles.subInfoMuted}>{me.sparte}</Text>}
</View>
)}
<View style={styles.menuRowBorderOnly} />
{/* MITTEILUNGEN */}
<TouchableOpacity
style={styles.menuRow}
activeOpacity={0.82}
onPress={() => setShowMitteilungen((v) => !v)}
>
<View style={styles.menuLeft}>
<View style={styles.menuIcon}>
<Ionicons name="notifications-outline" size={18} color="#475569" />
</View>
<Text style={styles.menuLabel}>Mitteilungen</Text>
</View>
<View style={styles.menuRight}>
{unreadCount > 0 && (
<View style={styles.rowBadgeAlert}>
<Text style={[styles.rowBadgeText, { color: '#fff' }]}>{unreadCount}</Text>
</View>
)}
<Ionicons name={showMitteilungen ? 'chevron-up' : 'chevron-down'} size={16} color="#94A3B8" />
</View>
</TouchableOpacity>
{showMitteilungen && (
<View style={styles.expandedCard}>
{unreadCount > 0 ? (
<TouchableOpacity
style={styles.mitteilungenAction}
onPress={() => router.push('/(app)/chat')}
>
<Ionicons name="chatbubbles-outline" size={20} color="#2563EB" />
<View style={{ flex: 1 }}>
<Text style={styles.mitteilungenTitle}>
{unreadCount} ungelesene Nachricht{unreadCount > 1 ? 'en' : ''}
</Text>
<Text style={styles.mitteilungenSub}>Direkt zu den Nachrichten</Text>
</View>
<Ionicons name="chevron-forward" size={16} color="#94A3B8" />
</TouchableOpacity>
) : (
<Text style={styles.emptyHint}>Keine neuen Mitteilungen.</Text>
)}
</View>
)}
<View style={styles.menuRowBorderOnly} />
{/* SICHERHEIT */}
<TouchableOpacity
style={styles.menuRow}
activeOpacity={0.82}
onPress={() => setShowSicherheit(!showSicherheit)}
>
<View style={styles.menuLeft}>
<View style={styles.menuIcon}>
<Ionicons name="shield-checkmark-outline" size={18} color="#475569" />
</View>
<Text style={styles.menuLabel}>Sicherheit & Login</Text>
</View>
<Ionicons name={showSicherheit ? 'chevron-up' : 'chevron-down'} size={16} color="#94A3B8" />
</TouchableOpacity>
{showSicherheit && (
<View style={styles.expandedCard}>
<InfoRow label="Login-E-Mail" value={me?.email} />
<InfoRow label="Account Status" value={me?.status === 'aktiv' ? 'Aktiviert' : 'Inaktiv'} />
{!isChangingPassword ? (
<TouchableOpacity
style={[styles.saveBtn, { marginTop: 10, width: '100%' }]}
onPress={() => {
setPasswordForm({ current: '', next: '', confirm: '' })
setPasswordError('')
setIsChangingPassword(true)
}}
>
<Text style={styles.saveBtnText}>Passwort ändern</Text>
</TouchableOpacity>
) : (
<View style={{ marginTop: 10, gap: 8 }}>
<View style={styles.infoRowEdit}>
<Text style={styles.infoLabel}>Aktuelles Passwort</Text>
<TextInput
style={styles.input}
value={passwordForm.current}
onChangeText={t => setPasswordForm(f => ({ ...f, current: t }))}
secureTextEntry
placeholder="••••••••"
placeholderTextColor="#CBD5E1"
autoCapitalize="none"
/>
</View>
<View style={styles.infoRowEdit}>
<Text style={styles.infoLabel}>Neues Passwort</Text>
<TextInput
style={styles.input}
value={passwordForm.next}
onChangeText={t => setPasswordForm(f => ({ ...f, next: t }))}
secureTextEntry
placeholder="Mindestens 8 Zeichen"
placeholderTextColor="#CBD5E1"
autoCapitalize="none"
/>
</View>
<View style={styles.infoRowEdit}>
<Text style={styles.infoLabel}>Wiederholen</Text>
<TextInput
style={styles.input}
value={passwordForm.confirm}
onChangeText={t => setPasswordForm(f => ({ ...f, confirm: t }))}
secureTextEntry
placeholder="Neues Passwort wiederholen"
placeholderTextColor="#CBD5E1"
autoCapitalize="none"
/>
</View>
{!!passwordError && (
<Text style={styles.passwordError}>{passwordError}</Text>
)}
<View style={styles.editActionRow}>
<TouchableOpacity
style={styles.cancelBtn}
onPress={() => setIsChangingPassword(false)}
disabled={passwordLoading}
>
<Text style={styles.cancelBtnText}>Abbrechen</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.saveBtn}
disabled={passwordLoading}
onPress={async () => {
setPasswordError('')
if (!passwordForm.current) {
setPasswordError('Bitte aktuelles Passwort eingeben.')
return
}
if (passwordForm.next.length < 8) {
setPasswordError('Das neue Passwort muss mindestens 8 Zeichen haben.')
return
}
if (passwordForm.next !== passwordForm.confirm) {
setPasswordError('Die Passwörter stimmen nicht überein.')
return
}
setPasswordLoading(true)
const result = await authClient.changePassword({
currentPassword: passwordForm.current,
newPassword: passwordForm.next,
})
setPasswordLoading(false)
if (result.error) {
setPasswordError(result.error.message ?? 'Passwort konnte nicht geändert werden.')
return
}
setIsChangingPassword(false)
Alert.alert('Erfolg', 'Passwort wurde erfolgreich geändert.')
}}
>
{passwordLoading
? <ActivityIndicator color="#fff" size="small" />
: <Text style={styles.saveBtnText}>Speichern</Text>
}
</TouchableOpacity>
</View>
</View>
)}
</View>
)}
</View>
{/* LOGOUT */}
<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>
@@ -106,226 +481,91 @@ export default function ProfilScreen() {
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: '#F8FAFC',
},
content: {
paddingHorizontal: 18,
paddingBottom: 30,
gap: 14,
},
safeArea: { flex: 1, backgroundColor: '#F8FAFC' },
content: { paddingHorizontal: 18, paddingBottom: 30, gap: 14 },
// Hero
hero: {
backgroundColor: '#FFFFFF',
alignItems: 'center',
paddingTop: 24,
paddingBottom: 18,
borderRadius: 22,
borderWidth: 1,
borderColor: '#E2E8F0',
marginTop: 8,
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,
avatarWrap: { position: 'relative' },
avatarCircle: {
width: 94, height: 94, borderRadius: 47,
alignItems: 'center', justifyContent: 'center',
borderWidth: 4, borderColor: '#FFFFFF',
shadowColor: '#000', shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.12, shadowRadius: 8, elevation: 3,
},
avatarText: { fontSize: 34, fontWeight: '800', lineHeight: 40, 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',
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' },
// Section
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,
marginTop: 2, paddingLeft: 2, fontSize: 11,
textTransform: 'uppercase', letterSpacing: 1.1,
color: '#94A3B8', fontWeight: '800',
},
// Menu card
menuCard: { backgroundColor: '#FFFFFF', borderRadius: 18, borderWidth: 1, borderColor: '#E2E8F0', overflow: 'hidden' },
menuRow: { paddingHorizontal: 14, paddingVertical: 13, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
menuRowBorderOnly: { height: 1, backgroundColor: '#F1F5F9', marginHorizontal: 14 },
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 },
rowBadgeActive: { backgroundColor: '#DCFCE7', paddingHorizontal: 8, paddingVertical: 3, borderRadius: 999 },
rowBadgeAlert: { backgroundColor: '#EF4444', paddingHorizontal: 8, paddingVertical: 3, borderRadius: 999 },
rowBadgeText: { fontSize: 10, fontWeight: '700' },
// Expanded card
expandedCard: { backgroundColor: '#F8FAFC', marginHorizontal: 14, marginBottom: 12, borderRadius: 12, padding: 12, gap: 6 },
infoRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 5, borderBottomWidth: 1, borderBottomColor: '#E2E8F0' },
infoLabel: { fontSize: 12, color: '#64748B', fontWeight: '600', width: 100 },
infoValue: { fontSize: 13, color: '#0F172A', fontWeight: '500', flex: 1, textAlign: 'right' },
emptyHint: { fontSize: 13, color: '#94A3B8', textAlign: 'center', paddingVertical: 8 },
passwordError: { fontSize: 12, color: '#B91C1C', backgroundColor: '#FEF2F2', borderRadius: 6, paddingHorizontal: 10, paddingVertical: 6 },
infoRowEdit: { paddingTop: 6, paddingBottom: 2, borderBottomWidth: 1, borderBottomColor: '#E2E8F0', marginTop: 2 },
input: { fontSize: 13, color: '#0F172A', fontWeight: '500', paddingVertical: 4, paddingHorizontal: 0, marginTop: 2 },
editActionRow: { flexDirection: 'row', justifyContent: 'flex-end', gap: 10, marginTop: 14 },
cancelBtn: { paddingHorizontal: 16, paddingVertical: 8, borderRadius: 8, backgroundColor: '#E2E8F0' },
cancelBtnText: { color: '#475569', fontWeight: '600', fontSize: 13 },
saveBtn: { paddingHorizontal: 16, paddingVertical: 8, borderRadius: 8, backgroundColor: '#2563EB', minWidth: 90, alignItems: 'center' },
saveBtnText: { color: '#FFFFFF', fontWeight: '600', fontSize: 13 },
// Sub info (Betrieb)
subInfo: { paddingHorizontal: 14, paddingBottom: 12, gap: 4 },
subInfoText: { fontSize: 13, fontWeight: '600', color: '#334155' },
subInfoMuted: { fontSize: 12, color: '#64748B' },
ausbildungPill: { flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 4 },
ausbildungText: { fontSize: 11, color: '#15803D', fontWeight: '600' },
// Mitteilungen action
mitteilungenAction: { flexDirection: 'row', alignItems: 'center', gap: 12, padding: 4 },
mitteilungenTitle: { fontSize: 13, fontWeight: '700', color: '#1E293B' },
mitteilungenSub: { fontSize: 11, color: '#64748B', marginTop: 1 },
// Logout
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',
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' },
})

View File

@@ -41,8 +41,11 @@ export default function StellenScreen() {
data={data ?? []}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
initialNumToRender={10}
maxToRenderPerBatch={10}
windowSize={5}
refreshControl={
<RefreshControl refreshing={isRefetching} onRefresh={refetch} tintColor="#003B7E" />
<RefreshControl refreshing={isRefetching} onRefresh={refetch} tintColor="#003B7E" progressViewOffset={50} />
}
renderItem={({ item }) => (
<StelleCard

View File

@@ -1,5 +1,5 @@
import {
View, Text, ScrollView, TouchableOpacity, Linking, ActivityIndicator, Alert, StyleSheet, Platform,
View, Text, ScrollView, TouchableOpacity, Linking, ActivityIndicator, Alert, Share, StyleSheet, Platform,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter, Stack } from 'expo-router'
@@ -10,6 +10,7 @@ import { Badge } from '@/components/ui/Badge'
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 }>()
@@ -29,6 +30,63 @@ export default function TerminDetailScreen() {
const datum = new Date(termin.datum)
const isPast = datum < new Date()
const handleAddToCalendar = async () => {
try {
const { status } = await Calendar.requestCalendarPermissionsAsync()
if (status !== 'granted') {
Alert.alert('Fehler', 'Kalender-Berechtigung wurde verweigert.')
return
}
const startDate = new Date(termin.datum)
let endDate = new Date(termin.datum)
if (termin.uhrzeit) {
const [hours, minutes] = termin.uhrzeit.split(':').map(Number)
startDate.setHours(hours || 0, minutes || 0)
if (termin.endeUhrzeit) {
const [endHours, endMinutes] = termin.endeUhrzeit.split(':').map(Number)
endDate.setHours(endHours || 0, endMinutes || 0)
} else {
endDate.setHours((hours || 0) + 1, minutes || 0)
}
} else {
endDate.setDate(startDate.getDate() + 1)
}
let calendarId
if (Platform.OS === 'ios') {
const defaultCalendar = await Calendar.getDefaultCalendarAsync()
calendarId = defaultCalendar.id
} else {
const calendars = await Calendar.getCalendarsAsync(Calendar.EntityTypes.EVENT)
// Try to prefer primary or typical default calendar
const primary = calendars.find(c => c.isPrimary) || calendars.find(c => c.accessLevel === 'owner') || calendars[0]
calendarId = primary?.id
}
if (!calendarId) {
Alert.alert('Fehler', 'Kein beschreibbarer Kalender gefunden.')
return
}
await Calendar.createEventAsync(calendarId, {
title: termin.titel,
startDate,
endDate,
allDay: !termin.uhrzeit,
location: [termin.ort, termin.adresse].filter(Boolean).join(', '),
notes: termin.beschreibung || undefined,
})
Alert.alert('Erfolg', 'Der Termin wurde in den Kalender eingetragen.')
} catch (e) {
console.error(e)
Alert.alert('Fehler', 'Der Termin konnte nicht eingetragen werden.')
}
}
return (
<>
<Stack.Screen options={{ headerShown: false }} />
@@ -39,7 +97,21 @@ export default function TerminDetailScreen() {
<Ionicons name="arrow-back" size={24} color="#0F172A" />
</TouchableOpacity>
<View style={styles.headerSpacer} />
<TouchableOpacity style={styles.shareButton}>
<TouchableOpacity
style={styles.shareButton}
onPress={() => {
const lines: string[] = []
lines.push(`📅 ${termin.titel}`)
lines.push(format(datum, 'EEEE, d. MMMM yyyy', { locale: de }))
if (termin.uhrzeit) {
lines.push(`🕐 ${termin.uhrzeit}${termin.endeUhrzeit ? ` ${termin.endeUhrzeit}` : ''} Uhr`)
}
if (termin.ort) lines.push(`📍 ${termin.ort}`)
if (termin.adresse) lines.push(` ${termin.adresse}`)
if (termin.beschreibung) lines.push(`\n${termin.beschreibung}`)
Share.share({ message: lines.join('\n') })
}}
>
<Ionicons name="share-outline" size={24} color="#0F172A" />
</TouchableOpacity>
</View>
@@ -156,11 +228,13 @@ export default function TerminDetailScreen() {
isAngemeldet={termin.isAngemeldet}
onToggle={() => mutate({ terminId: id })}
isLoading={isPending}
maxTeilnehmer={termin.maxTeilnehmer}
teilnehmerAnzahl={termin.teilnehmerAnzahl}
/>
</View>
<TouchableOpacity
style={styles.calendarButton}
onPress={() => Alert.alert('Kalender', 'Funktion folgt in Kürze')}
onPress={handleAddToCalendar}
>
<Ionicons name="calendar-outline" size={24} color="#0F172A" />
</TouchableOpacity>

View File

@@ -52,8 +52,11 @@ export default function TermineScreen() {
data={data ?? []}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
initialNumToRender={10}
maxToRenderPerBatch={10}
windowSize={5}
refreshControl={
<RefreshControl refreshing={isRefetching} onRefresh={refetch} tintColor="#003B7E" />
<RefreshControl refreshing={isRefetching} onRefresh={refetch} tintColor="#003B7E" progressViewOffset={50} />
}
renderItem={({ item }) => (
<TerminCard

View File

@@ -8,6 +8,8 @@ import { SafeAreaView } from 'react-native-safe-area-context'
import { Ionicons } from '@expo/vector-icons'
import { authClient } from '@/lib/auth-client'
import { useAuthStore } from '@/store/auth.store'
import { getApiBaseUrl } from '@/lib/api-url'
import { getOrgSlug } from '@/lib/org-config'
export default function LoginScreen() {
const router = useRouter()
@@ -36,9 +38,27 @@ export default function LoginScreen() {
}
const token = (result.data as any)?.session?.token
const user = (result.data as any)?.user
const rawUser = (result.data as any)?.user
const user = rawUser
? {
id: rawUser.id,
email: rawUser.email,
name: rawUser.name,
mustChangePassword: rawUser.mustChangePassword ?? false,
}
: null
await setSession(user ? { user } : null, token)
// Retroaktive Org-Bindung: idempotent, schlägt still fehl wenn bereits verknüpft
const orgSlug = getOrgSlug()
const emailClean = email.trim().toLowerCase()
const nameForSignup = user?.name?.trim() || emailClean.split('@')[0]
fetch(`${getApiBaseUrl()}/api/registrierung/${orgSlug}/signup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: nameForSignup, email: emailClean }),
}).catch(() => {/* ignore */})
setLoading(false)
router.replace('/(app)/home' as never)
}
@@ -113,9 +133,12 @@ export default function LoginScreen() {
</TouchableOpacity>
</View>
<Text style={styles.hint}>
Noch kein Zugang? Kontaktieren Sie Ihre Innungsgeschäftsstelle.
</Text>
<TouchableOpacity onPress={() => router.push('/(auth)/registrierung' as never)}>
<Text style={styles.hint}>
Noch kein Konto?{' '}
<Text style={styles.registerLink}>Jetzt registrieren</Text>
</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
</SafeAreaView>
@@ -157,4 +180,5 @@ const styles = StyleSheet.create({
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 },
registerLink: { color: '#003B7E', fontWeight: '700' },
})

View File

@@ -0,0 +1,253 @@
import {
View, Text, TextInput, TouchableOpacity,
KeyboardAvoidingView, Platform, ActivityIndicator, StyleSheet, ScrollView,
} from 'react-native'
import { useState } from 'react'
import { useRouter } from 'expo-router'
import { SafeAreaView } from 'react-native-safe-area-context'
import { Ionicons } from '@expo/vector-icons'
import { authClient } from '@/lib/auth-client'
import { useAuthStore } from '@/store/auth.store'
import { getApiBaseUrl } from '@/lib/api-url'
import { getOrgSlug } from '@/lib/org-config'
export default function RegistrierungScreen() {
const router = useRouter()
const setSession = useAuthStore((s) => s.setSession)
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [passwordConfirm, setPasswordConfirm] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const canSubmit =
name.trim().length > 0 &&
email.trim().length > 0 &&
password.length >= 8 &&
password === passwordConfirm &&
!loading
async function handleRegister() {
if (!canSubmit) return
setLoading(true)
setError('')
const orgSlug = getOrgSlug()
// Step 1: Create better-auth account
const signUpResult = await authClient.signUp.email({
name: name.trim(),
email: email.trim().toLowerCase(),
password,
})
if (signUpResult.error) {
setError(signUpResult.error.message ?? 'Registrierung fehlgeschlagen.')
setLoading(false)
return
}
// Step 2: Create Member record linked to org
try {
const apiUrl = getApiBaseUrl()
const res = await fetch(`${apiUrl}/api/registrierung/${orgSlug}/signup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.trim(),
email: email.trim().toLowerCase(),
}),
})
if (!res.ok) {
console.warn('Member-Datensatz konnte nicht angelegt werden:', await res.text())
}
} catch (e) {
console.warn('Member-API nicht erreichbar:', e)
}
// Step 3: Sign in to get session
const signInResult = await authClient.signIn.email({
email: email.trim().toLowerCase(),
password,
})
if (signInResult.error) {
setError('Konto erstellt, aber Anmeldung fehlgeschlagen. Bitte einloggen.')
setLoading(false)
router.replace('/(auth)/login' as never)
return
}
const token = (signInResult.data as any)?.session?.token
const user = (signInResult.data as any)?.user
await setSession(user ? { user } : null, token)
setLoading(false)
router.replace('/(app)/home' as never)
}
return (
<SafeAreaView style={styles.safeArea}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardView}
>
<ScrollView contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="handled">
<View style={styles.content}>
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
<Ionicons name="arrow-back" size={20} color="#334155" />
</TouchableOpacity>
<View style={styles.logoSection}>
<View style={styles.logoBox}>
<Text style={styles.logoLetter}>I</Text>
</View>
<Text style={styles.title}>Konto erstellen</Text>
<Text style={styles.subtitle}>Registrieren Sie sich für Ihre Innung</Text>
</View>
<View style={styles.form}>
<Text style={styles.inputLabel}>Name</Text>
<View style={styles.inputWrap}>
<Ionicons name="person-outline" size={18} color="#94A3B8" />
<TextInput
style={styles.input}
placeholder="Vor- und Nachname"
placeholderTextColor="#94A3B8"
autoCapitalize="words"
autoCorrect={false}
value={name}
onChangeText={setName}
returnKeyType="next"
/>
</View>
<Text style={[styles.inputLabel, { marginTop: 4 }]}>E-Mail-Adresse</Text>
<View style={styles.inputWrap}>
<Ionicons name="mail-outline" size={18} color="#94A3B8" />
<TextInput
style={styles.input}
placeholder="beispiel@handwerk.de"
placeholderTextColor="#94A3B8"
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
value={email}
onChangeText={setEmail}
returnKeyType="next"
/>
</View>
<Text style={[styles.inputLabel, { marginTop: 4 }]}>Passwort</Text>
<View style={styles.inputWrap}>
<Ionicons name="lock-closed-outline" size={18} color="#94A3B8" />
<TextInput
style={styles.input}
placeholder="Mindestens 8 Zeichen"
placeholderTextColor="#94A3B8"
secureTextEntry
value={password}
onChangeText={setPassword}
returnKeyType="next"
/>
</View>
<Text style={[styles.inputLabel, { marginTop: 4 }]}>Passwort bestätigen</Text>
<View style={[styles.inputWrap, passwordConfirm.length > 0 && password !== passwordConfirm && styles.inputError]}>
<Ionicons name="lock-closed-outline" size={18} color="#94A3B8" />
<TextInput
style={styles.input}
placeholder="Passwort wiederholen"
placeholderTextColor="#94A3B8"
secureTextEntry
value={passwordConfirm}
onChangeText={setPasswordConfirm}
onSubmitEditing={handleRegister}
returnKeyType="go"
/>
</View>
{error ? (
<View style={styles.errorBox}>
<Text style={styles.errorText}>{error}</Text>
</View>
) : null}
<TouchableOpacity
onPress={handleRegister}
disabled={!canSubmit}
style={[styles.submitBtn, !canSubmit && styles.submitBtnDisabled]}
activeOpacity={0.85}
>
{loading ? (
<ActivityIndicator color="#FFFFFF" />
) : (
<View style={styles.submitContent}>
<Text style={styles.submitLabel}>Registrieren</Text>
<Ionicons name="arrow-forward" size={16} color="#FFFFFF" />
</View>
)}
</TouchableOpacity>
</View>
<TouchableOpacity onPress={() => router.replace('/(auth)/login' as never)}>
<Text style={styles.loginLink}>
Bereits registriert?{' '}
<Text style={styles.loginLinkBold}>Jetzt anmelden</Text>
</Text>
</TouchableOpacity>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
safeArea: { flex: 1, backgroundColor: '#FFFFFF' },
keyboardView: { flex: 1 },
scrollContent: { flexGrow: 1 },
content: { flex: 1, paddingHorizontal: 24, paddingTop: 16, paddingBottom: 32 },
backBtn: {
width: 40, height: 40,
borderRadius: 12,
backgroundColor: '#F8FAFC',
borderWidth: 1, borderColor: '#E2E8F0',
alignItems: 'center', justifyContent: 'center',
marginBottom: 24,
},
logoSection: { alignItems: 'center', marginBottom: 32 },
logoBox: {
width: 64, height: 64, backgroundColor: '#003B7E',
borderRadius: 18, alignItems: 'center', justifyContent: 'center', marginBottom: 16,
},
logoLetter: { color: '#FFFFFF', fontSize: 30, fontWeight: '900' },
title: { fontSize: 26, fontWeight: '800', color: '#0F172A', letterSpacing: -0.5, marginBottom: 4 },
subtitle: { fontSize: 14, color: '#64748B', textAlign: 'center' },
form: { gap: 8, marginBottom: 24 },
inputLabel: { fontSize: 14, fontWeight: '700', color: '#334155' },
inputWrap: {
flexDirection: 'row', alignItems: 'center',
backgroundColor: '#F8FAFC', borderRadius: 14,
borderWidth: 1, borderColor: '#E2E8F0',
paddingHorizontal: 12, gap: 8,
},
inputError: { borderColor: '#FECACA' },
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: 8,
},
submitBtnDisabled: { backgroundColor: '#CBD5E1' },
submitContent: { flexDirection: 'row', alignItems: 'center', gap: 6 },
submitLabel: { color: '#FFFFFF', fontWeight: '700', fontSize: 15 },
loginLink: { textAlign: 'center', color: '#64748B', fontSize: 14 },
loginLinkBold: { color: '#003B7E', fontWeight: '700' },
})

View File

@@ -35,11 +35,15 @@ export default function StellenPublicScreen() {
data={data ?? []}
keyExtractor={(item) => item.id}
contentContainerStyle={{ padding: 12, gap: 8 }}
initialNumToRender={10}
maxToRenderPerBatch={10}
windowSize={5}
refreshControl={
<RefreshControl
refreshing={isRefetching}
onRefresh={refetch}
tintColor="#E63946"
progressViewOffset={50}
/>
}
renderItem={({ item }) => (

View File

@@ -1,6 +1,7 @@
import { TouchableOpacity, Text, View, StyleSheet, Platform } from 'react-native'
import { TouchableOpacity, Text, View, StyleSheet } from 'react-native'
import { Ionicons } from '@expo/vector-icons'
import * as WebBrowser from 'expo-web-browser'
import { getApiBaseUrl } from '@/lib/api-url'
interface Attachment {
id: string
@@ -10,7 +11,7 @@ interface Attachment {
mimeType?: string | null
}
const API_URL = process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3000'
const API_URL = getApiBaseUrl()
function getFileIcon(mimeType?: string | null): keyof typeof Ionicons.glyphMap {
if (!mimeType) return 'document-outline'

View File

@@ -5,25 +5,29 @@ interface AnmeldeButtonProps {
isAngemeldet: boolean
onToggle: () => void
isLoading: boolean
maxTeilnehmer?: number | null
teilnehmerAnzahl?: number
}
export function AnmeldeButton({ isAngemeldet, onToggle, isLoading }: AnmeldeButtonProps) {
export function AnmeldeButton({ isAngemeldet, onToggle, isLoading, maxTeilnehmer, teilnehmerAnzahl = 0 }: AnmeldeButtonProps) {
const isFull = !isAngemeldet && !!maxTeilnehmer && teilnehmerAnzahl >= maxTeilnehmer
return (
<TouchableOpacity
onPress={onToggle}
disabled={isLoading}
disabled={isLoading || isFull}
style={[
styles.btn,
isAngemeldet ? styles.btnRegistered : styles.btnRegister,
isLoading && styles.disabled,
isAngemeldet ? styles.btnRegistered : isFull ? styles.btnFull : styles.btnRegister,
(isLoading || isFull) && styles.disabled,
]}
activeOpacity={0.82}
>
{isLoading ? (
<ActivityIndicator color={isAngemeldet ? '#52525B' : '#FFFFFF'} />
) : (
<Text style={[styles.label, isAngemeldet && styles.labelRegistered]}>
{isAngemeldet ? '✓ Angemeldet Abmelden' : 'Jetzt anmelden'}
<Text style={[styles.label, (isAngemeldet || isFull) && styles.labelRegistered]}>
{isAngemeldet ? '✓ Angemeldet Abmelden' : isFull ? 'Ausgebucht' : 'Jetzt anmelden'}
</Text>
)}
</TouchableOpacity>
@@ -51,6 +55,11 @@ const styles = StyleSheet.create({
borderWidth: 1,
borderColor: '#E2E8F0',
},
btnFull: {
backgroundColor: '#F4F4F5',
borderWidth: 1,
borderColor: '#E2E8F0',
},
disabled: {
opacity: 0.5,
},

View File

@@ -0,0 +1,44 @@
import Constants from 'expo-constants'
import { Platform } from 'react-native'
function stripTrailingSlash(url: string): string {
return url.replace(/\/+$/, '')
}
function extractHost(hostUri?: string | null): string | null {
if (!hostUri) return null
const [host] = hostUri.split(':')
return host || null
}
function resolveExpoHost(): string | null {
const fromExpoConfig = (Constants.expoConfig as any)?.hostUri as string | undefined
const fromManifest2 = (Constants as any)?.manifest2?.extra?.expoClient?.hostUri as
| string
| undefined
const fromManifest = (Constants as any)?.manifest?.debuggerHost as string | undefined
return extractHost(fromExpoConfig ?? fromManifest2 ?? fromManifest ?? null)
}
export function getApiBaseUrl(): string {
const configuredApiUrl =
process.env.EXPO_PUBLIC_API_URL ??
((Constants.expoConfig?.extra as { apiUrl?: string } | undefined)?.apiUrl ?? undefined)
if (configuredApiUrl && !configuredApiUrl.includes('localhost')) {
return stripTrailingSlash(configuredApiUrl)
}
const expoHost = resolveExpoHost()
if (expoHost) {
return `http://${expoHost}:3000`
}
if (configuredApiUrl) {
return stripTrailingSlash(configuredApiUrl)
}
const fallbackHost = Platform.OS === 'android' ? '10.0.2.2' : '127.0.0.1'
return `http://${fallbackHost}:3000`
}

View File

@@ -1,16 +1,12 @@
import { createAuthClient } from 'better-auth/react'
import { magicLinkClient } from 'better-auth/client/plugins'
import Constants from 'expo-constants'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { getApiBaseUrl } from './api-url'
const apiUrl =
Constants.expoConfig?.extra?.apiUrl ??
process.env.EXPO_PUBLIC_API_URL ??
'http://localhost:3000'
const apiUrl = getApiBaseUrl()
export const authClient = createAuthClient({
baseURL: apiUrl,
plugins: [magicLinkClient()],
plugins: [],
fetchOptions: {
customFetchImpl: async (url, options) => {
const token = await AsyncStorage.getItem('better-auth-session')

View File

@@ -1,7 +1,6 @@
import * as Notifications from 'expo-notifications'
import { Platform } from 'react-native'
import { trpc } from './trpc'
import { queryClient } from './trpc'
import { getApiBaseUrl } from './api-url'
Notifications.setNotificationHandler({
handleNotification: async () => ({
@@ -30,11 +29,8 @@ export async function setupPushNotifications() {
projectId: process.env.EXPO_PUBLIC_PROJECT_ID,
})
// Store push token on the server
// We call the tRPC mutation to save the token
const caller = trpc.createClient as never
// Simple fetch to avoid circular deps:
const apiUrl = process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3000'
// Store push token on the server.
const apiUrl = getApiBaseUrl()
await fetch(`${apiUrl}/api/push-token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },

View File

@@ -0,0 +1,9 @@
import Constants from 'expo-constants'
export function getOrgSlug(): string {
return (
process.env.EXPO_PUBLIC_ORG_SLUG ??
(Constants.expoConfig?.extra as { orgSlug?: string } | undefined)?.orgSlug ??
'tischler'
)
}

View File

@@ -5,6 +5,7 @@ import superjson from 'superjson'
import { createElement, type ReactNode } from 'react'
import AsyncStorage from '@react-native-async-storage/async-storage'
import type { AppRouter } from '@innungsapp/admin'
import { getApiBaseUrl } from './api-url'
export const trpc = createTRPCReact<AppRouter>()
@@ -17,14 +18,10 @@ export const queryClient = new QueryClient({
},
})
function getApiUrl() {
return process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3000'
}
const trpcClient = trpc.createClient({
links: [
httpBatchLink({
url: `${getApiUrl()}/api/trpc`,
url: `${getApiBaseUrl()}/api/trpc`,
transformer: superjson,
async headers() {
// Include session cookie for auth

View File

@@ -1,6 +1,7 @@
const path = require('path')
const { createRequire } = require('module')
const { getDefaultConfig } = require('expo/metro-config')
const { withNativeWind } = require('nativewind/metro')
const projectRoot = __dirname
const workspaceRoot = path.resolve(projectRoot, '../..')
@@ -40,4 +41,4 @@ config.resolver.resolveRequest = (context, moduleName, platform) => {
return resolver(context, moduleName, platform)
}
module.exports = config
module.exports = withNativeWind(config, { input: './global.css' })

View File

@@ -3,7 +3,7 @@ import { authClient } from '@/lib/auth-client'
import AsyncStorage from '@react-native-async-storage/async-storage'
interface Session {
user: { id: string; email: string; name: string }
user: { id: string; email: string; name: string; mustChangePassword?: boolean }
}
interface AuthState {
@@ -29,8 +29,16 @@ export const useAuthStore = create<AuthState>((set) => ({
// authClient now sends the token via cookie header (see auth-client.ts)
const result = await authClient.getSession()
if (result?.data?.user) {
const u = result.data.user as any
set({
session: { user: result.data.user },
session: {
user: {
id: u.id,
email: u.email,
name: u.name,
mustChangePassword: u.mustChangePassword ?? false,
},
},
isInitialized: true,
})
} else {