diff --git a/.gitignore b/.gitignore index 21168d3..0e50c48 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,10 @@ +# Frontend /bloodlink-frontend/node_modules +/bloodlink-frontend/.next + +# Backend /bloodlink-backend/node_modules -/bloodlink-frontend/.next \ No newline at end of file + +# Shared +/node_modules +.next diff --git a/bloodlink-frontend/app/change-password/page.js b/bloodlink-frontend/app/change-password/page.js new file mode 100644 index 0000000..a52ced5 --- /dev/null +++ b/bloodlink-frontend/app/change-password/page.js @@ -0,0 +1,229 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { useAuth } from '../../contexts/AuthContext' +import { authAPI } from '../../lib/api' +import Navbar from '../../components/Navbar' +import FormInput from '../../components/FormInput' +import Toast from '../../components/Toast' + +export default function ChangePasswordPage() { + const router = useRouter() + const { user, isAuthenticated, isLoading: authLoading } = useAuth() + + const [formData, setFormData] = useState({ + currentPassword: '', + newPassword: '', + confirmPassword: '' + }) + const [errors, setErrors] = useState({}) + const [loading, setLoading] = useState(false) + const [toast, setToast] = useState({ show: false, message: '', type: '' }) + + // Redirect if not authenticated + if (!authLoading && !isAuthenticated) { + router.push('/login') + return null + } + + const handleInputChange = (e) => { + const { name, value } = e.target + setFormData(prev => ({ + ...prev, + [name]: value + })) + + // Clear error when user starts typing + if (errors[name]) { + setErrors(prev => ({ ...prev, [name]: '' })) + } + } + + const validateForm = () => { + const newErrors = {} + + if (!formData.currentPassword) { + newErrors.currentPassword = 'Current password is required' + } + + if (!formData.newPassword) { + newErrors.newPassword = 'New password is required' + } else if (formData.newPassword.length < 8) { + newErrors.newPassword = 'Password must be at least 8 characters long' + } + + if (!formData.confirmPassword) { + newErrors.confirmPassword = 'Please confirm your new password' + } else if (formData.newPassword !== formData.confirmPassword) { + newErrors.confirmPassword = 'Passwords do not match' + } + + if (formData.currentPassword === formData.newPassword) { + newErrors.newPassword = 'New password must be different from current password' + } + + setErrors(newErrors) + return Object.keys(newErrors).length === 0 + } + + const handleSubmit = async (e) => { + e.preventDefault() + + if (!validateForm()) return + + setLoading(true) + setErrors({}) + + try { + await authAPI.changePassword( + formData.currentPassword, + formData.newPassword, + formData.confirmPassword + ) + + setToast({ + show: true, + message: 'Password changed successfully', + type: 'success' + }) + + // Clear form + setFormData({ + currentPassword: '', + newPassword: '', + confirmPassword: '' + }) + + // Redirect to profile after success + setTimeout(() => { + router.push('/profile') + }, 2000) + + } catch (error) { + console.error('Change password error:', error) + + // Handle validation errors + if (error.response?.data?.errors) { + const newErrors = {} + error.response.data.errors.forEach(err => { + newErrors[err.field] = err.message + }) + setErrors(newErrors) + } + + setToast({ + show: true, + message: error.response?.data?.message || 'Failed to change password', + type: 'error' + }) + } finally { + setLoading(false) + } + } + + if (authLoading) { + return ( +
+
+
+

Loading...

