feat: Implement mobile application and lead processing utilities.
This commit is contained in:
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