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:
41
mobile/.gitignore
vendored
Normal file
41
mobile/.gitignore
vendored
Normal 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
19
mobile/App.tsx
Normal 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
28
mobile/app.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
mobile/assets/adaptive-icon.png
Normal file
BIN
mobile/assets/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
mobile/assets/favicon.png
Normal file
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
BIN
mobile/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
mobile/assets/splash-icon.png
Normal file
BIN
mobile/assets/splash-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
8
mobile/index.ts
Normal file
8
mobile/index.ts
Normal 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
8705
mobile/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
mobile/package.json
Normal file
34
mobile/package.json
Normal 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
33
mobile/src/api/auth.ts
Normal 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),
|
||||
};
|
||||
25
mobile/src/api/championships.ts
Normal file
25
mobile/src/api/championships.ts
Normal 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
73
mobile/src/api/client.ts
Normal 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
12
mobile/src/api/users.ts
Normal 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),
|
||||
};
|
||||
114
mobile/src/navigation/index.tsx
Normal file
114
mobile/src/navigation/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
290
mobile/src/screens/admin/AdminScreen.tsx
Normal file
290
mobile/src/screens/admin/AdminScreen.tsx
Normal 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' },
|
||||
});
|
||||
128
mobile/src/screens/auth/LoginScreen.tsx
Normal file
128
mobile/src/screens/auth/LoginScreen.tsx
Normal 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 },
|
||||
});
|
||||
36
mobile/src/screens/auth/PendingApprovalScreen.tsx
Normal file
36
mobile/src/screens/auth/PendingApprovalScreen.tsx
Normal 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' },
|
||||
});
|
||||
297
mobile/src/screens/auth/RegisterScreen.tsx
Normal file
297
mobile/src/screens/auth/RegisterScreen.tsx
Normal 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 },
|
||||
});
|
||||
246
mobile/src/screens/championships/ChampionshipDetailScreen.tsx
Normal file
246
mobile/src/screens/championships/ChampionshipDetailScreen.tsx
Normal 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' },
|
||||
});
|
||||
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 },
|
||||
});
|
||||
189
mobile/src/screens/championships/MyRegistrationsScreen.tsx
Normal file
189
mobile/src/screens/championships/MyRegistrationsScreen.tsx
Normal 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' },
|
||||
});
|
||||
149
mobile/src/screens/profile/ProfileScreen.tsx
Normal file
149
mobile/src/screens/profile/ProfileScreen.tsx
Normal 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' },
|
||||
});
|
||||
94
mobile/src/store/auth.store.ts
Normal file
94
mobile/src/store/auth.store.ts
Normal 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
63
mobile/src/types/index.ts
Normal 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;
|
||||
}
|
||||
49
mobile/src/utils/tokenStorage.ts
Normal file
49
mobile/src/utils/tokenStorage.ts
Normal 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
6
mobile/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user