This commit is contained in:
Tomas Dvorak
2026-01-26 08:13:18 +01:00
parent aa036b6550
commit dfc079288f
505 changed files with 95755 additions and 5712 deletions
@@ -0,0 +1,119 @@
import React, { useState } from 'react';
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, ActivityIndicator } from 'react-native';
import { CameraView, Camera } from 'expo-camera';
import { loginAdmin, logoutAdmin, isAdminLogged } from '../../services/auth';
import { getApi } from '../../services/api';
import { validateQrPayload } from '../../services/qrHelper';
export default function AdminQRValidatorScreen() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [loading, setLoading] = useState(false);
const [showScanner, setShowScanner] = useState(false);
const [lastResult, setLastResult] = useState<string | null>(null);
React.useEffect(() => {
(async () => {
const ok = await isAdminLogged();
setIsLoggedIn(ok);
})();
}, []);
const handleLogin = async () => {
if (!email || !password) {
Alert.alert('Chyba', 'Vyplňte email a heslo');
return;
}
setLoading(true);
try {
await loginAdmin(email, password);
setIsLoggedIn(true);
Alert.alert('Přihlášení', 'Přihlášen jako admin');
} catch (e: any) {
Alert.alert('Chyba', e.response?.data?.error || 'Přihlášení selhalo');
} finally {
setLoading(false);
}
};
const handleLogout = async () => {
await logoutAdmin();
setIsLoggedIn(false);
setShowScanner(false);
setLastResult(null);
};
const handleBarcodeScanned = async ({ data }: { data: string }) => {
setShowScanner(false);
try {
const payload = JSON.parse(data);
if (!validateQrPayload(payload)) {
Alert.alert('Neplatný QR', 'QR kód není platný');
return;
}
const api = await getApi();
await api.post(`/tickets/${payload.id}/validate`, { barcode: payload.barcode, used_by: 'admin-qr-validator' });
setLastResult(`Vstupenka ${payload.id} ověřena (${payload.event})`);
Alert.alert('Ověřeno', `Vstupenka ${payload.event} byla úspěšně ověřena.`);
} catch (e: any) {
Alert.alert('Chyba ověření', e.response?.data?.error || 'Nepodařilo se ověřit vstupenku');
}
};
if (!isLoggedIn) {
return (
<View style={styles.container}>
<Text style={styles.title}>Admin přihlášení</Text>
<TextInput style={styles.input} placeholder="Email" value={email} onChangeText={setEmail} autoCapitalize="none" keyboardType="email-address" />
<TextInput style={styles.input} placeholder="Heslo" value={password} onChangeText={setPassword} secureTextEntry />
<TouchableOpacity style={styles.button} onPress={handleLogin} disabled={loading}>
{loading ? <ActivityIndicator color="#fff" /> : <Text style={styles.buttonText}>Přihlásit</Text>}
</TouchableOpacity>
</View>
);
}
if (showScanner) {
return (
<View style={styles.scannerContainer}>
<CameraView
style={StyleSheet.absoluteFillObject}
barcodeScannerSettings={{
barcodeTypes: ['qr'],
}}
onBarcodeScanned={handleBarcodeScanned}
/>
<TouchableOpacity style={styles.cancel} onPress={() => setShowScanner(false)}>
<Text style={styles.cancelText}>Zrušit</Text>
</TouchableOpacity>
</View>
);
}
return (
<View style={styles.container}>
<Text style={styles.title}>QR validátor (Admin)</Text>
<TouchableOpacity style={styles.button} onPress={() => setShowScanner(true)}>
<Text style={styles.buttonText}>Skenovat QR kód</Text>
</TouchableOpacity>
{lastResult && <Text style={styles.result}>{lastResult}</Text>}
<TouchableOpacity style={[styles.button, styles.logout]} onPress={handleLogout}>
<Text style={styles.buttonText}>Odhlásit</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 24, gap: 16, backgroundColor: '#fff' },
title: { fontSize: 22, fontWeight: '700', textAlign: 'center' },
input: { borderWidth: 1, borderColor: '#E5E7EB', borderRadius: 8, padding: 12, fontSize: 16 },
button: { backgroundColor: '#0B5ED7', paddingVertical: 12, borderRadius: 8, alignItems: 'center' },
buttonText: { color: '#fff', fontWeight: '700' },
logout: { backgroundColor: '#DC2626', marginTop: 8 },
scannerContainer: { flex: 1 },
cancel: { position: 'absolute', top: 60, left: 24, backgroundColor: '#0006', padding: 8, borderRadius: 6 },
cancelText: { color: '#fff', fontWeight: '600' },
result: { marginTop: 16, fontSize: 14, color: '#059669' },
});
+105
View File
@@ -0,0 +1,105 @@
import React, { useState } from 'react';
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, ActivityIndicator } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { loginFan, logoutFan, isFanLogged } from '../../services/auth';
import { getApi } from '../../services/api';
import { buildQrPayload } from '../../services/qrHelper';
import { Ticket } from '../../types/tickets';
const FAN_QR_CACHE_KEY = 'myclub_fan_cached_qr';
export default function FanLoginScreen() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [loading, setLoading] = useState(false);
const [cachedQr, setCachedQr] = useState<string | null>(null);
React.useEffect(() => {
(async () => {
const ok = await isFanLogged();
setIsLoggedIn(ok);
if (ok) {
const cached = await AsyncStorage.getItem(FAN_QR_CACHE_KEY);
setCachedQr(cached);
}
})();
}, []);
const handleLogin = async () => {
if (!email || !password) {
Alert.alert('Chyba', 'Vyplňte email a heslo');
return;
}
setLoading(true);
try {
await loginFan(email, password);
setIsLoggedIn(true);
Alert.alert('Přihlášení', 'Přihlášen jako fanoušek');
// Načíst a uložit QR průkaz
const api = await getApi();
const res = await api.get('/tickets/my-tickets');
const tickets = Array.isArray(res.data) ? res.data : [];
const paid = tickets.find((t: Ticket) => t.status === 'paid');
if (paid) {
const payload = buildQrPayload(paid, paid.campaign);
await AsyncStorage.setItem(FAN_QR_CACHE_KEY, JSON.stringify(payload));
setCachedQr(JSON.stringify(payload));
}
} catch (e: any) {
Alert.alert('Chyba', e.response?.data?.error || 'Přihlášení selhalo');
} finally {
setLoading(false);
}
};
const handleLogout = async () => {
await logoutFan();
await AsyncStorage.removeItem(FAN_QR_CACHE_KEY);
setIsLoggedIn(false);
setCachedQr(null);
};
if (!isLoggedIn) {
return (
<View style={styles.container}>
<Text style={styles.title}>Přihlášení fanouška</Text>
<TextInput style={styles.input} placeholder="Email" value={email} onChangeText={setEmail} autoCapitalize="none" keyboardType="email-address" />
<TextInput style={styles.input} placeholder="Heslo" value={password} onChangeText={setPassword} secureTextEntry />
<TouchableOpacity style={styles.button} onPress={handleLogin} disabled={loading}>
{loading ? <ActivityIndicator color="#fff" /> : <Text style={styles.buttonText}>Přihlásit</Text>}
</TouchableOpacity>
</View>
);
}
return (
<View style={styles.container}>
<Text style={styles.title}>Můj QR průkaz</Text>
{cachedQr ? (
<View style={styles.qrBox}>
<Text style={styles.qrNote}>Uložený QR průkaz (offline)</Text>
<Text style={styles.qrCode}>{cachedQr}</Text>
</View>
) : (
<Text style={styles.noQr}>Nemáte žádnou zaplacenou vstupenku</Text>
)}
<TouchableOpacity style={[styles.button, styles.logout]} onPress={handleLogout}>
<Text style={styles.buttonText}>Odhlásit</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 24, gap: 16, backgroundColor: '#fff' },
title: { fontSize: 22, fontWeight: '700', textAlign: 'center' },
input: { borderWidth: 1, borderColor: '#E5E7EB', borderRadius: 8, padding: 12, fontSize: 16 },
button: { backgroundColor: '#0B5ED7', paddingVertical: 12, borderRadius: 8, alignItems: 'center' },
buttonText: { color: '#fff', fontWeight: '700' },
logout: { backgroundColor: '#DC2626', marginTop: 8 },
qrBox: { padding: 16, backgroundColor: '#F9FAFB', borderRadius: 8 },
qrNote: { fontSize: 14, color: '#4B5563', marginBottom: 8 },
qrCode: { fontFamily: 'monospace', fontSize: 10, color: '#111827' },
noQr: { fontSize: 16, color: '#6B7280', textAlign: 'center' },
});
@@ -0,0 +1,95 @@
import React, { useEffect, useState } from 'react';
import { View, Text, TextInput, FlatList, TouchableOpacity, ActivityIndicator, StyleSheet } from 'react-native';
import { searchClubs, ClubDirectoryEntry } from '../../services/directory';
import { useClub } from '../../contexts/ClubContext';
const ClubSelectorScreen: React.FC = () => {
const [query, setQuery] = useState('');
const [results, setResults] = useState<ClubDirectoryEntry[]>([]);
const [loading, setLoading] = useState(true);
const { switchClub } = useClub();
useEffect(() => {
let mounted = true;
(async () => {
setLoading(true);
const res = await searchClubs(query);
if (mounted) setResults(res);
setLoading(false);
})();
return () => {
mounted = false;
};
}, [query]);
const handleSelect = async (club: ClubDirectoryEntry) => {
try {
await switchClub({
id: club.id,
name: club.name,
apiBaseUrl: club.api_base_url,
logoUrl: club.logo_url,
});
} catch (error) {
console.error('Failed to select club:', error);
}
};
return (
<View style={styles.container}>
<Text style={styles.title}>Vyberte klub</Text>
<Text style={styles.subtitle}>Najděte svůj klub a připněte si ho k rychlému přístupu.</Text>
<TextInput
style={styles.input}
placeholder="Hledat klub..."
value={query}
onChangeText={setQuery}
/>
{loading ? (
<ActivityIndicator />
) : (
<FlatList
data={results}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<TouchableOpacity style={styles.item} onPress={() => handleSelect(item)}>
<View>
<Text style={styles.itemTitle}>{item.name}</Text>
<Text style={styles.itemSub}>{item.api_base_url}</Text>
</View>
<Text style={styles.pin}>Připnout</Text>
</TouchableOpacity>
)}
ListEmptyComponent={<Text style={styles.empty}>Žádný klub nenalezen.</Text>}
/>
)}
</View>
);
};
const styles = StyleSheet.create({
container: { flex: 1, padding: 24, gap: 12, backgroundColor: '#fff' },
title: { fontSize: 24, fontWeight: '700' },
subtitle: { fontSize: 14, color: '#4B5563' },
input: {
borderWidth: 1,
borderColor: '#E5E7EB',
borderRadius: 8,
padding: 12,
fontSize: 16,
},
item: {
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: '#E5E7EB',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
itemTitle: { fontSize: 16, fontWeight: '600' },
itemSub: { fontSize: 12, color: '#6B7280' },
pin: { color: '#0B5ED7', fontWeight: '600' },
empty: { textAlign: 'center', color: '#6B7280', marginTop: 24 },
});
export default ClubSelectorScreen;
@@ -0,0 +1,541 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, ScrollView, ActivityIndicator, TouchableOpacity, FlatList, Dimensions } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { getDashboardData, DashboardData, DashboardMatch, DashboardNews } from '../../services/dashboard';
import { useClubTheme } from '../../theme';
import { ModernCard, ModernButton, ModernBadge } from '../../components/ui/ModernComponents';
const { width } = Dimensions.get('window');
const DashboardScreen: React.FC = () => {
const { theme } = useClubTheme();
const [loading, setLoading] = useState(true);
const [data, setData] = useState<DashboardData | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
setLoading(true);
setError(null);
const dashboardData = await getDashboardData();
setData(dashboardData);
} catch (err) {
setError('Nepodařilo se načíst data');
} finally {
setLoading(false);
}
};
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleDateString('cs-CZ', { day: 'numeric', month: 'numeric' });
};
const formatTime = (timeStr: string) => {
return timeStr.substring(0, 5);
};
const MatchCard: React.FC<{ match: DashboardMatch; isUpcoming?: boolean }> = ({ match, isUpcoming = false }) => (
<ModernCard shadow={true} margin={8} padding={16} borderRadius={16}>
<View style={styles.matchHeader}>
<View style={styles.matchDateRow}>
<Ionicons name="calendar" size={16} color="#6B7280" />
<Text style={styles.matchDate}>{formatDate(match.date)} {formatTime(match.time)}</Text>
{match.competition && (
<ModernBadge text={match.competition} variant="secondary" size="small" />
)}
</View>
</View>
<View style={styles.matchTeamsContainer}>
<View style={styles.teamSection}>
<Text style={[styles.teamName, match.is_home && styles.homeTeam]}>
{match.home_team}
</Text>
{match.is_home && (
<ModernBadge text="DOMÁCÍ" variant="primary" size="small" />
)}
</View>
<View style={styles.vsSection}>
<View style={styles.vsCircle}>
<Text style={styles.vsText}>VS</Text>
</View>
</View>
<View style={styles.teamSection}>
<Text style={[styles.teamName, !match.is_home && styles.awayTeam]}>
{match.away_team}
</Text>
{!match.is_home && (
<ModernBadge text="HOSTÉ" variant="secondary" size="small" />
)}
</View>
</View>
{match.venue && (
<View style={styles.venueRow}>
<Ionicons name="location" size={16} color="#6B7280" />
<Text style={styles.venueText}>{match.venue}</Text>
</View>
)}
</ModernCard>
);
const NewsCard: React.FC<{ news: DashboardNews }> = ({ news }) => (
<TouchableOpacity activeOpacity={0.7}>
<ModernCard shadow={true} margin={8} padding={16} borderRadius={16}>
<View style={styles.newsHeader}>
<Text style={styles.newsTitle} numberOfLines={2}>{news.title}</Text>
<View style={styles.newsMeta}>
<Ionicons name="time" size={14} color="#6B7280" />
<Text style={styles.newsDate}>{formatDate(news.published_at)}</Text>
</View>
</View>
{news.summary && (
<Text style={styles.newsSummary} numberOfLines={3}>
{news.summary}
</Text>
)}
<View style={styles.newsFooter}>
<ModernBadge text="Číst více" variant="outline" size="small" />
<Ionicons name="chevron-forward" size={16} color={theme.primary} />
</View>
</ModernCard>
</TouchableOpacity>
);
const AnnouncementCard: React.FC<{ announcement: any }> = ({ announcement }) => {
const getVariant = () => {
switch (announcement.type) {
case 'warning': return 'warning';
case 'success': return 'success';
default: return 'primary';
}
};
const getIcon = () => {
switch (announcement.type) {
case 'warning': return 'warning';
case 'success': return 'checkmark-circle';
default: return 'information-circle';
}
};
return (
<ModernCard
shadow={false}
margin={8}
padding={16}
borderRadius={12}
style={{
backgroundColor: announcement.type === 'warning' ? '#FEF3C7' :
announcement.type === 'success' ? '#D1FAE5' : '#DBEAFE'
}}
>
<View style={styles.announcementHeader}>
<Ionicons name={getIcon() as any} size={20} color={
announcement.type === 'warning' ? '#D97706' :
announcement.type === 'success' ? '#059669' : '#2563EB'
} />
<Text style={styles.announcementTitle}>{announcement.title}</Text>
</View>
<Text style={styles.announcementContent}>{announcement.content}</Text>
</ModernCard>
);
};
if (loading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color={theme.primary} />
<Text style={styles.loadingText}>Načítám data...</Text>
</View>
);
}
if (error) {
return (
<View style={styles.center}>
<ModernCard shadow={false} padding={32} borderRadius={16} style={styles.errorCard}>
<Ionicons name="alert-circle" size={48} color="#EF4444" />
<Text style={styles.errorText}>{error}</Text>
<ModernButton
title="Zkusit znovu"
onPress={loadData}
variant="primary"
size="medium"
style={styles.retryButton}
/>
</ModernCard>
</View>
);
}
return (
<ScrollView style={styles.container} contentContainerStyle={styles.contentContainer}>
{/* Welcome Section */}
<ModernCard shadow={false} margin={16} padding={20} borderRadius={16} style={styles.welcomeCard}>
<View style={styles.welcomeHeader}>
<Text style={styles.welcomeTitle}>Vítejte v klubu</Text>
<Ionicons name="football" size={24} color={theme.primary} />
</View>
<Text style={styles.welcomeSubtitle}>Sledujte nejnovější informace o vašem týmu</Text>
</ModernCard>
{/* Upcoming Matches */}
{data?.upcoming_match && (
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Ionicons name="calendar" size={20} color={theme.primary} />
<Text style={styles.sectionTitle}>Nadcházející zápas</Text>
<ModernBadge text="1" variant="primary" size="small" />
</View>
<MatchCard match={data.upcoming_match} isUpcoming={true} />
</View>
)}
{/* Recent Matches */}
{data?.recent_matches && data.recent_matches.length > 0 && (
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Ionicons name="time" size={20} color={theme.primary} />
<Text style={styles.sectionTitle}>Poslední zápas</Text>
<ModernBadge text={data.recent_matches.length.toString()} variant="secondary" size="small" />
</View>
<MatchCard match={data.recent_matches[0]} />
</View>
)}
{/* News */}
{data?.news && data.news.length > 0 && (
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Ionicons name="newspaper" size={20} color={theme.primary} />
<Text style={styles.sectionTitle}>Novinky</Text>
<ModernBadge text={data.news.length.toString()} variant="primary" size="small" />
</View>
<FlatList
data={data.news}
renderItem={({ item }) => <NewsCard news={item} />}
keyExtractor={(item, index) => `news-${index}`}
scrollEnabled={false}
showsVerticalScrollIndicator={false}
/>
</View>
)}
{/* Announcements */}
{data?.announcements && data.announcements.length > 0 && (
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Ionicons name="megaphone" size={20} color={theme.primary} />
<Text style={styles.sectionTitle}>Oznámení</Text>
<ModernBadge text={data.announcements.length.toString()} variant="warning" size="small" />
</View>
<FlatList
data={data.announcements}
renderItem={({ item }) => <AnnouncementCard announcement={item} />}
keyExtractor={(item, index) => `announcement-${index}`}
scrollEnabled={false}
showsVerticalScrollIndicator={false}
/>
</View>
)}
{/* Empty State */}
{(!data?.upcoming_match && (!data?.recent_matches || data.recent_matches.length === 0) && (!data?.news || data.news.length === 0) && (!data?.announcements || data.announcements.length === 0)) && (
<View style={styles.emptyState}>
<ModernCard shadow={false} padding={32} borderRadius={16} style={styles.emptyCard}>
<Ionicons name="football-outline" size={64} color="#9CA3AF" />
<Text style={styles.emptyTitle}>Žádné obsah</Text>
<Text style={styles.emptySubtitle}>Zatím zde nejsou žádné informace</Text>
</ModernCard>
</View>
)}
</ScrollView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F9FAFB',
},
contentContainer: {
padding: 16,
gap: 20,
},
center: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 24,
},
loadingText: {
marginTop: 16,
fontSize: 16,
color: '#6B7280',
},
errorText: {
marginTop: 16,
fontSize: 16,
color: '#EF4444',
textAlign: 'center',
},
retryButton: {
marginTop: 16,
paddingHorizontal: 24,
paddingVertical: 12,
borderRadius: 8,
},
retryButtonText: {
color: '#FFFFFF',
fontWeight: '600',
},
section: {
gap: 12,
},
sectionTitle: {
fontSize: 18,
fontWeight: '700',
color: '#1F2937',
marginBottom: 4,
},
matchCard: {
backgroundColor: '#FFFFFF',
padding: 16,
borderRadius: 12,
borderLeftWidth: 4,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
},
matchHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
},
matchDate: {
fontSize: 14,
fontWeight: '600',
color: '#374151',
},
competition: {
fontSize: 12,
color: '#6B7280',
backgroundColor: '#F3F4F6',
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 4,
},
matchTeams: {
alignItems: 'center',
marginBottom: 8,
},
team: {
fontSize: 16,
fontWeight: '600',
color: '#1F2937',
textAlign: 'center',
},
homeTeam: {
color: '#059669',
},
awayTeam: {
color: '#DC2626',
},
vs: {
fontSize: 14,
color: '#6B7280',
marginVertical: 4,
},
venue: {
fontSize: 12,
color: '#6B7280',
textAlign: 'center',
},
newsCard: {
backgroundColor: '#FFFFFF',
padding: 16,
borderRadius: 12,
borderLeftWidth: 4,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
},
newsTitle: {
fontSize: 16,
fontWeight: '600',
color: '#1F2937',
marginBottom: 8,
},
newsSummary: {
fontSize: 14,
color: '#4B5563',
lineHeight: 20,
marginBottom: 8,
},
newsDate: {
fontSize: 12,
color: '#6B7280',
},
announcementCard: {
padding: 16,
borderRadius: 12,
borderLeftWidth: 4,
borderLeftColor: '#3B82F6',
},
announcementHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginBottom: 8,
},
announcementTitle: {
fontSize: 16,
fontWeight: '600',
color: '#1F2937',
},
announcementContent: {
fontSize: 14,
color: '#4B5563',
lineHeight: 20,
},
emptyText: {
fontSize: 18,
fontWeight: '600',
color: '#6B7280',
marginTop: 16,
},
emptySubtext: {
fontSize: 14,
color: '#9CA3AF',
marginTop: 4,
},
// Missing styles that were referenced in the component
matchDateRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
matchTeamsContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginVertical: 8,
},
teamSection: {
flex: 1,
alignItems: 'center',
},
teamName: {
fontSize: 16,
fontWeight: '600',
color: '#1F2937',
},
vsSection: {
paddingHorizontal: 16,
},
vsCircle: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: '#F3F4F6',
justifyContent: 'center',
alignItems: 'center',
},
vsText: {
fontSize: 12,
fontWeight: '600',
color: '#6B7280',
},
venueRow: {
marginTop: 8,
},
venueText: {
fontSize: 14,
color: '#6B7280',
textAlign: 'center',
},
newsHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
newsMeta: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
newsFooter: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: 8,
},
errorCard: {
backgroundColor: '#FEE2E2',
borderLeftColor: '#EF4444',
},
welcomeCard: {
backgroundColor: '#EBF8FF',
borderLeftColor: '#3B82F6',
},
welcomeHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
marginBottom: 16,
},
welcomeTitle: {
fontSize: 24,
fontWeight: '700',
color: '#1F2937',
},
welcomeSubtitle: {
fontSize: 16,
color: '#6B7280',
marginBottom: 16,
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginBottom: 12,
},
emptyState: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 32,
},
emptyCard: {
alignItems: 'center',
textAlign: 'center',
},
emptyTitle: {
fontSize: 18,
fontWeight: '600',
color: '#6B7280',
marginTop: 16,
},
emptySubtitle: {
fontSize: 14,
color: '#9CA3AF',
marginTop: 4,
},
});
export default DashboardScreen;
+442
View File
@@ -0,0 +1,442 @@
import React, { useState, useEffect } from 'react';
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, ActivityIndicator, Alert, TextInput, FlatList, Dimensions } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useClub } from '../../contexts/ClubContext';
import { useClubTheme } from '../../theme';
import { searchClubs, ClubDirectoryEntry } from '../../services/directory';
import { getAllClubs } from '../../services/directory';
import { loadPinnedClubs, savePinnedClubs, pinClub as pinClubService, unpinClub as unpinClubService, PinnedClub } from '../../services/pinnedClubs';
import { ModernCard, ModernButton, ModernInput, ModernBadge } from '../../components/ui/ModernComponents';
const { width } = Dimensions.get('window');
const ClubHubScreen: React.FC = () => {
const { switchClub } = useClub();
const { theme } = useClubTheme({ id: 'hub' }); // Use default theme for hub
const [pinnedClubs, setPinnedClubs] = useState<PinnedClub[]>([]);
const [availableClubs, setAvailableClubs] = useState<ClubDirectoryEntry[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [loading, setLoading] = useState(true);
const [showSearch, setShowSearch] = useState(false);
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
setLoading(true);
// Load pinned clubs from storage
const pinned = await loadPinnedClubs();
setPinnedClubs(pinned);
// Load all available clubs
const clubs = await getAllClubs();
setAvailableClubs(clubs);
} catch (error) {
console.error('Failed to load club data:', error);
Alert.alert('Chyba', 'Nepodařilo se načíst data klubů');
} finally {
setLoading(false);
}
};
const pinClub = async (club: ClubDirectoryEntry) => {
try {
const updated = await pinClubService(club);
setPinnedClubs(updated);
Alert.alert('Úspěch', `${club.name} bylo připnuto na hlavní stránku`);
setShowSearch(false);
setSearchQuery('');
} catch (error) {
Alert.alert('Chyba', 'Nepodařilo se připnout klub');
}
};
const unpinClub = async (clubId: string) => {
try {
const updated = await unpinClubService(clubId);
setPinnedClubs(updated);
} catch (error) {
Alert.alert('Chyba', 'Nepodařilo se odebrat klub');
}
};
const selectClub = async (club: PinnedClub) => {
try {
await switchClub({
id: club.id,
name: club.name,
apiBaseUrl: club.api_base_url,
logoUrl: club.logo_url,
});
// Navigation will be handled by the ClubContext
} catch (error) {
Alert.alert('Chyba', 'Nepodařilo se přepnout klub');
}
};
const searchResults = searchQuery
? availableClubs.filter(club =>
club.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
club.city?.toLowerCase().includes(searchQuery.toLowerCase())
)
: availableClubs;
const ClubCard: React.FC<{ club: ClubDirectoryEntry | PinnedClub; isPinned?: boolean }> = ({ club, isPinned = false }) => (
<ModernCard
shadow={true}
margin={8}
padding={16}
borderRadius={16}
style={styles.clubCard}
>
<View style={styles.clubContent}>
<View style={styles.clubHeader}>
<Text style={styles.clubName}>{club.name}</Text>
{club.city && (
<ModernBadge
text={club.city}
variant="secondary"
size="small"
style={styles.cityBadge}
/>
)}
</View>
<View style={styles.clubDetails}>
<View style={styles.detailRow}>
<Ionicons name="globe" size={16} color="#6B7280" />
<Text style={styles.detailText}>{club.api_base_url}</Text>
</View>
{club.country && (
<View style={styles.detailRow}>
<Ionicons name="location" size={16} color="#6B7280" />
<Text style={styles.detailText}>{club.country}</Text>
</View>
)}
</View>
<View style={styles.clubActions}>
{isPinned ? (
<View style={styles.pinnedActions}>
<ModernButton
title="Otevřít klub"
onPress={() => selectClub(club as PinnedClub)}
variant="primary"
size="small"
style={styles.actionButton}
/>
<TouchableOpacity
style={styles.unpinButton}
onPress={() => unpinClub(club.id)}
>
<Ionicons name="heart-dislike" size={20} color="#EF4444" />
</TouchableOpacity>
</View>
) : (
<ModernButton
title="Připnout"
onPress={() => pinClub(club)}
variant="primary"
size="small"
icon="heart"
style={styles.actionButton}
/>
)}
</View>
</View>
</ModernCard>
);
if (loading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color={theme.primary} />
<Text style={styles.loadingText}>Načítám kluby...</Text>
</View>
);
}
return (
<View style={styles.container}>
<View style={styles.header}>
<View style={styles.headerContent}>
<Text style={styles.title}>MyClub Hub</Text>
<Text style={styles.subtitle}>Objevte a připojte své oblíbené kluby</Text>
</View>
<ModernButton
title="Najít klub"
onPress={() => setShowSearch(!showSearch)}
variant="primary"
size="medium"
icon="search"
style={styles.searchButton}
/>
</View>
<ScrollView style={styles.content} contentContainerStyle={styles.contentContainer}>
{/* Pinned Clubs Section */}
{pinnedClubs.length > 0 && (
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Ionicons name="heart" size={20} color={theme.primary} />
<Text style={styles.sectionTitle}>Připnuté kluby</Text>
<ModernBadge text={pinnedClubs.length.toString()} variant="primary" size="small" />
</View>
<FlatList
data={pinnedClubs}
renderItem={({ item }) => <ClubCard club={item} isPinned={true} />}
keyExtractor={(item) => item.id}
scrollEnabled={false}
showsVerticalScrollIndicator={false}
/>
</View>
)}
{/* Search Section */}
{showSearch && (
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Ionicons name="search" size={20} color={theme.primary} />
<Text style={styles.sectionTitle}>
{searchQuery ? 'Výsledky hledání' : 'Dostupné kluby'}
</Text>
{searchQuery && (
<ModernBadge
text={searchResults.length.toString()}
variant="secondary"
size="small"
/>
)}
</View>
<ModernInput
value={searchQuery}
onChangeText={setSearchQuery}
placeholder="Hledat klub nebo město..."
icon="🔍"
style={styles.searchInput}
/>
{/* Search Results */}
{searchResults.length === 0 ? (
<View style={styles.noResults}>
<Ionicons name="search" size={48} color="#9CA3AF" />
<Text style={styles.noResultsText}>Žádné kluby nenalezeny</Text>
<Text style={styles.noResultsSubtext}>Zkuste jiný dotaz</Text>
</View>
) : (
<FlatList
data={searchResults.filter(club => !pinnedClubs.some(pinned => pinned.id === club.id))}
renderItem={({ item }) => <ClubCard club={item} />}
keyExtractor={(item) => item.id}
scrollEnabled={false}
showsVerticalScrollIndicator={false}
/>
)}
</View>
)}
{/* Empty State */}
{pinnedClubs.length === 0 && !showSearch && (
<View style={styles.emptyState}>
<View style={styles.emptyStateIcon}>
<Ionicons name="football" size={64} color={theme.primary} />
</View>
<Text style={styles.emptyTitle}>Vítejte v MyClub Hub</Text>
<Text style={styles.emptySubtitle}>
Objevte fotbalové kluby z celé České republiky
</Text>
<Text style={styles.emptyDescription}>
Klepněte na "Najít klub" a prozkoumejte dostupné týmy
</Text>
<ModernButton
title="Začít objevovat"
onPress={() => setShowSearch(true)}
variant="primary"
size="large"
style={styles.emptyButton}
/>
</View>
)}
</ScrollView>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F9FAFB',
},
header: {
padding: 24,
paddingBottom: 16,
backgroundColor: '#FFFFFF',
borderBottomWidth: 1,
borderBottomColor: '#E5E7EB',
},
headerContent: {
marginBottom: 20,
},
title: {
fontSize: 32,
fontWeight: '700',
color: '#1F2937',
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: '#6B7280',
marginBottom: 4,
},
searchButton: {
alignSelf: 'flex-start',
},
content: {
flex: 1,
},
contentContainer: {
padding: 16,
gap: 24,
},
section: {
gap: 16,
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
marginBottom: 4,
},
sectionTitle: {
fontSize: 20,
fontWeight: '600',
color: '#1F2937',
flex: 1,
},
clubCard: {
marginHorizontal: 0,
},
clubContent: {
gap: 12,
},
clubHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
clubName: {
fontSize: 18,
fontWeight: '700',
color: '#1F2937',
flex: 1,
},
cityBadge: {
marginLeft: 8,
},
clubDetails: {
gap: 8,
},
detailRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
detailText: {
fontSize: 14,
color: '#6B7280',
flex: 1,
},
clubActions: {
marginTop: 8,
},
pinnedActions: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
actionButton: {
flex: 1,
marginRight: 12,
},
unpinButton: {
padding: 8,
borderRadius: 8,
backgroundColor: '#FEE2E2',
},
searchInput: {
marginBottom: 16,
},
noResults: {
alignItems: 'center',
paddingVertical: 40,
gap: 12,
},
noResultsText: {
fontSize: 18,
fontWeight: '600',
color: '#6B7280',
textAlign: 'center',
},
noResultsSubtext: {
fontSize: 14,
color: '#9CA3AF',
textAlign: 'center',
},
center: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
loadingText: {
marginTop: 16,
fontSize: 16,
color: '#6B7280',
},
emptyState: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 60,
paddingHorizontal: 32,
gap: 16,
},
emptyStateIcon: {
width: 120,
height: 120,
borderRadius: 60,
backgroundColor: '#F3F4F6',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 8,
},
emptyTitle: {
fontSize: 24,
fontWeight: '700',
color: '#1F2937',
textAlign: 'center',
},
emptySubtitle: {
fontSize: 16,
color: '#6B7280',
textAlign: 'center',
marginBottom: 8,
},
emptyDescription: {
fontSize: 14,
color: '#9CA3AF',
textAlign: 'center',
lineHeight: 20,
marginBottom: 24,
},
emptyButton: {
paddingHorizontal: 32,
},
});
export default ClubHubScreen;
+91
View File
@@ -0,0 +1,91 @@
import React, { useState } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, Alert } from 'react-native';
import { isAdminLogged, isFanLogged } from '../../services/auth';
import { useClubTheme } from '../../theme';
import { useNavigation } from '@react-navigation/native';
import { useClub } from '../../contexts/ClubContext';
export default function MoreScreen() {
const { theme, settings } = useClubTheme();
const navigation = useNavigation<any>();
const { resetClub } = useClub();
const [isAdmin, setIsAdmin] = useState<boolean | null>(null);
const [isFan, setIsFan] = useState<boolean | null>(null);
React.useEffect(() => {
(async () => {
const admin = await isAdminLogged();
const fan = await isFanLogged();
setIsAdmin(admin);
setIsFan(fan);
})();
}, []);
const handleResetClub = async () => {
Alert.alert('Změnit klub', 'Opravdu chcete změnit klub? Budete odhlášeni.', [
{ text: 'Zrušit', style: 'cancel' },
{
text: 'Změnit',
style: 'destructive',
onPress: async () => {
try {
await resetClub();
Alert.alert('Hotovo', 'Klub byl změněn.');
} catch (error) {
Alert.alert('Chyba', 'Nepodařilo se změnit klub.');
}
},
},
]);
};
const goAdminValidator = () => navigation.navigate('AdminQRValidator');
const goFanLogin = () => navigation.navigate('FanLogin');
return (
<View style={styles.container}>
<Text style={styles.title}>Více</Text>
<Text style={styles.clubInfo}>Klub: {settings?.club_name || 'neznámý'}</Text>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Přihlášení</Text>
{isAdmin === false && (
<TouchableOpacity style={[styles.item, { borderLeftColor: theme.primary }]} onPress={goAdminValidator}>
<Text style={styles.itemText}>Admin přihlášení / QR validátor</Text>
</TouchableOpacity>
)}
{isFan === false && (
<TouchableOpacity style={[styles.item, { borderLeftColor: theme.primary }]} onPress={goFanLogin}>
<Text style={styles.itemText}>Přihlášení fanouška / QR průkaz</Text>
</TouchableOpacity>
)}
{isAdmin && <Text style={styles.status}> Přihlášen jako admin</Text>}
{isFan && <Text style={styles.status}> Přihlášen jako fanoušek</Text>}
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Nastavení</Text>
<TouchableOpacity style={[styles.item, { borderLeftColor: theme.secondary }]} onPress={handleResetClub}>
<Text style={styles.itemText}>Změnit klub</Text>
</TouchableOpacity>
</View>
<View style={styles.footer}>
<Text style={styles.footerText}>MyClub Mobile v0.1</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 24, gap: 20, backgroundColor: '#fff' },
title: { fontSize: 24, fontWeight: '700', textAlign: 'center' },
clubInfo: { fontSize: 16, color: '#4B5563', textAlign: 'center' },
section: { gap: 8 },
sectionTitle: { fontSize: 18, fontWeight: '600', marginBottom: 4 },
item: { paddingVertical: 12, paddingHorizontal: 16, borderLeftWidth: 4, backgroundColor: '#F9FAFB', borderRadius: 6 },
itemText: { fontSize: 16 },
status: { fontSize: 14, color: '#059669' },
footer: { marginTop: 'auto', alignItems: 'center' },
footerText: { fontSize: 12, color: '#9CA3AF' },
});
+67
View File
@@ -0,0 +1,67 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, ActivityIndicator, TouchableOpacity, ScrollView } from 'react-native';
import QRCode from 'react-native-qrcode-svg';
import { getApi } from '../../services/api';
import { TicketQRData } from '../../types/tickets';
import { buildQrPayload } from '../../services/qrHelper';
const TicketScreen: React.FC = () => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [ticket, setTicket] = useState<TicketQRData | null>(null);
useEffect(() => {
(async () => {
setLoading(true);
try {
const api = await getApi();
const res = await api.get('/tickets/my-tickets');
const first = Array.isArray(res.data) ? res.data.find((t: any) => t.status === 'paid') : null;
if (!first) {
setError('Žádná zaplacená vstupenka nebyla nalezena.');
} else {
setTicket(buildQrPayload(first, first.campaign));
}
} catch (e: any) {
setError('Nepodařilo se načíst vstupenky');
} finally {
setLoading(false);
}
})();
}, []);
if (loading) return <View style={styles.center}><ActivityIndicator /></View>;
if (error) return <View style={styles.center}><Text>{error}</Text></View>;
if (!ticket) return <View style={styles.center}><Text>Žádné vstupenky</Text></View>;
return (
<ScrollView contentContainerStyle={styles.container}>
<Text style={styles.title}>{ticket.campaignTitle}</Text>
<Text style={styles.sub}>QR průkaz pro vstup</Text>
<View style={styles.qrBox}>
<QRCode value={JSON.stringify(ticket)} size={240} backgroundColor="#fff" color="#000" />
</View>
<Text style={styles.code}>Kód: {ticket.barcode}</Text>
<Text style={styles.info}>Držitel: {ticket.holderName}</Text>
{ticket.matchDateTime && <Text style={styles.info}>Datum: {ticket.matchDateTime}</Text>}
{ticket.venue && <Text style={styles.info}>Místo: {ticket.venue}</Text>}
<TouchableOpacity style={styles.button} onPress={() => { /* TODO: add Wallet pass later */ }}>
<Text style={styles.buttonText}>Uložit / sdílet</Text>
</TouchableOpacity>
</ScrollView>
);
};
const styles = StyleSheet.create({
container: { padding: 24, alignItems: 'center', gap: 12 },
center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 },
title: { fontSize: 20, fontWeight: '700', textAlign: 'center' },
sub: { fontSize: 14, color: '#4B5563', marginBottom: 8 },
qrBox: { padding: 16, backgroundColor: '#fff', borderRadius: 12, elevation: 2 },
code: { fontFamily: 'monospace', marginTop: 8 },
info: { fontSize: 14 },
button: { marginTop: 16, backgroundColor: '#0B5ED7', paddingVertical: 12, paddingHorizontal: 24, borderRadius: 8 },
buttonText: { color: '#fff', fontWeight: '700' },
});
export default TicketScreen;