feat: Implement mobile application and lead processing utilities.
This commit is contained in:
115
innungsapp/apps/mobile/components/members/MemberCard.tsx
Normal file
115
innungsapp/apps/mobile/components/members/MemberCard.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
import { Avatar } from '@/components/ui/Avatar'
|
||||
|
||||
interface MemberCardProps {
|
||||
member: {
|
||||
id: string
|
||||
name: string
|
||||
betrieb: string
|
||||
sparte: string
|
||||
ort: string
|
||||
istAusbildungsbetrieb: boolean
|
||||
avatarUrl: string | null
|
||||
}
|
||||
onPress: () => void
|
||||
}
|
||||
|
||||
export function MemberCard({ member, onPress }: MemberCardProps) {
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress} style={styles.card} activeOpacity={0.76}>
|
||||
<Avatar name={member.name} imageUrl={member.avatarUrl ?? undefined} size={48} />
|
||||
|
||||
<View style={styles.info}>
|
||||
<Text style={styles.name} numberOfLines={1}>
|
||||
{member.name}
|
||||
</Text>
|
||||
<Text style={styles.company} numberOfLines={1}>
|
||||
{member.betrieb}
|
||||
</Text>
|
||||
<View style={styles.tagsRow}>
|
||||
<View style={styles.tag}>
|
||||
<Text style={styles.tagText}>{member.sparte}</Text>
|
||||
</View>
|
||||
<Text style={styles.separator}>·</Text>
|
||||
<Text style={styles.location}>{member.ort}</Text>
|
||||
{member.istAusbildungsbetrieb && (
|
||||
<View style={styles.ausbildungTag}>
|
||||
<Text style={styles.ausbildungText}>Ausbildung</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Ionicons name="chevron-forward" size={18} color="#D4D4D8" />
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 16,
|
||||
padding: 14,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
shadowColor: '#1C1917',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 12,
|
||||
elevation: 3,
|
||||
},
|
||||
info: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
name: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: '#0F172A',
|
||||
letterSpacing: -0.2,
|
||||
},
|
||||
company: {
|
||||
fontSize: 13,
|
||||
color: '#475569',
|
||||
marginTop: 2,
|
||||
},
|
||||
tagsRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: 6,
|
||||
marginTop: 6,
|
||||
},
|
||||
tag: {
|
||||
backgroundColor: '#F4F4F5',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 99,
|
||||
},
|
||||
tagText: {
|
||||
fontSize: 11,
|
||||
color: '#475569',
|
||||
fontWeight: '500',
|
||||
},
|
||||
separator: {
|
||||
fontSize: 11,
|
||||
color: '#D4D4D8',
|
||||
},
|
||||
location: {
|
||||
fontSize: 11,
|
||||
color: '#64748B',
|
||||
},
|
||||
ausbildungTag: {
|
||||
backgroundColor: '#F0FDF4',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 99,
|
||||
},
|
||||
ausbildungText: {
|
||||
fontSize: 11,
|
||||
color: '#15803D',
|
||||
fontWeight: '600',
|
||||
},
|
||||
})
|
||||
105
innungsapp/apps/mobile/components/news/AttachmentRow.tsx
Normal file
105
innungsapp/apps/mobile/components/news/AttachmentRow.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { TouchableOpacity, Text, View, StyleSheet, Platform } from 'react-native'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
import * as WebBrowser from 'expo-web-browser'
|
||||
|
||||
interface Attachment {
|
||||
id: string
|
||||
name: string
|
||||
storagePath: string
|
||||
sizeBytes: number | null
|
||||
mimeType?: string | null
|
||||
}
|
||||
|
||||
const API_URL = process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3000'
|
||||
|
||||
function getFileIcon(mimeType?: string | null): keyof typeof Ionicons.glyphMap {
|
||||
if (!mimeType) return 'document-outline'
|
||||
if (mimeType.includes('pdf')) return 'document-text-outline'
|
||||
if (mimeType.startsWith('image/')) return 'image-outline'
|
||||
if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) return 'grid-outline'
|
||||
if (mimeType.includes('word') || mimeType.includes('document')) return 'document-outline'
|
||||
return 'attach-outline'
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export function AttachmentRow({ attachment }: { attachment: Attachment }) {
|
||||
const url = `${API_URL}/uploads/${attachment.storagePath}`
|
||||
const icon = getFileIcon(attachment.mimeType)
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => WebBrowser.openBrowserAsync(url)}
|
||||
style={styles.row}
|
||||
activeOpacity={0.75}
|
||||
>
|
||||
<View style={styles.iconBox}>
|
||||
<Ionicons name={icon} size={20} color="#003B7E" />
|
||||
</View>
|
||||
|
||||
<View style={styles.info}>
|
||||
<Text style={styles.name} numberOfLines={1}>
|
||||
{attachment.name}
|
||||
</Text>
|
||||
{attachment.sizeBytes != null && (
|
||||
<Text style={styles.meta}>{formatSize(attachment.sizeBytes)}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.openChip}>
|
||||
<Ionicons name="download-outline" size={13} color="#003B7E" />
|
||||
<Text style={styles.openText}>Öffnen</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
paddingVertical: 13,
|
||||
},
|
||||
iconBox: {
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#EFF6FF',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
},
|
||||
info: {
|
||||
flex: 1,
|
||||
},
|
||||
name: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: '#0F172A',
|
||||
lineHeight: 18,
|
||||
},
|
||||
meta: {
|
||||
fontSize: 11,
|
||||
color: '#94A3B8',
|
||||
marginTop: 2,
|
||||
},
|
||||
openChip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
backgroundColor: '#EFF6FF',
|
||||
paddingHorizontal: 11,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 99,
|
||||
},
|
||||
openText: {
|
||||
fontSize: 12,
|
||||
color: '#003B7E',
|
||||
fontWeight: '600',
|
||||
},
|
||||
})
|
||||
133
innungsapp/apps/mobile/components/news/NewsCard.tsx
Normal file
133
innungsapp/apps/mobile/components/news/NewsCard.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { NEWS_KATEGORIE_LABELS } from '@innungsapp/shared/types'
|
||||
import { format } from 'date-fns'
|
||||
import { de } from 'date-fns/locale'
|
||||
import { useNewsReadStore } from '@/store/news.store'
|
||||
|
||||
interface NewsCardProps {
|
||||
news: {
|
||||
id: string
|
||||
title: string
|
||||
kategorie: string
|
||||
publishedAt: Date | null
|
||||
isRead: boolean
|
||||
author: { name: string } | null
|
||||
}
|
||||
onPress: () => void
|
||||
}
|
||||
|
||||
export function NewsCard({ news, onPress }: NewsCardProps) {
|
||||
const localReadIds = useNewsReadStore((s) => s.readIds)
|
||||
const isRead = news.isRead || localReadIds.has(news.id)
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress} style={styles.card} activeOpacity={0.84}>
|
||||
{!isRead && (
|
||||
<View style={styles.newBadge}>
|
||||
<Text style={styles.newBadgeText}>Neu</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.content}>
|
||||
<View style={styles.topRow}>
|
||||
<Badge label={NEWS_KATEGORIE_LABELS[news.kategorie]} kategorie={news.kategorie} />
|
||||
<Text style={styles.dateText}>
|
||||
{news.publishedAt
|
||||
? format(new Date(news.publishedAt), 'dd. MMM', { locale: de })
|
||||
: 'Entwurf'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text style={isRead ? styles.titleRead : styles.titleUnread} numberOfLines={2}>
|
||||
{news.title}
|
||||
</Text>
|
||||
|
||||
<View style={styles.metaRow}>
|
||||
<View style={styles.dot} />
|
||||
<Text style={styles.authorText}>{news.author?.name ?? 'Innung'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Ionicons name="chevron-forward" size={16} color="#94A3B8" />
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderWidth: 1,
|
||||
borderColor: '#E2E8F0',
|
||||
borderRadius: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 14,
|
||||
shadowColor: '#0F172A',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.06,
|
||||
shadowRadius: 10,
|
||||
elevation: 2,
|
||||
position: 'relative',
|
||||
},
|
||||
newBadge: {
|
||||
position: 'absolute',
|
||||
right: 30,
|
||||
top: 8,
|
||||
borderRadius: 999,
|
||||
backgroundColor: '#EF4444',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
zIndex: 2,
|
||||
},
|
||||
newBadgeText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
marginRight: 10,
|
||||
},
|
||||
topRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 8,
|
||||
},
|
||||
dateText: {
|
||||
fontSize: 11,
|
||||
color: '#94A3B8',
|
||||
fontWeight: '600',
|
||||
},
|
||||
titleUnread: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
color: '#0F172A',
|
||||
lineHeight: 21,
|
||||
marginBottom: 8,
|
||||
},
|
||||
titleRead: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
color: '#475569',
|
||||
lineHeight: 20,
|
||||
marginBottom: 8,
|
||||
},
|
||||
metaRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
dot: {
|
||||
width: 4,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#CBD5E1',
|
||||
},
|
||||
authorText: {
|
||||
fontSize: 11,
|
||||
color: '#64748B',
|
||||
},
|
||||
})
|
||||
162
innungsapp/apps/mobile/components/stellen/StelleCard.tsx
Normal file
162
innungsapp/apps/mobile/components/stellen/StelleCard.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
|
||||
const SPARTE_COLOR: Record<string, string> = {
|
||||
elektro: '#1D4ED8',
|
||||
sanit: '#0F766E',
|
||||
it: '#4338CA',
|
||||
heizung: '#B45309',
|
||||
maler: '#15803D',
|
||||
info: '#4338CA',
|
||||
}
|
||||
|
||||
function getSparteColor(sparte: string): string {
|
||||
const lower = sparte.toLowerCase()
|
||||
for (const [key, color] of Object.entries(SPARTE_COLOR)) {
|
||||
if (lower.includes(key)) return color
|
||||
}
|
||||
return '#003B7E'
|
||||
}
|
||||
|
||||
interface StelleCardProps {
|
||||
stelle: {
|
||||
id: string
|
||||
sparte: string
|
||||
stellenAnz: number
|
||||
verguetung: string | null
|
||||
lehrjahr: string | null
|
||||
member: { betrieb: string; ort: string }
|
||||
org: { name: string }
|
||||
}
|
||||
onPress: () => void
|
||||
}
|
||||
|
||||
export function StelleCard({ stelle, onPress }: StelleCardProps) {
|
||||
const color = getSparteColor(stelle.sparte)
|
||||
const initial = stelle.sparte.charAt(0).toUpperCase()
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress} style={styles.card} activeOpacity={0.82}>
|
||||
<View style={styles.row}>
|
||||
<View style={[styles.iconBox, { backgroundColor: `${color}18` }]}>
|
||||
<Text style={[styles.iconLetter, { color }]}>{initial}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.company} numberOfLines={1}>
|
||||
{stelle.member.betrieb}
|
||||
</Text>
|
||||
<Text style={styles.sparte}>{stelle.sparte}</Text>
|
||||
|
||||
<View style={styles.chips}>
|
||||
<View style={styles.chipBrand}>
|
||||
<Text style={styles.chipBrandText}>
|
||||
{stelle.stellenAnz} Stelle{stelle.stellenAnz > 1 ? 'n' : ''}
|
||||
</Text>
|
||||
</View>
|
||||
{stelle.lehrjahr ? (
|
||||
<View style={styles.chip}>
|
||||
<Text style={styles.chipText}>{stelle.lehrjahr}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{stelle.verguetung ? (
|
||||
<View style={styles.chip}>
|
||||
<Text style={styles.chipText}>{stelle.verguetung}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View style={styles.locationRow}>
|
||||
<Ionicons name="location-outline" size={12} color="#64748B" />
|
||||
<Text style={styles.location}>{stelle.member.ort}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Ionicons name="chevron-forward" size={16} color="#94A3B8" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E2E8F0',
|
||||
padding: 14,
|
||||
shadowColor: '#0F172A',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.06,
|
||||
shadowRadius: 8,
|
||||
elevation: 2,
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: 12,
|
||||
},
|
||||
iconBox: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
iconLetter: {
|
||||
fontSize: 22,
|
||||
fontWeight: '800',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
gap: 4,
|
||||
},
|
||||
company: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
color: '#0F172A',
|
||||
letterSpacing: -0.2,
|
||||
},
|
||||
sparte: {
|
||||
fontSize: 12,
|
||||
color: '#64748B',
|
||||
},
|
||||
chips: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 6,
|
||||
marginTop: 4,
|
||||
},
|
||||
chipBrand: {
|
||||
backgroundColor: '#EFF6FF',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 99,
|
||||
},
|
||||
chipBrandText: {
|
||||
fontSize: 11,
|
||||
color: '#003B7E',
|
||||
fontWeight: '700',
|
||||
},
|
||||
chip: {
|
||||
backgroundColor: '#F1F5F9',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 99,
|
||||
},
|
||||
chipText: {
|
||||
fontSize: 11,
|
||||
color: '#475569',
|
||||
fontWeight: '500',
|
||||
},
|
||||
locationRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
marginTop: 2,
|
||||
},
|
||||
location: {
|
||||
fontSize: 11,
|
||||
color: '#64748B',
|
||||
},
|
||||
})
|
||||
67
innungsapp/apps/mobile/components/termine/AnmeldeButton.tsx
Normal file
67
innungsapp/apps/mobile/components/termine/AnmeldeButton.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { TouchableOpacity, Text, ActivityIndicator, StyleSheet } from 'react-native'
|
||||
|
||||
interface AnmeldeButtonProps {
|
||||
terminId: string
|
||||
isAngemeldet: boolean
|
||||
onToggle: () => void
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export function AnmeldeButton({ isAngemeldet, onToggle, isLoading }: AnmeldeButtonProps) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onToggle}
|
||||
disabled={isLoading}
|
||||
style={[
|
||||
styles.btn,
|
||||
isAngemeldet ? styles.btnRegistered : styles.btnRegister,
|
||||
isLoading && styles.disabled,
|
||||
]}
|
||||
activeOpacity={0.82}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color={isAngemeldet ? '#52525B' : '#FFFFFF'} />
|
||||
) : (
|
||||
<Text style={[styles.label, isAngemeldet && styles.labelRegistered]}>
|
||||
{isAngemeldet ? '✓ Angemeldet – Abmelden' : 'Jetzt anmelden'}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
btn: {
|
||||
borderRadius: 14,
|
||||
paddingVertical: 15,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
btnRegister: {
|
||||
backgroundColor: '#003B7E',
|
||||
shadowColor: '#003B7E',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.24,
|
||||
shadowRadius: 10,
|
||||
elevation: 5,
|
||||
},
|
||||
btnRegistered: {
|
||||
backgroundColor: '#F4F4F5',
|
||||
borderWidth: 1,
|
||||
borderColor: '#E2E8F0',
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
label: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
labelRegistered: {
|
||||
color: '#52525B',
|
||||
},
|
||||
})
|
||||
|
||||
253
innungsapp/apps/mobile/components/termine/TerminCard.tsx
Normal file
253
innungsapp/apps/mobile/components/termine/TerminCard.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import { View, Text, TouchableOpacity, ActivityIndicator, StyleSheet } from 'react-native'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
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'
|
||||
|
||||
interface TerminCardProps {
|
||||
termin: {
|
||||
id: string
|
||||
titel: string
|
||||
datum: Date
|
||||
uhrzeit: string | null
|
||||
ort: string | null
|
||||
typ: string
|
||||
isAngemeldet: boolean
|
||||
teilnehmerAnzahl: number
|
||||
}
|
||||
onPress: () => void
|
||||
onToggleAnmeldung?: () => void
|
||||
isToggling?: boolean
|
||||
isPast?: boolean
|
||||
}
|
||||
|
||||
export function TerminCard({
|
||||
termin,
|
||||
onPress,
|
||||
onToggleAnmeldung,
|
||||
isToggling = false,
|
||||
isPast = false,
|
||||
}: TerminCardProps) {
|
||||
const datum = new Date(termin.datum)
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
style={[styles.card, isPast && styles.cardPast]}
|
||||
activeOpacity={0.76}
|
||||
>
|
||||
{/* Date column */}
|
||||
<View style={[styles.dateColumn, isPast && styles.dateColumnPast]}>
|
||||
<Text style={[styles.dayNumber, isPast && styles.dayNumberPast]}>
|
||||
{format(datum, 'dd')}
|
||||
</Text>
|
||||
<Text style={[styles.monthLabel, isPast && styles.monthLabelPast]}>
|
||||
{format(datum, 'MMM', { locale: de }).toUpperCase()}
|
||||
</Text>
|
||||
{!isPast && termin.isAngemeldet && <View style={styles.registeredDot} />}
|
||||
{isPast && (
|
||||
<View style={styles.pastBadge}>
|
||||
<Text style={styles.pastBadgeText}>vorbei</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Divider */}
|
||||
<View style={[styles.divider, isPast && styles.dividerPast]} />
|
||||
|
||||
{/* Content */}
|
||||
<View style={styles.content}>
|
||||
<Badge label={TERMIN_TYP_LABELS[termin.typ]} typ={termin.typ} />
|
||||
<Text style={[styles.title, isPast && styles.titlePast]} numberOfLines={2}>
|
||||
{termin.titel}
|
||||
</Text>
|
||||
<View style={styles.metaRow}>
|
||||
{termin.uhrzeit && (
|
||||
<View style={styles.metaItem}>
|
||||
<Ionicons name="time-outline" size={12} color={isPast ? '#94A3B8' : '#64748B'} />
|
||||
<Text style={[styles.metaText, isPast && styles.metaTextPast]}>
|
||||
{termin.uhrzeit} Uhr
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{termin.ort && (
|
||||
<View style={styles.metaItem}>
|
||||
<Ionicons name="location-outline" size={12} color={isPast ? '#94A3B8' : '#64748B'} />
|
||||
<Text style={[styles.metaText, isPast && styles.metaTextPast]} numberOfLines={1}>
|
||||
{termin.ort}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Registration status chip — only for upcoming events */}
|
||||
{!isPast && termin.isAngemeldet && onToggleAnmeldung && (
|
||||
<TouchableOpacity
|
||||
onPress={(e) => {
|
||||
e.stopPropagation?.()
|
||||
onToggleAnmeldung()
|
||||
}}
|
||||
style={styles.registeredChip}
|
||||
disabled={isToggling}
|
||||
activeOpacity={0.75}
|
||||
>
|
||||
{isToggling ? (
|
||||
<ActivityIndicator size="small" color="#047857" style={{ marginRight: 4 }} />
|
||||
) : (
|
||||
<Ionicons name="checkmark-circle" size={14} color="#059669" />
|
||||
)}
|
||||
<Text style={styles.registeredChipText}>
|
||||
Angemeldet · <Text style={styles.abmeldenText}>Abmelden</Text>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Chevron */}
|
||||
<View style={styles.chevronWrap}>
|
||||
<Ionicons name="chevron-forward" size={20} color={isPast ? '#E2E8F0' : '#CBD5E1'} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#1C1917',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 12,
|
||||
elevation: 3,
|
||||
},
|
||||
cardPast: {
|
||||
shadowOpacity: 0.04,
|
||||
elevation: 1,
|
||||
},
|
||||
dateColumn: {
|
||||
width: 64,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 18,
|
||||
backgroundColor: '#EFF6FF',
|
||||
},
|
||||
dateColumnPast: {
|
||||
backgroundColor: '#F4F4F5',
|
||||
},
|
||||
dayNumber: {
|
||||
fontSize: 28,
|
||||
fontWeight: '800',
|
||||
color: '#003B7E',
|
||||
lineHeight: 30,
|
||||
},
|
||||
dayNumberPast: {
|
||||
color: '#94A3B8',
|
||||
},
|
||||
monthLabel: {
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
color: '#003B7E',
|
||||
letterSpacing: 1,
|
||||
opacity: 0.75,
|
||||
marginTop: 1,
|
||||
},
|
||||
monthLabelPast: {
|
||||
color: '#94A3B8',
|
||||
opacity: 1,
|
||||
},
|
||||
registeredDot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: '#059669',
|
||||
marginTop: 6,
|
||||
},
|
||||
pastBadge: {
|
||||
marginTop: 6,
|
||||
backgroundColor: '#E2E8F0',
|
||||
borderRadius: 99,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
},
|
||||
pastBadgeText: {
|
||||
fontSize: 9,
|
||||
fontWeight: '700',
|
||||
color: '#94A3B8',
|
||||
letterSpacing: 0.5,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
divider: {
|
||||
width: 1,
|
||||
alignSelf: 'stretch',
|
||||
marginVertical: 14,
|
||||
backgroundColor: '#F0EDED',
|
||||
},
|
||||
dividerPast: {
|
||||
backgroundColor: '#F4F4F5',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 14,
|
||||
gap: 8,
|
||||
},
|
||||
title: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: '#0F172A',
|
||||
letterSpacing: -0.2,
|
||||
lineHeight: 21,
|
||||
marginTop: 2,
|
||||
},
|
||||
titlePast: {
|
||||
color: '#94A3B8',
|
||||
fontWeight: '500',
|
||||
},
|
||||
metaRow: {
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
marginTop: 4,
|
||||
},
|
||||
metaItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
metaText: {
|
||||
fontSize: 12,
|
||||
color: '#64748B',
|
||||
},
|
||||
metaTextPast: {
|
||||
color: '#94A3B8',
|
||||
},
|
||||
registeredChip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
marginTop: 4,
|
||||
alignSelf: 'flex-start',
|
||||
backgroundColor: '#ECFDF5',
|
||||
borderRadius: 99,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 5,
|
||||
borderWidth: 1,
|
||||
borderColor: '#A7F3D0',
|
||||
},
|
||||
registeredChipText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: '#047857',
|
||||
},
|
||||
abmeldenText: {
|
||||
color: '#DC2626',
|
||||
fontWeight: '600',
|
||||
},
|
||||
chevronWrap: {
|
||||
paddingRight: 12,
|
||||
},
|
||||
})
|
||||
93
innungsapp/apps/mobile/components/ui/Avatar.tsx
Normal file
93
innungsapp/apps/mobile/components/ui/Avatar.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { View, Text, Image, ViewStyle, ImageStyle } from 'react-native'
|
||||
|
||||
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): string {
|
||||
return name
|
||||
.split(' ')
|
||||
.slice(0, 2)
|
||||
.map((w) => w[0]?.toUpperCase() ?? '')
|
||||
.join('')
|
||||
}
|
||||
|
||||
function getPalette(name: string) {
|
||||
const index = name.charCodeAt(0) % AVATAR_PALETTES.length
|
||||
return AVATAR_PALETTES[index]
|
||||
}
|
||||
|
||||
interface AvatarProps {
|
||||
name: string
|
||||
imageUrl?: string
|
||||
size?: number
|
||||
shadow?: boolean
|
||||
}
|
||||
|
||||
export function Avatar({ name, imageUrl, size = 48, shadow = false }: AvatarProps) {
|
||||
const initials = getInitials(name)
|
||||
const palette = getPalette(name)
|
||||
|
||||
const viewShadowStyle: ViewStyle = shadow
|
||||
? {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
}
|
||||
: {}
|
||||
|
||||
const imageShadowStyle: ImageStyle = shadow
|
||||
? {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 8,
|
||||
}
|
||||
: {}
|
||||
|
||||
if (imageUrl) {
|
||||
return (
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={[
|
||||
{ width: size, height: size, borderRadius: size / 2 },
|
||||
imageShadowStyle,
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
backgroundColor: palette.bg,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
viewShadowStyle,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: palette.text,
|
||||
fontSize: size * 0.36,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.5,
|
||||
}}
|
||||
>
|
||||
{initials}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
46
innungsapp/apps/mobile/components/ui/Badge.tsx
Normal file
46
innungsapp/apps/mobile/components/ui/Badge.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { View, Text, StyleSheet } from 'react-native'
|
||||
|
||||
const BADGE_CONFIG: Record<string, { bg: string; color: string }> = {
|
||||
Wichtig: { bg: '#FEE2E2', color: '#B91C1C' },
|
||||
Pruefung: { bg: '#DBEAFE', color: '#1D4ED8' },
|
||||
Foerderung: { bg: '#DCFCE7', color: '#15803D' },
|
||||
Veranstaltung: { bg: '#E0E7FF', color: '#4338CA' },
|
||||
Allgemein: { bg: '#F1F5F9', color: '#475569' },
|
||||
Versammlung: { bg: '#E0E7FF', color: '#4338CA' },
|
||||
Kurs: { bg: '#DCFCE7', color: '#15803D' },
|
||||
Event: { bg: '#FEF3C7', color: '#B45309' },
|
||||
Sonstiges: { bg: '#F1F5F9', color: '#475569' },
|
||||
}
|
||||
|
||||
interface BadgeProps {
|
||||
label: string
|
||||
kategorie?: string
|
||||
typ?: string
|
||||
}
|
||||
|
||||
export function Badge({ label, kategorie, typ }: BadgeProps) {
|
||||
const cfg =
|
||||
(kategorie && BADGE_CONFIG[kategorie]) ||
|
||||
(typ && BADGE_CONFIG[typ]) ||
|
||||
{ bg: '#F1F5F9', color: '#475569' }
|
||||
|
||||
return (
|
||||
<View style={[styles.pill, { backgroundColor: cfg.bg }]}>
|
||||
<Text style={[styles.label, { color: cfg.color }]}>{label}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
pill: {
|
||||
alignSelf: 'flex-start',
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 99,
|
||||
},
|
||||
label: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
})
|
||||
@@ -1,47 +1,69 @@
|
||||
import { TouchableOpacity, Text, ActivityIndicator } from 'react-native'
|
||||
import { Text, TouchableOpacity, ActivityIndicator } from 'react-native'
|
||||
import { cn } from '../../lib/utils'
|
||||
|
||||
interface ButtonProps {
|
||||
label: string
|
||||
onPress: () => void
|
||||
variant?: 'primary' | 'secondary' | 'ghost'
|
||||
variant?: 'primary' | 'secondary' | 'ghost' | 'destructive' | 'outline'
|
||||
size?: 'default' | 'sm' | 'lg'
|
||||
loading?: boolean
|
||||
disabled?: boolean
|
||||
icon?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Button({
|
||||
label,
|
||||
onPress,
|
||||
variant = 'primary',
|
||||
size = 'default',
|
||||
loading,
|
||||
disabled,
|
||||
icon,
|
||||
className,
|
||||
}: ButtonProps) {
|
||||
const base = 'rounded-2xl py-4 flex-row items-center justify-center gap-2'
|
||||
const variants = {
|
||||
primary: `${base} bg-brand-500`,
|
||||
secondary: `${base} bg-white border border-gray-200`,
|
||||
ghost: `${base}`,
|
||||
}
|
||||
const textVariants = {
|
||||
primary: 'text-white font-semibold text-base',
|
||||
secondary: 'text-gray-900 font-semibold text-base',
|
||||
ghost: 'text-brand-500 font-semibold text-base',
|
||||
}
|
||||
const isDisabled = disabled || loading
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
disabled={disabled || loading}
|
||||
className={`${variants[variant]} ${disabled || loading ? 'opacity-50' : ''}`}
|
||||
disabled={isDisabled}
|
||||
className={cn(
|
||||
'flex-row items-center justify-center rounded-xl',
|
||||
{
|
||||
'bg-primary shadow-sm': variant === 'primary',
|
||||
'bg-secondary border border-border': variant === 'secondary',
|
||||
'bg-transparent': variant === 'ghost',
|
||||
'bg-destructive': variant === 'destructive',
|
||||
'bg-background border border-input': variant === 'outline',
|
||||
|
||||
'h-10 px-4 py-2': size === 'default',
|
||||
'h-9 rounded-md px-3': size === 'sm',
|
||||
'h-11 rounded-md px-8': size === 'lg',
|
||||
|
||||
'opacity-50': isDisabled,
|
||||
},
|
||||
className
|
||||
)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color={variant === 'primary' ? 'white' : '#E63946'} />
|
||||
<ActivityIndicator
|
||||
color={variant === 'primary' || variant === 'destructive' ? 'white' : 'black'}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{icon && <Text className="text-xl">{icon}</Text>}
|
||||
<Text className={textVariants[variant]}>{label}</Text>
|
||||
</>
|
||||
<Text
|
||||
className={cn(
|
||||
'text-sm font-medium',
|
||||
{
|
||||
'text-primary-foreground': variant === 'primary',
|
||||
'text-secondary-foreground': variant === 'secondary',
|
||||
'text-primary': variant === 'ghost',
|
||||
'text-destructive-foreground': variant === 'destructive',
|
||||
'text-foreground': variant === 'outline',
|
||||
}
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)
|
||||
|
||||
@@ -1,25 +1,39 @@
|
||||
import { View, TouchableOpacity } from 'react-native'
|
||||
import { View, TouchableOpacity, ViewStyle } from 'react-native'
|
||||
import { cn } from '../../lib/utils'
|
||||
|
||||
// Keep shadow style for now as it's often better handled natively than via Tailwind utilities for cross-platform consistency
|
||||
// OR use nativewind shadow classes if configured properly. Let's use Tailwind classes for shadow to align with the system if possible,
|
||||
// but often standard CSS shadows don't map perfectly to RN shadow props (elevation vs shadowOffset).
|
||||
// For specific "card" feel, we might want to keep a consistent shadow.
|
||||
// However, the prompt asked for "10x better" design.
|
||||
|
||||
interface CardProps {
|
||||
children: React.ReactNode
|
||||
onPress?: () => void
|
||||
className?: string
|
||||
noPadding?: boolean
|
||||
}
|
||||
|
||||
export function Card({ children, onPress, className = '' }: CardProps) {
|
||||
export function Card({ children, onPress, className = '', noPadding = false }: CardProps) {
|
||||
const baseClasses = cn(
|
||||
'bg-card rounded-xl border border-border shadow-sm',
|
||||
!noPadding && 'p-4',
|
||||
className
|
||||
)
|
||||
|
||||
if (onPress) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
className={`bg-white rounded-2xl border border-gray-100 p-4 ${className}`}
|
||||
activeOpacity={0.7}
|
||||
className={baseClasses}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{children}
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<View className={`bg-white rounded-2xl border border-gray-100 p-4 ${className}`}>
|
||||
<View className={baseClasses}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
|
||||
59
innungsapp/apps/mobile/components/ui/EmptyState.tsx
Normal file
59
innungsapp/apps/mobile/components/ui/EmptyState.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
import { View, Text, StyleSheet } from 'react-native'
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon: keyof typeof Ionicons.glyphMap
|
||||
title: string
|
||||
subtitle: string
|
||||
}
|
||||
|
||||
export function EmptyState({ icon, title, subtitle }: EmptyStateProps) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.iconBox}>
|
||||
<Ionicons name={icon} size={32} color="#94A3B8" />
|
||||
</View>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Text style={styles.subtitle}>{subtitle}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 80,
|
||||
paddingHorizontal: 32,
|
||||
},
|
||||
iconBox: {
|
||||
width: 72,
|
||||
height: 72,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 22,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 18,
|
||||
shadowColor: '#1C1917',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.06,
|
||||
shadowRadius: 10,
|
||||
elevation: 2,
|
||||
},
|
||||
title: {
|
||||
fontSize: 17,
|
||||
fontWeight: '700',
|
||||
color: '#0F172A',
|
||||
textAlign: 'center',
|
||||
letterSpacing: -0.2,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 13,
|
||||
color: '#64748B',
|
||||
textAlign: 'center',
|
||||
marginTop: 6,
|
||||
lineHeight: 19,
|
||||
},
|
||||
})
|
||||
|
||||
9
innungsapp/apps/mobile/components/ui/LoadingSpinner.tsx
Normal file
9
innungsapp/apps/mobile/components/ui/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { View, ActivityIndicator } from 'react-native'
|
||||
|
||||
export function LoadingSpinner() {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator size="large" color="#003B7E" />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user