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:
Dianaka123
2026-02-25 22:46:50 +03:00
parent 9eb68695e9
commit 789d2bf0a6
81 changed files with 16283 additions and 310 deletions

41
mobile/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android

19
mobile/App.tsx Normal file
View File

@@ -0,0 +1,19 @@
import { useEffect } from 'react';
import { StatusBar } from 'expo-status-bar';
import RootNavigator from './src/navigation';
import { useAuthStore } from './src/store/auth.store';
export default function App() {
const initialize = useAuthStore((s) => s.initialize);
useEffect(() => {
initialize();
}, []);
return (
<>
<StatusBar style="dark" />
<RootNavigator />
</>
);
}

28
mobile/app.json Normal file
View File

@@ -0,0 +1,28 @@
{
"expo": {
"name": "Pole Championships",
"slug": "pole-championships",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"newArchEnabled": false,
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
}
},
"web": {
"favicon": "./assets/favicon.png"
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
mobile/assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
mobile/assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

8
mobile/index.ts Normal file
View File

@@ -0,0 +1,8 @@
import { registerRootComponent } from 'expo';
import App from './App';
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);

8705
mobile/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
mobile/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "mobile",
"version": "1.0.0",
"main": "index.ts",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@react-navigation/bottom-tabs": "^7.14.0",
"@react-navigation/native": "^7.1.28",
"@react-navigation/native-stack": "^7.13.0",
"@tanstack/react-query": "^5.90.21",
"axios": "^1.13.5",
"expo": "~54.0.33",
"expo-secure-store": "^15.0.8",
"expo-status-bar": "~3.0.9",
"react": "19.1.0",
"react-hook-form": "^7.71.2",
"react-native": "0.81.5",
"react-native-safe-area-context": "^5.7.0",
"react-native-screens": "4.16.0",
"zod": "^4.3.6",
"zustand": "^5.0.11"
},
"devDependencies": {
"@types/react": "~19.1.0",
"typescript": "~5.9.2"
},
"private": true
}

33
mobile/src/api/auth.ts Normal file
View File

@@ -0,0 +1,33 @@
import { apiClient } from './client';
import type { TokenPair, User } from '../types';
export const authApi = {
register: (data: {
email: string;
password: string;
full_name: string;
phone?: string;
requested_role: 'member' | 'organizer';
organization_name?: string;
instagram_handle?: string;
}) =>
apiClient
.post<{ user: User; access_token?: string; refresh_token?: string }>('/auth/register', data)
.then((r) => r.data),
login: (data: { email: string; password: string }) =>
apiClient.post<TokenPair>('/auth/login', data).then((r) => r.data),
refresh: (refresh_token: string) =>
apiClient
.post<{ access_token: string; refresh_token: string }>('/auth/refresh', { refresh_token })
.then((r) => r.data),
logout: (refresh_token: string) =>
apiClient.post('/auth/logout', { refresh_token }),
me: () => apiClient.get<User>('/auth/me').then((r) => r.data),
updateMe: (data: { full_name?: string; phone?: string; expo_push_token?: string }) =>
apiClient.patch<User>('/auth/me', data).then((r) => r.data),
};

View File

@@ -0,0 +1,25 @@
import { apiClient } from './client';
import type { Championship, Registration } from '../types';
export const championshipsApi = {
list: (status?: string) =>
apiClient.get<Championship[]>('/championships', { params: status ? { status } : {} }).then((r) => r.data),
get: (id: string) =>
apiClient.get<Championship>(`/championships/${id}`).then((r) => r.data),
register: (data: { championship_id: string; category?: string; level?: string; notes?: string }) =>
apiClient.post<Registration>('/registrations', data).then((r) => r.data),
myRegistrations: () =>
apiClient.get<Registration[]>('/registrations/my').then((r) => r.data),
getRegistration: (id: string) =>
apiClient.get<Registration>(`/registrations/${id}`).then((r) => r.data),
updateRegistration: (id: string, data: { video_url?: string; notes?: string }) =>
apiClient.patch<Registration>(`/registrations/${id}`, data).then((r) => r.data),
cancelRegistration: (id: string) =>
apiClient.delete(`/registrations/${id}`),
};

73
mobile/src/api/client.ts Normal file
View File

@@ -0,0 +1,73 @@
import axios from 'axios';
import { tokenStorage } from '../utils/tokenStorage';
// Replace with your machine's LAN IP when testing on a physical device
export const BASE_URL = 'http://192.168.2.56:8000/api/v1';
export const apiClient = axios.create({
baseURL: BASE_URL,
timeout: 10000,
});
// Attach access token from in-memory cache (synchronous — no await needed)
apiClient.interceptors.request.use((config) => {
const token = tokenStorage.getAccessTokenSync();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Refresh token on 401
let isRefreshing = false;
let queue: Array<{ resolve: (token: string) => void; reject: (err: unknown) => void }> = [];
function processQueue(error: unknown, token: string | null = null) {
queue.forEach((p) => (error ? p.reject(error) : p.resolve(token!)));
queue = [];
}
apiClient.interceptors.response.use(
(res) => res,
async (error) => {
const original = error.config;
if (error.response?.status === 401 && !original._retry) {
if (isRefreshing) {
return new Promise((resolve, reject) => {
queue.push({
resolve: (token) => {
original.headers.Authorization = `Bearer ${token}`;
resolve(apiClient(original));
},
reject,
});
});
}
original._retry = true;
isRefreshing = true;
try {
const refreshToken = tokenStorage.getRefreshTokenSync();
if (!refreshToken) throw new Error('No refresh token');
const { data } = await axios.post(`${BASE_URL}/auth/refresh`, {
refresh_token: refreshToken,
});
await tokenStorage.saveTokens(data.access_token, data.refresh_token);
apiClient.defaults.headers.common.Authorization = `Bearer ${data.access_token}`;
processQueue(null, data.access_token);
original.headers.Authorization = `Bearer ${data.access_token}`;
return apiClient(original);
} catch (err) {
processQueue(err, null);
await tokenStorage.clearTokens();
return Promise.reject(err);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);

12
mobile/src/api/users.ts Normal file
View File

@@ -0,0 +1,12 @@
import { apiClient } from './client';
import type { User } from '../types';
export const usersApi = {
list: () => apiClient.get<User[]>('/users').then((r) => r.data),
approve: (id: string) =>
apiClient.patch<User>(`/users/${id}/approve`).then((r) => r.data),
reject: (id: string) =>
apiClient.patch<User>(`/users/${id}/reject`).then((r) => r.data),
};

View File

@@ -0,0 +1,114 @@
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { ActivityIndicator, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useAuthStore } from '../store/auth.store';
// Screens
import LoginScreen from '../screens/auth/LoginScreen';
import RegisterScreen from '../screens/auth/RegisterScreen';
import PendingApprovalScreen from '../screens/auth/PendingApprovalScreen';
import ChampionshipsScreen from '../screens/championships/ChampionshipsScreen';
import ChampionshipDetailScreen from '../screens/championships/ChampionshipDetailScreen';
import MyRegistrationsScreen from '../screens/championships/MyRegistrationsScreen';
import ProfileScreen from '../screens/profile/ProfileScreen';
import AdminScreen from '../screens/admin/AdminScreen';
export type AuthStackParams = {
Login: undefined;
Register: undefined;
PendingApproval: undefined;
};
export type AppStackParams = {
Tabs: undefined;
ChampionshipDetail: { id: string };
};
export type TabParams = {
Championships: undefined;
MyRegistrations: undefined;
Admin: undefined;
Profile: undefined;
};
const AuthStack = createNativeStackNavigator<AuthStackParams>();
const AppStack = createNativeStackNavigator<AppStackParams>();
const Tab = createBottomTabNavigator<TabParams>();
function AppTabs({ isAdmin }: { isAdmin: boolean }) {
return (
<Tab.Navigator
screenOptions={({ route }) => ({
headerShown: true,
headerTitleStyle: { fontWeight: '700', fontSize: 18, color: '#1a1a2e' },
headerShadowVisible: false,
headerStyle: { backgroundColor: '#fff' },
tabBarActiveTintColor: '#7c3aed',
tabBarInactiveTintColor: '#9ca3af',
tabBarIcon: ({ focused, color, size }) => {
if (route.name === 'Championships') {
return <Ionicons name={focused ? 'trophy' : 'trophy-outline'} size={size} color={color} />;
}
if (route.name === 'MyRegistrations') {
return <Ionicons name={focused ? 'list' : 'list-outline'} size={size} color={color} />;
}
if (route.name === 'Admin') {
return <Ionicons name={focused ? 'shield' : 'shield-outline'} size={size} color={color} />;
}
if (route.name === 'Profile') {
return <Ionicons name={focused ? 'person' : 'person-outline'} size={size} color={color} />;
}
},
})}
>
<Tab.Screen name="Championships" component={ChampionshipsScreen} options={{ title: 'Championships' }} />
<Tab.Screen name="MyRegistrations" component={MyRegistrationsScreen} options={{ title: 'My Registrations' }} />
{isAdmin && <Tab.Screen name="Admin" component={AdminScreen} options={{ title: 'Admin' }} />}
<Tab.Screen name="Profile" component={ProfileScreen} />
</Tab.Navigator>
);
}
function AppNavigator({ isAdmin }: { isAdmin: boolean }) {
return (
<AppStack.Navigator>
<AppStack.Screen name="Tabs" options={{ headerShown: false }}>
{() => <AppTabs isAdmin={isAdmin} />}
</AppStack.Screen>
<AppStack.Screen name="ChampionshipDetail" component={ChampionshipDetailScreen} options={{ title: 'Details' }} />
</AppStack.Navigator>
);
}
function AuthNavigator() {
return (
<AuthStack.Navigator screenOptions={{ headerShown: false }}>
<AuthStack.Screen name="Login" component={LoginScreen} />
<AuthStack.Screen name="Register" component={RegisterScreen} />
<AuthStack.Screen name="PendingApproval" component={PendingApprovalScreen} />
</AuthStack.Navigator>
);
}
export default function RootNavigator() {
const { user, isInitialized } = useAuthStore();
if (!isInitialized) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" />
</View>
);
}
return (
<NavigationContainer>
{user?.status === 'approved'
? <AppNavigator isAdmin={user.role === 'admin'} />
: <AuthNavigator />}
</NavigationContainer>
);
}

View File

@@ -0,0 +1,290 @@
import { useEffect, useState, useCallback } from 'react';
import {
View,
Text,
FlatList,
TouchableOpacity,
StyleSheet,
Alert,
ActivityIndicator,
RefreshControl,
} from 'react-native';
import { usersApi } from '../../api/users';
import type { User } from '../../types';
const STATUS_COLOR: Record<string, string> = {
pending: '#f59e0b',
approved: '#16a34a',
rejected: '#dc2626',
};
const ROLE_LABEL: Record<string, string> = {
member: 'Member',
organizer: 'Organizer',
admin: 'Admin',
};
function UserCard({
user,
onApprove,
onReject,
acting,
}: {
user: User;
onApprove: () => void;
onReject: () => void;
acting: boolean;
}) {
return (
<View style={styles.card}>
<View style={styles.cardHeader}>
<View style={styles.avatar}>
<Text style={styles.avatarText}>{user.full_name.charAt(0).toUpperCase()}</Text>
</View>
<View style={styles.cardInfo}>
<Text style={styles.cardName}>{user.full_name}</Text>
<Text style={styles.cardEmail}>{user.email}</Text>
{user.organization_name && (
<Text style={styles.cardOrg}>{user.organization_name}</Text>
)}
{user.phone && <Text style={styles.cardDetail}>{user.phone}</Text>}
{user.instagram_handle && (
<Text style={styles.cardDetail}>{user.instagram_handle}</Text>
)}
</View>
<View style={[styles.statusDot, { backgroundColor: STATUS_COLOR[user.status] ?? '#9ca3af' }]} />
</View>
<View style={styles.meta}>
<Text style={styles.metaText}>Role: {ROLE_LABEL[user.role] ?? user.role}</Text>
<Text style={styles.metaText}>
Registered: {new Date(user.created_at).toLocaleDateString()}
</Text>
</View>
{user.status === 'pending' && (
<View style={styles.actions}>
<TouchableOpacity
style={[styles.btn, styles.approveBtn, acting && styles.btnDisabled]}
onPress={onApprove}
disabled={acting}
>
{acting ? (
<ActivityIndicator color="#fff" size="small" />
) : (
<Text style={styles.btnText}>Approve</Text>
)}
</TouchableOpacity>
<TouchableOpacity
style={[styles.btn, styles.rejectBtn, acting && styles.btnDisabled]}
onPress={onReject}
disabled={acting}
>
<Text style={[styles.btnText, styles.rejectText]}>Reject</Text>
</TouchableOpacity>
</View>
)}
{user.status !== 'pending' && (
<View style={styles.resolvedBanner}>
<Text style={[styles.resolvedText, { color: STATUS_COLOR[user.status] }]}>
{user.status === 'approved' ? '✓ Approved' : '✗ Rejected'}
</Text>
</View>
)}
</View>
);
}
export default function AdminScreen() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [actingId, setActingId] = useState<string | null>(null);
const [filter, setFilter] = useState<'pending' | 'all'>('pending');
const load = useCallback(async (silent = false) => {
if (!silent) setLoading(true);
try {
const data = await usersApi.list();
setUsers(data);
} catch {
Alert.alert('Error', 'Failed to load users');
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const handleApprove = (user: User) => {
Alert.alert('Approve', `Approve "${user.full_name}" (${user.organization_name ?? user.email})?`, [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Approve',
onPress: async () => {
setActingId(user.id);
try {
const updated = await usersApi.approve(user.id);
setUsers((prev) => prev.map((u) => (u.id === updated.id ? updated : u)));
} catch {
Alert.alert('Error', 'Failed to approve user');
} finally {
setActingId(null);
}
},
},
]);
};
const handleReject = (user: User) => {
Alert.alert('Reject', `Reject "${user.full_name}"? They will not be able to sign in.`, [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Reject',
style: 'destructive',
onPress: async () => {
setActingId(user.id);
try {
const updated = await usersApi.reject(user.id);
setUsers((prev) => prev.map((u) => (u.id === updated.id ? updated : u)));
} catch {
Alert.alert('Error', 'Failed to reject user');
} finally {
setActingId(null);
}
},
},
]);
};
const displayed = filter === 'pending'
? users.filter((u) => u.status === 'pending')
: users;
const pendingCount = users.filter((u) => u.status === 'pending').length;
if (loading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#7c3aed" />
</View>
);
}
return (
<FlatList
data={displayed}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); load(true); }} />
}
ListHeaderComponent={
<View>
<View style={styles.filterRow}>
<TouchableOpacity
style={[styles.filterBtn, filter === 'pending' && styles.filterBtnActive]}
onPress={() => setFilter('pending')}
>
<Text style={[styles.filterText, filter === 'pending' && styles.filterTextActive]}>
Pending {pendingCount > 0 ? `(${pendingCount})` : ''}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.filterBtn, filter === 'all' && styles.filterBtnActive]}
onPress={() => setFilter('all')}
>
<Text style={[styles.filterText, filter === 'all' && styles.filterTextActive]}>
All Users
</Text>
</TouchableOpacity>
</View>
</View>
}
ListEmptyComponent={
<View style={styles.center}>
<Text style={styles.empty}>
{filter === 'pending' ? 'No pending approvals' : 'No users found'}
</Text>
</View>
}
renderItem={({ item }) => (
<UserCard
user={item}
onApprove={() => handleApprove(item)}
onReject={() => handleReject(item)}
acting={actingId === item.id}
/>
)}
/>
);
}
const styles = StyleSheet.create({
list: { padding: 16, flexGrow: 1 },
heading: { fontSize: 24, fontWeight: '700', color: '#1a1a2e', marginBottom: 16 },
center: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingTop: 60 },
empty: { color: '#9ca3af', fontSize: 15 },
filterRow: { flexDirection: 'row', gap: 8, marginBottom: 16 },
filterBtn: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 20,
borderWidth: 1.5,
borderColor: '#e5e7eb',
},
filterBtnActive: { borderColor: '#7c3aed', backgroundColor: '#f3f0ff' },
filterText: { fontSize: 13, fontWeight: '600', color: '#9ca3af' },
filterTextActive: { color: '#7c3aed' },
card: {
backgroundColor: '#fff',
borderRadius: 14,
marginBottom: 12,
padding: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.07,
shadowRadius: 6,
elevation: 3,
},
cardHeader: { flexDirection: 'row', alignItems: 'flex-start' },
avatar: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: '#7c3aed',
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
},
avatarText: { color: '#fff', fontSize: 18, fontWeight: '700' },
cardInfo: { flex: 1 },
cardName: { fontSize: 15, fontWeight: '700', color: '#1a1a2e' },
cardEmail: { fontSize: 13, color: '#6b7280', marginTop: 1 },
cardOrg: { fontSize: 13, color: '#7c3aed', fontWeight: '600', marginTop: 3 },
cardDetail: { fontSize: 12, color: '#9ca3af', marginTop: 1 },
statusDot: { width: 10, height: 10, borderRadius: 5, marginTop: 4 },
meta: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 12, marginBottom: 12 },
metaText: { fontSize: 12, color: '#9ca3af' },
actions: { flexDirection: 'row', gap: 8 },
btn: {
flex: 1,
paddingVertical: 10,
borderRadius: 8,
alignItems: 'center',
},
btnDisabled: { opacity: 0.5 },
approveBtn: { backgroundColor: '#16a34a' },
rejectBtn: { backgroundColor: '#fff', borderWidth: 1.5, borderColor: '#ef4444' },
btnText: { fontSize: 14, fontWeight: '600', color: '#fff' },
rejectText: { color: '#ef4444' },
resolvedBanner: { alignItems: 'center', paddingTop: 4 },
resolvedText: { fontSize: 13, fontWeight: '600' },
});

