This commit is contained in:
Iliyan Angelov
2025-11-16 20:05:08 +02:00
parent 98ccd5b6ff
commit 48353cde9c
118 changed files with 9488 additions and 1336 deletions

View File

@@ -0,0 +1,543 @@
import React, { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';
import {
User,
Mail,
Phone,
Save,
Loader2,
CheckCircle,
AlertCircle,
Lock,
Camera
} from 'lucide-react';
import { toast } from 'react-toastify';
import authService from '../../services/api/authService';
import useAuthStore from '../../store/useAuthStore';
import { Loading, EmptyState } from '../../components/common';
import { useAsync } from '../../hooks/useAsync';
import { useGlobalLoading } from '../../contexts/GlobalLoadingContext';
// Validation schema
const profileValidationSchema = yup.object().shape({
name: yup
.string()
.required('Full name is required')
.min(2, 'Full name must be at least 2 characters')
.max(100, 'Full name cannot exceed 100 characters'),
email: yup
.string()
.required('Email is required')
.email('Invalid email address'),
phone: yup
.string()
.required('Phone number is required')
.matches(
/^[0-9]{10,11}$/,
'Phone number must have 10-11 digits'
),
});
const passwordValidationSchema = yup.object().shape({
currentPassword: yup
.string()
.required('Current password is required'),
newPassword: yup
.string()
.required('New password is required')
.min(6, 'Password must be at least 6 characters'),
confirmPassword: yup
.string()
.required('Please confirm your password')
.oneOf([yup.ref('newPassword')], 'Passwords must match'),
});
type ProfileFormData = yup.InferType<typeof profileValidationSchema>;
type PasswordFormData = yup.InferType<typeof passwordValidationSchema>;
const ProfilePage: React.FC = () => {
const { userInfo, setUser } = useAuthStore();
const { setLoading } = useGlobalLoading();
const [activeTab, setActiveTab] = useState<'profile' | 'password'>('profile');
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
// Fetch profile data
const fetchProfile = async () => {
const response = await authService.getProfile();
if (response.status === 'success' || response.success) {
const user = response.data?.user || response.data;
if (user) {
setUser(user);
return user;
}
}
throw new Error('Failed to load profile');
};
const {
data: profileData,
loading: loadingProfile,
error: profileError,
execute: refetchProfile
} = useAsync(fetchProfile, {
immediate: true,
onError: (error: any) => {
toast.error(error.message || 'Unable to load profile');
}
});
// Profile form
const {
register: registerProfile,
handleSubmit: handleSubmitProfile,
formState: { errors: profileErrors },
reset: resetProfile,
} = useForm<ProfileFormData>({
resolver: yupResolver(profileValidationSchema),
defaultValues: {
name: userInfo?.name || '',
email: userInfo?.email || '',
phone: userInfo?.phone || '',
},
});
// Password form
const {
register: registerPassword,
handleSubmit: handleSubmitPassword,
formState: { errors: passwordErrors },
reset: resetPassword,
} = useForm<PasswordFormData>({
resolver: yupResolver(passwordValidationSchema),
});
// Update form when profile data loads
useEffect(() => {
if (profileData || userInfo) {
const data = profileData || userInfo;
resetProfile({
name: data?.name || '',
email: data?.email || '',
phone: data?.phone || '',
});
if (data?.avatar) {
setAvatarPreview(data.avatar);
}
}
}, [profileData, userInfo, resetProfile]);
// Handle profile update
const onSubmitProfile = async (data: ProfileFormData) => {
try {
setLoading(true, 'Updating profile...');
// Check if updateProfile exists in authService, otherwise use userService
if ('updateProfile' in authService) {
const response = await (authService as any).updateProfile({
full_name: data.name,
email: data.email,
phone_number: data.phone,
});
if (response.status === 'success' || response.success) {
const updatedUser = response.data?.user || response.data;
if (updatedUser) {
setUser(updatedUser);
toast.success('Profile updated successfully!');
refetchProfile();
}
}
} else {
// Fallback: use userService if updateProfile doesn't exist
const { updateUser } = await import('../../services/api/userService');
const response = await updateUser(userInfo!.id, {
full_name: data.name,
email: data.email,
phone_number: data.phone,
});
if (response.success || response.status === 'success') {
const updatedUser = response.data?.user || response.data;
if (updatedUser) {
setUser({
id: updatedUser.id,
name: updatedUser.full_name || updatedUser.name,
email: updatedUser.email,
phone: updatedUser.phone_number || updatedUser.phone,
avatar: updatedUser.avatar,
role: updatedUser.role,
});
toast.success('Profile updated successfully!');
refetchProfile();
}
}
}
} catch (error: any) {
const errorMessage =
error.response?.data?.message ||
error.message ||
'Failed to update profile';
toast.error(errorMessage);
} finally {
setLoading(false);
}
};
// Handle password change
const onSubmitPassword = async (data: PasswordFormData) => {
try {
setLoading(true, 'Changing password...');
// Use updateProfile with password fields if available
if ('updateProfile' in authService) {
const response = await (authService as any).updateProfile({
currentPassword: data.currentPassword,
password: data.newPassword,
});
if (response.status === 'success' || response.success) {
toast.success('Password changed successfully!');
resetPassword();
}
} else {
// Fallback: use userService
const { updateUser } = await import('../../services/api/userService');
const response = await updateUser(userInfo!.id, {
password: data.newPassword,
});
if (response.success || response.status === 'success') {
toast.success('Password changed successfully!');
resetPassword();
}
}
} catch (error: any) {
const errorMessage =
error.response?.data?.message ||
error.message ||
'Failed to change password';
toast.error(errorMessage);
} finally {
setLoading(false);
}
};
// Handle avatar upload (placeholder - would need backend support)
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
// Validate file type
if (!file.type.startsWith('image/')) {
toast.error('Please select an image file');
return;
}
// Validate file size (max 2MB)
if (file.size > 2 * 1024 * 1024) {
toast.error('Image size must be less than 2MB');
return;
}
// Create preview
const reader = new FileReader();
reader.onloadend = () => {
setAvatarPreview(reader.result as string);
};
reader.readAsDataURL(file);
// TODO: Upload to backend
toast.info('Avatar upload feature coming soon');
}
};
if (loadingProfile) {
return <Loading fullScreen text="Loading profile..." />;
}
if (profileError && !userInfo) {
return (
<div className="container mx-auto px-4 py-8">
<EmptyState
title="Unable to Load Profile"
description={profileError.message || 'Something went wrong. Please try again.'}
action={{
label: 'Retry',
onClick: refetchProfile
}}
/>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8 max-w-4xl">
<div className="mb-8 animate-fade-in">
<h1 className="enterprise-section-title mb-2">
Profile Settings
</h1>
<p className="enterprise-section-subtitle">
Manage your account information and preferences
</p>
</div>
{/* Tabs */}
<div className="mb-6 border-b border-gray-200">
<div className="flex space-x-8">
<button
onClick={() => setActiveTab('profile')}
className={`py-4 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'profile'
? 'border-[#d4af37] text-[#d4af37]'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Profile Information
</button>
<button
onClick={() => setActiveTab('password')}
className={`py-4 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'password'
? 'border-[#d4af37] text-[#d4af37]'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Change Password
</button>
</div>
</div>
{/* Profile Tab */}
{activeTab === 'profile' && (
<div className="enterprise-card animate-slide-up">
<form onSubmit={handleSubmitProfile(onSubmitProfile)} className="space-y-6">
{/* Avatar Section */}
<div className="flex items-center space-x-6 pb-6 border-b border-gray-200">
<div className="relative">
{avatarPreview || userInfo?.avatar ? (
<img
src={avatarPreview || userInfo?.avatar}
alt="Profile"
className="w-24 h-24 rounded-full object-cover ring-4 ring-[#d4af37]/20"
/>
) : (
<div className="w-24 h-24 rounded-full bg-gradient-to-br from-[#d4af37] to-[#c9a227] flex items-center justify-center ring-4 ring-[#d4af37]/20">
<User className="w-12 h-12 text-white" />
</div>
)}
<label
htmlFor="avatar-upload"
className="absolute bottom-0 right-0 p-2 bg-[#d4af37] rounded-full cursor-pointer hover:bg-[#c9a227] transition-colors shadow-lg"
>
<Camera className="w-4 h-4 text-white" />
<input
id="avatar-upload"
type="file"
accept="image/*"
className="hidden"
onChange={handleAvatarChange}
/>
</label>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900">
{userInfo?.name || 'User'}
</h3>
<p className="text-sm text-gray-500">{userInfo?.email}</p>
<p className="text-xs text-gray-400 mt-1">
{userInfo?.role?.charAt(0).toUpperCase() + userInfo?.role?.slice(1)}
</p>
</div>
</div>
{/* Full Name */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<User className="w-4 h-4 inline mr-2" />
Full Name
<span className="text-red-500 ml-1">*</span>
</label>
<input
{...registerProfile('name')}
type="text"
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37] transition-colors ${
profileErrors.name ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="Enter your full name"
/>
{profileErrors.name && (
<p className="text-sm text-red-600 mt-1 flex items-center gap-1">
<AlertCircle className="w-4 h-4" />
{profileErrors.name.message}
</p>
)}
</div>
{/* Email */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<Mail className="w-4 h-4 inline mr-2" />
Email Address
<span className="text-red-500 ml-1">*</span>
</label>
<input
{...registerProfile('email')}
type="email"
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37] transition-colors ${
profileErrors.email ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="Enter your email"
/>
{profileErrors.email && (
<p className="text-sm text-red-600 mt-1 flex items-center gap-1">
<AlertCircle className="w-4 h-4" />
{profileErrors.email.message}
</p>
)}
</div>
{/* Phone */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<Phone className="w-4 h-4 inline mr-2" />
Phone Number
<span className="text-red-500 ml-1">*</span>
</label>
<input
{...registerProfile('phone')}
type="tel"
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37] transition-colors ${
profileErrors.phone ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="Enter your phone number"
/>
{profileErrors.phone && (
<p className="text-sm text-red-600 mt-1 flex items-center gap-1">
<AlertCircle className="w-4 h-4" />
{profileErrors.phone.message}
</p>
)}
</div>
{/* Submit Button */}
<div className="flex justify-end pt-4 border-t border-gray-200">
<button
type="submit"
className="flex items-center space-x-2 px-6 py-2.5 bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-white rounded-lg hover:from-[#f5d76e] hover:to-[#d4af37] transition-all duration-300 font-medium shadow-lg shadow-[#d4af37]/20 hover:shadow-[#d4af37]/30"
>
<Save className="w-4 h-4" />
<span>Save Changes</span>
</button>
</div>
</form>
</div>
)}
{/* Password Tab */}
{activeTab === 'password' && (
<div className="enterprise-card animate-slide-up">
<form onSubmit={handleSubmitPassword(onSubmitPassword)} className="space-y-6">
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-blue-600 mt-0.5" />
<div>
<h4 className="text-sm font-medium text-blue-900 mb-1">
Password Requirements
</h4>
<ul className="text-xs text-blue-700 space-y-1">
<li> At least 6 characters long</li>
<li> Use a combination of letters and numbers for better security</li>
</ul>
</div>
</div>
</div>
{/* Current Password */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<Lock className="w-4 h-4 inline mr-2" />
Current Password
<span className="text-red-500 ml-1">*</span>
</label>
<input
{...registerPassword('currentPassword')}
type="password"
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37] transition-colors ${
passwordErrors.currentPassword ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="Enter your current password"
/>
{passwordErrors.currentPassword && (
<p className="text-sm text-red-600 mt-1 flex items-center gap-1">
<AlertCircle className="w-4 h-4" />
{passwordErrors.currentPassword.message}
</p>
)}
</div>
{/* New Password */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<Lock className="w-4 h-4 inline mr-2" />
New Password
<span className="text-red-500 ml-1">*</span>
</label>
<input
{...registerPassword('newPassword')}
type="password"
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37] transition-colors ${
passwordErrors.newPassword ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="Enter your new password"
/>
{passwordErrors.newPassword && (
<p className="text-sm text-red-600 mt-1 flex items-center gap-1">
<AlertCircle className="w-4 h-4" />
{passwordErrors.newPassword.message}
</p>
)}
</div>
{/* Confirm Password */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<Lock className="w-4 h-4 inline mr-2" />
Confirm New Password
<span className="text-red-500 ml-1">*</span>
</label>
<input
{...registerPassword('confirmPassword')}
type="password"
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37] transition-colors ${
passwordErrors.confirmPassword ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="Confirm your new password"
/>
{passwordErrors.confirmPassword && (
<p className="text-sm text-red-600 mt-1 flex items-center gap-1">
<AlertCircle className="w-4 h-4" />
{passwordErrors.confirmPassword.message}
</p>
)}
</div>
{/* Submit Button */}
<div className="flex justify-end pt-4 border-t border-gray-200">
<button
type="submit"
className="flex items-center space-x-2 px-6 py-2.5 bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-white rounded-lg hover:from-[#f5d76e] hover:to-[#d4af37] transition-all duration-300 font-medium shadow-lg shadow-[#d4af37]/20 hover:shadow-[#d4af37]/30"
>
<Lock className="w-4 h-4" />
<span>Change Password</span>
</button>
</div>
</form>
</div>
)}
</div>
);
};
export default ProfilePage;