Full app rebuild: FastAPI backend + React Native mobile with auth, championships, admin
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>
This commit is contained in:
135
mobile/src/screens/championships/ChampionshipsScreen.tsx
Normal file
135
mobile/src/screens/championships/ChampionshipsScreen.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
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 },
|
||||
});
|
||||
Reference in New Issue
Block a user