View File

@@ -0,0 +1,128 @@
import { useRef, useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
KeyboardAvoidingView,
Platform,
Alert,
ActivityIndicator,
} from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { Ionicons } from '@expo/vector-icons';
import { useAuthStore } from '../../store/auth.store';
import type { AuthStackParams } from '../../navigation';
type Props = NativeStackScreenProps<AuthStackParams, 'Login'>;
export default function LoginScreen({ navigation }: Props) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const passwordRef = useRef<TextInput>(null);
const { login, isLoading } = useAuthStore();
const handleLogin = async () => {
if (!email.trim() || !password.trim()) {
Alert.alert('Error', 'Please enter email and password');
return;
}
try {
await login(email.trim().toLowerCase(), password);
} catch (err: any) {
const msg = err?.response?.data?.detail ?? 'Login failed. Check your credentials.';
Alert.alert('Login failed', msg);
}
};
return (
<KeyboardAvoidingView style={styles.container} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
<View style={styles.inner}>
<Text style={styles.title}>Pole Championships</Text>
<Text style={styles.subtitle}>Sign in to your account</Text>
<TextInput
style={styles.input}
placeholder="Email"
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
returnKeyType="next"
onSubmitEditing={() => passwordRef.current?.focus()}
value={email}
onChangeText={setEmail}
/>
<View style={styles.passwordRow}>
<TextInput
ref={passwordRef}
style={styles.passwordInput}
placeholder="Password"
secureTextEntry={!showPassword}
autoComplete="password"
returnKeyType="done"
onSubmitEditing={handleLogin}
value={password}
onChangeText={setPassword}
/>
<TouchableOpacity style={styles.eyeBtn} onPress={() => setShowPassword((v) => !v)}>
<Ionicons name={showPassword ? 'eye-off-outline' : 'eye-outline'} size={20} color="#6b7280" />
</TouchableOpacity>
</View>
<TouchableOpacity style={styles.btn} onPress={handleLogin} disabled={isLoading}>
{isLoading ? <ActivityIndicator color="#fff" /> : <Text style={styles.btnText}>Sign In</Text>}
</TouchableOpacity>
<TouchableOpacity onPress={() => navigation.navigate('Register')}>
<Text style={styles.link}>Don't have an account? Register</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#fff' },
inner: { flex: 1, justifyContent: 'center', padding: 24 },
title: { fontSize: 28, fontWeight: '700', textAlign: 'center', marginBottom: 8, color: '#1a1a2e' },
subtitle: { fontSize: 15, textAlign: 'center', color: '#666', marginBottom: 32 },
input: {
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 10,
padding: 14,
marginBottom: 14,
fontSize: 16,
backgroundColor: '#fafafa',
},
passwordRow: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 10,
backgroundColor: '#fafafa',
marginBottom: 14,
},
passwordInput: {
flex: 1,
padding: 14,
fontSize: 16,
},
eyeBtn: {
paddingHorizontal: 14,
paddingVertical: 14,
},
btn: {
backgroundColor: '#7c3aed',
padding: 16,
borderRadius: 10,
alignItems: 'center',
marginTop: 8,
marginBottom: 20,
},
btnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
link: { textAlign: 'center', color: '#7c3aed', fontSize: 14 },
});

