update
This commit is contained in:
543
Frontend/src/pages/customer/ProfilePage.tsx
Normal file
543
Frontend/src/pages/customer/ProfilePage.tsx
Normal 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;
|
||||
|
||||
Reference in New Issue
Block a user