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

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

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

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

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

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

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