View File

@@ -0,0 +1,36 @@
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import type { AuthStackParams } from '../../navigation';
type Props = NativeStackScreenProps<AuthStackParams, 'PendingApproval'>;
export default function PendingApprovalScreen({ navigation }: Props) {
return (
<View style={styles.container}>
<Text style={styles.icon}></Text>
<Text style={styles.title}>Application Submitted</Text>
<Text style={styles.body}>
Your registration has been received. An administrator will review and approve your account shortly.
{'\n\n'}
Once approved, you can sign in with your email and password.
</Text>
<TouchableOpacity style={styles.btn} onPress={() => navigation.navigate('Login')}>
<Text style={styles.btnText}>Go to Sign In</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 32, backgroundColor: '#fff' },
icon: { fontSize: 64, marginBottom: 20 },
title: { fontSize: 24, fontWeight: '700', color: '#1a1a2e', marginBottom: 16, textAlign: 'center' },
body: { fontSize: 15, color: '#555', lineHeight: 24, textAlign: 'center', marginBottom: 36 },
btn: {
backgroundColor: '#7c3aed',
paddingVertical: 14,
paddingHorizontal: 40,
borderRadius: 10,
},
btnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
});

View File

@@ -0,0 +1,297 @@
import { useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
KeyboardAvoidingView,
Platform,
Alert,
ActivityIndicator,
ScrollView,
} from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { Ionicons } from '@expo/vector-icons';
import { useAuthStore } from '../../store/auth.store';
import type { AuthStackParams } from '../../navigation';
type Props = NativeStackScreenProps<AuthStackParams, 'Register'>;
type Role = 'member' | 'organizer';
export default function RegisterScreen({ navigation }: Props) {
const [role, setRole] = useState<Role>('member');
const [fullName, setFullName] = useState('');
const [email, setEmail] = useState('');
const [phone, setPhone] = useState('');
const [password, setPassword] = useState('');
const [orgName, setOrgName] = useState('');
const [instagram, setInstagram] = useState('');
const [showPassword, setShowPassword] = useState(false);
const { register, isLoading } = useAuthStore();
const handleRegister = async () => {
if (!fullName.trim() || !email.trim() || !password.trim()) {
Alert.alert('Error', 'Please fill in all required fields');
return;
}
if (role === 'organizer' && !orgName.trim()) {
Alert.alert('Error', 'Organization name is required for organizers');
return;
}
try {
const autoLoggedIn = await register({
email: email.trim().toLowerCase(),
password,
full_name: fullName.trim(),
phone: phone.trim() || undefined,
requested_role: role,
organization_name: role === 'organizer' ? orgName.trim() : undefined,
instagram_handle: role === 'organizer' && instagram.trim() ? instagram.trim() : undefined,
});
if (!autoLoggedIn) {
// Organizer — navigate to pending screen
navigation.navigate('PendingApproval');
}
// Member — autoLoggedIn=true means the store already has user set,
// RootNavigator will switch to AppStack automatically
} catch (err: any) {
const detail = err?.response?.data?.detail;
const msg = Array.isArray(detail)
? detail.map((d: any) => d.msg).join('\n')
: detail ?? 'Registration failed';
Alert.alert('Registration failed', msg);
}
};
return (
<KeyboardAvoidingView style={styles.container} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
<ScrollView contentContainerStyle={styles.inner} keyboardShouldPersistTaps="handled">
<Text style={styles.title}>Create Account</Text>
<Text style={styles.subtitle}>Who are you registering as?</Text>
{/* Role selector — large cards */}
<View style={styles.roleRow}>
<TouchableOpacity
style={[styles.roleCard, role === 'member' && styles.roleCardActive]}
onPress={() => setRole('member')}
activeOpacity={0.8}
>
<Text style={styles.roleEmoji}>🏅</Text>
<Text style={[styles.roleTitle, role === 'member' && styles.roleTitleActive]}>Member</Text>
<Text style={[styles.roleDesc, role === 'member' && styles.roleDescActive]}>
Compete in championships
</Text>
{role === 'member' && <View style={styles.roleCheck}><Text style={styles.roleCheckText}></Text></View>}
</TouchableOpacity>
<TouchableOpacity
style={[styles.roleCard, role === 'organizer' && styles.roleCardActive]}
onPress={() => setRole('organizer')}
activeOpacity={0.8}
>
<Text style={styles.roleEmoji}>🏆</Text>
<Text style={[styles.roleTitle, role === 'organizer' && styles.roleTitleActive]}>Organizer</Text>
<Text style={[styles.roleDesc, role === 'organizer' && styles.roleDescActive]}>
Create & manage events
</Text>
{role === 'organizer' && <View style={styles.roleCheck}><Text style={styles.roleCheckText}></Text></View>}
</TouchableOpacity>
</View>
{/* Info banner — organizer only */}
{role === 'organizer' && (
<View style={[styles.infoBanner, styles.infoBannerAmber]}>
<Text style={[styles.infoText, styles.infoTextAmber]}>
Organizer accounts require admin approval before you can sign in.
</Text>
</View>
)}
{/* Common fields */}
<Text style={styles.label}>{role === 'organizer' ? 'Contact Person *' : 'Full Name *'}</Text>
<TextInput
style={styles.input}
placeholder={role === 'organizer' ? 'Your name (account manager)' : 'Anna Petrova'}
returnKeyType="next"
value={fullName}
onChangeText={setFullName}
/>
<Text style={styles.label}>Email *</Text>
<TextInput
style={styles.input}
placeholder="you@example.com"
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
returnKeyType="next"
value={email}
onChangeText={setEmail}
/>
<Text style={styles.label}>{role === 'organizer' ? 'Contact Phone' : 'Phone'}</Text>
<TextInput
style={styles.input}
placeholder="+375 29 000 0000 (optional)"
keyboardType="phone-pad"
returnKeyType="next"
value={phone}
onChangeText={setPhone}
/>
<Text style={styles.label}>Password *</Text>
<View style={styles.passwordRow}>
<TextInput
style={styles.passwordInput}
placeholder="Min 6 characters"
secureTextEntry={!showPassword}
returnKeyType={role === 'member' ? 'done' : 'next'}
value={password}
onChangeText={setPassword}
/>
<TouchableOpacity style={styles.eyeBtn} onPress={() => setShowPassword((v) => !v)}>
<Ionicons name={showPassword ? 'eye-off-outline' : 'eye-outline'} size={20} color="#6b7280" />
</TouchableOpacity>
</View>
{/* Organizer-only fields */}
{role === 'organizer' && (
<>
<View style={styles.divider}>
<Text style={styles.dividerLabel}>Organization Details</Text>
</View>
<Text style={styles.label}>Organization Name *</Text>
<TextInput
style={styles.input}
placeholder="Pole Sport Federation"
value={orgName}
onChangeText={setOrgName}
/>
<Text style={styles.label}>Instagram Handle</Text>
<TextInput
style={styles.input}
placeholder="@your_org (optional)"
autoCapitalize="none"
value={instagram}
onChangeText={(v) => setInstagram(v.startsWith('@') ? v : v ? `@${v}` : '')}
/>
</>
)}
<TouchableOpacity style={styles.btn} onPress={handleRegister} disabled={isLoading}>
{isLoading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.btnText}>
{role === 'member' ? 'Register & Sign In' : 'Submit Application'}
</Text>
)}
</TouchableOpacity>
<TouchableOpacity onPress={() => navigation.goBack()}>
<Text style={styles.link}>Already have an account? Sign In</Text>
</TouchableOpacity>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#fff' },
inner: { flexGrow: 1, padding: 24, paddingTop: 48 },
title: { fontSize: 26, fontWeight: '700', color: '#1a1a2e', marginBottom: 4, textAlign: 'center' },
subtitle: { fontSize: 14, color: '#6b7280', textAlign: 'center', marginBottom: 20 },
// Role cards
roleRow: { flexDirection: 'row', gap: 12, marginBottom: 16 },
roleCard: {
flex: 1,
padding: 16,
borderRadius: 14,
borderWidth: 2,
borderColor: '#e5e7eb',
alignItems: 'center',
backgroundColor: '#f9fafb',
position: 'relative',
},
roleCardActive: { borderColor: '#7c3aed', backgroundColor: '#f3f0ff' },
roleEmoji: { fontSize: 28, marginBottom: 8 },
roleTitle: { fontSize: 16, fontWeight: '700', color: '#9ca3af', marginBottom: 4 },
roleTitleActive: { color: '#7c3aed' },
roleDesc: { fontSize: 12, color: '#d1d5db', textAlign: 'center', lineHeight: 16 },
roleDescActive: { color: '#a78bfa' },
roleCheck: {
position: 'absolute',
top: 8,
right: 8,
width: 20,
height: 20,
borderRadius: 10,
backgroundColor: '#7c3aed',
justifyContent: 'center',
alignItems: 'center',
},
roleCheckText: { color: '#fff', fontSize: 11, fontWeight: '700' },
// Info banner
infoBanner: { borderRadius: 10, padding: 12, marginBottom: 20 },
infoBannerAmber: { backgroundColor: '#fef3c7' },
infoText: { fontSize: 13, lineHeight: 19 },
infoTextAmber: { color: '#92400e' },
// Form
label: { fontSize: 13, fontWeight: '600', color: '#374151', marginBottom: 5 },
input: {
borderWidth: 1,
borderColor: '#e5e7eb',
borderRadius: 10,
padding: 13,
marginBottom: 14,
fontSize: 15,
backgroundColor: '#fafafa',
},
passwordRow: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderColor: '#e5e7eb',
borderRadius: 10,
backgroundColor: '#fafafa',
marginBottom: 14,
},
passwordInput: {
flex: 1,
padding: 13,
fontSize: 15,
},
eyeBtn: {
paddingHorizontal: 13,
paddingVertical: 13,
},
divider: {
flexDirection: 'row',
alignItems: 'center',
marginVertical: 16,
},
dividerLabel: {
fontSize: 13,
fontWeight: '700',
color: '#7c3aed',
backgroundColor: '#fff',
paddingRight: 8,
},
btn: {
backgroundColor: '#7c3aed',
padding: 16,
borderRadius: 12,
alignItems: 'center',
marginBottom: 16,
marginTop: 4,
},
btnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
link: { textAlign: 'center', color: '#7c3aed', fontSize: 14 },
});

