push
This commit is contained in:
@@ -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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,10 @@
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
},
|
||||
"extra": {
|
||||
"orgSlug": "innung-elektro-stuttgart",
|
||||
"apiUrl": "http://localhost:3000"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
410
innungsapp/apps/mobile/app/(app)/chat/[id].tsx
Normal file
410
innungsapp/apps/mobile/app/(app)/chat/[id].tsx
Normal 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',
|
||||
},
|
||||
})
|
||||
401
innungsapp/apps/mobile/app/(app)/chat/index.tsx
Normal file
401
innungsapp/apps/mobile/app/(app)/chat/index.tsx
Normal 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,
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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." />
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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' },
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
})
|
||||
|
||||
253
innungsapp/apps/mobile/app/(auth)/registrierung.tsx
Normal file
253
innungsapp/apps/mobile/app/(auth)/registrierung.tsx
Normal 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' },
|
||||
})
|
||||
@@ -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 }) => (
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
44
innungsapp/apps/mobile/lib/api-url.ts
Normal file
44
innungsapp/apps/mobile/lib/api-url.ts
Normal 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`
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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' },
|
||||
|
||||
9
innungsapp/apps/mobile/lib/org-config.ts
Normal file
9
innungsapp/apps/mobile/lib/org-config.ts
Normal 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'
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user