Fix mobile tracked as regular files (not submodule)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1
mobile
1
mobile
Submodule mobile deleted from 76ceb04245
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
|
||||||
49
mobile/App.tsx
Normal file
49
mobile/App.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { NavigationContainer } from '@react-navigation/native';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import * as Notifications from 'expo-notifications';
|
||||||
|
import { RootNavigator } from './src/navigation/RootNavigator';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: 1,
|
||||||
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const notificationListener = useRef<Notifications.EventSubscription>();
|
||||||
|
const responseListener = useRef<Notifications.EventSubscription>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Handle notifications received while app is in foreground
|
||||||
|
notificationListener.current = Notifications.addNotificationReceivedListener(
|
||||||
|
(notification) => {
|
||||||
|
console.log('Notification received:', notification);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle notification tap
|
||||||
|
responseListener.current = Notifications.addNotificationResponseReceivedListener(
|
||||||
|
(response) => {
|
||||||
|
console.log('Notification tapped:', response);
|
||||||
|
// TODO: navigate to relevant screen based on response.notification.request.content.data
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
notificationListener.current?.remove();
|
||||||
|
responseListener.current?.remove();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<NavigationContainer>
|
||||||
|
<RootNavigator />
|
||||||
|
</NavigationContainer>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
mobile/app.json
Normal file
50
mobile/app.json
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "Pole Dance Championships",
|
||||||
|
"slug": "poledance-championships",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icon": "./assets/icon.png",
|
||||||
|
"userInterfaceStyle": "light",
|
||||||
|
"newArchEnabled": false,
|
||||||
|
"splash": {
|
||||||
|
"image": "./assets/splash-icon.png",
|
||||||
|
"resizeMode": "contain",
|
||||||
|
"backgroundColor": "#6C3FC5"
|
||||||
|
},
|
||||||
|
"ios": {
|
||||||
|
"supportsTablet": true,
|
||||||
|
"bundleIdentifier": "com.yourorg.poledance",
|
||||||
|
"infoPlist": {
|
||||||
|
"UIBackgroundModes": ["remote-notification"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"adaptiveIcon": {
|
||||||
|
"foregroundImage": "./assets/adaptive-icon.png",
|
||||||
|
"backgroundColor": "#6C3FC5"
|
||||||
|
},
|
||||||
|
"package": "com.yourorg.poledance",
|
||||||
|
"edgeToEdgeEnabled": true,
|
||||||
|
"predictiveBackGestureEnabled": false
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"favicon": "./assets/favicon.png"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
[
|
||||||
|
"expo-notifications",
|
||||||
|
{
|
||||||
|
"icon": "./assets/icon.png",
|
||||||
|
"color": "#6C3FC5",
|
||||||
|
"defaultChannel": "default"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"extra": {
|
||||||
|
"eas": {
|
||||||
|
"projectId": "YOUR_EAS_PROJECT_ID"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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);
|
||||||
9246
mobile/package-lock.json
generated
Normal file
9246
mobile/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
mobile/package.json
Normal file
37
mobile/package.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"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-notifications": "~0.32.16",
|
||||||
|
"expo-secure-store": "^15.0.8",
|
||||||
|
"expo-status-bar": "~3.0.9",
|
||||||
|
"react": "19.1.0",
|
||||||
|
"react-dom": "19.1.0",
|
||||||
|
"react-hook-form": "^7.71.2",
|
||||||
|
"react-native": "0.81.5",
|
||||||
|
"react-native-safe-area-context": "^5.6.2",
|
||||||
|
"react-native-screens": "^4.16.0",
|
||||||
|
"react-native-web": "~0.21.0",
|
||||||
|
"zod": "^4.3.6",
|
||||||
|
"zustand": "^5.0.11"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "~19.1.0",
|
||||||
|
"typescript": "~5.9.2"
|
||||||
|
},
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
20
mobile/src/api/auth.api.ts
Normal file
20
mobile/src/api/auth.api.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { LoginRequest, RegisterRequest, TokenResponse, User } from '../types/auth.types';
|
||||||
|
import { apiClient } from './client';
|
||||||
|
|
||||||
|
export const authApi = {
|
||||||
|
register: (data: RegisterRequest) =>
|
||||||
|
apiClient.post<TokenResponse>('/auth/register', data).then((r) => r.data),
|
||||||
|
|
||||||
|
login: (data: LoginRequest) =>
|
||||||
|
apiClient.post<TokenResponse>('/auth/login', data).then((r) => r.data),
|
||||||
|
|
||||||
|
refresh: (refreshToken: string) =>
|
||||||
|
apiClient
|
||||||
|
.post<TokenResponse>('/auth/refresh', { refresh_token: refreshToken })
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
logout: (refreshToken: string) =>
|
||||||
|
apiClient.post('/auth/logout', { refresh_token: refreshToken }),
|
||||||
|
|
||||||
|
me: () => apiClient.get<User>('/auth/me').then((r) => r.data),
|
||||||
|
};
|
||||||
10
mobile/src/api/championships.api.ts
Normal file
10
mobile/src/api/championships.api.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Championship } from '../types/championship.types';
|
||||||
|
import { apiClient } from './client';
|
||||||
|
|
||||||
|
export const championshipsApi = {
|
||||||
|
list: (params?: { status?: string; skip?: number; limit?: number }) =>
|
||||||
|
apiClient.get<Championship[]>('/championships', { params }).then((r) => r.data),
|
||||||
|
|
||||||
|
get: (id: string) =>
|
||||||
|
apiClient.get<Championship>(`/championships/${id}`).then((r) => r.data),
|
||||||
|
};
|
||||||
84
mobile/src/api/client.ts
Normal file
84
mobile/src/api/client.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||||
|
import { tokenStorage } from '../utils/tokenStorage';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:8000/api/v1';
|
||||||
|
|
||||||
|
export const apiClient = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach access token to every request
|
||||||
|
apiClient.interceptors.request.use(async (config) => {
|
||||||
|
const token = await tokenStorage.getAccessToken();
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
// On 401, try refreshing once then retry
|
||||||
|
let isRefreshing = false;
|
||||||
|
let failedQueue: Array<{
|
||||||
|
resolve: (value: string) => void;
|
||||||
|
reject: (reason?: unknown) => void;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
function processQueue(error: unknown, token: string | null = null) {
|
||||||
|
failedQueue.forEach((p) => {
|
||||||
|
if (error) {
|
||||||
|
p.reject(error);
|
||||||
|
} else {
|
||||||
|
p.resolve(token!);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
failedQueue = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error: AxiosError) => {
|
||||||
|
const originalRequest = error.config as InternalAxiosRequestConfig & {
|
||||||
|
_retry?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
|
if (isRefreshing) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
failedQueue.push({ resolve, reject });
|
||||||
|
}).then((token) => {
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||||
|
return apiClient(originalRequest);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
originalRequest._retry = true;
|
||||||
|
isRefreshing = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const refreshToken = await tokenStorage.getRefreshToken();
|
||||||
|
if (!refreshToken) throw new Error('No refresh token');
|
||||||
|
|
||||||
|
const { data } = await axios.post(`${API_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);
|
||||||
|
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${data.access_token}`;
|
||||||
|
return apiClient(originalRequest);
|
||||||
|
} catch (refreshError) {
|
||||||
|
processQueue(refreshError, null);
|
||||||
|
await tokenStorage.clearTokens();
|
||||||
|
return Promise.reject(refreshError);
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
13
mobile/src/api/registrations.api.ts
Normal file
13
mobile/src/api/registrations.api.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Registration, RegistrationCreate } from '../types/registration.types';
|
||||||
|
import { apiClient } from './client';
|
||||||
|
|
||||||
|
export const registrationsApi = {
|
||||||
|
submit: (data: RegistrationCreate) =>
|
||||||
|
apiClient.post<Registration>('/registrations', data).then((r) => r.data),
|
||||||
|
|
||||||
|
myRegistrations: () =>
|
||||||
|
apiClient.get<Registration[]>('/registrations/my').then((r) => r.data),
|
||||||
|
|
||||||
|
withdrawRegistration: (id: string) =>
|
||||||
|
apiClient.delete(`/registrations/${id}`),
|
||||||
|
};
|
||||||
6
mobile/src/api/users.api.ts
Normal file
6
mobile/src/api/users.api.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { apiClient } from './client';
|
||||||
|
|
||||||
|
export const usersApi = {
|
||||||
|
updatePushToken: (userId: string, expoPushToken: string) =>
|
||||||
|
apiClient.patch(`/users/${userId}/push-token`, { expo_push_token: expoPushToken }),
|
||||||
|
};
|
||||||
31
mobile/src/components/StatusBadge.tsx
Normal file
31
mobile/src/components/StatusBadge.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
const COLORS: Record<string, { bg: string; text: string }> = {
|
||||||
|
draft: { bg: '#F3F4F6', text: '#6B7280' },
|
||||||
|
open: { bg: '#D1FAE5', text: '#065F46' },
|
||||||
|
closed: { bg: '#FEE2E2', text: '#991B1B' },
|
||||||
|
completed: { bg: '#EDE9FE', text: '#5B21B6' },
|
||||||
|
submitted: { bg: '#FEF3C7', text: '#92400E' },
|
||||||
|
accepted: { bg: '#D1FAE5', text: '#065F46' },
|
||||||
|
rejected: { bg: '#FEE2E2', text: '#991B1B' },
|
||||||
|
waitlisted: { bg: '#DBEAFE', text: '#1E40AF' },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusBadge({ status }: Props) {
|
||||||
|
const colors = COLORS[status] ?? { bg: '#F3F4F6', text: '#374151' };
|
||||||
|
return (
|
||||||
|
<View style={[styles.badge, { backgroundColor: colors.bg }]}>
|
||||||
|
<Text style={[styles.text, { color: colors.text }]}>{status.toUpperCase()}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
badge: { borderRadius: 12, paddingHorizontal: 10, paddingVertical: 4, alignSelf: 'flex-start' },
|
||||||
|
text: { fontSize: 11, fontWeight: '700', letterSpacing: 0.5 },
|
||||||
|
});
|
||||||
55
mobile/src/hooks/useAuth.ts
Normal file
55
mobile/src/hooks/useAuth.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { authApi } from '../api/auth.api';
|
||||||
|
import { LoginRequest, RegisterRequest } from '../types/auth.types';
|
||||||
|
import { tokenStorage } from '../utils/tokenStorage';
|
||||||
|
import { useAuthStore } from '../store/auth.store';
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const { user, isLoading, setUser, setLoading } = useAuthStore();
|
||||||
|
|
||||||
|
const initialize = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const token = await tokenStorage.getAccessToken();
|
||||||
|
if (token) {
|
||||||
|
const me = await authApi.me();
|
||||||
|
setUser(me);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await tokenStorage.clearTokens();
|
||||||
|
setUser(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const login = async (data: LoginRequest) => {
|
||||||
|
const tokens = await authApi.login(data);
|
||||||
|
await tokenStorage.saveTokens(tokens.access_token, tokens.refresh_token);
|
||||||
|
const me = await authApi.me();
|
||||||
|
setUser(me);
|
||||||
|
return me;
|
||||||
|
};
|
||||||
|
|
||||||
|
const register = async (data: RegisterRequest) => {
|
||||||
|
const tokens = await authApi.register(data);
|
||||||
|
await tokenStorage.saveTokens(tokens.access_token, tokens.refresh_token);
|
||||||
|
const me = await authApi.me();
|
||||||
|
setUser(me);
|
||||||
|
return me;
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
const refreshToken = await tokenStorage.getRefreshToken();
|
||||||
|
if (refreshToken) {
|
||||||
|
try {
|
||||||
|
await authApi.logout(refreshToken);
|
||||||
|
} catch {
|
||||||
|
// Ignore errors on logout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await tokenStorage.clearTokens();
|
||||||
|
setUser(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { user, isLoading, initialize, login, register, logout };
|
||||||
|
}
|
||||||
42
mobile/src/hooks/usePushNotifications.ts
Normal file
42
mobile/src/hooks/usePushNotifications.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import * as Notifications from 'expo-notifications';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
import { usersApi } from '../api/users.api';
|
||||||
|
import { useAuthStore } from '../store/auth.store';
|
||||||
|
|
||||||
|
Notifications.setNotificationHandler({
|
||||||
|
handleNotification: async () => ({
|
||||||
|
shouldShowAlert: true,
|
||||||
|
shouldPlaySound: true,
|
||||||
|
shouldSetBadge: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function usePushNotifications() {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
|
||||||
|
const registerToken = async () => {
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
const { status: existingStatus } = await Notifications.getPermissionsAsync();
|
||||||
|
let finalStatus = existingStatus;
|
||||||
|
|
||||||
|
if (existingStatus !== 'granted') {
|
||||||
|
const { status } = await Notifications.requestPermissionsAsync();
|
||||||
|
finalStatus = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalStatus !== 'granted') return;
|
||||||
|
|
||||||
|
if (Platform.OS === 'android') {
|
||||||
|
await Notifications.setNotificationChannelAsync('default', {
|
||||||
|
name: 'default',
|
||||||
|
importance: Notifications.AndroidImportance.MAX,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenData = await Notifications.getExpoPushTokenAsync();
|
||||||
|
await usersApi.updatePushToken(user.id, tokenData.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { registerToken };
|
||||||
|
}
|
||||||
59
mobile/src/navigation/AppStack.tsx
Normal file
59
mobile/src/navigation/AppStack.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||||
|
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||||
|
import { ChampionshipListScreen } from '../screens/championships/ChampionshipListScreen';
|
||||||
|
import { ChampionshipDetailScreen } from '../screens/championships/ChampionshipDetailScreen';
|
||||||
|
import { RegistrationFormScreen } from '../screens/registration/RegistrationFormScreen';
|
||||||
|
import { ProfileScreen } from '../screens/profile/ProfileScreen';
|
||||||
|
import { usePushNotifications } from '../hooks/usePushNotifications';
|
||||||
|
|
||||||
|
export type AppStackParamList = {
|
||||||
|
Championships: undefined;
|
||||||
|
ChampionshipDetail: { id: string };
|
||||||
|
RegistrationForm: { championshipId: string; championshipTitle: string };
|
||||||
|
Profile: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Tab = createBottomTabNavigator();
|
||||||
|
const Stack = createNativeStackNavigator<AppStackParamList>();
|
||||||
|
|
||||||
|
function ChampionshipStack() {
|
||||||
|
return (
|
||||||
|
<Stack.Navigator>
|
||||||
|
<Stack.Screen
|
||||||
|
name="Championships"
|
||||||
|
component={ChampionshipListScreen}
|
||||||
|
options={{ title: 'Championships' }}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="ChampionshipDetail"
|
||||||
|
component={ChampionshipDetailScreen}
|
||||||
|
options={{ title: 'Details' }}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="RegistrationForm"
|
||||||
|
component={RegistrationFormScreen}
|
||||||
|
options={{ title: 'Register' }}
|
||||||
|
/>
|
||||||
|
</Stack.Navigator>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppStack() {
|
||||||
|
const { registerToken } = usePushNotifications();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
registerToken();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tab.Navigator>
|
||||||
|
<Tab.Screen
|
||||||
|
name="ChampionshipTab"
|
||||||
|
component={ChampionshipStack}
|
||||||
|
options={{ headerShown: false, title: 'Schedule' }}
|
||||||
|
/>
|
||||||
|
<Tab.Screen name="ProfileTab" component={ProfileScreen} options={{ title: 'Profile' }} />
|
||||||
|
</Tab.Navigator>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
mobile/src/navigation/AuthStack.tsx
Normal file
20
mobile/src/navigation/AuthStack.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||||
|
import { LoginScreen } from '../screens/auth/LoginScreen';
|
||||||
|
import { RegisterScreen } from '../screens/auth/RegisterScreen';
|
||||||
|
|
||||||
|
export type AuthStackParamList = {
|
||||||
|
Login: undefined;
|
||||||
|
Register: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Stack = createNativeStackNavigator<AuthStackParamList>();
|
||||||
|
|
||||||
|
export function AuthStack() {
|
||||||
|
return (
|
||||||
|
<Stack.Navigator screenOptions={{ headerShown: false }}>
|
||||||
|
<Stack.Screen name="Login" component={LoginScreen} />
|
||||||
|
<Stack.Screen name="Register" component={RegisterScreen} />
|
||||||
|
</Stack.Navigator>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
mobile/src/navigation/RootNavigator.tsx
Normal file
28
mobile/src/navigation/RootNavigator.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { ActivityIndicator, View } from 'react-native';
|
||||||
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
import { AuthStack } from './AuthStack';
|
||||||
|
import { AppStack } from './AppStack';
|
||||||
|
import { PendingApprovalScreen } from '../screens/auth/PendingApprovalScreen';
|
||||||
|
|
||||||
|
export function RootNavigator() {
|
||||||
|
const { user, isLoading, initialize } = useAuth();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initialize();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||||
|
<ActivityIndicator size="large" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) return <AuthStack />;
|
||||||
|
if (user.status === 'pending') return <PendingApprovalScreen />;
|
||||||
|
if (user.status === 'rejected') return <AuthStack />;
|
||||||
|
|
||||||
|
return <AppStack />;
|
||||||
|
}
|
||||||
17
mobile/src/queries/useChampionships.ts
Normal file
17
mobile/src/queries/useChampionships.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { championshipsApi } from '../api/championships.api';
|
||||||
|
|
||||||
|
export function useChampionships(status?: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['championships', status],
|
||||||
|
queryFn: () => championshipsApi.list({ status }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChampionshipDetail(id: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['championship', id],
|
||||||
|
queryFn: () => championshipsApi.get(id),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
}
|
||||||
30
mobile/src/queries/useRegistrations.ts
Normal file
30
mobile/src/queries/useRegistrations.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { registrationsApi } from '../api/registrations.api';
|
||||||
|
import { RegistrationCreate } from '../types/registration.types';
|
||||||
|
|
||||||
|
export function useMyRegistrations() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['registrations', 'my'],
|
||||||
|
queryFn: registrationsApi.myRegistrations,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSubmitRegistration() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: RegistrationCreate) => registrationsApi.submit(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['registrations', 'my'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWithdrawRegistration() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => registrationsApi.withdrawRegistration(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['registrations', 'my'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
104
mobile/src/screens/auth/LoginScreen.tsx
Normal file
104
mobile/src/screens/auth/LoginScreen.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
} from 'react-native';
|
||||||
|
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||||
|
import { useAuth } from '../../hooks/useAuth';
|
||||||
|
import { AuthStackParamList } from '../../navigation/AuthStack';
|
||||||
|
|
||||||
|
type Props = NativeStackScreenProps<AuthStackParamList, 'Login'>;
|
||||||
|
|
||||||
|
export function LoginScreen({ navigation }: Props) {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { login } = useAuth();
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
if (!email || !password) {
|
||||||
|
Alert.alert('Error', 'Please enter your email and password');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await login({ email, password });
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err?.code === 'ECONNABORTED'
|
||||||
|
? `Cannot reach server at ${process.env.EXPO_PUBLIC_API_URL}.\nMake sure your phone is on the same Wi-Fi and Windows Firewall allows port 8000.`
|
||||||
|
: err?.response?.status === 401
|
||||||
|
? 'Invalid email or password'
|
||||||
|
: `Error: ${err?.message ?? 'Unknown error'}`;
|
||||||
|
Alert.alert('Login failed', msg);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={styles.container}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
|
>
|
||||||
|
<Text style={styles.title}>Pole Dance Championships</Text>
|
||||||
|
<Text style={styles.subtitle}>Member Portal</Text>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="Email"
|
||||||
|
autoCapitalize="none"
|
||||||
|
keyboardType="email-address"
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="Password"
|
||||||
|
secureTextEntry
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, loading && styles.buttonDisabled]}
|
||||||
|
onPress={handleLogin}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>{loading ? 'Logging in...' : 'Log In'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity onPress={() => navigation.navigate('Register')}>
|
||||||
|
<Text style={styles.link}>Don't have an account? Register</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, justifyContent: 'center', padding: 24, backgroundColor: '#fff' },
|
||||||
|
title: { fontSize: 26, fontWeight: '700', textAlign: 'center', marginBottom: 4 },
|
||||||
|
subtitle: { fontSize: 14, color: '#666', textAlign: 'center', marginBottom: 40 },
|
||||||
|
input: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#ddd',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 14,
|
||||||
|
marginBottom: 14,
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
backgroundColor: '#6C3FC5',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
buttonDisabled: { opacity: 0.6 },
|
||||||
|
buttonText: { color: '#fff', fontWeight: '600', fontSize: 16 },
|
||||||
|
link: { textAlign: 'center', color: '#6C3FC5', fontSize: 14 },
|
||||||
|
});
|
||||||
42
mobile/src/screens/auth/PendingApprovalScreen.tsx
Normal file
42
mobile/src/screens/auth/PendingApprovalScreen.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import { useAuth } from '../../hooks/useAuth';
|
||||||
|
|
||||||
|
export function PendingApprovalScreen() {
|
||||||
|
const { logout } = useAuth();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.emoji}>⏳</Text>
|
||||||
|
<Text style={styles.title}>Account Pending Approval</Text>
|
||||||
|
<Text style={styles.body}>
|
||||||
|
Your registration has been received. An organizer will review and approve your account.
|
||||||
|
You will receive a notification once approved.
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity style={styles.button} onPress={logout}>
|
||||||
|
<Text style={styles.buttonText}>Log Out</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 32,
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
},
|
||||||
|
emoji: { fontSize: 60, marginBottom: 24 },
|
||||||
|
title: { fontSize: 22, fontWeight: '700', textAlign: 'center', marginBottom: 16 },
|
||||||
|
body: { fontSize: 15, color: '#555', textAlign: 'center', lineHeight: 22, marginBottom: 40 },
|
||||||
|
button: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#6C3FC5',
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 32,
|
||||||
|
},
|
||||||
|
buttonText: { color: '#6C3FC5', fontWeight: '600' },
|
||||||
|
});
|
||||||
116
mobile/src/screens/auth/RegisterScreen.tsx
Normal file
116
mobile/src/screens/auth/RegisterScreen.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
} from 'react-native';
|
||||||
|
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||||
|
import { useAuth } from '../../hooks/useAuth';
|
||||||
|
import { AuthStackParamList } from '../../navigation/AuthStack';
|
||||||
|
|
||||||
|
type Props = NativeStackScreenProps<AuthStackParamList, 'Register'>;
|
||||||
|
|
||||||
|
export function RegisterScreen({ navigation }: Props) {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [fullName, setFullName] = useState('');
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { register } = useAuth();
|
||||||
|
|
||||||
|
const handleRegister = async () => {
|
||||||
|
if (!email || !password || !fullName) {
|
||||||
|
Alert.alert('Error', 'Email, password and full name are required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await register({ email, password, full_name: fullName, phone: phone || undefined });
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err?.response?.data?.detail ?? 'Registration failed';
|
||||||
|
Alert.alert('Error', msg);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
|
>
|
||||||
|
<ScrollView contentContainerStyle={styles.container}>
|
||||||
|
<Text style={styles.title}>Create Account</Text>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="Full Name"
|
||||||
|
value={fullName}
|
||||||
|
onChangeText={setFullName}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="Email"
|
||||||
|
autoCapitalize="none"
|
||||||
|
keyboardType="email-address"
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="Password"
|
||||||
|
secureTextEntry
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="Phone (optional)"
|
||||||
|
keyboardType="phone-pad"
|
||||||
|
value={phone}
|
||||||
|
onChangeText={setPhone}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, loading && styles.buttonDisabled]}
|
||||||
|
onPress={handleRegister}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>{loading ? 'Registering...' : 'Register'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity onPress={() => navigation.goBack()}>
|
||||||
|
<Text style={styles.link}>Already have an account? Log In</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flexGrow: 1, justifyContent: 'center', padding: 24, backgroundColor: '#fff' },
|
||||||
|
title: { fontSize: 26, fontWeight: '700', textAlign: 'center', marginBottom: 32 },
|
||||||
|
input: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#ddd',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 14,
|
||||||
|
marginBottom: 14,
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
backgroundColor: '#6C3FC5',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
buttonDisabled: { opacity: 0.6 },
|
||||||
|
buttonText: { color: '#fff', fontWeight: '600', fontSize: 16 },
|
||||||
|
link: { textAlign: 'center', color: '#6C3FC5', fontSize: 14 },
|
||||||
|
});
|
||||||
118
mobile/src/screens/championships/ChampionshipDetailScreen.tsx
Normal file
118
mobile/src/screens/championships/ChampionshipDetailScreen.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Image,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||||
|
import { useChampionshipDetail } from '../../queries/useChampionships';
|
||||||
|
import { StatusBadge } from '../../components/StatusBadge';
|
||||||
|
import { formatDate } from '../../utils/dateFormatters';
|
||||||
|
import { isRegistrationOpen } from '../../utils/dateFormatters';
|
||||||
|
import { AppStackParamList } from '../../navigation/AppStack';
|
||||||
|
|
||||||
|
type Props = NativeStackScreenProps<AppStackParamList, 'ChampionshipDetail'>;
|
||||||
|
|
||||||
|
export function ChampionshipDetailScreen({ route, navigation }: Props) {
|
||||||
|
const { id } = route.params;
|
||||||
|
const { data: champ, isLoading } = useChampionshipDetail(id);
|
||||||
|
|
||||||
|
if (isLoading || !champ) {
|
||||||
|
return (
|
||||||
|
<View style={styles.center}>
|
||||||
|
<ActivityIndicator size="large" color="#6C3FC5" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const canRegister = isRegistrationOpen(
|
||||||
|
champ.registration_open_at,
|
||||||
|
champ.registration_close_at,
|
||||||
|
champ.status,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={styles.container}>
|
||||||
|
{champ.image_url ? (
|
||||||
|
<Image source={{ uri: champ.image_url }} style={styles.image} />
|
||||||
|
) : (
|
||||||
|
<View style={styles.imagePlaceholder} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.body}>
|
||||||
|
<View style={styles.row}>
|
||||||
|
<StatusBadge status={champ.status} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.title}>{champ.title}</Text>
|
||||||
|
|
||||||
|
{champ.event_date && (
|
||||||
|
<View style={styles.detail}>
|
||||||
|
<Text style={styles.detailLabel}>Date</Text>
|
||||||
|
<Text style={styles.detailValue}>{formatDate(champ.event_date)}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{champ.location && (
|
||||||
|
<View style={styles.detail}>
|
||||||
|
<Text style={styles.detailLabel}>Location</Text>
|
||||||
|
<Text style={styles.detailValue}>{champ.location}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{champ.registration_close_at && (
|
||||||
|
<View style={styles.detail}>
|
||||||
|
<Text style={styles.detailLabel}>Registration deadline</Text>
|
||||||
|
<Text style={styles.detailValue}>{formatDate(champ.registration_close_at)}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{champ.description && (
|
||||||
|
<Text style={styles.description}>{champ.description}</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, !canRegister && styles.buttonDisabled]}
|
||||||
|
disabled={!canRegister}
|
||||||
|
onPress={() =>
|
||||||
|
navigation.navigate('RegistrationForm', {
|
||||||
|
championshipId: champ.id,
|
||||||
|
championshipTitle: champ.title,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>
|
||||||
|
{canRegister ? 'Register for this Championship' : 'Registration Closed'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: '#fff' },
|
||||||
|
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
|
||||||
|
image: { width: '100%', height: 220 },
|
||||||
|
imagePlaceholder: { width: '100%', height: 120, backgroundColor: '#E9D5FF' },
|
||||||
|
body: { padding: 20, gap: 12 },
|
||||||
|
row: { flexDirection: 'row' },
|
||||||
|
title: { fontSize: 22, fontWeight: '800', marginTop: 4 },
|
||||||
|
detail: { gap: 2 },
|
||||||
|
detailLabel: { fontSize: 12, color: '#888', fontWeight: '500', textTransform: 'uppercase' },
|
||||||
|
detailValue: { fontSize: 15, color: '#222' },
|
||||||
|
description: { fontSize: 15, color: '#444', lineHeight: 22, marginTop: 8 },
|
||||||
|
button: {
|
||||||
|
backgroundColor: '#6C3FC5',
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 16,
|
||||||
|
},
|
||||||
|
buttonDisabled: { backgroundColor: '#C4B5FD' },
|
||||||
|
buttonText: { color: '#fff', fontWeight: '700', fontSize: 16 },
|
||||||
|
});
|
||||||
91
mobile/src/screens/championships/ChampionshipListScreen.tsx
Normal file
91
mobile/src/screens/championships/ChampionshipListScreen.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
FlatList,
|
||||||
|
Image,
|
||||||
|
RefreshControl,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||||
|
import { useChampionships } from '../../queries/useChampionships';
|
||||||
|
import { StatusBadge } from '../../components/StatusBadge';
|
||||||
|
import { Championship } from '../../types/championship.types';
|
||||||
|
import { formatDate } from '../../utils/dateFormatters';
|
||||||
|
import { AppStackParamList } from '../../navigation/AppStack';
|
||||||
|
|
||||||
|
type Props = NativeStackScreenProps<AppStackParamList, 'Championships'>;
|
||||||
|
|
||||||
|
export function ChampionshipListScreen({ navigation }: Props) {
|
||||||
|
const { data, isLoading, refetch, isRefetching } = useChampionships();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View style={styles.center}>
|
||||||
|
<ActivityIndicator size="large" color="#6C3FC5" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderItem = ({ item }: { item: Championship }) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.card}
|
||||||
|
onPress={() => navigation.navigate('ChampionshipDetail', { id: item.id })}
|
||||||
|
>
|
||||||
|
{item.image_url ? (
|
||||||
|
<Image source={{ uri: item.image_url }} style={styles.image} />
|
||||||
|
) : (
|
||||||
|
<View style={styles.imagePlaceholder} />
|
||||||
|
)}
|
||||||
|
<View style={styles.cardBody}>
|
||||||
|
<Text style={styles.cardTitle} numberOfLines={2}>
|
||||||
|
{item.title}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.cardDate}>{formatDate(item.event_date)}</Text>
|
||||||
|
{item.location ? <Text style={styles.cardLocation}>{item.location}</Text> : null}
|
||||||
|
<StatusBadge status={item.status} />
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlatList
|
||||||
|
data={data ?? []}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
renderItem={renderItem}
|
||||||
|
contentContainerStyle={styles.list}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={isRefetching} onRefresh={refetch} tintColor="#6C3FC5" />
|
||||||
|
}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View style={styles.center}>
|
||||||
|
<Text style={styles.emptyText}>No championships scheduled yet</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
center: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 32 },
|
||||||
|
list: { padding: 16, gap: 12 },
|
||||||
|
card: {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
borderRadius: 12,
|
||||||
|
overflow: 'hidden',
|
||||||
|
elevation: 2,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 4,
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
},
|
||||||
|
image: { width: '100%', height: 160 },
|
||||||
|
imagePlaceholder: { width: '100%', height: 100, backgroundColor: '#E9D5FF' },
|
||||||
|
cardBody: { padding: 14, gap: 6 },
|
||||||
|
cardTitle: { fontSize: 17, fontWeight: '700' },
|
||||||
|
cardDate: { fontSize: 13, color: '#6C3FC5', fontWeight: '500' },
|
||||||
|
cardLocation: { fontSize: 13, color: '#555' },
|
||||||
|
emptyText: { color: '#888', fontSize: 15 },
|
||||||
|
});
|
||||||
124
mobile/src/screens/profile/ProfileScreen.tsx
Normal file
124
mobile/src/screens/profile/ProfileScreen.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useAuth } from '../../hooks/useAuth';
|
||||||
|
import { useMyRegistrations, useWithdrawRegistration } from '../../queries/useRegistrations';
|
||||||
|
import { useChampionshipDetail } from '../../queries/useChampionships';
|
||||||
|
import { StatusBadge } from '../../components/StatusBadge';
|
||||||
|
import { formatDate } from '../../utils/dateFormatters';
|
||||||
|
import { Registration } from '../../types/registration.types';
|
||||||
|
|
||||||
|
function RegistrationItem({ reg }: { reg: Registration }) {
|
||||||
|
const { data: champ } = useChampionshipDetail(reg.championship_id);
|
||||||
|
const { mutate: withdraw } = useWithdrawRegistration();
|
||||||
|
|
||||||
|
const handleWithdraw = () => {
|
||||||
|
Alert.alert('Withdraw Registration', 'Are you sure?', [
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Withdraw',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () => withdraw(reg.id),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.regCard}>
|
||||||
|
<Text style={styles.regTitle} numberOfLines={2}>
|
||||||
|
{champ?.title ?? 'Loading...'}
|
||||||
|
</Text>
|
||||||
|
{champ?.event_date && (
|
||||||
|
<Text style={styles.regDate}>{formatDate(champ.event_date)}</Text>
|
||||||
|
)}
|
||||||
|
<Text style={styles.regCategory}>
|
||||||
|
{reg.category} · {reg.level}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.regFooter}>
|
||||||
|
<StatusBadge status={reg.status} />
|
||||||
|
{reg.status === 'submitted' && (
|
||||||
|
<TouchableOpacity onPress={handleWithdraw}>
|
||||||
|
<Text style={styles.withdrawText}>Withdraw</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileScreen() {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const { data: registrations, isLoading } = useMyRegistrations();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={styles.container}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.name}>{user?.full_name}</Text>
|
||||||
|
<Text style={styles.email}>{user?.email}</Text>
|
||||||
|
<StatusBadge status={user?.role ?? 'member'} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.sectionTitle}>My Registrations</Text>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<ActivityIndicator color="#6C3FC5" style={{ marginTop: 24 }} />
|
||||||
|
) : registrations?.length === 0 ? (
|
||||||
|
<Text style={styles.emptyText}>No registrations yet</Text>
|
||||||
|
) : (
|
||||||
|
registrations?.map((reg) => <RegistrationItem key={reg.id} reg={reg} />)
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.logoutButton} onPress={logout}>
|
||||||
|
<Text style={styles.logoutText}>Log Out</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: '#F9FAFB' },
|
||||||
|
header: {
|
||||||
|
backgroundColor: '#6C3FC5',
|
||||||
|
padding: 24,
|
||||||
|
paddingTop: 48,
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
name: { fontSize: 22, fontWeight: '700', color: '#fff' },
|
||||||
|
email: { fontSize: 14, color: '#DDD6FE' },
|
||||||
|
sectionTitle: { fontSize: 16, fontWeight: '700', margin: 16, color: '#333' },
|
||||||
|
regCard: {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
marginHorizontal: 16,
|
||||||
|
marginBottom: 12,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 16,
|
||||||
|
gap: 6,
|
||||||
|
elevation: 1,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 3,
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
},
|
||||||
|
regTitle: { fontSize: 15, fontWeight: '700' },
|
||||||
|
regDate: { fontSize: 13, color: '#6C3FC5' },
|
||||||
|
regCategory: { fontSize: 13, color: '#555' },
|
||||||
|
regFooter: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
|
||||||
|
withdrawText: { fontSize: 13, color: '#DC2626' },
|
||||||
|
emptyText: { textAlign: 'center', color: '#888', marginTop: 24 },
|
||||||
|
logoutButton: {
|
||||||
|
margin: 24,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#6C3FC5',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
logoutText: { color: '#6C3FC5', fontWeight: '600' },
|
||||||
|
});
|
||||||
140
mobile/src/screens/registration/RegistrationFormScreen.tsx
Normal file
140
mobile/src/screens/registration/RegistrationFormScreen.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||||
|
import { AppStackParamList } from '../../navigation/AppStack';
|
||||||
|
import { useSubmitRegistration } from '../../queries/useRegistrations';
|
||||||
|
|
||||||
|
type Props = NativeStackScreenProps<AppStackParamList, 'RegistrationForm'>;
|
||||||
|
|
||||||
|
const CATEGORIES = ['Pole Sport', 'Pole Art', 'Pole Exotic', 'Doubles', 'Kids'];
|
||||||
|
const LEVELS = ['Beginner', 'Amateur', 'Semi-Pro', 'Professional'];
|
||||||
|
|
||||||
|
export function RegistrationFormScreen({ route, navigation }: Props) {
|
||||||
|
const { championshipId, championshipTitle } = route.params;
|
||||||
|
const [category, setCategory] = useState('');
|
||||||
|
const [level, setLevel] = useState('');
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
|
||||||
|
const { mutate: submit, isPending } = useSubmitRegistration();
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!category || !level) {
|
||||||
|
Alert.alert('Required', 'Please select a category and level');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submit(
|
||||||
|
{ championship_id: championshipId, category, level, notes: notes || undefined },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
Alert.alert('Submitted!', 'Your registration has been received. You will be notified when the participant list is published.', [
|
||||||
|
{ text: 'OK', onPress: () => navigation.goBack() },
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
const msg = err?.response?.data?.detail ?? 'Submission failed';
|
||||||
|
Alert.alert('Error', msg);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
|
>
|
||||||
|
<ScrollView contentContainerStyle={styles.container}>
|
||||||
|
<Text style={styles.champTitle}>{championshipTitle}</Text>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Category *</Text>
|
||||||
|
<View style={styles.chips}>
|
||||||
|
{CATEGORIES.map((c) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={c}
|
||||||
|
style={[styles.chip, category === c && styles.chipSelected]}
|
||||||
|
onPress={() => setCategory(c)}
|
||||||
|
>
|
||||||
|
<Text style={[styles.chipText, category === c && styles.chipTextSelected]}>{c}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Level *</Text>
|
||||||
|
<View style={styles.chips}>
|
||||||
|
{LEVELS.map((l) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={l}
|
||||||
|
style={[styles.chip, level === l && styles.chipSelected]}
|
||||||
|
onPress={() => setLevel(l)}
|
||||||
|
>
|
||||||
|
<Text style={[styles.chipText, level === l && styles.chipTextSelected]}>{l}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Additional Notes</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.textarea}
|
||||||
|
placeholder="Any additional information for the organizers..."
|
||||||
|
multiline
|
||||||
|
numberOfLines={4}
|
||||||
|
value={notes}
|
||||||
|
onChangeText={setNotes}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, isPending && styles.buttonDisabled]}
|
||||||
|
onPress={handleSubmit}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>{isPending ? 'Submitting...' : 'Submit Registration'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { padding: 20, backgroundColor: '#fff', flexGrow: 1 },
|
||||||
|
champTitle: { fontSize: 18, fontWeight: '700', marginBottom: 24, color: '#333' },
|
||||||
|
label: { fontSize: 14, fontWeight: '600', color: '#444', marginBottom: 8, marginTop: 16 },
|
||||||
|
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
|
||||||
|
chip: {
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: '#C4B5FD',
|
||||||
|
borderRadius: 20,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 8,
|
||||||
|
},
|
||||||
|
chipSelected: { backgroundColor: '#6C3FC5', borderColor: '#6C3FC5' },
|
||||||
|
chipText: { fontSize: 13, color: '#6C3FC5' },
|
||||||
|
chipTextSelected: { color: '#fff', fontWeight: '600' },
|
||||||
|
textarea: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#ddd',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
fontSize: 15,
|
||||||
|
textAlignVertical: 'top',
|
||||||
|
minHeight: 100,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
backgroundColor: '#6C3FC5',
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 32,
|
||||||
|
},
|
||||||
|
buttonDisabled: { opacity: 0.6 },
|
||||||
|
buttonText: { color: '#fff', fontWeight: '700', fontSize: 16 },
|
||||||
|
});
|
||||||
16
mobile/src/store/auth.store.ts
Normal file
16
mobile/src/store/auth.store.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { User } from '../types/auth.types';
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: User | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
setUser: (user: User | null) => void;
|
||||||
|
setLoading: (loading: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>((set) => ({
|
||||||
|
user: null,
|
||||||
|
isLoading: true,
|
||||||
|
setUser: (user) => set({ user }),
|
||||||
|
setLoading: (isLoading) => set({ isLoading }),
|
||||||
|
}));
|
||||||
26
mobile/src/types/auth.types.ts
Normal file
26
mobile/src/types/auth.types.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
full_name: string;
|
||||||
|
phone: string | null;
|
||||||
|
role: 'member' | 'organizer' | 'admin';
|
||||||
|
status: 'pending' | 'approved' | 'rejected';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenResponse {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
token_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginRequest {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterRequest {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
full_name: string;
|
||||||
|
phone?: string;
|
||||||
|
}
|
||||||
14
mobile/src/types/championship.types.ts
Normal file
14
mobile/src/types/championship.types.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
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;
|
||||||
|
status: 'draft' | 'open' | 'closed' | 'completed';
|
||||||
|
source: 'manual' | 'instagram';
|
||||||
|
image_url: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
18
mobile/src/types/registration.types.ts
Normal file
18
mobile/src/types/registration.types.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export interface Registration {
|
||||||
|
id: string;
|
||||||
|
championship_id: string;
|
||||||
|
user_id: string;
|
||||||
|
category: string | null;
|
||||||
|
level: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
status: 'submitted' | 'accepted' | 'rejected' | 'waitlisted';
|
||||||
|
submitted_at: string;
|
||||||
|
decided_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegistrationCreate {
|
||||||
|
championship_id: string;
|
||||||
|
category?: string;
|
||||||
|
level?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
33
mobile/src/utils/dateFormatters.ts
Normal file
33
mobile/src/utils/dateFormatters.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
export function formatDate(iso: string | null): string {
|
||||||
|
if (!iso) return 'TBA';
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleDateString('en-GB', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(iso: string | null): string {
|
||||||
|
if (!iso) return 'TBA';
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleString('en-GB', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRegistrationOpen(
|
||||||
|
openAt: string | null,
|
||||||
|
closeAt: string | null,
|
||||||
|
status: string,
|
||||||
|
): boolean {
|
||||||
|
if (status !== 'open') return false;
|
||||||
|
const now = Date.now();
|
||||||
|
if (openAt && new Date(openAt).getTime() > now) return false;
|
||||||
|
if (closeAt && new Date(closeAt).getTime() < now) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
19
mobile/src/utils/tokenStorage.ts
Normal file
19
mobile/src/utils/tokenStorage.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
|
||||||
|
const ACCESS_TOKEN_KEY = 'access_token';
|
||||||
|
const REFRESH_TOKEN_KEY = 'refresh_token';
|
||||||
|
|
||||||
|
export const tokenStorage = {
|
||||||
|
saveTokens: async (access: string, refresh: string): Promise<void> => {
|
||||||
|
await SecureStore.setItemAsync(ACCESS_TOKEN_KEY, access);
|
||||||
|
await SecureStore.setItemAsync(REFRESH_TOKEN_KEY, refresh);
|
||||||
|
},
|
||||||
|
getAccessToken: (): Promise<string | null> =>
|
||||||
|
SecureStore.getItemAsync(ACCESS_TOKEN_KEY),
|
||||||
|
getRefreshToken: (): Promise<string | null> =>
|
||||||
|
SecureStore.getItemAsync(REFRESH_TOKEN_KEY),
|
||||||
|
clearTokens: async (): Promise<void> => {
|
||||||
|
await SecureStore.deleteItemAsync(ACCESS_TOKEN_KEY);
|
||||||
|
await SecureStore.deleteItemAsync(REFRESH_TOKEN_KEY);
|
||||||
|
},
|
||||||
|
};
|
||||||
17
mobile/tsconfig.json
Normal file
17
mobile/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@api/*": ["src/api/*"],
|
||||||
|
"@store/*": ["src/store/*"],
|
||||||
|
"@screens/*": ["src/screens/*"],
|
||||||
|
"@components/*": ["src/components/*"],
|
||||||
|
"@hooks/*": ["src/hooks/*"],
|
||||||
|
"@queries/*": ["src/queries/*"],
|
||||||
|
"@types/*": ["src/types/*"],
|
||||||
|
"@utils/*": ["src/utils/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user