Merge pull request #16 from fullsizemalt/claude/add-mobile-handoff-doc-01C2LQpdUfs85pWjnfT86XBH
Add React Native mobile handoff document
This commit is contained in:
commit
79edb05c20
10 changed files with 2909 additions and 17 deletions
565
mobile/app/(auth)/forgot-password.tsx
Normal file
565
mobile/app/(auth)/forgot-password.tsx
Normal file
|
|
@ -0,0 +1,565 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { authApi, handleApiError } from '../lib/api';
|
||||
|
||||
type Step = 'email' | 'sent' | 'reset';
|
||||
|
||||
export default function ForgotPasswordScreen() {
|
||||
const router = useRouter();
|
||||
|
||||
const [step, setStep] = useState<Step>('email');
|
||||
const [email, setEmail] = useState('');
|
||||
const [resetToken, setResetToken] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [validationErrors, setValidationErrors] = useState<{
|
||||
email?: string;
|
||||
resetToken?: string;
|
||||
newPassword?: string;
|
||||
confirmPassword?: string;
|
||||
}>({});
|
||||
|
||||
// Validate email format
|
||||
const validateEmail = (email: string): boolean => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
};
|
||||
|
||||
// Validate email step
|
||||
const validateEmailStep = (): boolean => {
|
||||
const errors: { email?: string } = {};
|
||||
|
||||
if (!email.trim()) {
|
||||
errors.email = 'Email is required';
|
||||
} else if (!validateEmail(email)) {
|
||||
errors.email = 'Please enter a valid email';
|
||||
}
|
||||
|
||||
setValidationErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
// Validate reset step
|
||||
const validateResetStep = (): boolean => {
|
||||
const errors: {
|
||||
resetToken?: string;
|
||||
newPassword?: string;
|
||||
confirmPassword?: string;
|
||||
} = {};
|
||||
|
||||
if (!resetToken.trim()) {
|
||||
errors.resetToken = 'Reset code is required';
|
||||
}
|
||||
|
||||
if (!newPassword) {
|
||||
errors.newPassword = 'New password is required';
|
||||
} else if (newPassword.length < 8) {
|
||||
errors.newPassword = 'Password must be at least 8 characters';
|
||||
}
|
||||
|
||||
if (!confirmPassword) {
|
||||
errors.confirmPassword = 'Please confirm your password';
|
||||
} else if (newPassword !== confirmPassword) {
|
||||
errors.confirmPassword = 'Passwords do not match';
|
||||
}
|
||||
|
||||
setValidationErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
// Handle send reset email
|
||||
const handleSendReset = async () => {
|
||||
setError(null);
|
||||
|
||||
if (!validateEmailStep()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await authApi.forgotPassword({ email: email.trim().toLowerCase() });
|
||||
setStep('sent');
|
||||
} catch (err) {
|
||||
setError(handleApiError(err));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle reset password
|
||||
const handleResetPassword = async () => {
|
||||
setError(null);
|
||||
|
||||
if (!validateResetStep()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await authApi.resetPassword({
|
||||
token: resetToken.trim(),
|
||||
newPassword,
|
||||
});
|
||||
// Navigate to login with success message
|
||||
router.replace('/(auth)/login');
|
||||
} catch (err) {
|
||||
setError(handleApiError(err));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Navigate back to login
|
||||
const handleBackToLogin = () => {
|
||||
router.push('/(auth)/login');
|
||||
};
|
||||
|
||||
// Render email step
|
||||
const renderEmailStep = () => (
|
||||
<>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Forgot Password</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Enter your email address and we'll send you a link to reset your password.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.form}>
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>Email</Text>
|
||||
<TextInput
|
||||
style={[styles.input, validationErrors.email && styles.inputError]}
|
||||
placeholder="Enter your email"
|
||||
placeholderTextColor="#999"
|
||||
value={email}
|
||||
onChangeText={(text) => {
|
||||
setEmail(text);
|
||||
if (validationErrors.email) {
|
||||
setValidationErrors((prev) => ({ ...prev, email: undefined }));
|
||||
}
|
||||
}}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoComplete="email"
|
||||
editable={!isLoading}
|
||||
/>
|
||||
{validationErrors.email && (
|
||||
<Text style={styles.errorText}>{validationErrors.email}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{error && (
|
||||
<View style={styles.apiError}>
|
||||
<Text style={styles.apiErrorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, isLoading && styles.buttonDisabled]}
|
||||
onPress={handleSendReset}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color="#FFFFFF" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Send Reset Link</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={handleBackToLogin}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Text style={styles.backButtonText}>Back to Sign In</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
|
||||
// Render sent confirmation step
|
||||
const renderSentStep = () => (
|
||||
<>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.iconContainer}>
|
||||
<Text style={styles.checkIcon}>✓</Text>
|
||||
</View>
|
||||
<Text style={styles.title}>Check Your Email</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
We've sent a password reset link to{'\n'}
|
||||
<Text style={styles.emailHighlight}>{email}</Text>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.form}>
|
||||
<View style={styles.instructionsContainer}>
|
||||
<Text style={styles.instructionsTitle}>What's next?</Text>
|
||||
<Text style={styles.instructionsText}>
|
||||
1. Check your email inbox{'\n'}
|
||||
2. Click the reset link in the email{'\n'}
|
||||
3. Enter your reset code below
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={() => setStep('reset')}
|
||||
>
|
||||
<Text style={styles.buttonText}>I Have My Reset Code</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.resendButton}
|
||||
onPress={handleSendReset}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color="#007AFF" />
|
||||
) : (
|
||||
<Text style={styles.resendButtonText}>Resend Email</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={handleBackToLogin}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Text style={styles.backButtonText}>Back to Sign In</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
|
||||
// Render reset password step
|
||||
const renderResetStep = () => (
|
||||
<>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Reset Password</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Enter the code from your email and create a new password.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.form}>
|
||||
{/* Reset Token Input */}
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>Reset Code</Text>
|
||||
<TextInput
|
||||
style={[styles.input, validationErrors.resetToken && styles.inputError]}
|
||||
placeholder="Enter reset code"
|
||||
placeholderTextColor="#999"
|
||||
value={resetToken}
|
||||
onChangeText={(text) => {
|
||||
setResetToken(text);
|
||||
if (validationErrors.resetToken) {
|
||||
setValidationErrors((prev) => ({ ...prev, resetToken: undefined }));
|
||||
}
|
||||
}}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
editable={!isLoading}
|
||||
/>
|
||||
{validationErrors.resetToken && (
|
||||
<Text style={styles.errorText}>{validationErrors.resetToken}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* New Password Input */}
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>New Password</Text>
|
||||
<View style={styles.passwordContainer}>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
styles.passwordInput,
|
||||
validationErrors.newPassword && styles.inputError,
|
||||
]}
|
||||
placeholder="Create new password"
|
||||
placeholderTextColor="#999"
|
||||
value={newPassword}
|
||||
onChangeText={(text) => {
|
||||
setNewPassword(text);
|
||||
if (validationErrors.newPassword) {
|
||||
setValidationErrors((prev) => ({ ...prev, newPassword: undefined }));
|
||||
}
|
||||
}}
|
||||
secureTextEntry={!showPassword}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
editable={!isLoading}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={styles.showPasswordButton}
|
||||
onPress={() => setShowPassword(!showPassword)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Text style={styles.showPasswordText}>
|
||||
{showPassword ? 'Hide' : 'Show'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{validationErrors.newPassword && (
|
||||
<Text style={styles.errorText}>{validationErrors.newPassword}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Confirm Password Input */}
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>Confirm New Password</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
validationErrors.confirmPassword && styles.inputError,
|
||||
]}
|
||||
placeholder="Confirm new password"
|
||||
placeholderTextColor="#999"
|
||||
value={confirmPassword}
|
||||
onChangeText={(text) => {
|
||||
setConfirmPassword(text);
|
||||
if (validationErrors.confirmPassword) {
|
||||
setValidationErrors((prev) => ({
|
||||
...prev,
|
||||
confirmPassword: undefined,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
secureTextEntry={!showPassword}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
editable={!isLoading}
|
||||
/>
|
||||
{validationErrors.confirmPassword && (
|
||||
<Text style={styles.errorText}>{validationErrors.confirmPassword}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{error && (
|
||||
<View style={styles.apiError}>
|
||||
<Text style={styles.apiErrorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, isLoading && styles.buttonDisabled]}
|
||||
onPress={handleResetPassword}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color="#FFFFFF" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Reset Password</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={() => setStep('sent')}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Text style={styles.backButtonText}>Go Back</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.container}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{step === 'email' && renderEmailStep()}
|
||||
{step === 'sent' && renderSentStep()}
|
||||
{step === 'reset' && renderResetStep()}
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
paddingHorizontal: 24,
|
||||
paddingTop: 60,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
header: {
|
||||
marginBottom: 32,
|
||||
alignItems: 'center',
|
||||
},
|
||||
iconContainer: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
backgroundColor: '#E8F5E9',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
checkIcon: {
|
||||
fontSize: 32,
|
||||
color: '#34C759',
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
color: '#333333',
|
||||
marginBottom: 12,
|
||||
textAlign: 'center',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: '#666666',
|
||||
textAlign: 'center',
|
||||
lineHeight: 24,
|
||||
},
|
||||
emailHighlight: {
|
||||
fontWeight: '600',
|
||||
color: '#007AFF',
|
||||
},
|
||||
form: {
|
||||
flex: 1,
|
||||
},
|
||||
inputContainer: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#333333',
|
||||
marginBottom: 8,
|
||||
},
|
||||
input: {
|
||||
height: 50,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E0E0E0',
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 16,
|
||||
fontSize: 16,
|
||||
color: '#333333',
|
||||
backgroundColor: '#FAFAFA',
|
||||
},
|
||||
inputError: {
|
||||
borderColor: '#FF3B30',
|
||||
},
|
||||
passwordContainer: {
|
||||
position: 'relative',
|
||||
},
|
||||
passwordInput: {
|
||||
paddingRight: 60,
|
||||
},
|
||||
showPasswordButton: {
|
||||
position: 'absolute',
|
||||
right: 16,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
showPasswordText: {
|
||||
fontSize: 14,
|
||||
color: '#007AFF',
|
||||
fontWeight: '600',
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 12,
|
||||
color: '#FF3B30',
|
||||
marginTop: 4,
|
||||
},
|
||||
apiError: {
|
||||
backgroundColor: '#FFF0F0',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
marginBottom: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#FF3B30',
|
||||
},
|
||||
apiErrorText: {
|
||||
fontSize: 14,
|
||||
color: '#FF3B30',
|
||||
textAlign: 'center',
|
||||
},
|
||||
instructionsContainer: {
|
||||
backgroundColor: '#F5F5F5',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
marginBottom: 24,
|
||||
},
|
||||
instructionsTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#333333',
|
||||
marginBottom: 8,
|
||||
},
|
||||
instructionsText: {
|
||||
fontSize: 14,
|
||||
color: '#666666',
|
||||
lineHeight: 22,
|
||||
},
|
||||
button: {
|
||||
height: 50,
|
||||
backgroundColor: '#007AFF',
|
||||
borderRadius: 8,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
buttonDisabled: {
|
||||
backgroundColor: '#B0B0B0',
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
resendButton: {
|
||||
height: 50,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#007AFF',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
resendButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#007AFF',
|
||||
},
|
||||
backButton: {
|
||||
height: 50,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
backButtonText: {
|
||||
fontSize: 14,
|
||||
color: '#666666',
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
354
mobile/app/(auth)/login.tsx
Normal file
354
mobile/app/(auth)/login.tsx
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useAuthStore } from '../lib/auth-store';
|
||||
|
||||
export default function LoginScreen() {
|
||||
const router = useRouter();
|
||||
const { login, isLoading, error, clearError } = useAuthStore();
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [validationErrors, setValidationErrors] = useState<{
|
||||
email?: string;
|
||||
password?: string;
|
||||
}>({});
|
||||
|
||||
// Validate email format
|
||||
const validateEmail = (email: string): boolean => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
};
|
||||
|
||||
// Validate form
|
||||
const validateForm = (): boolean => {
|
||||
const errors: { email?: string; password?: string } = {};
|
||||
|
||||
if (!email.trim()) {
|
||||
errors.email = 'Email is required';
|
||||
} else if (!validateEmail(email)) {
|
||||
errors.email = 'Please enter a valid email';
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
errors.password = 'Password is required';
|
||||
} else if (password.length < 6) {
|
||||
errors.password = 'Password must be at least 6 characters';
|
||||
}
|
||||
|
||||
setValidationErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
// Handle login
|
||||
const handleLogin = async () => {
|
||||
clearError();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await login({ email: email.trim().toLowerCase(), password });
|
||||
router.replace('/(tabs)');
|
||||
} catch {
|
||||
// Error is handled by the store
|
||||
}
|
||||
};
|
||||
|
||||
// Navigate to signup
|
||||
const handleSignup = () => {
|
||||
clearError();
|
||||
router.push('/(auth)/signup');
|
||||
};
|
||||
|
||||
// Navigate to forgot password
|
||||
const handleForgotPassword = () => {
|
||||
clearError();
|
||||
router.push('/(auth)/forgot-password');
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.container}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Welcome Back</Text>
|
||||
<Text style={styles.subtitle}>Sign in to your account</Text>
|
||||
</View>
|
||||
|
||||
{/* Form */}
|
||||
<View style={styles.form}>
|
||||
{/* Email Input */}
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>Email</Text>
|
||||
<TextInput
|
||||
style={[styles.input, validationErrors.email && styles.inputError]}
|
||||
placeholder="Enter your email"
|
||||
placeholderTextColor="#999"
|
||||
value={email}
|
||||
onChangeText={(text) => {
|
||||
setEmail(text);
|
||||
if (validationErrors.email) {
|
||||
setValidationErrors((prev) => ({ ...prev, email: undefined }));
|
||||
}
|
||||
}}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoComplete="email"
|
||||
editable={!isLoading}
|
||||
/>
|
||||
{validationErrors.email && (
|
||||
<Text style={styles.errorText}>{validationErrors.email}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Password Input */}
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>Password</Text>
|
||||
<View style={styles.passwordContainer}>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
styles.passwordInput,
|
||||
validationErrors.password && styles.inputError,
|
||||
]}
|
||||
placeholder="Enter your password"
|
||||
placeholderTextColor="#999"
|
||||
value={password}
|
||||
onChangeText={(text) => {
|
||||
setPassword(text);
|
||||
if (validationErrors.password) {
|
||||
setValidationErrors((prev) => ({ ...prev, password: undefined }));
|
||||
}
|
||||
}}
|
||||
secureTextEntry={!showPassword}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoComplete="password"
|
||||
editable={!isLoading}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={styles.showPasswordButton}
|
||||
onPress={() => setShowPassword(!showPassword)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Text style={styles.showPasswordText}>
|
||||
{showPassword ? 'Hide' : 'Show'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{validationErrors.password && (
|
||||
<Text style={styles.errorText}>{validationErrors.password}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Forgot Password Link */}
|
||||
<TouchableOpacity
|
||||
style={styles.forgotPassword}
|
||||
onPress={handleForgotPassword}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Text style={styles.forgotPasswordText}>Forgot Password?</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* API Error */}
|
||||
{error && (
|
||||
<View style={styles.apiError}>
|
||||
<Text style={styles.apiErrorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Sign In Button */}
|
||||
<TouchableOpacity
|
||||
style={[styles.button, isLoading && styles.buttonDisabled]}
|
||||
onPress={handleLogin}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color="#FFFFFF" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Sign In</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Sign Up Link */}
|
||||
<View style={styles.signupContainer}>
|
||||
<Text style={styles.signupText}>Don't have an account? </Text>
|
||||
<TouchableOpacity onPress={handleSignup} disabled={isLoading}>
|
||||
<Text style={styles.signupLink}>Sign Up</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* App Info */}
|
||||
<View style={styles.footer}>
|
||||
<Text style={styles.footerText}>MoreThanADiagnosis</Text>
|
||||
<Text style={styles.footerSubtext}>
|
||||
Supporting the kidney disease community
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
paddingHorizontal: 24,
|
||||
paddingTop: 60,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
header: {
|
||||
marginBottom: 40,
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
color: '#333333',
|
||||
marginBottom: 8,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: '#666666',
|
||||
},
|
||||
form: {
|
||||
flex: 1,
|
||||
},
|
||||
inputContainer: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#333333',
|
||||
marginBottom: 8,
|
||||
},
|
||||
input: {
|
||||
height: 50,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E0E0E0',
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 16,
|
||||
fontSize: 16,
|
||||
color: '#333333',
|
||||
backgroundColor: '#FAFAFA',
|
||||
},
|
||||
inputError: {
|
||||
borderColor: '#FF3B30',
|
||||
},
|
||||
passwordContainer: {
|
||||
position: 'relative',
|
||||
},
|
||||
passwordInput: {
|
||||
paddingRight: 60,
|
||||
},
|
||||
showPasswordButton: {
|
||||
position: 'absolute',
|
||||
right: 16,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
showPasswordText: {
|
||||
fontSize: 14,
|
||||
color: '#007AFF',
|
||||
fontWeight: '600',
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 12,
|
||||
color: '#FF3B30',
|
||||
marginTop: 4,
|
||||
},
|
||||
forgotPassword: {
|
||||
alignSelf: 'flex-end',
|
||||
marginBottom: 24,
|
||||
},
|
||||
forgotPasswordText: {
|
||||
fontSize: 14,
|
||||
color: '#007AFF',
|
||||
fontWeight: '600',
|
||||
},
|
||||
apiError: {
|
||||
backgroundColor: '#FFF0F0',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
marginBottom: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#FF3B30',
|
||||
},
|
||||
apiErrorText: {
|
||||
fontSize: 14,
|
||||
color: '#FF3B30',
|
||||
textAlign: 'center',
|
||||
},
|
||||
button: {
|
||||
height: 50,
|
||||
backgroundColor: '#007AFF',
|
||||
borderRadius: 8,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
buttonDisabled: {
|
||||
backgroundColor: '#B0B0B0',
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
signupContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
signupText: {
|
||||
fontSize: 14,
|
||||
color: '#666666',
|
||||
},
|
||||
signupLink: {
|
||||
fontSize: 14,
|
||||
color: '#007AFF',
|
||||
fontWeight: '600',
|
||||
},
|
||||
footer: {
|
||||
marginTop: 40,
|
||||
alignItems: 'center',
|
||||
},
|
||||
footerText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#333333',
|
||||
marginBottom: 4,
|
||||
},
|
||||
footerSubtext: {
|
||||
fontSize: 12,
|
||||
color: '#999999',
|
||||
},
|
||||
});
|
||||
475
mobile/app/(auth)/signup.tsx
Normal file
475
mobile/app/(auth)/signup.tsx
Normal file
|
|
@ -0,0 +1,475 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useAuthStore } from '../lib/auth-store';
|
||||
|
||||
export default function SignupScreen() {
|
||||
const router = useRouter();
|
||||
const { signup, isLoading, error, clearError } = useAuthStore();
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [validationErrors, setValidationErrors] = useState<{
|
||||
name?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
confirmPassword?: string;
|
||||
}>({});
|
||||
|
||||
// Validate email format
|
||||
const validateEmail = (email: string): boolean => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
};
|
||||
|
||||
// Calculate password strength
|
||||
const getPasswordStrength = (password: string): { level: number; text: string; color: string } => {
|
||||
if (!password) {
|
||||
return { level: 0, text: '', color: '#E0E0E0' };
|
||||
}
|
||||
|
||||
let strength = 0;
|
||||
|
||||
if (password.length >= 8) strength++;
|
||||
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) strength++;
|
||||
if (/\d/.test(password)) strength++;
|
||||
if (/[^a-zA-Z0-9]/.test(password)) strength++;
|
||||
|
||||
const levels = [
|
||||
{ level: 1, text: 'Weak', color: '#FF3B30' },
|
||||
{ level: 2, text: 'Fair', color: '#FF9500' },
|
||||
{ level: 3, text: 'Good', color: '#FFCC00' },
|
||||
{ level: 4, text: 'Strong', color: '#34C759' },
|
||||
];
|
||||
|
||||
return levels[Math.min(strength, 4) - 1] || { level: 0, text: 'Too short', color: '#FF3B30' };
|
||||
};
|
||||
|
||||
// Validate form
|
||||
const validateForm = (): boolean => {
|
||||
const errors: {
|
||||
name?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
confirmPassword?: string;
|
||||
} = {};
|
||||
|
||||
if (!name.trim()) {
|
||||
errors.name = 'Name is required';
|
||||
} else if (name.trim().length < 2) {
|
||||
errors.name = 'Name must be at least 2 characters';
|
||||
}
|
||||
|
||||
if (!email.trim()) {
|
||||
errors.email = 'Email is required';
|
||||
} else if (!validateEmail(email)) {
|
||||
errors.email = 'Please enter a valid email';
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
errors.password = 'Password is required';
|
||||
} else if (password.length < 8) {
|
||||
errors.password = 'Password must be at least 8 characters';
|
||||
}
|
||||
|
||||
if (!confirmPassword) {
|
||||
errors.confirmPassword = 'Please confirm your password';
|
||||
} else if (password !== confirmPassword) {
|
||||
errors.confirmPassword = 'Passwords do not match';
|
||||
}
|
||||
|
||||
setValidationErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
// Handle signup
|
||||
const handleSignup = async () => {
|
||||
clearError();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await signup({
|
||||
name: name.trim(),
|
||||
email: email.trim().toLowerCase(),
|
||||
password,
|
||||
});
|
||||
router.replace('/(tabs)');
|
||||
} catch {
|
||||
// Error is handled by the store
|
||||
}
|
||||
};
|
||||
|
||||
// Navigate to login
|
||||
const handleLogin = () => {
|
||||
clearError();
|
||||
router.push('/(auth)/login');
|
||||
};
|
||||
|
||||
const passwordStrength = getPasswordStrength(password);
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.container}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Create Account</Text>
|
||||
<Text style={styles.subtitle}>Join our community today</Text>
|
||||
</View>
|
||||
|
||||
{/* Form */}
|
||||
<View style={styles.form}>
|
||||
{/* Name Input */}
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>Name</Text>
|
||||
<TextInput
|
||||
style={[styles.input, validationErrors.name && styles.inputError]}
|
||||
placeholder="Enter your name"
|
||||
placeholderTextColor="#999"
|
||||
value={name}
|
||||
onChangeText={(text) => {
|
||||
setName(text);
|
||||
if (validationErrors.name) {
|
||||
setValidationErrors((prev) => ({ ...prev, name: undefined }));
|
||||
}
|
||||
}}
|
||||
autoCapitalize="words"
|
||||
autoCorrect={false}
|
||||
autoComplete="name"
|
||||
editable={!isLoading}
|
||||
/>
|
||||
{validationErrors.name && (
|
||||
<Text style={styles.errorText}>{validationErrors.name}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Email Input */}
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>Email</Text>
|
||||
<TextInput
|
||||
style={[styles.input, validationErrors.email && styles.inputError]}
|
||||
placeholder="Enter your email"
|
||||
placeholderTextColor="#999"
|
||||
value={email}
|
||||
onChangeText={(text) => {
|
||||
setEmail(text);
|
||||
if (validationErrors.email) {
|
||||
setValidationErrors((prev) => ({ ...prev, email: undefined }));
|
||||
}
|
||||
}}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoComplete="email"
|
||||
editable={!isLoading}
|
||||
/>
|
||||
{validationErrors.email && (
|
||||
<Text style={styles.errorText}>{validationErrors.email}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Password Input */}
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>Password</Text>
|
||||
<View style={styles.passwordContainer}>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
styles.passwordInput,
|
||||
validationErrors.password && styles.inputError,
|
||||
]}
|
||||
placeholder="Create a password"
|
||||
placeholderTextColor="#999"
|
||||
value={password}
|
||||
onChangeText={(text) => {
|
||||
setPassword(text);
|
||||
if (validationErrors.password) {
|
||||
setValidationErrors((prev) => ({ ...prev, password: undefined }));
|
||||
}
|
||||
}}
|
||||
secureTextEntry={!showPassword}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoComplete="password-new"
|
||||
editable={!isLoading}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={styles.showPasswordButton}
|
||||
onPress={() => setShowPassword(!showPassword)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Text style={styles.showPasswordText}>
|
||||
{showPassword ? 'Hide' : 'Show'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{validationErrors.password && (
|
||||
<Text style={styles.errorText}>{validationErrors.password}</Text>
|
||||
)}
|
||||
{/* Password Strength Indicator */}
|
||||
{password.length > 0 && (
|
||||
<View style={styles.strengthContainer}>
|
||||
<View style={styles.strengthBars}>
|
||||
{[1, 2, 3, 4].map((level) => (
|
||||
<View
|
||||
key={level}
|
||||
style={[
|
||||
styles.strengthBar,
|
||||
{
|
||||
backgroundColor:
|
||||
level <= passwordStrength.level
|
||||
? passwordStrength.color
|
||||
: '#E0E0E0',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<Text style={[styles.strengthText, { color: passwordStrength.color }]}>
|
||||
{passwordStrength.text}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Confirm Password Input */}
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>Confirm Password</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
validationErrors.confirmPassword && styles.inputError,
|
||||
]}
|
||||
placeholder="Confirm your password"
|
||||
placeholderTextColor="#999"
|
||||
value={confirmPassword}
|
||||
onChangeText={(text) => {
|
||||
setConfirmPassword(text);
|
||||
if (validationErrors.confirmPassword) {
|
||||
setValidationErrors((prev) => ({
|
||||
...prev,
|
||||
confirmPassword: undefined,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
secureTextEntry={!showPassword}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoComplete="password-new"
|
||||
editable={!isLoading}
|
||||
/>
|
||||
{validationErrors.confirmPassword && (
|
||||
<Text style={styles.errorText}>{validationErrors.confirmPassword}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* API Error */}
|
||||
{error && (
|
||||
<View style={styles.apiError}>
|
||||
<Text style={styles.apiErrorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Create Account Button */}
|
||||
<TouchableOpacity
|
||||
style={[styles.button, isLoading && styles.buttonDisabled]}
|
||||
onPress={handleSignup}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color="#FFFFFF" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Create Account</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Terms */}
|
||||
<Text style={styles.termsText}>
|
||||
By creating an account, you agree to our{' '}
|
||||
<Text style={styles.termsLink}>Terms of Service</Text> and{' '}
|
||||
<Text style={styles.termsLink}>Privacy Policy</Text>
|
||||
</Text>
|
||||
|
||||
{/* Sign In Link */}
|
||||
<View style={styles.loginContainer}>
|
||||
<Text style={styles.loginText}>Already have an account? </Text>
|
||||
<TouchableOpacity onPress={handleLogin} disabled={isLoading}>
|
||||
<Text style={styles.loginLink}>Sign In</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
paddingHorizontal: 24,
|
||||
paddingTop: 60,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
header: {
|
||||
marginBottom: 32,
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
color: '#333333',
|
||||
marginBottom: 8,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: '#666666',
|
||||
},
|
||||
form: {
|
||||
flex: 1,
|
||||
},
|
||||
inputContainer: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#333333',
|
||||
marginBottom: 8,
|
||||
},
|
||||
input: {
|
||||
height: 50,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E0E0E0',
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 16,
|
||||
fontSize: 16,
|
||||
color: '#333333',
|
||||
backgroundColor: '#FAFAFA',
|
||||
},
|
||||
inputError: {
|
||||
borderColor: '#FF3B30',
|
||||
},
|
||||
passwordContainer: {
|
||||
position: 'relative',
|
||||
},
|
||||
passwordInput: {
|
||||
paddingRight: 60,
|
||||
},
|
||||
showPasswordButton: {
|
||||
position: 'absolute',
|
||||
right: 16,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
showPasswordText: {
|
||||
fontSize: 14,
|
||||
color: '#007AFF',
|
||||
fontWeight: '600',
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 12,
|
||||
color: '#FF3B30',
|
||||
marginTop: 4,
|
||||
},
|
||||
strengthContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: 8,
|
||||
},
|
||||
strengthBars: {
|
||||
flexDirection: 'row',
|
||||
flex: 1,
|
||||
gap: 4,
|
||||
},
|
||||
strengthBar: {
|
||||
flex: 1,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
},
|
||||
strengthText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
marginLeft: 8,
|
||||
minWidth: 50,
|
||||
},
|
||||
apiError: {
|
||||
backgroundColor: '#FFF0F0',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
marginBottom: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#FF3B30',
|
||||
},
|
||||
apiErrorText: {
|
||||
fontSize: 14,
|
||||
color: '#FF3B30',
|
||||
textAlign: 'center',
|
||||
},
|
||||
button: {
|
||||
height: 50,
|
||||
backgroundColor: '#007AFF',
|
||||
borderRadius: 8,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
buttonDisabled: {
|
||||
backgroundColor: '#B0B0B0',
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
termsText: {
|
||||
fontSize: 12,
|
||||
color: '#666666',
|
||||
textAlign: 'center',
|
||||
marginBottom: 24,
|
||||
lineHeight: 18,
|
||||
},
|
||||
termsLink: {
|
||||
color: '#007AFF',
|
||||
fontWeight: '600',
|
||||
},
|
||||
loginContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
loginText: {
|
||||
fontSize: 14,
|
||||
color: '#666666',
|
||||
},
|
||||
loginLink: {
|
||||
fontSize: 14,
|
||||
color: '#007AFF',
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState } from 'react';
|
||||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
ScrollView,
|
||||
|
|
@ -7,8 +7,11 @@ import {
|
|||
SafeAreaView,
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useAuthStore } from '../lib/auth-store';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
|
|
@ -111,21 +114,38 @@ const styles = StyleSheet.create({
|
|||
});
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
const router = useRouter();
|
||||
const { user, isAuthenticated, isLoading, logout } = useAuthStore();
|
||||
|
||||
const handleSignOut = () => {
|
||||
Alert.alert('Sign Out', 'Are you sure you want to sign out?', [
|
||||
{ text: 'Cancel', onPress: () => {} },
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Sign Out',
|
||||
onPress: () => {
|
||||
setIsLoggedIn(false);
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
await logout();
|
||||
router.replace('/(auth)/login');
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
if (!isLoggedIn) {
|
||||
const handleSignIn = () => {
|
||||
router.push('/(auth)/login');
|
||||
};
|
||||
|
||||
// Get user initials for avatar
|
||||
const getInitials = () => {
|
||||
if (!user?.name) return '?';
|
||||
const names = user.name.split(' ');
|
||||
if (names.length >= 2) {
|
||||
return `${names[0][0]}${names[1][0]}`.toUpperCase();
|
||||
}
|
||||
return names[0][0].toUpperCase();
|
||||
};
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.profileHeader}>
|
||||
|
|
@ -139,7 +159,7 @@ export default function ProfileScreen() {
|
|||
<View style={styles.section}>
|
||||
<TouchableOpacity
|
||||
style={[styles.menuItem, styles.menuItemFirst, styles.menuItemLast]}
|
||||
onPress={() => setIsLoggedIn(true)}
|
||||
onPress={handleSignIn}
|
||||
>
|
||||
<View style={styles.menuIcon}>
|
||||
<Ionicons name="log-in" size={20} color="#0066cc" />
|
||||
|
|
@ -182,10 +202,10 @@ export default function ProfileScreen() {
|
|||
<ScrollView showsVerticalScrollIndicator={false}>
|
||||
<View style={styles.profileHeader}>
|
||||
<View style={styles.avatar}>
|
||||
<Text style={styles.avatarText}>👤</Text>
|
||||
<Text style={styles.avatarText}>{getInitials()}</Text>
|
||||
</View>
|
||||
<Text style={styles.profileName}>John Doe</Text>
|
||||
<Text style={styles.profileStatus}>Active Member</Text>
|
||||
<Text style={styles.profileName}>{user?.name || 'User'}</Text>
|
||||
<Text style={styles.profileStatus}>{user?.email || 'Active Member'}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,57 @@
|
|||
import { Stack } from 'expo-router';
|
||||
import { Stack, useRouter, useSegments } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { useEffect } from 'react';
|
||||
import { View, ActivityIndicator, StyleSheet } from 'react-native';
|
||||
import { useAuthStore } from './lib/auth-store';
|
||||
|
||||
export const unstable_settings = {
|
||||
initialRouteName: '(tabs)',
|
||||
};
|
||||
|
||||
export default function RootLayout() {
|
||||
// Auth protection hook
|
||||
function useProtectedRoute() {
|
||||
const { isAuthenticated, isInitialized } = useAuthStore();
|
||||
const segments = useSegments();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
// Setup any global configuration here
|
||||
if (!isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
const inAuthGroup = segments[0] === '(auth)';
|
||||
|
||||
if (!isAuthenticated && !inAuthGroup) {
|
||||
// Redirect to login if not authenticated and not in auth group
|
||||
router.replace('/(auth)/login');
|
||||
} else if (isAuthenticated && inAuthGroup) {
|
||||
// Redirect to tabs if authenticated and in auth group
|
||||
router.replace('/(tabs)');
|
||||
}
|
||||
}, [isAuthenticated, isInitialized, segments]);
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
const { initialize, isInitialized } = useAuthStore();
|
||||
|
||||
// Initialize auth state on app start
|
||||
useEffect(() => {
|
||||
initialize();
|
||||
}, []);
|
||||
|
||||
// Use protected route hook
|
||||
useProtectedRoute();
|
||||
|
||||
// Show loading screen while initializing
|
||||
if (!isInitialized) {
|
||||
return (
|
||||
<View style={styles.loadingContainer}>
|
||||
<StatusBar style="dark" />
|
||||
<ActivityIndicator size="large" color="#007AFF" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatusBar style="dark" />
|
||||
|
|
@ -21,3 +62,12 @@ export default function RootLayout() {
|
|||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
284
mobile/app/lib/api.ts
Normal file
284
mobile/app/lib/api.ts
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
import axios, { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from 'axios';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import {
|
||||
LoginRequest,
|
||||
SignupRequest,
|
||||
ForgotPasswordRequest,
|
||||
ResetPasswordRequest,
|
||||
AuthResponse,
|
||||
User,
|
||||
Resource,
|
||||
ResourceCategory,
|
||||
CommunitySpace,
|
||||
UserPreferences,
|
||||
ApiError,
|
||||
} from './types';
|
||||
|
||||
// Storage keys
|
||||
const TOKEN_KEY = 'auth_token';
|
||||
const REFRESH_TOKEN_KEY = 'refresh_token';
|
||||
|
||||
// API base URL - update this for your environment
|
||||
const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL || 'https://api.morethanadiagnosis.org/api/v1';
|
||||
|
||||
// Create axios instance
|
||||
const api: AxiosInstance = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor - add auth token to requests
|
||||
api.interceptors.request.use(
|
||||
async (config: InternalAxiosRequestConfig) => {
|
||||
const token = await AsyncStorage.getItem(TOKEN_KEY);
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor - handle token refresh
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError<ApiError>) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// If 401 and we haven't tried to refresh yet
|
||||
if (error.response?.status === 401 && originalRequest && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
const refreshToken = await AsyncStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
if (refreshToken) {
|
||||
const response = await axios.post<AuthResponse>(`${API_BASE_URL}/auth/refresh`, {
|
||||
refreshToken,
|
||||
});
|
||||
|
||||
const { accessToken, refreshToken: newRefreshToken } = response.data;
|
||||
|
||||
// Store new tokens
|
||||
await AsyncStorage.setItem(TOKEN_KEY, accessToken);
|
||||
await AsyncStorage.setItem(REFRESH_TOKEN_KEY, newRefreshToken);
|
||||
|
||||
// Retry original request with new token
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
|
||||
}
|
||||
return api(originalRequest);
|
||||
}
|
||||
} catch (refreshError) {
|
||||
// Refresh failed - clear tokens and redirect to login
|
||||
await AsyncStorage.multiRemove([TOKEN_KEY, REFRESH_TOKEN_KEY]);
|
||||
// The auth store should handle navigation
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Extend AxiosRequestConfig to include _retry property
|
||||
declare module 'axios' {
|
||||
export interface InternalAxiosRequestConfig {
|
||||
_retry?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Authentication API
|
||||
// ==========================================
|
||||
|
||||
export const authApi = {
|
||||
login: async (data: LoginRequest): Promise<AuthResponse> => {
|
||||
const response = await api.post<AuthResponse>('/auth/login', data);
|
||||
const { accessToken, refreshToken } = response.data;
|
||||
|
||||
// Store tokens
|
||||
await AsyncStorage.setItem(TOKEN_KEY, accessToken);
|
||||
await AsyncStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
|
||||
|
||||
return response.data;
|
||||
},
|
||||
|
||||
signup: async (data: SignupRequest): Promise<AuthResponse> => {
|
||||
const response = await api.post<AuthResponse>('/auth/signup', data);
|
||||
const { accessToken, refreshToken } = response.data;
|
||||
|
||||
// Store tokens
|
||||
await AsyncStorage.setItem(TOKEN_KEY, accessToken);
|
||||
await AsyncStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
|
||||
|
||||
return response.data;
|
||||
},
|
||||
|
||||
forgotPassword: async (data: ForgotPasswordRequest): Promise<{ message: string }> => {
|
||||
const response = await api.post<{ message: string }>('/auth/forgot-password', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
resetPassword: async (data: ResetPasswordRequest): Promise<{ message: string }> => {
|
||||
const response = await api.post<{ message: string }>('/auth/reset-password', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
logout: async (): Promise<void> => {
|
||||
try {
|
||||
await api.post('/auth/logout');
|
||||
} catch {
|
||||
// Ignore errors on logout
|
||||
} finally {
|
||||
await AsyncStorage.multiRemove([TOKEN_KEY, REFRESH_TOKEN_KEY]);
|
||||
}
|
||||
},
|
||||
|
||||
refreshToken: async (): Promise<AuthResponse> => {
|
||||
const refreshToken = await AsyncStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
const response = await api.post<AuthResponse>('/auth/refresh', { refreshToken });
|
||||
const { accessToken, refreshToken: newRefreshToken } = response.data;
|
||||
|
||||
await AsyncStorage.setItem(TOKEN_KEY, accessToken);
|
||||
await AsyncStorage.setItem(REFRESH_TOKEN_KEY, newRefreshToken);
|
||||
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getStoredToken: async (): Promise<string | null> => {
|
||||
return AsyncStorage.getItem(TOKEN_KEY);
|
||||
},
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
// User API
|
||||
// ==========================================
|
||||
|
||||
export const userApi = {
|
||||
getMe: async (): Promise<User> => {
|
||||
const response = await api.get<User>('/user/me');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateMe: async (data: Partial<User>): Promise<User> => {
|
||||
const response = await api.put<User>('/user/me', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getPreferences: async (): Promise<UserPreferences> => {
|
||||
const response = await api.get<UserPreferences>('/user/preferences');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updatePreferences: async (data: Partial<UserPreferences>): Promise<UserPreferences> => {
|
||||
const response = await api.put<UserPreferences>('/user/preferences', data);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
// Resources API
|
||||
// ==========================================
|
||||
|
||||
export const resourcesApi = {
|
||||
getCategories: async (): Promise<ResourceCategory[]> => {
|
||||
const response = await api.get<ResourceCategory[]>('/resources/categories');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getAll: async (): Promise<Resource[]> => {
|
||||
const response = await api.get<Resource[]>('/resources');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<Resource> => {
|
||||
const response = await api.get<Resource>(`/resources/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getByCategory: async (categoryId: string): Promise<Resource[]> => {
|
||||
const response = await api.get<Resource[]>(`/resources/category/${categoryId}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
// Community API
|
||||
// ==========================================
|
||||
|
||||
export const communityApi = {
|
||||
getSpaces: async (): Promise<CommunitySpace[]> => {
|
||||
const response = await api.get<CommunitySpace[]>('/community');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getSpaceById: async (id: string): Promise<CommunitySpace> => {
|
||||
const response = await api.get<CommunitySpace>(`/community/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
joinSpace: async (id: string): Promise<{ message: string }> => {
|
||||
const response = await api.post<{ message: string }>(`/community/${id}/join`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
leaveSpace: async (id: string): Promise<{ message: string }> => {
|
||||
const response = await api.post<{ message: string }>(`/community/${id}/leave`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
// Helper functions
|
||||
// ==========================================
|
||||
|
||||
export const handleApiError = (error: unknown): string => {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const axiosError = error as AxiosError<ApiError>;
|
||||
|
||||
// Network error
|
||||
if (!axiosError.response) {
|
||||
return 'Network error. Please check your connection.';
|
||||
}
|
||||
|
||||
// API error response
|
||||
const apiError = axiosError.response.data;
|
||||
if (apiError?.message) {
|
||||
return apiError.message;
|
||||
}
|
||||
|
||||
// HTTP status errors
|
||||
switch (axiosError.response.status) {
|
||||
case 400:
|
||||
return 'Invalid request. Please check your input.';
|
||||
case 401:
|
||||
return 'Invalid credentials. Please try again.';
|
||||
case 403:
|
||||
return 'You do not have permission to perform this action.';
|
||||
case 404:
|
||||
return 'Resource not found.';
|
||||
case 422:
|
||||
return 'Validation error. Please check your input.';
|
||||
case 500:
|
||||
return 'Server error. Please try again later.';
|
||||
default:
|
||||
return 'An unexpected error occurred.';
|
||||
}
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return 'An unexpected error occurred.';
|
||||
};
|
||||
|
||||
export default api;
|
||||
220
mobile/app/lib/auth-store.ts
Normal file
220
mobile/app/lib/auth-store.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
import { create } from 'zustand';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { authApi, userApi, handleApiError } from './api';
|
||||
import { User, LoginRequest, SignupRequest } from './types';
|
||||
|
||||
// Storage keys
|
||||
const USER_KEY = 'auth_user';
|
||||
|
||||
interface AuthState {
|
||||
// State
|
||||
user: User | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
isInitialized: boolean;
|
||||
error: string | null;
|
||||
|
||||
// Actions
|
||||
initialize: () => Promise<void>;
|
||||
login: (data: LoginRequest) => Promise<void>;
|
||||
signup: (data: SignupRequest) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
setUser: (user: User) => void;
|
||||
updateUser: (data: Partial<User>) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
// Initial state
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
isInitialized: false,
|
||||
error: null,
|
||||
|
||||
// Initialize auth state on app start
|
||||
initialize: async () => {
|
||||
try {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
// Check for stored token
|
||||
const token = await authApi.getStoredToken();
|
||||
|
||||
if (token) {
|
||||
// Try to fetch current user
|
||||
try {
|
||||
const user = await userApi.getMe();
|
||||
await AsyncStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||
|
||||
set({
|
||||
user,
|
||||
isAuthenticated: true,
|
||||
isInitialized: true,
|
||||
isLoading: false,
|
||||
});
|
||||
} catch {
|
||||
// Token invalid or expired - try to refresh
|
||||
try {
|
||||
await authApi.refreshToken();
|
||||
const user = await userApi.getMe();
|
||||
await AsyncStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||
|
||||
set({
|
||||
user,
|
||||
isAuthenticated: true,
|
||||
isInitialized: true,
|
||||
isLoading: false,
|
||||
});
|
||||
} catch {
|
||||
// Refresh failed - clear auth state
|
||||
await authApi.logout();
|
||||
await AsyncStorage.removeItem(USER_KEY);
|
||||
|
||||
set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isInitialized: true,
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No token - check for cached user data for offline display
|
||||
const cachedUser = await AsyncStorage.getItem(USER_KEY);
|
||||
|
||||
set({
|
||||
user: cachedUser ? JSON.parse(cachedUser) : null,
|
||||
isAuthenticated: false,
|
||||
isInitialized: true,
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isInitialized: true,
|
||||
isLoading: false,
|
||||
error: handleApiError(error),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// Login
|
||||
login: async (data: LoginRequest) => {
|
||||
try {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
const response = await authApi.login(data);
|
||||
const { user } = response;
|
||||
|
||||
// Cache user data
|
||||
await AsyncStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||
|
||||
set({
|
||||
user,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
set({
|
||||
isLoading: false,
|
||||
error: handleApiError(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Signup
|
||||
signup: async (data: SignupRequest) => {
|
||||
try {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
const response = await authApi.signup(data);
|
||||
const { user } = response;
|
||||
|
||||
// Cache user data
|
||||
await AsyncStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||
|
||||
set({
|
||||
user,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
set({
|
||||
isLoading: false,
|
||||
error: handleApiError(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Logout
|
||||
logout: async () => {
|
||||
try {
|
||||
set({ isLoading: true });
|
||||
|
||||
await authApi.logout();
|
||||
await AsyncStorage.removeItem(USER_KEY);
|
||||
|
||||
set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
// Still clear local state even if API call fails
|
||||
await AsyncStorage.removeItem(USER_KEY);
|
||||
|
||||
set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// Clear error
|
||||
clearError: () => {
|
||||
set({ error: null });
|
||||
},
|
||||
|
||||
// Set user (for updates from other parts of the app)
|
||||
setUser: (user: User) => {
|
||||
set({ user });
|
||||
AsyncStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||
},
|
||||
|
||||
// Update user profile
|
||||
updateUser: async (data: Partial<User>) => {
|
||||
try {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
const updatedUser = await userApi.updateMe(data);
|
||||
await AsyncStorage.setItem(USER_KEY, JSON.stringify(updatedUser));
|
||||
|
||||
set({
|
||||
user: updatedUser,
|
||||
isLoading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
set({
|
||||
isLoading: false,
|
||||
error: handleApiError(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// Selector hooks for specific state slices
|
||||
export const useUser = () => useAuthStore((state) => state.user);
|
||||
export const useIsAuthenticated = () => useAuthStore((state) => state.isAuthenticated);
|
||||
export const useIsLoading = () => useAuthStore((state) => state.isLoading);
|
||||
export const useAuthError = () => useAuthStore((state) => state.error);
|
||||
export const useIsInitialized = () => useAuthStore((state) => state.isInitialized);
|
||||
93
mobile/app/lib/types.ts
Normal file
93
mobile/app/lib/types.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
// User types
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
displayName?: string;
|
||||
avatarUrl?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Auth types
|
||||
export interface LoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface SignupRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ForgotPasswordRequest {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface ResetPasswordRequest {
|
||||
token: string;
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
user: User;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
export interface RefreshTokenRequest {
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
// Resource types
|
||||
export interface Resource {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
url?: string;
|
||||
imageUrl?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ResourceCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
resourceCount: number;
|
||||
}
|
||||
|
||||
// Community types
|
||||
export interface CommunitySpace {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
memberCount: number;
|
||||
isPrivate: boolean;
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
// User preferences
|
||||
export interface UserPreferences {
|
||||
notifications: boolean;
|
||||
emailUpdates: boolean;
|
||||
darkMode: boolean;
|
||||
language: string;
|
||||
}
|
||||
|
||||
// API Error response
|
||||
export interface ApiError {
|
||||
message: string;
|
||||
code?: string;
|
||||
details?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
// Generic API response wrapper
|
||||
export interface ApiResponse<T> {
|
||||
data: T;
|
||||
message?: string;
|
||||
success: boolean;
|
||||
}
|
||||
837
mobile/package-lock.json
generated
837
mobile/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -16,12 +16,14 @@
|
|||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
"@tanstack/react-query": "^5.25.0",
|
||||
"axios": "^1.6.0",
|
||||
"expo": "^51.0.0",
|
||||
"expo-constants": "~15.4.0",
|
||||
"expo-linking": "~6.0.0",
|
||||
"expo-router": "^3.4.0",
|
||||
"expo-secure-store": "^15.0.7",
|
||||
"expo-splash-screen": "^31.0.11",
|
||||
"expo-status-bar": "~1.12.1",
|
||||
"react": "^18.2.0",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue