diff --git a/app/(dashboard)/settings/profile/page.tsx b/app/(dashboard)/settings/profile/page.tsx new file mode 100644 index 0000000..a50f985 --- /dev/null +++ b/app/(dashboard)/settings/profile/page.tsx @@ -0,0 +1,325 @@ +'use client'; + +import { useState, useRef } from 'react'; +import { useForm } from 'react-hook-form'; +import { useUser } from '@/hooks/useUser'; +import { + User, + Lock, + Phone, + Camera, + Loader2, + CheckCircle2, + AlertCircle +} from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface ProfileFormValues { + name: string; + phone: string; +} + +interface PasswordFormValues { + currentPassword: string; + newPassword: string; + confirmPassword: string; +} + +export default function ProfileSettingsPage() { + const { profile, isLoading, updateProfile, changePassword } = useUser(); + const [activeTab, setActiveTab] = useState<'profile' | 'security'>('profile'); + const [isUploading, setIsUploading] = useState(false); + const fileInputRef = useRef(null); + + // Profile Form + const { + register: registerProfile, + handleSubmit: handleSubmitProfile, + formState: { errors: profileErrors, isDirty: isProfileDirty }, + } = useForm({ + values: { + name: profile?.name || '', + phone: profile?.phone || '', + }, + }); + + // Password Form + const { + register: registerPassword, + handleSubmit: handleSubmitPassword, + formState: { errors: passwordErrors, isSubmitting: isChangingPassword }, + reset: resetPassword, + watch: watchPassword + } = useForm(); + + const newPassword = watchPassword('newPassword'); + + const onProfileSubmit = async (data: ProfileFormValues) => { + await updateProfile(data); + }; + + const onPasswordSubmit = async (data: PasswordFormValues) => { + const success = await changePassword(data); + if (success) resetPassword(); + }; + + const handleImageClick = () => { + fileInputRef.current?.click(); + }; + + const handleImageChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + setIsUploading(true); + await updateProfile({ profileImage: file }); + setIsUploading(false); + } + }; + + if (!profile && isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+
+

Account Settings

+

Manage your personal information and security settings.

+
+ +
+ {/* Sidebar / Tabs */} + + + {/* Main Content */} +
+
+ {activeTab === 'profile' ? ( +
+
+
+
+ {profile?.profileImage ? ( + {profile.name} + ) : ( +
+ {profile?.name?.charAt(0) || 'U'} +
+ )} + + {isUploading && ( +
+ +
+ )} +
+ + + +
+ +
+

{profile?.name}

+

{profile?.email}

+

Member Since 2024

+
+
+ +
+
+
+ + + {profileErrors.name && ( +

+ + {profileErrors.name.message} +

+ )} +
+ +
+ + + {profileErrors.phone && ( +

+ + {profileErrors.phone.message} +

+ )} +
+
+ +
+ +
+
+
+ ) : ( +
+
+

Change Password

+

Ensure your account is using a long, random password to stay secure.

+
+ +
+
+ + + {passwordErrors.currentPassword && ( +

{passwordErrors.currentPassword.message}

+ )} +
+ +
+ + + {passwordErrors.newPassword && ( +

{passwordErrors.newPassword.message}

+ )} +
+ +
+ + value === newPassword || "Passwords don't match" + })} + className={cn( + "w-full px-4 py-3 rounded-xl border transition-all outline-none focus:ring-2", + passwordErrors.confirmPassword + ? "border-red-300 focus:ring-red-100" + : "border-gray-200 focus:border-blue-500 focus:ring-blue-100" + )} + /> + {passwordErrors.confirmPassword && ( +

{passwordErrors.confirmPassword.message}

+ )} +
+ +
+ +
+
+
+ )} +
+
+
+
+ ); +} diff --git a/hooks/__tests__/useUser.test.ts b/hooks/__tests__/useUser.test.ts new file mode 100644 index 0000000..8829291 --- /dev/null +++ b/hooks/__tests__/useUser.test.ts @@ -0,0 +1,97 @@ +import { renderHook, act } from '@testing-library/react'; +import { useUser } from '../useUser'; +import { userService } from '@/services/userService'; +import { toast } from 'sonner'; + +// Mock the userService and sonner +jest.mock('@/services/userService'); +jest.mock('sonner'); + +describe('useUser Hook', () => { + const mockProfile = { + id: '1', + name: 'John Doe', + email: 'john@example.com', + phone: '1234567890', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should fetch profile on mount', async () => { + (userService.getUserProfile as jest.Mock).mockResolvedValue({ + success: true, + data: mockProfile, + }); + + const { result } = renderHook(() => useUser()); + + // Initially loading + expect(result.current.isLoading).toBe(true); + + // Wait for the hook to finish fetching + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(result.current.profile).toEqual(mockProfile); + expect(result.current.isLoading).toBe(false); + expect(userService.getUserProfile).toHaveBeenCalledTimes(1); + }); + + it('should handle fetch profile error', async () => { + (userService.getUserProfile as jest.Mock).mockRejectedValue(new Error('API Error')); + + const { result } = renderHook(() => useUser()); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(result.current.error).toBe('An error occurred while fetching profile'); + expect(result.current.isLoading).toBe(false); + }); + + it('should update profile successfully', async () => { + (userService.getUserProfile as jest.Mock).mockResolvedValue({ + success: true, + data: mockProfile, + }); + + (userService.updateProfile as jest.Mock).mockResolvedValue({ + success: true, + data: { ...mockProfile, name: 'Jane Doe' }, + }); + + const { result } = renderHook(() => useUser()); + + await act(async () => { + await result.current.updateProfile({ name: 'Jane Doe' }); + }); + + expect(result.current.profile?.name).toBe('Jane Doe'); + expect(toast.success).toHaveBeenCalledWith('Profile updated successfully'); + }); + + it('should change password successfully', async () => { + (userService.changePassword as jest.Mock).mockResolvedValue({ + success: true, + message: 'Password changed', + }); + + const { result } = renderHook(() => useUser()); + + let success; + await act(async () => { + success = await result.current.changePassword({ + currentPassword: 'old', + newPassword: 'new', + confirmPassword: 'new', + }); + }); + + expect(success).toBe(true); + expect(toast.success).toHaveBeenCalledWith('Password changed successfully'); + }); +}); diff --git a/hooks/useUser.ts b/hooks/useUser.ts new file mode 100644 index 0000000..a2b0ec1 --- /dev/null +++ b/hooks/useUser.ts @@ -0,0 +1,83 @@ +import { useState, useCallback, useEffect } from 'react'; +import { userService, UserProfile, UpdateProfileData, ChangePasswordData } from '@/services/userService'; +import { toast } from 'sonner'; + +/** + * useUser — the single hook for user profile management. + * + * Provides methods to fetch, update profile, and change password. + * Ensures the UI reflects changes instantly via local state updates. + */ +export function useUser() { + const [profile, setProfile] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchProfile = useCallback(async () => { + setIsLoading(true); + try { + const response = await userService.getUserProfile(); + if (response.success && response.data) { + setProfile(response.data); + } else { + setError(response.message || 'Failed to fetch profile'); + } + } catch (err: any) { + setError(err.response?.data?.message || 'An error occurred while fetching profile'); + } finally { + setIsLoading(false); + } + }, []); + + const updateProfile = async (data: UpdateProfileData) => { + setIsLoading(true); + try { + const response = await userService.updateProfile(data); + if (response.success && response.data) { + setProfile(response.data); + toast.success('Profile updated successfully'); + return true; + } else { + toast.error(response.message || 'Failed to update profile'); + return false; + } + } catch (err: any) { + toast.error(err.response?.data?.message || 'An error occurred while updating profile'); + return false; + } finally { + setIsLoading(false); + } + }; + + const changePassword = async (data: ChangePasswordData) => { + setIsLoading(true); + try { + const response = await userService.changePassword(data); + if (response.success) { + toast.success('Password changed successfully'); + return true; + } else { + toast.error(response.message || 'Failed to change password'); + return false; + } + } catch (err: any) { + toast.error(err.response?.data?.message || 'An error occurred while changing password'); + return false; + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchProfile(); + }, [fetchProfile]); + + return { + profile, + isLoading, + error, + updateProfile, + changePassword, + refreshProfile: fetchProfile + }; +} diff --git a/lib/utils.ts b/lib/utils.ts new file mode 100644 index 0000000..365058c --- /dev/null +++ b/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/services/userService.ts b/services/userService.ts new file mode 100644 index 0000000..a633062 --- /dev/null +++ b/services/userService.ts @@ -0,0 +1,72 @@ +import axios from 'axios'; + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? ''; + +export interface UserProfile { + id: string; + name: string; + email: string; + phone: string; + profileImage?: string; +} + +export interface UpdateProfileData { + name?: string; + phone?: string; + profileImage?: File | string; +} + +export interface ChangePasswordData { + currentPassword: string; + newPassword: string; + confirmPassword: string; +} + +export interface ApiResponse { + success: boolean; + message: string; + data?: T; +} + +/** + * userService — responsible for all user-related API communication. + * Follows the Strict Layered Architecture: Component -> Hook -> Service. + */ +export const userService = { + async getUserProfile(): Promise> { + const { data } = await axios.get>(`${API_BASE_URL}/api/user/profile`); + return data; + }, + + async updateProfile(profileData: UpdateProfileData): Promise> { + // If there's a file, we might need FormData + let payload: any = profileData; + + if (profileData.profileImage instanceof File) { + const formData = new FormData(); + if (profileData.name) formData.append('name', profileData.name); + if (profileData.phone) formData.append('phone', profileData.phone); + formData.append('profileImage', profileData.profileImage); + payload = formData; + } + + const { data } = await axios.patch>( + `${API_BASE_URL}/api/user/profile`, + payload, + { + headers: { + 'Content-Type': profileData.profileImage instanceof File ? 'multipart/form-data' : 'application/json', + }, + } + ); + return data; + }, + + async changePassword(passwordData: ChangePasswordData): Promise> { + const { data } = await axios.post>( + `${API_BASE_URL}/api/user/change-password`, + passwordData + ); + return data; + }, +};