View File

@@ -0,0 +1,246 @@
import { useEffect, useState } from 'react';
import {
View,
Text,
ScrollView,
StyleSheet,
TouchableOpacity,
Alert,
ActivityIndicator,
Image,
Linking,
} from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { championshipsApi } from '../../api/championships';
import type { Championship, Registration } from '../../types';
import type { AppStackParams } from '../../navigation';
type Props = NativeStackScreenProps<AppStackParams, 'ChampionshipDetail'>;
export default function ChampionshipDetailScreen({ route }: Props) {
const { id } = route.params;
const [champ, setChamp] = useState<Championship | null>(null);
const [myReg, setMyReg] = useState<Registration | null>(null);
const [loading, setLoading] = useState(true);
const [registering, setRegistering] = useState(false);
useEffect(() => {
const load = async () => {
try {
const detail = await championshipsApi.get(id);
setChamp(detail);
try {
const regs = await championshipsApi.myRegistrations();
setMyReg(regs.find((r) => r.championship_id === id) ?? null);
} catch {
// myRegistrations failing shouldn't hide the championship
}
} finally {
setLoading(false);
}
};
load();
}, [id]);
const handleRegister = async () => {
if (!champ) return;
Alert.alert('Register', `Register for "${champ.title}"?`, [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Register',
onPress: async () => {
setRegistering(true);
try {
const reg = await championshipsApi.register({ championship_id: id });
setMyReg(reg);
Alert.alert('Success', 'You are registered! Complete the next steps on the registration form.');
} catch (err: any) {
Alert.alert('Error', err?.response?.data?.detail ?? 'Registration failed');
} finally {
setRegistering(false);
}
},
},
]);
};
if (loading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#7c3aed" />
</View>
);
}
if (!champ) {
return (
<View style={styles.center}>
<Text>Championship not found</Text>
</View>
);
}
const steps = [
{ key: 'submitted', label: 'Application submitted' },
{ key: 'form_submitted', label: 'Registration form submitted' },
{ key: 'payment_pending', label: 'Payment pending' },
{ key: 'payment_confirmed', label: 'Payment confirmed' },
{ key: 'video_submitted', label: 'Video submitted' },
{ key: 'accepted', label: 'Accepted' },
];
const currentStepIndex = myReg
? steps.findIndex((s) => s.key === myReg.status)
: -1;
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
{champ.image_url && (
<Image source={{ uri: champ.image_url }} style={styles.image} resizeMode="cover" />
)}
<Text style={styles.title}>{champ.title}</Text>
{champ.location && <Text style={styles.meta}>📍 {champ.location}</Text>}
{champ.event_date && (
<Text style={styles.meta}>
📅 {new Date(champ.event_date).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
</Text>
)}
{champ.entry_fee != null && <Text style={styles.meta}>💰 Entry fee: {champ.entry_fee} BYN</Text>}
{champ.video_max_duration != null && <Text style={styles.meta}>🎥 Max video duration: {champ.video_max_duration}s</Text>}
{champ.description && <Text style={styles.description}>{champ.description}</Text>}
{/* Categories */}
{champ.categories && champ.categories.length > 0 && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Categories</Text>
<View style={styles.tags}>
{champ.categories.map((cat) => (
<View key={cat} style={styles.tag}>
<Text style={styles.tagText}>{cat}</Text>
</View>
))}
</View>
</View>
)}
{/* Judges */}
{champ.judges && champ.judges.length > 0 && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Judges</Text>
{champ.judges.map((j) => (
<View key={j.name} style={styles.judgeRow}>
<Text style={styles.judgeName}>{j.name}</Text>
{j.bio && <Text style={styles.judgeBio}>{j.bio}</Text>}
{j.instagram && <Text style={styles.judgeInsta}>{j.instagram}</Text>}
</View>
))}
</View>
)}
{/* Registration form link */}
{champ.form_url && (
<TouchableOpacity style={styles.formBtn} onPress={() => Linking.openURL(champ.form_url!)}>
<Text style={styles.formBtnText}>Open Registration Form </Text>
</TouchableOpacity>
)}
{/* My registration progress */}
{myReg && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>My Registration Progress</Text>
{steps.map((step, i) => {
const done = i <= currentStepIndex;
const isRejected = myReg.status === 'rejected' || myReg.status === 'waitlisted';
return (
<View key={step.key} style={styles.step}>
<View style={[styles.stepDot, done && !isRejected && styles.stepDotDone]} />
<Text style={[styles.stepLabel, done && !isRejected && styles.stepLabelDone]}>
{step.label}
</Text>
</View>
);
})}
{(myReg.status === 'rejected' || myReg.status === 'waitlisted') && (
<Text style={styles.rejectedText}>
Status: {myReg.status === 'rejected' ? '❌ Rejected' : '⏳ Waitlisted'}
</Text>
)}
</View>
)}
{/* Register button / status */}
{!myReg && (
champ.status === 'open' ? (
<TouchableOpacity style={styles.registerBtn} onPress={handleRegister} disabled={registering}>
{registering ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.registerBtnText}>Register for Championship</Text>
)}
</TouchableOpacity>
) : (
<View style={styles.closedBanner}>
<Text style={styles.closedText}>
{champ.status === 'draft' && '⏳ Registration is not open yet'}
{champ.status === 'closed' && '🔒 Registration is closed'}
{champ.status === 'completed' && '✅ This championship has ended'}
</Text>
</View>
)
)}
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#fff' },
content: { paddingBottom: 40 },
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
image: { width: '100%', height: 220 },
title: { fontSize: 22, fontWeight: '700', color: '#1a1a2e', margin: 16, marginBottom: 8 },
meta: { fontSize: 14, color: '#555', marginHorizontal: 16, marginBottom: 4 },
description: { fontSize: 14, color: '#444', lineHeight: 22, margin: 16, marginTop: 12 },
section: { marginHorizontal: 16, marginTop: 20 },
sectionTitle: { fontSize: 17, fontWeight: '600', color: '#1a1a2e', marginBottom: 12 },
tags: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
tag: { backgroundColor: '#f3f0ff', paddingHorizontal: 12, paddingVertical: 6, borderRadius: 8 },
tagText: { color: '#7c3aed', fontSize: 13, fontWeight: '500' },
judgeRow: { marginBottom: 12, padding: 12, backgroundColor: '#f9fafb', borderRadius: 10 },
judgeName: { fontSize: 15, fontWeight: '600', color: '#1a1a2e' },
judgeBio: { fontSize: 13, color: '#555', marginTop: 2 },
judgeInsta: { fontSize: 13, color: '#7c3aed', marginTop: 2 },
formBtn: {
margin: 16,
padding: 14,
borderWidth: 2,
borderColor: '#7c3aed',
borderRadius: 10,
alignItems: 'center',
},
formBtnText: { color: '#7c3aed', fontSize: 15, fontWeight: '600' },
step: { flexDirection: 'row', alignItems: 'center', marginBottom: 10 },
stepDot: { width: 14, height: 14, borderRadius: 7, backgroundColor: '#ddd', marginRight: 10 },
stepDotDone: { backgroundColor: '#16a34a' },
stepLabel: { fontSize: 14, color: '#9ca3af' },
stepLabelDone: { color: '#1a1a2e' },
rejectedText: { fontSize: 14, color: '#dc2626', marginTop: 8, fontWeight: '600' },
registerBtn: {
margin: 16,
backgroundColor: '#7c3aed',
padding: 16,
borderRadius: 10,
alignItems: 'center',
},
registerBtnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
closedBanner: {
margin: 16,
padding: 14,
backgroundColor: '#f3f4f6',
borderRadius: 10,
alignItems: 'center',
},
closedText: { color: '#6b7280', fontSize: 14, fontWeight: '500' },
});

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

