Backend (FastAPI + SQLAlchemy + SQLite): - JWT auth with access/refresh tokens, bcrypt password hashing - User model with member/organizer/admin roles, auto-approve members - Championship, Registration, ParticipantList, Notification models - Alembic async migrations, seed data with test users - Registration endpoint returns tokens for members, pending for organizers - /registrations/my returns championship title/date/location via eager loading - Admin endpoints: list users, approve/reject organizers Mobile (React Native + Expo + TypeScript): - Zustand auth store, Axios client with token refresh interceptor - Role-based registration (Member vs Organizer) with contextual form labels - Tab navigation with Ionicons, safe area headers, admin tab for admin role - Championships list with status badges, detail screen with registration progress - My Registrations with championship title, progress bar, and tap-to-navigate - Admin panel with pending/all filter, approve/reject with confirmation - Profile screen with role badge, Ionicons info rows, sign out - Password visibility toggle (Ionicons), keyboard flow hints (returnKeyType) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
136 lines
4.3 KiB
TypeScript
136 lines
4.3 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
FlatList,
|
|
TouchableOpacity,
|
|
StyleSheet,
|
|
RefreshControl,
|
|
ActivityIndicator,
|
|
Image,
|
|
} from 'react-native';
|
|
import { useNavigation } from '@react-navigation/native';
|
|
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
|
import { championshipsApi } from '../../api/championships';
|
|
import type { Championship } from '../../types';
|
|
import type { AppStackParams } from '../../navigation';
|
|
|
|
const STATUS_COLOR: Record<string, string> = {
|
|
open: '#16a34a',
|
|
draft: '#9ca3af',
|
|
closed: '#dc2626',
|
|
completed: '#2563eb',
|
|
};
|
|
|
|
function StatusBadge({ status }: { status: string }) {
|
|
return (
|
|
<View style={[styles.badge, { backgroundColor: STATUS_COLOR[status] ?? '#9ca3af' }]}>
|
|
<Text style={styles.badgeText}>{status.toUpperCase()}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function ChampionshipCard({ item, onPress }: { item: Championship; onPress: () => void }) {
|
|
return (
|
|
<TouchableOpacity style={styles.card} onPress={onPress} activeOpacity={0.85}>
|
|
{item.image_url && (
|
|
<Image source={{ uri: item.image_url }} style={styles.cardImage} resizeMode="cover" />
|
|
)}
|
|
<View style={styles.cardBody}>
|
|
<View style={styles.cardHeader}>
|
|
<Text style={styles.cardTitle} numberOfLines={2}>{item.title}</Text>
|
|
<StatusBadge status={item.status} />
|
|
</View>
|
|
{item.location && <Text style={styles.cardMeta}>📍 {item.location}</Text>}
|
|
{item.event_date && (
|
|
<Text style={styles.cardMeta}>
|
|
📅 {new Date(item.event_date).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}
|
|
</Text>
|
|
)}
|
|
{item.entry_fee != null && (
|
|
<Text style={styles.cardMeta}>💰 Entry fee: {item.entry_fee} BYN</Text>
|
|
)}
|
|
</View>
|
|
</TouchableOpacity>
|
|
);
|
|
}
|
|
|
|
export default function ChampionshipsScreen() {
|
|
const navigation = useNavigation<NativeStackNavigationProp<AppStackParams>>();
|
|
const [championships, setChampionships] = useState<Championship[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const load = async (silent = false) => {
|
|
if (!silent) setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const data = await championshipsApi.list();
|
|
setChampionships(data);
|
|
} catch {
|
|
setError('Failed to load championships');
|
|
} finally {
|
|
setLoading(false);
|
|
setRefreshing(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => { load(); }, []);
|
|
|
|
if (loading) {
|
|
return (
|
|
<View style={styles.center}>
|
|
<ActivityIndicator size="large" color="#7c3aed" />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<FlatList
|
|
data={championships}
|
|
keyExtractor={(item) => item.id}
|
|
contentContainerStyle={styles.list}
|
|
ListEmptyComponent={
|
|
<View style={styles.center}>
|
|
<Text style={styles.empty}>{error ?? 'No championships yet'}</Text>
|
|
</View>
|
|
}
|
|
renderItem={({ item }) => (
|
|
<ChampionshipCard
|
|
item={item}
|
|
onPress={() => navigation.navigate('ChampionshipDetail', { id: item.id })}
|
|
/>
|
|
)}
|
|
refreshControl={
|
|
<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); load(true); }} />
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
list: { padding: 16 },
|
|
heading: { fontSize: 24, fontWeight: '700', color: '#1a1a2e', marginBottom: 16 },
|
|
center: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingTop: 60 },
|
|
empty: { color: '#9ca3af', fontSize: 15 },
|
|
card: {
|
|
backgroundColor: '#fff',
|
|
borderRadius: 14,
|
|
marginBottom: 14,
|
|
overflow: 'hidden',
|
|
shadowColor: '#000',
|
|
shadowOffset: { width: 0, height: 2 },
|
|
shadowOpacity: 0.08,
|
|
shadowRadius: 6,
|
|
elevation: 3,
|
|
},
|
|
cardImage: { width: '100%', height: 160 },
|
|
cardBody: { padding: 14 },
|
|
cardHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8 },
|
|
cardTitle: { flex: 1, fontSize: 17, fontWeight: '600', color: '#1a1a2e', marginRight: 8 },
|
|
badge: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: 6 },
|
|
badgeText: { color: '#fff', fontSize: 11, fontWeight: '700' },
|
|
cardMeta: { fontSize: 13, color: '#555', marginTop: 4 },
|
|
});
|