+
+
+ ) + } + + return ( +
+ + +
+ {/* Header */} +
+

+ Change Password +

+

+ Update your account password +

+
+ + {/* Form */} +
+
+ + + + + + +
+ + +
+ +
+ + {/* Back to Profile */} +
+ +
+
+ + {/* Toast Notification */} + setToast({ show: false, message: '', type: '' })} + /> +
+ ) +} \ No newline at end of file diff --git a/bloodlink-frontend/app/layout.js b/bloodlink-frontend/app/layout.js index cf4fa5b..4f88c41 100644 --- a/bloodlink-frontend/app/layout.js +++ b/bloodlink-frontend/app/layout.js @@ -32,9 +32,11 @@ export const metadata = { apple: '/apple-touch-icon.png', }, manifest: '/manifest.json', +} - // Other meta tags - viewport: 'width=device-width, initial-scale=1', +export const viewport = { + width: 'device-width', + initialScale: 1, } export default function RootLayout({ children }) { diff --git a/bloodlink-frontend/app/profile/page.js b/bloodlink-frontend/app/profile/page.js new file mode 100644 index 0000000..b33844a --- /dev/null +++ b/bloodlink-frontend/app/profile/page.js @@ -0,0 +1,655 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useRouter } from 'next/navigation' +import { useAuth } from '../../contexts/AuthContext' +import { usersAPI, donorsAPI, healthCentersAPI } from '../../lib/api' +import Navbar from '../../components/Navbar' +import FormInput from '../../components/FormInput' +import Toast from '../../components/Toast' +// Remove this line: import LoadingSpinner from '../../components/LoadingSpinner' + +export default function ProfilePage() { + const router = useRouter() + const { user, isAuthenticated, isLoading: authLoading, updateUser } = useAuth() + + const [isLoading, setIsLoading] = useState(false) + const [isSaving, setIsSaving] = useState(false) + const [activeTab, setActiveTab] = useState('general') + const [toast, setToast] = useState({ show: false, message: '', type: '' }) + + // Form data state - synced with backend schema + const [formData, setFormData] = useState({ + // General user info (from users endpoint) + phone: '', + location: '', + telegramUsername: '', + + // Donor specific (from donors endpoint) + fullName: '', + bloodType: '', + dateOfBirth: '', + weight: '', + emergencyContact: '', + medicalNotes: '', + isAvailable: true, + + // Health Center specific (from health-centers endpoint) + centerName: '', + contactPerson: '', + registrationNumber: '', + centerType: '', + capacity: '', + operatingHours: '', + services: '' + }) + + const [errors, setErrors] = useState({}) + + // Redirect if not authenticated + useEffect(() => { + if (!authLoading && !isAuthenticated) { + router.push('/login') + } + }, [isAuthenticated, authLoading, router]) + + // Load profile data + useEffect(() => { + if (user && isAuthenticated) { + loadProfileData() + } + }, [user, isAuthenticated]) + + const loadProfileData = async () => { + try { + setIsLoading(true) + + // Load general user profile from /users/profile + const userResponse = await usersAPI.getProfile() + const userData = userResponse.data.data.user + + // Load role-specific profile + let roleData = {} + if (user.role === 'DONOR') { + const donorResponse = await donorsAPI.getProfile() + roleData = donorResponse.data.data.donor + } else if (user.role === 'HEALTH_CENTER') { + const centerResponse = await healthCentersAPI.getProfile() + roleData = centerResponse.data.data.healthCenter + } + + // Populate form data - handle backend field mapping + setFormData({ + // General user fields + phone: userData.phone || '', + location: userData.location || '', + telegramUsername: userData.telegramUsername || '', + + // Donor fields - handle blood type enum conversion + fullName: roleData.fullName || '', + bloodType: roleData.bloodType ? + roleData.bloodType.replace('_POSITIVE', '+').replace('_NEGATIVE', '-') : '', + dateOfBirth: roleData.dateOfBirth ? + new Date(roleData.dateOfBirth).toISOString().split('T')[0] : '', + weight: roleData.weight ? roleData.weight.toString() : '', + emergencyContact: roleData.emergencyContact || '', + medicalNotes: roleData.medicalNotes || '', + isAvailable: roleData.isAvailable !== undefined ? roleData.isAvailable : true, + + // Health Center fields + centerName: roleData.centerName || '', + contactPerson: roleData.contactPerson || '', + registrationNumber: roleData.registrationNumber || '', + centerType: roleData.centerType || '', + capacity: roleData.capacity ? roleData.capacity.toString() : '', + operatingHours: roleData.operatingHours || '', + services: roleData.services || '' + }) + + } catch (error) { + console.error('Error loading profile:', error) + setToast({ + show: true, + message: 'Failed to load profile data', + type: 'error' + }) + } finally { + setIsLoading(false) + } + } + + const handleInputChange = (e) => { + const { name, value, type, checked } = e.target + setFormData(prev => ({ + ...prev, + [name]: type === 'checkbox' ? checked : value + })) + + // Clear error when user starts typing + if (errors[name]) { + setErrors(prev => ({ ...prev, [name]: '' })) + } + } + + const handleGeneralSubmit = async (e) => { + e.preventDefault() + setIsSaving(true) + setErrors({}) + + try { + // Prepare data for /users/profile PUT endpoint + const updateData = { + phone: formData.phone || null, + location: formData.location || null, + telegramUsername: formData.telegramUsername || null + } + + const response = await usersAPI.updateProfile(updateData) + + // Update user context with new data + updateUser(response.data.data.user) + + setToast({ + show: true, + message: 'General profile updated successfully', + type: 'success' + }) + + } catch (error) { + console.error('Error updating general profile:', error) + + // Handle validation errors from backend + if (error.response?.data?.errors) { + const newErrors = {} + error.response.data.errors.forEach(err => { + newErrors[err.field] = err.message + }) + setErrors(newErrors) + } + + setToast({ + show: true, + message: error.response?.data?.message || 'Failed to update profile', + type: 'error' + }) + } finally { + setIsSaving(false) + } + } + + const handleRoleSpecificSubmit = async (e) => { + e.preventDefault() + setIsSaving(true) + setErrors({}) + + try { + if (user.role === 'DONOR') { + // Prepare data for /donors/profile PUT endpoint + const updateData = { + fullName: formData.fullName, + bloodType: formData.bloodType ? + formData.bloodType.replace('+', '_POSITIVE').replace('-', '_NEGATIVE') : null, + dateOfBirth: formData.dateOfBirth ? new Date(formData.dateOfBirth).toISOString() : null, + weight: formData.weight ? parseFloat(formData.weight) : null, + emergencyContact: formData.emergencyContact || null, + medicalNotes: formData.medicalNotes || null + } + + await donorsAPI.updateProfile(updateData) + + // Update availability separately using /donors/availability PUT endpoint + const currentAvailability = user.donor?.isAvailable + if (formData.isAvailable !== currentAvailability) { + await donorsAPI.updateAvailability({ isAvailable: formData.isAvailable }) + } + + } else if (user.role === 'HEALTH_CENTER') { + // Prepare data for /health-centers/profile PUT endpoint + const updateData = { + centerName: formData.centerName, + contactPerson: formData.contactPerson, + registrationNumber: formData.registrationNumber || null, + centerType: formData.centerType || null, + capacity: formData.capacity ? parseInt(formData.capacity) : null, + operatingHours: formData.operatingHours || null, + services: formData.services || null + } + + await healthCentersAPI.updateProfile(updateData) + } + + setToast({ + show: true, + message: `${user.role === 'DONOR' ? 'Donor' : 'Health Center'} profile updated successfully`, + type: 'success' + }) + + // Reload profile data to get updated info + await loadProfileData() + + } catch (error) { + console.error('Error updating role-specific profile:', error) + + // Handle validation errors from backend + if (error.response?.data?.errors) { + const newErrors = {} + error.response.data.errors.forEach(err => { + newErrors[err.field] = err.message + }) + setErrors(newErrors) + } + + setToast({ + show: true, + message: error.response?.data?.message || 'Failed to update profile', + type: 'error' + }) + } finally { + setIsSaving(false) + } + } + + if (authLoading || !user) { + return ( +
+
+
+