View File

@@ -0,0 +1,189 @@
import { useEffect, useState } from 'react';
import {
View,
Text,
FlatList,
TouchableOpacity,
StyleSheet,
RefreshControl,
ActivityIndicator,
Alert,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Ionicons } from '@expo/vector-icons';
import { championshipsApi } from '../../api/championships';
import type { Registration } from '../../types';
import type { AppStackParams } from '../../navigation';
const STATUS_CONFIG: Record<string, { color: string; icon: string; label: string }> = {
submitted: { color: '#f59e0b', icon: 'time-outline', label: 'Submitted' },
form_submitted: { color: '#3b82f6', icon: 'document-text-outline', label: 'Form Done' },
payment_pending: { color: '#f97316', icon: 'card-outline', label: 'Payment Pending' },
payment_confirmed: { color: '#8b5cf6', icon: 'checkmark-circle-outline', label: 'Paid' },
video_submitted: { color: '#06b6d4', icon: 'videocam-outline', label: 'Video Sent' },
accepted: { color: '#16a34a', icon: 'trophy-outline', label: 'Accepted' },
rejected: { color: '#dc2626', icon: 'close-circle-outline', label: 'Rejected' },
waitlisted: { color: '#9ca3af', icon: 'hourglass-outline', label: 'Waitlisted' },
};
const STEP_KEYS = ['submitted', 'form_submitted', 'payment_pending', 'payment_confirmed', 'video_submitted', 'accepted'];
function RegistrationCard({ item, onPress }: { item: Registration; onPress: () => void }) {
const config = STATUS_CONFIG[item.status] ?? { color: '#9ca3af', icon: 'help-outline', label: item.status };
const stepIndex = STEP_KEYS.indexOf(item.status);
const isFinal = item.status === 'rejected' || item.status === 'waitlisted';
return (
<TouchableOpacity style={styles.card} onPress={onPress} activeOpacity={0.85}>
<View style={styles.cardTop}>
<View style={styles.cardTitleArea}>
<Text style={styles.cardTitle} numberOfLines={2}>
{item.championship_title ?? 'Championship'}
</Text>
{item.championship_location && (
<Text style={styles.cardMeta}>
<Ionicons name="location-outline" size={12} color="#6b7280" /> {item.championship_location}
</Text>
)}
{item.championship_event_date && (
<Text style={styles.cardMeta}>
<Ionicons name="calendar-outline" size={12} color="#6b7280" />{' '}
{new Date(item.championship_event_date).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</Text>
)}
</View>
<View style={[styles.statusBadge, { backgroundColor: config.color + '18' }]}>
<Ionicons name={config.icon as any} size={14} color={config.color} />
<Text style={[styles.statusText, { color: config.color }]}>{config.label}</Text>
</View>
</View>
{/* Progress bar */}
<View style={styles.progressRow}>
{STEP_KEYS.map((key, i) => {
const done = !isFinal && i <= stepIndex;
return <View key={key} style={[styles.progressDot, done && { backgroundColor: config.color }]} />;
})}
</View>
<View style={styles.cardBottom}>
<Text style={styles.dateText}>
Registered {new Date(item.submitted_at).toLocaleDateString()}
</Text>
<Ionicons name="chevron-forward" size={16} color="#9ca3af" />
</View>
</TouchableOpacity>
);
}
export default function MyRegistrationsScreen() {
const navigation = useNavigation<NativeStackNavigationProp<AppStackParams>>();
const [registrations, setRegistrations] = useState<Registration[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const load = async (silent = false) => {
if (!silent) setLoading(true);
try {
const data = await championshipsApi.myRegistrations();
setRegistrations(data);
} catch {
Alert.alert('Error', 'Failed to load registrations');
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => { load(); }, []);
if (loading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#7c3aed" />
</View>
);
}
return (
<FlatList
data={registrations}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Ionicons name="document-text-outline" size={48} color="#d1d5db" />
<Text style={styles.empty}>No registrations yet</Text>
<Text style={styles.emptySub}>Browse championships and register for events</Text>
</View>
}
renderItem={({ item }) => (
<RegistrationCard
item={item}
onPress={() => navigation.navigate('ChampionshipDetail', { id: item.championship_id })}
/>
)}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); load(true); }} />
}
/>
);
}
const styles = StyleSheet.create({
list: { padding: 16, flexGrow: 1 },
heading: { fontSize: 24, fontWeight: '700', color: '#1a1a2e', marginBottom: 16 },
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
emptyContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingTop: 80 },
empty: { color: '#6b7280', fontSize: 16, fontWeight: '600', marginTop: 12, marginBottom: 4 },
emptySub: { color: '#9ca3af', fontSize: 13 },
card: {
backgroundColor: '#fff',
borderRadius: 14,
padding: 16,
marginBottom: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.07,
shadowRadius: 6,
elevation: 3,
},
cardTop: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start' },
cardTitleArea: { flex: 1, marginRight: 10 },
cardTitle: { fontSize: 16, fontWeight: '700', color: '#1a1a2e', marginBottom: 4 },
cardMeta: { fontSize: 12, color: '#6b7280', marginTop: 2 },
statusBadge: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 8,
gap: 4,
},
statusText: { fontSize: 11, fontWeight: '700' },
progressRow: { flexDirection: 'row', gap: 4, marginTop: 14, marginBottom: 12 },
progressDot: {
flex: 1,
height: 4,
borderRadius: 2,
backgroundColor: '#e5e7eb',
},
cardBottom: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
borderTopWidth: 1,
borderTopColor: '#f3f4f6',
paddingTop: 10,
},
dateText: { fontSize: 12, color: '#9ca3af' },
});

View File

@@ -0,0 +1,149 @@
import { View, Text, StyleSheet, TouchableOpacity, Alert, ScrollView } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useAuthStore } from '../../store/auth.store';
const ROLE_CONFIG: Record<string, { color: string; bg: string; label: string }> = {
member: { color: '#16a34a', bg: '#f0fdf4', label: 'Member' },
organizer: { color: '#7c3aed', bg: '#f3f0ff', label: 'Organizer' },
admin: { color: '#dc2626', bg: '#fef2f2', label: 'Admin' },
};
export default function ProfileScreen() {
const { user, logout } = useAuthStore();
const handleLogout = () => {
Alert.alert('Sign Out', 'Are you sure you want to sign out?', [
{ text: 'Cancel', style: 'cancel' },
{ text: 'Sign Out', style: 'destructive', onPress: logout },
]);
};
if (!user) return null;
const roleConfig = ROLE_CONFIG[user.role] ?? { color: '#6b7280', bg: '#f3f4f6', label: user.role };
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
{/* Avatar + Name */}
<View style={styles.header}>
<View style={styles.avatar}>
<Text style={styles.avatarText}>{user.full_name.charAt(0).toUpperCase()}</Text>
</View>
<Text style={styles.name}>{user.full_name}</Text>
<Text style={styles.email}>{user.email}</Text>
<View style={[styles.roleBadge, { backgroundColor: roleConfig.bg }]}>
<Text style={[styles.roleText, { color: roleConfig.color }]}>{roleConfig.label}</Text>
</View>
</View>
{/* Info Card */}
<View style={styles.card}>
{user.phone && (
<Row icon="call-outline" label="Phone" value={user.phone} />
)}
{user.organization_name && (
<Row icon="business-outline" label="Organization" value={user.organization_name} />
)}
{user.instagram_handle && (
<Row icon="logo-instagram" label="Instagram" value={user.instagram_handle} />
)}
<Row
icon="calendar-outline"
label="Member since"
value={new Date(user.created_at).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
isLast
/>
</View>
{/* Sign Out */}
<TouchableOpacity style={styles.logoutBtn} onPress={handleLogout}>
<Ionicons name="log-out-outline" size={18} color="#ef4444" />
<Text style={styles.logoutText}>Sign Out</Text>
</TouchableOpacity>
</ScrollView>
);
}
function Row({
icon,
label,
value,
isLast,
}: {
icon: string;
label: string;
value: string;
isLast?: boolean;
}) {
return (
<View style={[styles.row, isLast && styles.rowLast]}>
<View style={styles.rowLeft}>
<Ionicons name={icon as any} size={16} color="#7c3aed" />
<Text style={styles.rowLabel}>{label}</Text>
</View>
<Text style={styles.rowValue}>{value}</Text>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#fff' },
content: { padding: 24, paddingBottom: 40 },
header: { alignItems: 'center', marginBottom: 28 },
avatar: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: '#7c3aed',
justifyContent: 'center',
alignItems: 'center',
marginBottom: 14,
},
avatarText: { color: '#fff', fontSize: 32, fontWeight: '700' },
name: { fontSize: 22, fontWeight: '700', color: '#1a1a2e', marginBottom: 4 },
email: { fontSize: 14, color: '#6b7280', marginBottom: 10 },
roleBadge: {
paddingHorizontal: 14,
paddingVertical: 5,
borderRadius: 20,
},
roleText: { fontSize: 13, fontWeight: '700' },
card: {
backgroundColor: '#f9fafb',
borderRadius: 14,
marginBottom: 28,
overflow: 'hidden',
},
row: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 14,
paddingHorizontal: 16,
borderBottomWidth: 1,
borderBottomColor: '#f3f4f6',
},
rowLast: { borderBottomWidth: 0 },
rowLeft: { flexDirection: 'row', alignItems: 'center', gap: 8 },
rowLabel: { fontSize: 14, color: '#6b7280' },
rowValue: { fontSize: 14, color: '#1a1a2e', fontWeight: '500' },
logoutBtn: {
flexDirection: 'row',
gap: 8,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 1.5,
borderColor: '#fecaca',
backgroundColor: '#fef2f2',
borderRadius: 12,
padding: 14,
},
logoutText: { color: '#ef4444', fontSize: 15, fontWeight: '600' },
});

