mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 10:42:57 +00:00
hot fix #1
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { NavigationContainer, DefaultTheme, Theme } from '@react-navigation/native';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { useClubTheme } from './theme';
|
||||
import { RootNavigator } from './navigation/RootNavigator';
|
||||
import ClubHubScreen from './features/hub/ClubHubScreen';
|
||||
import { ClubProvider, useClub } from './contexts/ClubContext';
|
||||
import { initializePushNotifications } from './services/notifications';
|
||||
import { isFanLogged, isAdminLogged } from './services/auth';
|
||||
|
||||
const AppContent = () => {
|
||||
const { pinnedClub, isClubReady } = useClub();
|
||||
const { themeReady, theme } = useClubTheme(pinnedClub);
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize push notifications when club is ready
|
||||
if (isClubReady && pinnedClub) {
|
||||
(async () => {
|
||||
try {
|
||||
// Check if user is logged in and get their ID for token registration
|
||||
const isFan = await isFanLogged();
|
||||
const isAdmin = await isAdminLogged();
|
||||
|
||||
// For now, we'll initialize without user ID
|
||||
// In a real implementation, you'd get the actual user ID from auth
|
||||
await initializePushNotifications();
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize push notifications:', error);
|
||||
}
|
||||
})();
|
||||
}
|
||||
}, [isClubReady, pinnedClub]);
|
||||
|
||||
if (!isClubReady) {
|
||||
return null; // Loading screen could be added here
|
||||
}
|
||||
|
||||
if (!pinnedClub) {
|
||||
return <ClubHubScreen />;
|
||||
}
|
||||
|
||||
if (!themeReady) {
|
||||
return null; // Could render a splash/loading screen if desired
|
||||
}
|
||||
|
||||
const navTheme: Theme = {
|
||||
...DefaultTheme,
|
||||
colors: {
|
||||
...DefaultTheme.colors,
|
||||
primary: theme.primary,
|
||||
background: theme.background,
|
||||
card: '#FFFFFF',
|
||||
text: theme.text,
|
||||
border: '#E5E7EB',
|
||||
notification: theme.accent,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<NavigationContainer theme={navTheme}>
|
||||
<RootNavigator />
|
||||
</NavigationContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<StatusBar style="auto" />
|
||||
<ClubProvider>
|
||||
<AppContent />
|
||||
</ClubProvider>
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TextInput, TouchableOpacity } from 'react-native';
|
||||
import { useClubTheme } from '../../theme';
|
||||
|
||||
interface ModernCardProps {
|
||||
children: React.ReactNode;
|
||||
style?: any;
|
||||
shadow?: boolean;
|
||||
padding?: number;
|
||||
margin?: number;
|
||||
borderRadius?: number;
|
||||
}
|
||||
|
||||
export const ModernCard: React.FC<ModernCardProps> = ({
|
||||
children,
|
||||
style,
|
||||
shadow = true,
|
||||
padding = 16,
|
||||
margin = 0,
|
||||
borderRadius = 16,
|
||||
}) => {
|
||||
const { theme } = useClubTheme();
|
||||
|
||||
const cardStyle = {
|
||||
padding,
|
||||
margin,
|
||||
borderRadius,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: theme.primary,
|
||||
...(shadow && {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
}),
|
||||
...style,
|
||||
};
|
||||
|
||||
return <View style={cardStyle}>{children}</View>;
|
||||
};
|
||||
|
||||
interface ModernButtonProps {
|
||||
title: string;
|
||||
onPress: () => void;
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
style?: any;
|
||||
textStyle?: any;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export const ModernButton: React.FC<ModernButtonProps> = ({
|
||||
title,
|
||||
onPress,
|
||||
variant = 'primary',
|
||||
size = 'medium',
|
||||
disabled = false,
|
||||
loading = false,
|
||||
style,
|
||||
textStyle,
|
||||
icon,
|
||||
}) => {
|
||||
const { theme } = useClubTheme();
|
||||
|
||||
const getButtonStyle = () => {
|
||||
let baseStyle: any = {
|
||||
alignItems: 'center' as const,
|
||||
justifyContent: 'center' as const,
|
||||
borderRadius: 12,
|
||||
};
|
||||
|
||||
// Size variants
|
||||
if (size === 'small') {
|
||||
baseStyle = { ...baseStyle, paddingHorizontal: 12, paddingVertical: 8 };
|
||||
} else if (size === 'large') {
|
||||
baseStyle = { ...baseStyle, paddingHorizontal: 24, paddingVertical: 16 };
|
||||
} else {
|
||||
baseStyle = { ...baseStyle, paddingHorizontal: 20, paddingVertical: 12 };
|
||||
}
|
||||
|
||||
// Variant styles
|
||||
if (variant === 'primary') {
|
||||
baseStyle = { ...baseStyle, backgroundColor: theme.primary };
|
||||
} else if (variant === 'secondary') {
|
||||
baseStyle = { ...baseStyle, backgroundColor: theme.secondary };
|
||||
} else if (variant === 'outline') {
|
||||
baseStyle = { ...baseStyle, backgroundColor: 'transparent', borderWidth: 2, borderColor: theme.primary };
|
||||
} else {
|
||||
baseStyle = { ...baseStyle, backgroundColor: 'transparent' };
|
||||
}
|
||||
|
||||
if (disabled) {
|
||||
baseStyle = { ...baseStyle, opacity: 0.5 };
|
||||
}
|
||||
|
||||
return { ...baseStyle, ...style };
|
||||
};
|
||||
|
||||
const getTextStyle = () => {
|
||||
let baseStyle: any = {
|
||||
fontWeight: '600' as const,
|
||||
textAlign: 'center' as const,
|
||||
};
|
||||
|
||||
if (size === 'small') {
|
||||
baseStyle = { ...baseStyle, fontSize: 14 };
|
||||
} else if (size === 'large') {
|
||||
baseStyle = { ...baseStyle, fontSize: 18 };
|
||||
} else {
|
||||
baseStyle = { ...baseStyle, fontSize: 16 };
|
||||
}
|
||||
|
||||
if (variant === 'outline' || variant === 'ghost') {
|
||||
baseStyle = { ...baseStyle, color: theme.primary };
|
||||
} else {
|
||||
baseStyle = { ...baseStyle, color: '#FFFFFF' };
|
||||
}
|
||||
|
||||
if (disabled) {
|
||||
baseStyle = { ...baseStyle, opacity: 0.7 };
|
||||
}
|
||||
|
||||
return { ...baseStyle, ...textStyle };
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={getButtonStyle()}
|
||||
onPress={onPress}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: icon ? 8 : 0 }}>
|
||||
{icon && <Text style={getTextStyle()}>{icon}</Text>}
|
||||
<Text style={getTextStyle()}>
|
||||
{loading ? 'Načítám...' : title}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
interface ModernInputProps {
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
placeholder?: string;
|
||||
label?: string;
|
||||
error?: string;
|
||||
icon?: string;
|
||||
secureTextEntry?: boolean;
|
||||
keyboardType?: 'default' | 'email-address' | 'numeric' | 'phone-pad';
|
||||
autoCapitalize?: 'none' | 'sentences' | 'words' | 'characters';
|
||||
style?: any;
|
||||
}
|
||||
|
||||
export const ModernInput: React.FC<ModernInputProps> = ({
|
||||
value,
|
||||
onChangeText,
|
||||
placeholder,
|
||||
label,
|
||||
error,
|
||||
icon,
|
||||
secureTextEntry = false,
|
||||
keyboardType = 'default',
|
||||
autoCapitalize = 'sentences',
|
||||
style,
|
||||
}) => {
|
||||
const { theme } = useClubTheme();
|
||||
|
||||
return (
|
||||
<View style={[{ marginBottom: 16 }, style]}>
|
||||
{label && <Text style={{ fontSize: 14, fontWeight: '600', marginBottom: 8, color: '#374151' }}>{label}</Text>}
|
||||
<View style={[
|
||||
{ flexDirection: 'row', alignItems: 'center', borderWidth: 1, borderRadius: 12, paddingHorizontal: 16, paddingVertical: 12, backgroundColor: '#FFFFFF' },
|
||||
error && { borderWidth: 2, borderColor: '#EF4444' },
|
||||
!error && { borderColor: theme.primary + '30' }
|
||||
]}>
|
||||
{icon && <Text style={{ marginRight: 12, fontSize: 16 }}>{icon}</Text>}
|
||||
<TextInput
|
||||
style={[
|
||||
{ flex: 1, fontSize: 16 },
|
||||
{ color: theme.text }
|
||||
]}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor="#9CA3AF"
|
||||
secureTextEntry={secureTextEntry}
|
||||
keyboardType={keyboardType}
|
||||
autoCapitalize={autoCapitalize}
|
||||
/>
|
||||
</View>
|
||||
{error && <Text style={{ fontSize: 12, color: '#EF4444', marginTop: 4 }}>{error}</Text>}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
interface ModernBadgeProps {
|
||||
text: string;
|
||||
variant?: 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'outline';
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
style?: any;
|
||||
}
|
||||
|
||||
export const ModernBadge: React.FC<ModernBadgeProps> = ({
|
||||
text,
|
||||
variant = 'primary',
|
||||
size = 'medium',
|
||||
style,
|
||||
}) => {
|
||||
const { theme } = useClubTheme();
|
||||
|
||||
const getBadgeStyle = () => {
|
||||
let baseStyle: any = {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 999,
|
||||
alignSelf: 'flex-start' as const,
|
||||
};
|
||||
|
||||
// Size variants
|
||||
if (size === 'small') {
|
||||
baseStyle = { ...baseStyle, paddingHorizontal: 6, paddingVertical: 2 };
|
||||
} else if (size === 'large') {
|
||||
baseStyle = { ...baseStyle, paddingHorizontal: 12, paddingVertical: 6 };
|
||||
}
|
||||
|
||||
// Variant colors
|
||||
if (variant === 'primary') {
|
||||
baseStyle = { ...baseStyle, backgroundColor: theme.primary };
|
||||
} else if (variant === 'secondary') {
|
||||
baseStyle = { ...baseStyle, backgroundColor: theme.secondary };
|
||||
} else if (variant === 'success') {
|
||||
baseStyle = { ...baseStyle, backgroundColor: '#10B981' };
|
||||
} else if (variant === 'warning') {
|
||||
baseStyle = { ...baseStyle, backgroundColor: '#F59E0B' };
|
||||
} else if (variant === 'error') {
|
||||
baseStyle = { ...baseStyle, backgroundColor: '#EF4444' };
|
||||
} else if (variant === 'outline') {
|
||||
baseStyle = { ...baseStyle, backgroundColor: 'transparent', borderWidth: 1, borderColor: theme.primary };
|
||||
}
|
||||
|
||||
return { ...baseStyle, ...style };
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={getBadgeStyle()}>
|
||||
<Text style={{ fontSize: 12, fontWeight: '600', color: variant === 'outline' ? theme.primary : '#FFFFFF' }}>{text}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
inputContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
inputError: {
|
||||
borderWidth: 2,
|
||||
},
|
||||
inputIcon: {
|
||||
marginRight: 12,
|
||||
fontSize: 16,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 12,
|
||||
color: '#EF4444',
|
||||
marginTop: 4,
|
||||
},
|
||||
badge: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 999,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
badgeSmall: {
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
},
|
||||
badgeLarge: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
},
|
||||
badgeText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
});
|
||||
|
||||
export default {
|
||||
ModernCard,
|
||||
ModernButton,
|
||||
ModernInput,
|
||||
ModernBadge,
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { ClubPin, loadClubPin, saveClubPin, clearClubPin } from '../services/club';
|
||||
import { setApiBaseUrl } from '../services/api';
|
||||
import { logoutAdmin, logoutFan } from '../services/auth';
|
||||
|
||||
interface ClubContextType {
|
||||
pinnedClub: ClubPin | null;
|
||||
isClubReady: boolean;
|
||||
switchClub: (club: ClubPin) => Promise<void>;
|
||||
resetClub: () => Promise<void>;
|
||||
}
|
||||
|
||||
const ClubContext = createContext<ClubContextType | undefined>(undefined);
|
||||
|
||||
export const useClub = () => {
|
||||
const context = useContext(ClubContext);
|
||||
if (!context) {
|
||||
throw new Error('useClub must be used within a ClubProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface ClubProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ClubProvider: React.FC<ClubProviderProps> = ({ children }) => {
|
||||
const [pinnedClub, setPinnedClub] = useState<ClubPin | null>(null);
|
||||
const [isClubReady, setIsClubReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
initializeClub();
|
||||
}, []);
|
||||
|
||||
const initializeClub = async () => {
|
||||
try {
|
||||
const stored = await loadClubPin();
|
||||
if (stored) {
|
||||
await setApiBaseUrl(stored.apiBaseUrl);
|
||||
setPinnedClub(stored);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize club:', error);
|
||||
} finally {
|
||||
setIsClubReady(true);
|
||||
}
|
||||
};
|
||||
|
||||
const switchClub = async (club: ClubPin) => {
|
||||
try {
|
||||
// Clear existing auth sessions
|
||||
await logoutAdmin();
|
||||
await logoutFan();
|
||||
|
||||
// Save new club and update API URL
|
||||
await saveClubPin(club);
|
||||
await setApiBaseUrl(club.apiBaseUrl);
|
||||
setPinnedClub(club);
|
||||
} catch (error) {
|
||||
console.error('Failed to switch club:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const resetClub = async () => {
|
||||
try {
|
||||
// Clear existing auth sessions
|
||||
await logoutAdmin();
|
||||
await logoutFan();
|
||||
|
||||
// Clear club pin
|
||||
await clearClubPin();
|
||||
setPinnedClub(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to reset club:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const value = {
|
||||
pinnedClub,
|
||||
isClubReady,
|
||||
switchClub,
|
||||
resetClub,
|
||||
};
|
||||
|
||||
return <ClubContext.Provider value={value}>{children}</ClubContext.Provider>;
|
||||
};
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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;
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import DashboardScreen from '../features/dashboard/DashboardScreen';
|
||||
import TicketScreen from '../features/tickets/TicketScreen';
|
||||
import MoreScreen from '../features/more/MoreScreen';
|
||||
import AdminQRValidatorScreen from '../features/admin/AdminQRValidatorScreen';
|
||||
import FanLoginScreen from '../features/auth/FanLoginScreen';
|
||||
import { View, Text } from 'react-native';
|
||||
|
||||
const Tab = createBottomTabNavigator();
|
||||
const Stack = createNativeStackNavigator();
|
||||
|
||||
const PlaceholderScreen = () => (
|
||||
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Text>Brzy přidáme další funkce</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
export const RootNavigator = () => {
|
||||
return (
|
||||
<Stack.Navigator screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="MainTabs" component={MainTabsNavigator} />
|
||||
<Stack.Screen name="AdminQRValidator" component={AdminQRValidatorScreen} options={{ presentation: 'modal' }} />
|
||||
<Stack.Screen name="FanLogin" component={FanLoginScreen} options={{ presentation: 'modal' }} />
|
||||
</Stack.Navigator>
|
||||
);
|
||||
};
|
||||
|
||||
const MainTabsNavigator = () => {
|
||||
return (
|
||||
<Tab.Navigator
|
||||
screenOptions={({ route }) => ({
|
||||
headerShown: false,
|
||||
tabBarIcon: ({ color, size }) => {
|
||||
let iconName: keyof typeof Ionicons.glyphMap = 'home';
|
||||
if (route.name === 'Dashboard') iconName = 'home';
|
||||
if (route.name === 'Vstupenky') iconName = 'qr-code';
|
||||
if (route.name === 'Více') iconName = 'ellipsis-horizontal';
|
||||
return <Ionicons name={iconName} size={size} color={color} />;
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Tab.Screen name="Dashboard" component={DashboardScreen} />
|
||||
<Tab.Screen name="Vstupenky" component={TicketScreen} />
|
||||
<Tab.Screen name="Více" component={MoreScreen} />
|
||||
</Tab.Navigator>
|
||||
);
|
||||
};
|
||||
|
||||
export default RootNavigator;
|
||||
@@ -0,0 +1,66 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||
import Constants from 'expo-constants';
|
||||
import * as Linking from 'expo-linking';
|
||||
|
||||
const STORAGE_KEY = 'myclub_api_base_url';
|
||||
|
||||
function ensureApiPath(url: string): string {
|
||||
if (!url) return 'http://localhost:8080/api/v1';
|
||||
const trimmed = url.replace(/\/$/, '');
|
||||
if (/\/api\/v\d+$/i.test(trimmed)) return trimmed;
|
||||
return `${trimmed}/api/v1`;
|
||||
}
|
||||
|
||||
async function loadBaseUrl(): Promise<string> {
|
||||
// 1) cached in AsyncStorage
|
||||
const cached = await AsyncStorage.getItem(STORAGE_KEY);
|
||||
if (cached) return cached;
|
||||
|
||||
// 2) from Expo extra (web url set during initial setup)
|
||||
const extra = Constants.expoConfig?.extra as Record<string, any> | undefined;
|
||||
const webUrl = extra?.webUrl || extra?.frontendUrl || extra?.siteUrl;
|
||||
const apiUrl = extra?.apiBaseUrl || extra?.api_url;
|
||||
const resolved = ensureApiPath(apiUrl || webUrl || 'http://localhost:8080/api/v1');
|
||||
if (resolved) {
|
||||
await AsyncStorage.setItem(STORAGE_KEY, resolved);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// 3) dev fallback: infer from Metro host (switch 8081 -> 8080)
|
||||
const hostUri = Constants.expoConfig?.hostUri || extra?.hostUri || Linking.createURL('/');
|
||||
try {
|
||||
const inferred = new URL(hostUri.startsWith('http') ? hostUri : `http://${hostUri}`);
|
||||
const base = `${inferred.protocol}//${inferred.hostname}:8080/api/v1`;
|
||||
await AsyncStorage.setItem(STORAGE_KEY, base);
|
||||
return base;
|
||||
} catch {
|
||||
return 'http://localhost:8080/api/v1';
|
||||
}
|
||||
}
|
||||
|
||||
let apiInstance: AxiosInstance | null = null;
|
||||
|
||||
export async function getApi(): Promise<AxiosInstance> {
|
||||
if (apiInstance) return apiInstance;
|
||||
const baseURL = ensureApiPath(await loadBaseUrl());
|
||||
apiInstance = axios.create({
|
||||
baseURL,
|
||||
withCredentials: true,
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
apiInstance.interceptors.request.use((config: AxiosRequestConfig) => config);
|
||||
apiInstance.interceptors.response.use(
|
||||
(resp: AxiosResponse) => resp,
|
||||
(err) => Promise.reject(err)
|
||||
);
|
||||
|
||||
return apiInstance;
|
||||
}
|
||||
|
||||
export async function setApiBaseUrl(url: string) {
|
||||
const normalized = ensureApiPath(url);
|
||||
await AsyncStorage.setItem(STORAGE_KEY, normalized);
|
||||
apiInstance = null;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { getApi } from './api';
|
||||
|
||||
const ADMIN_KEY = 'myclub_admin_session';
|
||||
const FAN_KEY = 'myclub_fan_session';
|
||||
|
||||
export async function loginAdmin(email: string, password: string) {
|
||||
const api = await getApi();
|
||||
await api.post('/admin/login', { email, password });
|
||||
await AsyncStorage.setItem(ADMIN_KEY, 'true');
|
||||
}
|
||||
|
||||
export async function logoutAdmin() {
|
||||
const api = await getApi();
|
||||
try {
|
||||
await api.post('/admin/logout');
|
||||
} catch {}
|
||||
await AsyncStorage.removeItem(ADMIN_KEY);
|
||||
}
|
||||
|
||||
export async function isAdminLogged(): Promise<boolean> {
|
||||
const flag = await AsyncStorage.getItem(ADMIN_KEY);
|
||||
return flag === 'true';
|
||||
}
|
||||
|
||||
export async function loginFan(email: string, password: string) {
|
||||
const api = await getApi();
|
||||
await api.post('/auth/login', { email, password });
|
||||
await AsyncStorage.setItem(FAN_KEY, 'true');
|
||||
}
|
||||
|
||||
export async function logoutFan() {
|
||||
const api = await getApi();
|
||||
try {
|
||||
await api.post('/auth/logout');
|
||||
} catch {}
|
||||
await AsyncStorage.removeItem(FAN_KEY);
|
||||
}
|
||||
|
||||
export async function isFanLogged(): Promise<boolean> {
|
||||
const flag = await AsyncStorage.getItem(FAN_KEY);
|
||||
return flag === 'true';
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { setApiBaseUrl } from './api';
|
||||
|
||||
const KEY = 'myclub_pinned_club_v1';
|
||||
|
||||
export type ClubPin = {
|
||||
id: string;
|
||||
name: string;
|
||||
apiBaseUrl: string;
|
||||
logoUrl?: string;
|
||||
};
|
||||
|
||||
export async function saveClubPin(pin: ClubPin) {
|
||||
await AsyncStorage.setItem(KEY, JSON.stringify(pin));
|
||||
await setApiBaseUrl(pin.apiBaseUrl);
|
||||
}
|
||||
|
||||
export async function loadClubPin(): Promise<ClubPin | null> {
|
||||
const raw = await AsyncStorage.getItem(KEY);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as ClubPin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearClubPin() {
|
||||
await AsyncStorage.removeItem(KEY);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { getApi } from './api';
|
||||
import { offlineSync } from './offlineSync';
|
||||
|
||||
export interface DashboardMatch {
|
||||
id: string;
|
||||
home_team: string;
|
||||
away_team: string;
|
||||
date: string;
|
||||
time: string;
|
||||
venue?: string;
|
||||
competition?: string;
|
||||
is_home: boolean;
|
||||
score_home?: number;
|
||||
score_away?: number;
|
||||
status?: 'upcoming' | 'live' | 'finished' | 'postponed';
|
||||
}
|
||||
|
||||
export interface DashboardNews {
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
published_at: string;
|
||||
image_url?: string;
|
||||
}
|
||||
|
||||
export interface DashboardAnnouncement {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
type: 'info' | 'warning' | 'success';
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface DashboardData {
|
||||
upcoming_match: DashboardMatch | null;
|
||||
recent_matches: DashboardMatch[];
|
||||
news: DashboardNews[];
|
||||
announcements: DashboardAnnouncement[];
|
||||
}
|
||||
|
||||
export async function getDashboardData(useOffline: boolean = true): Promise<DashboardData> {
|
||||
try {
|
||||
const api = await getApi();
|
||||
|
||||
// Try to fetch fresh data first
|
||||
try {
|
||||
// Fetch upcoming matches
|
||||
const matchesRes = await api.get('/matches?limit=5');
|
||||
const matches = matchesRes.data || [];
|
||||
|
||||
// Fetch news articles
|
||||
const newsRes = await api.get('/articles?limit=3&published=true');
|
||||
const news = newsRes.data || [];
|
||||
|
||||
// Fetch announcements (could be from a dedicated endpoint or settings)
|
||||
const announcementsRes = await api.get('/announcements').catch(() => ({ data: [] }));
|
||||
const announcements = announcementsRes.data || [];
|
||||
|
||||
// Process matches
|
||||
const now = new Date();
|
||||
const upcomingMatch = matches.find((m: any) => new Date(m.date + ' ' + m.time) > now) || null;
|
||||
const recentMatches = matches
|
||||
.filter((m: any) => new Date(m.date + ' ' + m.time) <= now)
|
||||
.slice(0, 3)
|
||||
.map((m: any) => ({
|
||||
id: m.id,
|
||||
home_team: m.home_team_name || m.home_team,
|
||||
away_team: m.away_team_name || m.away_team,
|
||||
date: m.date,
|
||||
time: m.time,
|
||||
venue: m.venue,
|
||||
competition: m.competition_name,
|
||||
is_home: m.is_home || false,
|
||||
score_home: m.score_home,
|
||||
score_away: m.score_away,
|
||||
status: m.status || 'upcoming'
|
||||
}));
|
||||
|
||||
// Process news
|
||||
const processedNews = news.map((n: any) => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
summary: n.summary || n.content?.substring(0, 150) + '...',
|
||||
published_at: n.published_at || n.created_at,
|
||||
image_url: n.image_url
|
||||
}));
|
||||
|
||||
const result = {
|
||||
upcoming_match: upcomingMatch ? {
|
||||
id: upcomingMatch.id,
|
||||
home_team: upcomingMatch.home_team_name || upcomingMatch.home_team,
|
||||
away_team: upcomingMatch.away_team_name || upcomingMatch.away_team,
|
||||
date: upcomingMatch.date,
|
||||
time: upcomingMatch.time,
|
||||
venue: upcomingMatch.venue,
|
||||
competition: upcomingMatch.competition_name,
|
||||
is_home: upcomingMatch.is_home || false,
|
||||
score_home: upcomingMatch.score_home,
|
||||
score_away: upcomingMatch.score_away,
|
||||
status: upcomingMatch.status || 'upcoming'
|
||||
} : null,
|
||||
recent_matches: recentMatches,
|
||||
news: processedNews,
|
||||
announcements: announcements.map((a: any) => ({
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
content: a.content,
|
||||
type: a.type || 'info',
|
||||
created_at: a.created_at
|
||||
}))
|
||||
};
|
||||
|
||||
// Cache the fresh data for offline use
|
||||
if (useOffline) {
|
||||
await offlineSync.cacheMatches(matches);
|
||||
await offlineSync.cacheNews(news);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (networkError) {
|
||||
// Network error, try to use cached data
|
||||
if (useOffline) {
|
||||
console.warn('Network error, using cached data:', networkError);
|
||||
return await getCachedDashboardData();
|
||||
}
|
||||
throw networkError;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch dashboard data:', error);
|
||||
// Return empty data structure on error
|
||||
return {
|
||||
upcoming_match: null,
|
||||
recent_matches: [],
|
||||
news: [],
|
||||
announcements: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function getCachedDashboardData(): Promise<DashboardData> {
|
||||
try {
|
||||
const cachedMatches = await offlineSync.getCachedMatches();
|
||||
const cachedNews = await offlineSync.getCachedNews();
|
||||
|
||||
const now = new Date();
|
||||
const upcomingMatch = cachedMatches.find(m =>
|
||||
new Date(m.date + ' ' + m.time) > now && m.status !== 'finished'
|
||||
) || null;
|
||||
|
||||
const recentMatches = cachedMatches
|
||||
.filter(m => new Date(m.date + ' ' + m.time) <= now || m.status === 'finished')
|
||||
.slice(0, 3);
|
||||
|
||||
return {
|
||||
upcoming_match: upcomingMatch,
|
||||
recent_matches: recentMatches,
|
||||
news: cachedNews.slice(0, 3),
|
||||
announcements: [] // Announcements are typically not cached
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to get cached dashboard data:', error);
|
||||
return {
|
||||
upcoming_match: null,
|
||||
recent_matches: [],
|
||||
news: [],
|
||||
announcements: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateMatchScore(matchId: string, scoreHome: number, scoreAway: number) {
|
||||
await offlineSync.updateMatchScore(matchId, scoreHome, scoreAway);
|
||||
}
|
||||
|
||||
export async function recordMatchAttendance(matchId: string, attendance: number) {
|
||||
await offlineSync.recordAttendance(matchId, attendance);
|
||||
}
|
||||
|
||||
export async function markNewsAsRead(articleId: string) {
|
||||
await offlineSync.markNewsAsRead(articleId);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { getApi } from './api';
|
||||
|
||||
export type ClubDirectoryEntry = {
|
||||
id: string;
|
||||
name: string;
|
||||
api_base_url: string;
|
||||
logo_url?: string;
|
||||
city?: string;
|
||||
country?: string;
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
// Central directory service configuration
|
||||
const CENTRAL_DIRECTORY_URL = 'https://error.sportcreative.eu/api/v1/clubs';
|
||||
const LOCAL_DIRECTORY_URL = 'http://localhost:8080/api/v1/admin/directory/info';
|
||||
const FALLBACK_CLUBS: ClubDirectoryEntry[] = [
|
||||
{
|
||||
id: 'demo-1',
|
||||
name: 'FK Demo',
|
||||
api_base_url: 'https://demo1.myclub.cz/api/v1',
|
||||
logo_url: undefined,
|
||||
city: 'Demo City',
|
||||
country: 'Czechia',
|
||||
active: true,
|
||||
},
|
||||
{
|
||||
id: 'demo-2',
|
||||
name: 'SK Příklad',
|
||||
api_base_url: 'https://demo2.myclub.cz/api/v1',
|
||||
logo_url: undefined,
|
||||
city: 'Příklad',
|
||||
country: 'Czechia',
|
||||
active: true,
|
||||
},
|
||||
];
|
||||
|
||||
export async function searchClubs(query: string): Promise<ClubDirectoryEntry[]> {
|
||||
try {
|
||||
// Try to fetch from the central MyClub directory API
|
||||
const response = await fetch(`${CENTRAL_DIRECTORY_URL}?search=${encodeURIComponent(query)}&active=true&limit=50`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'MyClub-Mobile/1.0',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Directory API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const clubs = await response.json();
|
||||
|
||||
// Transform API response to our format
|
||||
if (Array.isArray(clubs)) {
|
||||
return clubs.map((club: any) => ({
|
||||
id: club.club_id || club.id,
|
||||
name: club.club_name || club.name,
|
||||
api_base_url: club.api_base_url || club.url,
|
||||
logo_url: club.logo_url,
|
||||
city: club.city,
|
||||
country: club.country,
|
||||
active: club.active !== false,
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch clubs from central directory API, using fallback:', error);
|
||||
|
||||
// Fallback to mock data for development
|
||||
if (!query) return FALLBACK_CLUBS;
|
||||
const q = query.toLowerCase();
|
||||
return FALLBACK_CLUBS.filter((c) =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.city?.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAllClubs(): Promise<ClubDirectoryEntry[]> {
|
||||
return searchClubs(''); // Empty query returns all clubs
|
||||
}
|
||||
|
||||
export async function getClubById(clubId: string): Promise<ClubDirectoryEntry | null> {
|
||||
try {
|
||||
const response = await fetch(`${CENTRAL_DIRECTORY_URL}/${clubId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'MyClub-Mobile/1.0',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Directory API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const club = await response.json();
|
||||
if (!club) return null;
|
||||
|
||||
return {
|
||||
id: club.club_id || club.id,
|
||||
name: club.club_name || club.name,
|
||||
api_base_url: club.api_base_url || club.url,
|
||||
logo_url: club.logo_url,
|
||||
city: club.city,
|
||||
country: club.country,
|
||||
active: club.active !== false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch club by ID:', error);
|
||||
// Try fallback
|
||||
return FALLBACK_CLUBS.find(c => c.id === clubId) || null;
|
||||
}
|
||||
}
|
||||
|
||||
// Cache clubs locally for offline access
|
||||
export async function cacheClubsLocally(clubs: ClubDirectoryEntry[]): Promise<void> {
|
||||
try {
|
||||
// In a real implementation, this would use AsyncStorage
|
||||
// For now, we'll just log it
|
||||
console.log('Caching clubs locally:', clubs.length);
|
||||
} catch (error) {
|
||||
console.error('Failed to cache clubs:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCachedClubs(): Promise<ClubDirectoryEntry[]> {
|
||||
try {
|
||||
// In a real implementation, this would use AsyncStorage
|
||||
// For now, return fallback data
|
||||
return FALLBACK_CLUBS;
|
||||
} catch (error) {
|
||||
console.error('Failed to get cached clubs:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { Platform } from 'react-native';
|
||||
import { getApi } from './api';
|
||||
|
||||
const PUSH_TOKEN_KEY = 'myclub_push_token';
|
||||
|
||||
// Configure notifications
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: true,
|
||||
shouldPlaySound: true,
|
||||
shouldSetBadge: true,
|
||||
}),
|
||||
});
|
||||
|
||||
export interface PushNotificationData {
|
||||
type: 'match_reminder' | 'match_result' | 'news' | 'announcement' | 'ticket';
|
||||
clubId?: string;
|
||||
matchId?: string;
|
||||
articleId?: string;
|
||||
ticketId?: string;
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export async function requestPushPermissions(): Promise<boolean> {
|
||||
try {
|
||||
if (Platform.OS === 'android') {
|
||||
await Notifications.setNotificationChannelAsync('default', {
|
||||
name: 'Výchozí',
|
||||
importance: Notifications.AndroidImportance.MAX,
|
||||
vibrationPattern: [0, 250, 250, 250],
|
||||
sound: 'default',
|
||||
});
|
||||
}
|
||||
|
||||
const { status } = await Notifications.requestPermissionsAsync();
|
||||
return status === 'granted';
|
||||
} catch (error) {
|
||||
console.error('Failed to request push permissions:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPushToken(): Promise<string | null> {
|
||||
try {
|
||||
const hasPermission = await requestPushPermissions();
|
||||
if (!hasPermission) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenData = await Notifications.getExpoPushTokenAsync({
|
||||
projectId: 'your-project-id', // This should be configured in app.json
|
||||
});
|
||||
|
||||
return tokenData.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to get push token:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerPushToken(userId?: string): Promise<boolean> {
|
||||
try {
|
||||
const token = await getPushToken();
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if token has changed
|
||||
const storedToken = await AsyncStorage.getItem(PUSH_TOKEN_KEY);
|
||||
if (storedToken === token) {
|
||||
return true; // Already registered
|
||||
}
|
||||
|
||||
const api = await getApi();
|
||||
await api.post('/notifications/register', {
|
||||
token,
|
||||
platform: Platform.OS,
|
||||
user_id: userId, // Optional: associate with logged-in user
|
||||
});
|
||||
|
||||
// Store the token locally
|
||||
await AsyncStorage.setItem(PUSH_TOKEN_KEY, token);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to register push token:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function unregisterPushToken(): Promise<void> {
|
||||
try {
|
||||
const token = await AsyncStorage.getItem(PUSH_TOKEN_KEY);
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
const api = await getApi();
|
||||
try {
|
||||
await api.post('/notifications/unregister', { token });
|
||||
} catch (error) {
|
||||
console.error('Failed to unregister token on server:', error);
|
||||
}
|
||||
|
||||
// Remove local token
|
||||
await AsyncStorage.removeItem(PUSH_TOKEN_KEY);
|
||||
} catch (error) {
|
||||
console.error('Failed to unregister push token:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function scheduleLocalNotification(
|
||||
title: string,
|
||||
body: string,
|
||||
data?: PushNotificationData,
|
||||
trigger?: Notifications.NotificationTriggerInput
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const notificationId = await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title,
|
||||
body,
|
||||
data: data || {},
|
||||
sound: 'default',
|
||||
},
|
||||
trigger: trigger || null,
|
||||
});
|
||||
|
||||
return notificationId;
|
||||
} catch (error) {
|
||||
console.error('Failed to schedule local notification:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelScheduledNotification(notificationId: string): Promise<void> {
|
||||
try {
|
||||
await Notifications.cancelScheduledNotificationAsync(notificationId);
|
||||
} catch (error) {
|
||||
console.error('Failed to cancel notification:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function setupNotificationListeners(): void {
|
||||
// Handle notification received when app is in foreground
|
||||
Notifications.addNotificationReceivedListener((notification) => {
|
||||
console.log('Notification received:', notification);
|
||||
// You can handle in-app notification display here
|
||||
});
|
||||
|
||||
// Handle notification interaction when user taps it
|
||||
Notifications.addNotificationResponseReceivedListener((response) => {
|
||||
console.log('Notification response:', response);
|
||||
// Handle navigation based on notification data
|
||||
const data = response.notification.request.content.data as PushNotificationData;
|
||||
|
||||
// You can navigate to specific screens based on notification type
|
||||
// This would typically be handled by a navigation service
|
||||
if (data.type === 'match_reminder' && data.matchId) {
|
||||
// Navigate to match details
|
||||
} else if (data.type === 'news' && data.articleId) {
|
||||
// Navigate to article
|
||||
} else if (data.type === 'ticket' && data.ticketId) {
|
||||
// Navigate to ticket screen
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize push notifications on app start
|
||||
export async function initializePushNotifications(userId?: string): Promise<void> {
|
||||
try {
|
||||
setupNotificationListeners();
|
||||
await registerPushToken(userId);
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize push notifications:', error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { getApi } from './api';
|
||||
|
||||
const SYNC_QUEUE_KEY = 'myclub_sync_queue';
|
||||
const OFFLINE_MATCHES_KEY = 'myclub_offline_matches';
|
||||
const OFFLINE_NEWS_KEY = 'myclub_offline_news';
|
||||
|
||||
export interface SyncItem {
|
||||
id: string;
|
||||
type: 'match_update' | 'match_score' | 'attendance' | 'news_read';
|
||||
data: any;
|
||||
timestamp: number;
|
||||
retryCount: number;
|
||||
}
|
||||
|
||||
export interface OfflineMatch {
|
||||
id: string;
|
||||
home_team: string;
|
||||
away_team: string;
|
||||
date: string;
|
||||
time: string;
|
||||
venue?: string;
|
||||
competition?: string;
|
||||
score_home?: number;
|
||||
score_away?: number;
|
||||
status: 'upcoming' | 'live' | 'finished' | 'postponed';
|
||||
last_updated: number;
|
||||
is_offline_data: boolean;
|
||||
is_home: boolean;
|
||||
}
|
||||
|
||||
export interface OfflineNews {
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
published_at: string;
|
||||
image_url?: string;
|
||||
last_updated: number;
|
||||
is_offline_data: boolean;
|
||||
}
|
||||
|
||||
class OfflineSyncService {
|
||||
private syncQueue: SyncItem[] = [];
|
||||
private isOnline: boolean = true;
|
||||
private syncInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor() {
|
||||
this.initializeNetworkListener();
|
||||
this.loadSyncQueue();
|
||||
this.startPeriodicSync();
|
||||
}
|
||||
|
||||
private initializeNetworkListener() {
|
||||
// In a real app, you'd use NetInfo to detect network status
|
||||
// For now, we'll assume we're online
|
||||
this.isOnline = true;
|
||||
}
|
||||
|
||||
private async loadSyncQueue() {
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(SYNC_QUEUE_KEY);
|
||||
if (stored) {
|
||||
this.syncQueue = JSON.parse(stored);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load sync queue:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async saveSyncQueue() {
|
||||
try {
|
||||
await AsyncStorage.setItem(SYNC_QUEUE_KEY, JSON.stringify(this.syncQueue));
|
||||
} catch (error) {
|
||||
console.error('Failed to save sync queue:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private startPeriodicSync() {
|
||||
// Try to sync every 30 seconds when online
|
||||
this.syncInterval = setInterval(() => {
|
||||
if (this.isOnline && this.syncQueue.length > 0) {
|
||||
this.processSyncQueue();
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
public stopPeriodicSync() {
|
||||
if (this.syncInterval) {
|
||||
clearInterval(this.syncInterval);
|
||||
this.syncInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
public addToSyncQueue(item: Omit<SyncItem, 'timestamp' | 'retryCount'>) {
|
||||
const syncItem: SyncItem = {
|
||||
...item,
|
||||
timestamp: Date.now(),
|
||||
retryCount: 0,
|
||||
};
|
||||
|
||||
this.syncQueue.push(syncItem);
|
||||
this.saveSyncQueue();
|
||||
|
||||
// Try to sync immediately if online
|
||||
if (this.isOnline) {
|
||||
this.processSyncQueue();
|
||||
}
|
||||
}
|
||||
|
||||
private async processSyncQueue() {
|
||||
if (!this.isOnline || this.syncQueue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const api = await getApi();
|
||||
const itemsToProcess = [...this.syncQueue];
|
||||
const remainingItems: SyncItem[] = [];
|
||||
|
||||
for (const item of itemsToProcess) {
|
||||
try {
|
||||
await this.processSyncItem(item, api);
|
||||
// Successfully processed, don't add back to queue
|
||||
} catch (error) {
|
||||
console.error('Failed to sync item:', item.id, error);
|
||||
|
||||
// Increment retry count and add back if under limit
|
||||
item.retryCount++;
|
||||
if (item.retryCount < 3) {
|
||||
remainingItems.push(item);
|
||||
} else {
|
||||
console.warn('Item exceeded retry limit, dropping:', item.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.syncQueue = remainingItems;
|
||||
this.saveSyncQueue();
|
||||
}
|
||||
|
||||
private async processSyncItem(item: SyncItem, api: any) {
|
||||
switch (item.type) {
|
||||
case 'match_update':
|
||||
await api.post(`/matches/${item.data.matchId}/update`, item.data);
|
||||
break;
|
||||
case 'match_score':
|
||||
await api.post(`/matches/${item.data.matchId}/score`, item.data);
|
||||
break;
|
||||
case 'attendance':
|
||||
await api.post(`/matches/${item.data.matchId}/attendance`, item.data);
|
||||
break;
|
||||
case 'news_read':
|
||||
await api.post(`/articles/${item.data.articleId}/read`, item.data);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown sync item type: ${item.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async cacheMatches(matches: any[]) {
|
||||
try {
|
||||
const offlineMatches: OfflineMatch[] = matches.map(match => ({
|
||||
id: match.id,
|
||||
home_team: match.home_team_name || match.home_team,
|
||||
away_team: match.away_team_name || match.away_team,
|
||||
date: match.date,
|
||||
time: match.time,
|
||||
venue: match.venue,
|
||||
competition: match.competition_name,
|
||||
score_home: match.score_home,
|
||||
score_away: match.score_away,
|
||||
status: match.status || 'upcoming',
|
||||
last_updated: Date.now(),
|
||||
is_offline_data: false,
|
||||
is_home: match.is_home || false,
|
||||
}));
|
||||
|
||||
await AsyncStorage.setItem(OFFLINE_MATCHES_KEY, JSON.stringify(offlineMatches));
|
||||
} catch (error) {
|
||||
console.error('Failed to cache matches:', error);
|
||||
}
|
||||
}
|
||||
|
||||
public async getCachedMatches(): Promise<OfflineMatch[]> {
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(OFFLINE_MATCHES_KEY);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch (error) {
|
||||
console.error('Failed to get cached matches:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async updateMatchScore(matchId: string, scoreHome: number, scoreAway: number) {
|
||||
try {
|
||||
// Update local cache immediately
|
||||
const matches = await this.getCachedMatches();
|
||||
const matchIndex = matches.findIndex(m => m.id === matchId);
|
||||
|
||||
if (matchIndex !== -1) {
|
||||
matches[matchIndex].score_home = scoreHome;
|
||||
matches[matchIndex].score_away = scoreAway;
|
||||
matches[matchIndex].status = 'finished';
|
||||
matches[matchIndex].last_updated = Date.now();
|
||||
matches[matchIndex].is_offline_data = true; // Mark as modified offline
|
||||
|
||||
await AsyncStorage.setItem(OFFLINE_MATCHES_KEY, JSON.stringify(matches));
|
||||
}
|
||||
|
||||
// Add to sync queue
|
||||
this.addToSyncQueue({
|
||||
id: `score_${matchId}_${Date.now()}`,
|
||||
type: 'match_score',
|
||||
data: {
|
||||
matchId,
|
||||
score_home: scoreHome,
|
||||
score_away: scoreAway,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to update match score:', error);
|
||||
}
|
||||
}
|
||||
|
||||
public async recordAttendance(matchId: string, attendance: number) {
|
||||
try {
|
||||
// Add to sync queue
|
||||
this.addToSyncQueue({
|
||||
id: `attendance_${matchId}_${Date.now()}`,
|
||||
type: 'attendance',
|
||||
data: {
|
||||
matchId,
|
||||
attendance,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to record attendance:', error);
|
||||
}
|
||||
}
|
||||
|
||||
public async cacheNews(news: any[]) {
|
||||
try {
|
||||
const offlineNews: OfflineNews[] = news.map(article => ({
|
||||
id: article.id,
|
||||
title: article.title,
|
||||
summary: article.summary || article.content?.substring(0, 150) + '...',
|
||||
published_at: article.published_at || article.created_at,
|
||||
image_url: article.image_url,
|
||||
last_updated: Date.now(),
|
||||
is_offline_data: false,
|
||||
}));
|
||||
|
||||
await AsyncStorage.setItem(OFFLINE_NEWS_KEY, JSON.stringify(offlineNews));
|
||||
} catch (error) {
|
||||
console.error('Failed to cache news:', error);
|
||||
}
|
||||
}
|
||||
|
||||
public async getCachedNews(): Promise<OfflineNews[]> {
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(OFFLINE_NEWS_KEY);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch (error) {
|
||||
console.error('Failed to get cached news:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async markNewsAsRead(articleId: string) {
|
||||
try {
|
||||
// Add to sync queue
|
||||
this.addToSyncQueue({
|
||||
id: `read_${articleId}_${Date.now()}`,
|
||||
type: 'news_read',
|
||||
data: {
|
||||
articleId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to mark news as read:', error);
|
||||
}
|
||||
}
|
||||
|
||||
public getSyncStatus() {
|
||||
return {
|
||||
isOnline: this.isOnline,
|
||||
queueSize: this.syncQueue.length,
|
||||
pendingItems: this.syncQueue,
|
||||
};
|
||||
}
|
||||
|
||||
public async clearOfflineData() {
|
||||
try {
|
||||
await AsyncStorage.removeItem(OFFLINE_MATCHES_KEY);
|
||||
await AsyncStorage.removeItem(OFFLINE_NEWS_KEY);
|
||||
await AsyncStorage.removeItem(SYNC_QUEUE_KEY);
|
||||
this.syncQueue = [];
|
||||
} catch (error) {
|
||||
console.error('Failed to clear offline data:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const offlineSync = new OfflineSyncService();
|
||||
@@ -0,0 +1,70 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { ClubDirectoryEntry } from './directory';
|
||||
|
||||
export interface PinnedClub extends ClubDirectoryEntry {
|
||||
pinnedAt: number;
|
||||
}
|
||||
|
||||
const PINNED_CLUBS_KEY = 'myclub_pinned_clubs';
|
||||
|
||||
export async function loadPinnedClubs(): Promise<PinnedClub[]> {
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(PINNED_CLUBS_KEY);
|
||||
if (!stored) return [];
|
||||
|
||||
const pinned = JSON.parse(stored);
|
||||
return Array.isArray(pinned) ? pinned : [];
|
||||
} catch (error) {
|
||||
console.error('Failed to load pinned clubs:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function savePinnedClubs(clubs: PinnedClub[]): Promise<void> {
|
||||
try {
|
||||
await AsyncStorage.setItem(PINNED_CLUBS_KEY, JSON.stringify(clubs));
|
||||
} catch (error) {
|
||||
console.error('Failed to save pinned clubs:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function pinClub(club: ClubDirectoryEntry): Promise<PinnedClub[]> {
|
||||
const pinnedClub: PinnedClub = {
|
||||
...club,
|
||||
pinnedAt: Date.now(),
|
||||
};
|
||||
|
||||
const existing = await loadPinnedClubs();
|
||||
|
||||
// Check if already pinned
|
||||
if (existing.some(c => c.id === club.id)) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const updated = [...existing, pinnedClub];
|
||||
await savePinnedClubs(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function unpinClub(clubId: string): Promise<PinnedClub[]> {
|
||||
const existing = await loadPinnedClubs();
|
||||
const updated = existing.filter(c => c.id !== clubId);
|
||||
await savePinnedClubs(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function isClubPinned(clubId: string): Promise<boolean> {
|
||||
const pinned = await loadPinnedClubs();
|
||||
return pinned.some(c => c.id === clubId);
|
||||
}
|
||||
|
||||
export async function reorderPinnedClubs(clubIds: string[]): Promise<PinnedClub[]> {
|
||||
const pinned = await loadPinnedClubs();
|
||||
const reordered = clubIds
|
||||
.map(id => pinned.find(c => c.id === id))
|
||||
.filter(Boolean) as PinnedClub[];
|
||||
|
||||
await savePinnedClubs(reordered);
|
||||
return reordered;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Ticket, TicketQRData } from '../types/tickets';
|
||||
|
||||
export function buildQrPayload(ticket: Ticket, campaign: Ticket['campaign']): TicketQRData {
|
||||
const generated = new Date().toISOString();
|
||||
const checksum = generateChecksum(ticket.id, ticket.barcode, ticket.holder_email, generated);
|
||||
return {
|
||||
id: ticket.id,
|
||||
barcode: ticket.barcode,
|
||||
holder: ticket.holder_name,
|
||||
email: ticket.holder_email,
|
||||
event: campaign.title,
|
||||
type: ticket.ticket_type.name,
|
||||
qty: ticket.quantity,
|
||||
price: ((ticket.total_price_cents) / 100).toFixed(2) + ' Kč',
|
||||
date: campaign.match_date_time,
|
||||
venue: campaign.venue,
|
||||
generated,
|
||||
checksum,
|
||||
};
|
||||
}
|
||||
|
||||
export function generateChecksum(ticketId: number, barcode: string, email: string, generated: string): string {
|
||||
const str = `${ticketId}${barcode}${email}${generated}`;
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash;
|
||||
}
|
||||
return Math.abs(hash).toString(16);
|
||||
}
|
||||
|
||||
export function validateQrPayload(data: any): data is TicketQRData {
|
||||
if (!data || typeof data !== 'object') return false;
|
||||
const required = ['id', 'barcode', 'holder', 'email', 'event', 'type', 'qty', 'price', 'generated', 'checksum'];
|
||||
for (const field of required) {
|
||||
if (!(field in data)) return false;
|
||||
}
|
||||
const expected = generateChecksum(data.id, data.barcode, data.email, data.generated);
|
||||
return expected === data.checksum;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { getApi } from './api';
|
||||
|
||||
export type PublicSettings = {
|
||||
club_name?: string;
|
||||
club_logo_url?: string;
|
||||
primary_color?: string;
|
||||
secondary_color?: string;
|
||||
accent_color?: string;
|
||||
background_color?: string;
|
||||
text_color?: string;
|
||||
};
|
||||
|
||||
export async function getPublicSettings(): Promise<PublicSettings> {
|
||||
const api = await getApi();
|
||||
const res = await api.get('/settings');
|
||||
return res.data;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { getPublicSettings, PublicSettings } from './services/settings';
|
||||
|
||||
export type ClubTheme = {
|
||||
primary: string;
|
||||
secondary: string;
|
||||
accent: string;
|
||||
background: string;
|
||||
text: string;
|
||||
clubName?: string;
|
||||
logoUrl?: string;
|
||||
};
|
||||
|
||||
const defaultTheme: ClubTheme = {
|
||||
primary: '#0B5ED7',
|
||||
secondary: '#0A58CA',
|
||||
accent: '#FFC107',
|
||||
background: '#FFFFFF',
|
||||
text: '#0F172A',
|
||||
};
|
||||
|
||||
export function deriveTheme(settings?: PublicSettings): ClubTheme {
|
||||
if (!settings) return defaultTheme;
|
||||
return {
|
||||
primary: settings.primary_color || defaultTheme.primary,
|
||||
secondary: settings.secondary_color || settings.accent_color || defaultTheme.secondary,
|
||||
accent: settings.accent_color || settings.secondary_color || defaultTheme.accent,
|
||||
background: settings.background_color || defaultTheme.background,
|
||||
text: settings.text_color || defaultTheme.text,
|
||||
clubName: settings.club_name,
|
||||
logoUrl: settings.club_logo_url,
|
||||
};
|
||||
}
|
||||
|
||||
export const useClubTheme = (pinnedClub?: { id: string }) => {
|
||||
const [theme, setTheme] = useState<ClubTheme>(defaultTheme);
|
||||
const [settings, setSettings] = useState<PublicSettings | null>(null);
|
||||
const [themeReady, setThemeReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const s = await getPublicSettings();
|
||||
setSettings(s);
|
||||
setTheme(deriveTheme(s));
|
||||
} catch (e) {
|
||||
// fallback to defaults; errors can be shown in UI later
|
||||
setTheme(defaultTheme);
|
||||
} finally {
|
||||
setThemeReady(true);
|
||||
}
|
||||
})();
|
||||
}, [pinnedClub?.id]);
|
||||
|
||||
return useMemo(() => ({ theme, settings, themeReady }), [theme, settings, themeReady]);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
export interface TicketQRData {
|
||||
id: number;
|
||||
barcode: string;
|
||||
holder: string;
|
||||
email: string;
|
||||
event: string;
|
||||
type: string;
|
||||
qty: number;
|
||||
price: string;
|
||||
date?: string;
|
||||
venue?: string;
|
||||
generated: string;
|
||||
checksum: string;
|
||||
}
|
||||
|
||||
export interface Ticket {
|
||||
id: number;
|
||||
barcode: string;
|
||||
holder_name: string;
|
||||
holder_email: string;
|
||||
status: string;
|
||||
quantity: number;
|
||||
total_price_cents: number;
|
||||
ticket_type: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
campaign: {
|
||||
id: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
match_date_time?: string;
|
||||
venue?: string;
|
||||
home_team?: string;
|
||||
away_team?: string;
|
||||
};
|
||||
used_at?: string;
|
||||
created_at: string;
|
||||
}
|
||||
Reference in New Issue
Block a user