feat: Implement mobile application and lead processing utilities.

This commit is contained in:
2026-02-19 14:21:51 +01:00
parent fca42db4d2
commit c53a71a5f9
120 changed files with 24080 additions and 851 deletions

View 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>
)
}

View 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,
},
})

View File

@@ -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>
)

View File

@@ -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>
)

View 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,
},
})

View 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>
)
}