View File

@@ -0,0 +1,94 @@
import { create } from 'zustand';
import { apiClient } from '../api/client';
import { authApi } from '../api/auth';
import { tokenStorage } from '../utils/tokenStorage';
import type { User } from '../types';
interface AuthState {
user: User | null;
isLoading: boolean;
isInitialized: boolean;
login: (email: string, password: string) => Promise<void>;
// Returns true if auto-logged in (member), false if pending approval (organizer)
register: (data: {
email: string;
password: string;
full_name: string;
phone?: string;
requested_role: 'member' | 'organizer';
organization_name?: string;
instagram_handle?: string;
}) => Promise<boolean>;
logout: () => Promise<void>;
initialize: () => Promise<void>;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
isLoading: false,
isInitialized: false,
initialize: async () => {
try {
await tokenStorage.loadFromStorage();
const token = tokenStorage.getAccessTokenSync();
if (token) {
apiClient.defaults.headers.common.Authorization = `Bearer ${token}`;
const user = await authApi.me();
set({ user, isInitialized: true });
} else {
set({ isInitialized: true });
}
} catch {
await tokenStorage.clearTokens();
set({ user: null, isInitialized: true });
}
},
login: async (email, password) => {
set({ isLoading: true });
try {
const data = await authApi.login({ email, password });
await tokenStorage.saveTokens(data.access_token, data.refresh_token);
apiClient.defaults.headers.common.Authorization = `Bearer ${data.access_token}`;
set({ user: data.user, isLoading: false });
} catch (err) {
set({ isLoading: false });
throw err;
}
},
register: async (data) => {
set({ isLoading: true });
try {
const res = await authApi.register(data);
if (res.access_token && res.refresh_token) {
// Member: auto-approved — save tokens and log in immediately
await tokenStorage.saveTokens(res.access_token, res.refresh_token);
apiClient.defaults.headers.common.Authorization = `Bearer ${res.access_token}`;
set({ user: res.user, isLoading: false });
return true;
}
// Organizer: pending admin approval
set({ isLoading: false });
return false;
} catch (err) {
set({ isLoading: false });
throw err;
}
},
logout: async () => {
const refresh = tokenStorage.getRefreshTokenSync();
if (refresh) {
try {
await authApi.logout(refresh);
} catch {
// ignore
}
}
await tokenStorage.clearTokens();
delete apiClient.defaults.headers.common.Authorization;
set({ user: null });
},
}));

63
mobile/src/types/index.ts Normal file
View File

@@ -0,0 +1,63 @@
export interface User {
id: string;
email: string;
full_name: string;
phone: string | null;
role: 'member' | 'organizer' | 'admin';
status: 'pending' | 'approved' | 'rejected';
organization_name: string | null;
instagram_handle: string | null;
expo_push_token: string | null;
created_at: string;
}
export interface TokenPair {
access_token: string;
refresh_token: string;
token_type: string;
user: User;
}
export interface Championship {
id: string;
title: string;
description: string | null;
location: string | null;
event_date: string | null;
registration_open_at: string | null;
registration_close_at: string | null;
form_url: string | null;
entry_fee: number | null;
video_max_duration: number | null;
judges: { name: string; bio: string; instagram: string }[] | null;
categories: string[] | null;
status: 'draft' | 'open' | 'closed' | 'completed';
source: string;
image_url: string | null;
created_at: string;
updated_at: string;
}
export interface Registration {
id: string;
championship_id: string;
user_id: string;
category: string | null;
level: string | null;
notes: string | null;
status:
| 'submitted'
| 'form_submitted'
| 'payment_pending'
| 'payment_confirmed'
| 'video_submitted'
| 'accepted'
| 'rejected'
| 'waitlisted';
video_url: string | null;
submitted_at: string;
decided_at: string | null;
championship_title: string | null;
championship_event_date: string | null;
championship_location: string | null;
}

View File

@@ -0,0 +1,49 @@
import * as SecureStore from 'expo-secure-store';
const ACCESS_KEY = 'access_token';
const REFRESH_KEY = 'refresh_token';
// In-memory cache so synchronous reads work immediately after login
let _accessToken: string | null = null;
let _refreshToken: string | null = null;
export const tokenStorage = {
async saveTokens(access: string, refresh: string): Promise<void> {
_accessToken = access;
_refreshToken = refresh;
await SecureStore.setItemAsync(ACCESS_KEY, access);
await SecureStore.setItemAsync(REFRESH_KEY, refresh);
},
getAccessTokenSync(): string | null {
return _accessToken;
},
getRefreshTokenSync(): string | null {
return _refreshToken;
},
async getAccessToken(): Promise<string | null> {
if (_accessToken) return _accessToken;
_accessToken = await SecureStore.getItemAsync(ACCESS_KEY);
return _accessToken;
},
async getRefreshToken(): Promise<string | null> {
if (_refreshToken) return _refreshToken;
_refreshToken = await SecureStore.getItemAsync(REFRESH_KEY);
return _refreshToken;
},
async clearTokens(): Promise<void> {
_accessToken = null;
_refreshToken = null;
await SecureStore.deleteItemAsync(ACCESS_KEY);
await SecureStore.deleteItemAsync(REFRESH_KEY);
},
async loadFromStorage(): Promise<void> {
_accessToken = await SecureStore.getItemAsync(ACCESS_KEY);
_refreshToken = await SecureStore.getItemAsync(REFRESH_KEY);
},
};

6
mobile/tsconfig.json Normal file
View File

@@ -0,0 +1,6 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
}
}