Loading...

+
+
+ ) + } + + // Blood type options matching backend enum + const bloodTypeOptions = [ + { value: 'A+', label: 'A+' }, + { value: 'A-', label: 'A-' }, + { value: 'B+', label: 'B+' }, + { value: 'B-', label: 'B-' }, + { value: 'AB+', label: 'AB+' }, + { value: 'AB-', label: 'AB-' }, + { value: 'O+', label: 'O+' }, + { value: 'O-', label: 'O-' } + ] + + // Center type options matching backend enum + const centerTypeOptions = [ + { value: 'HOSPITAL', label: 'Hospital' }, + { value: 'CLINIC', label: 'Clinic' }, + { value: 'BLOOD_BANK', label: 'Blood Bank' }, + { value: 'LABORATORY', label: 'Laboratory' } + ] + + return ( +
+ + +
+ {/* Header */} +
+

+ Profile Settings +

+

+ Manage your account information and preferences +

+
+ + {/* Tab Navigation */} +
+
+ +
+ +
+ {isLoading ? ( +
+
+
+ ) : ( + <> + {/* General Information Tab */} + {activeTab === 'general' && ( +
+
+ + + + + + +
+ +

+ Optional: Get instant notifications about donation requests and updates. +

+
+
+ +
+ +
+
+ )} + + {/* Role-specific Tab */} + {activeTab === 'role' && ( +
+ {user.role === 'DONOR' ? ( + <> + {/* Donor Fields */} +
+ + + + + + + + + + + +
+ + {/* Availability Toggle */} +
+
+
+

+ Donation Availability +

+

+ Let health centers know if you're available to donate +

+
+ +
+
+ + ) : ( + <> + {/* Health Center Fields */} +
+ + + + + + + + + + + + + +
+ + )} + +
+ +
+
+ )} + + {/* Security Tab */} + {activeTab === 'security' && ( +
+
+

+ Change Password +

+

+ Use the change password endpoint to update your password securely. +

+ +
+ +
+

+ Danger Zone +

+

+ Once you delete your account, there is no going back. Please be certain. +

+ +
+
+ )} + + )} +
+
+ + {/* Back to Dashboard */} +
+ +
+
+ + {/* Toast Notification */} + setToast({ show: false, message: '', type: '' })} + /> +
+ ) +} + diff --git a/bloodlink-frontend/public/manifest.json b/bloodlink-frontend/public/manifest.json new file mode 100644 index 0000000..cf3b560 --- /dev/null +++ b/bloodlink-frontend/public/manifest.json @@ -0,0 +1,9 @@ +{ + "name": "Bloodlink", + "short_name": "Bloodlink", + "description": "Blood donor platform", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#D72638" +} \ No newline at end of file