feat: enable user profile avatar upload and display across header and settings.

This commit is contained in:
pradeepkumar
2026-02-01 00:35:22 +05:30
parent 465551fea5
commit 1ec1696db7
3 changed files with 389 additions and 61 deletions

View File

@@ -4,6 +4,8 @@ import { useState, useEffect, useRef } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import { useSession, signOut } from 'next-auth/react'; import { useSession, signOut } from 'next-auth/react';
import { agentsService } from '@/services/agents.service';
import { uploadService } from '@/services/upload.service';
const navLinks = [ const navLinks = [
{ label: 'Education', href: '/education' }, { label: 'Education', href: '/education' },
@@ -15,6 +17,7 @@ export function CommonHeader() {
const { data: session } = useSession(); const { data: session } = useSession();
const [showProfileMenu, setShowProfileMenu] = useState(false); const [showProfileMenu, setShowProfileMenu] = useState(false);
const profileMenuRef = useRef<HTMLDivElement>(null); const profileMenuRef = useRef<HTMLDivElement>(null);
const [profileImage, setProfileImage] = useState<string | null>(null);
// Close dropdown when clicking outside // Close dropdown when clicking outside
useEffect(() => { useEffect(() => {
@@ -33,9 +36,34 @@ export function CommonHeader() {
}; };
}, [showProfileMenu]); }, [showProfileMenu]);
// Fetch profile image from backend
useEffect(() => {
const fetchProfileImage = async () => {
try {
const profile = await agentsService.getMyProfile();
if (profile.avatar) {
try {
const avatarUrl = await uploadService.getPresignedDownloadUrl(profile.avatar);
setProfileImage(avatarUrl);
} catch {
setProfileImage(profile.avatar);
}
}
} catch (err) {
console.error('Failed to fetch profile image:', err);
}
};
if (session) {
fetchProfileImage();
}
}, [session]);
const userName = session?.user?.name; const userName = session?.user?.name;
const userEmail = session?.user?.email; const userEmail = session?.user?.email;
const userRole = (session?.user as any)?.role; const userRole = (session?.user as any)?.role;
// Use fetched profile image, fallback to session image
const userImage = profileImage || session?.user?.image;
// Determine dashboard link based on user role // Determine dashboard link based on user role
const dashboardLink = userRole === 'AGENT' ? '/agent/dashboard' : '/user/dashboard'; const dashboardLink = userRole === 'AGENT' ? '/agent/dashboard' : '/user/dashboard';
@@ -90,14 +118,22 @@ export function CommonHeader() {
className="flex items-center gap-3 hover:opacity-80 transition-opacity cursor-pointer" className="flex items-center gap-3 hover:opacity-80 transition-opacity cursor-pointer"
> >
{/* Avatar */} {/* Avatar */}
<div className="w-[35px] h-[35px] rounded-full overflow-hidden border-2 border-[#e58625]"> <div className="w-[35px] h-[35px] rounded-full overflow-hidden border-2 border-[#e58625] bg-gray-100">
<Image {userImage ? (
src="/assets/icons/user-placeholder-icon.svg" <img
alt="Profile" src={userImage}
width={35} alt="Profile"
height={35} className="w-full h-full object-cover"
className="w-full h-full object-cover" />
/> ) : (
<Image
src="/assets/icons/user-placeholder-icon.svg"
alt="Profile"
width={35}
height={35}
className="w-full h-full object-cover"
/>
)}
</div> </div>
{/* Greeting Text */} {/* Greeting Text */}
<div className="flex flex-col text-left"> <div className="flex flex-col text-left">
@@ -118,14 +154,22 @@ export function CommonHeader() {
{/* User Profile Section */} {/* User Profile Section */}
<div className="flex items-center gap-3 px-4 py-4 border-b border-black/10"> <div className="flex items-center gap-3 px-4 py-4 border-b border-black/10">
<div className="w-[42px] h-[42px] rounded-full overflow-hidden flex-shrink-0"> <div className="w-[42px] h-[42px] rounded-full overflow-hidden flex-shrink-0 bg-gray-100">
<Image {userImage ? (
src="/assets/icons/user-placeholder-icon.svg" <img
alt="Profile" src={userImage}
width={42} alt="Profile"
height={42} className="w-full h-full object-cover"
className="w-full h-full object-cover" />
/> ) : (
<Image
src="/assets/icons/user-placeholder-icon.svg"
alt="Profile"
width={42}
height={42}
className="w-full h-full object-cover"
/>
)}
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<p className="font-fractul font-medium text-[16px] leading-[20px] text-black"> <p className="font-fractul font-medium text-[16px] leading-[20px] text-black">

View File

@@ -1,7 +1,10 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useRef, useEffect } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import { useSession, signIn } from 'next-auth/react';
import { uploadService } from '@/services/upload.service';
import { agentsService } from '@/services/agents.service';
interface ProfileSettingsFormProps { interface ProfileSettingsFormProps {
initialData?: { initialData?: {
@@ -10,6 +13,7 @@ interface ProfileSettingsFormProps {
email: string; email: string;
phone: string; phone: string;
location: string; location: string;
avatar?: string;
}; };
onSave?: (data: { onSave?: (data: {
fullName: string; fullName: string;
@@ -22,34 +26,234 @@ interface ProfileSettingsFormProps {
} }
export function ProfileSettingsForm({ export function ProfileSettingsForm({
initialData = { initialData,
fullName: 'Brain Neeland',
career: 'Real Estate Agent',
email: 'Brain.Neeland1234@gmail.com',
phone: '+917483849544',
location: 'New York',
},
onSave, onSave,
descriptionText = 'This information will be displayed on your public profile page.', descriptionText = 'This information will be displayed on your public profile page.',
}: ProfileSettingsFormProps) { }: ProfileSettingsFormProps) {
const [formData, setFormData] = useState(initialData); const { data: session, update: updateSession } = useSession();
const fileInputRef = useRef<HTMLInputElement>(null);
const [formData, setFormData] = useState({
fullName: initialData?.fullName || '',
career: initialData?.career || '',
email: initialData?.email || '',
phone: initialData?.phone || '',
location: initialData?.location || '',
});
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
// Fetch profile data on mount
useEffect(() => {
const fetchProfile = async () => {
try {
setIsLoading(true);
const profile = await agentsService.getMyProfile();
setFormData({
fullName: `${profile.firstName || ''} ${profile.lastName || ''}`.trim(),
career: profile.agentType?.name || 'Real Estate Agent',
email: profile.email || session?.user?.email || '',
phone: profile.phone || '',
location: profile.serviceAreas?.[0] || '',
});
// Handle avatar URL - if it's an S3 key, get presigned URL
if (profile.avatar) {
try {
const presignedUrl = await uploadService.getPresignedDownloadUrl(profile.avatar);
setAvatarUrl(presignedUrl);
} catch {
// If getting presigned URL fails, the avatar might be a full URL
setAvatarUrl(profile.avatar);
}
}
} catch (err) {
console.error('Failed to fetch profile:', err);
// Use session data as fallback
if (session?.user) {
setFormData(prev => ({
...prev,
fullName: session.user?.name || prev.fullName,
email: session.user?.email || prev.email,
}));
if (session.user.image) {
setAvatarUrl(session.user.image);
}
}
} finally {
setIsLoading(false);
}
};
if (session) {
fetchProfile();
}
}, [session]);
const handleChange = (field: string, value: string) => { const handleChange = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value })); setFormData((prev) => ({ ...prev, [field]: value }));
setError(null);
setSuccessMessage(null);
}; };
const handleSave = () => { const handleUploadClick = () => {
if (onSave) { fileInputRef.current?.click();
onSave(formData); };
} else {
console.log('Saving profile settings:', formData); const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
// Validate file type
const validTypes = ['image/jpeg', 'image/png', 'image/gif'];
if (!validTypes.includes(file.type)) {
setError('Please upload a JPEG, PNG, or GIF image.');
return;
}
// Validate file size (2MB max)
const maxSize = 2 * 1024 * 1024;
if (file.size > maxSize) {
setError('File size must be less than 2MB.');
return;
}
try {
setIsUploading(true);
setError(null);
setUploadProgress(0);
// Upload the avatar
const uploadedFile = await uploadService.uploadAvatar(file, (progress) => {
setUploadProgress(progress.percentage);
});
// Update the profile with the new avatar key
await agentsService.updateProfile({ avatar: uploadedFile.url });
// Get the presigned URL for display
const presignedUrl = await uploadService.getPresignedDownloadUrl(uploadedFile.url);
setAvatarUrl(presignedUrl);
// Update the session to reflect the new avatar
await updateSession({
...session,
user: {
...session?.user,
image: presignedUrl,
},
});
setSuccessMessage('Profile picture updated successfully!');
} catch (err) {
console.error('Upload failed:', err);
setError('Failed to upload profile picture. Please try again.');
} finally {
setIsUploading(false);
setUploadProgress(0);
// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
const handleDeleteAvatar = async () => {
try {
setIsUploading(true);
setError(null);
// Update profile to remove avatar
await agentsService.updateProfile({ avatar: null });
setAvatarUrl(null);
// Update session
await updateSession({
...session,
user: {
...session?.user,
image: null,
},
});
setSuccessMessage('Profile picture removed successfully!');
} catch (err) {
console.error('Delete failed:', err);
setError('Failed to remove profile picture. Please try again.');
} finally {
setIsUploading(false);
}
};
const handleSave = async () => {
try {
setIsSaving(true);
setError(null);
// Split full name into first and last name
const nameParts = formData.fullName.trim().split(' ');
const firstName = nameParts[0] || '';
const lastName = nameParts.slice(1).join(' ') || '';
// Update profile
await agentsService.updateProfile({
firstName,
lastName,
email: formData.email,
phone: formData.phone,
serviceAreas: formData.location ? [formData.location] : [],
});
// Update session with new name
await updateSession({
...session,
user: {
...session?.user,
name: formData.fullName,
},
});
if (onSave) {
onSave(formData);
}
setSuccessMessage('Profile settings saved successfully!');
} catch (err) {
console.error('Save failed:', err);
setError('Failed to save profile settings. Please try again.');
} finally {
setIsSaving(false);
} }
}; };
const handleCancel = () => { const handleCancel = () => {
setFormData(initialData); // Reset to initial data or session data
if (session?.user) {
setFormData(prev => ({
...prev,
fullName: session.user?.name || prev.fullName,
email: session.user?.email || prev.email,
}));
}
setError(null);
setSuccessMessage(null);
}; };
if (isLoading) {
return (
<div className="border border-[#00293d]/20 rounded-[15px] bg-white p-8 flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#e58625]"></div>
</div>
);
}
return ( return (
<div className="border border-[#00293d]/20 rounded-[15px] bg-white p-8"> <div className="border border-[#00293d]/20 rounded-[15px] bg-white p-8">
{/* Header */} {/* Header */}
@@ -62,27 +266,62 @@ export function ProfileSettingsForm({
</p> </p>
</div> </div>
{/* Error/Success Messages */}
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-[10px] text-red-600 text-sm font-serif">
{error}
</div>
)}
{successMessage && (
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-[10px] text-green-600 text-sm font-serif">
{successMessage}
</div>
)}
{/* Profile Photo Section */} {/* Profile Photo Section */}
<div className="mb-8"> <div className="mb-8">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="w-[60px] h-[60px] rounded-full overflow-hidden border border-[#00293D]/20 flex-shrink-0"> <div className="w-[60px] h-[60px] rounded-full overflow-hidden border border-[#00293D]/20 flex-shrink-0 bg-gray-100">
<Image {avatarUrl ? (
src="/assets/icons/user-placeholder-icon.svg" <img
alt="Profile" src={avatarUrl}
width={60} alt="Profile"
height={60} className="w-full h-full object-cover"
className="w-full h-full object-cover" />
/> ) : (
<Image
src="/assets/icons/user-placeholder-icon.svg"
alt="Profile"
width={60}
height={60}
className="w-full h-full object-cover"
/>
)}
</div> </div>
<div> <div>
<p className="font-serif font-medium text-[14px] text-[#00293D] mb-2"> <p className="font-serif font-medium text-[14px] text-[#00293D] mb-2">
Public Picture Public Picture
</p> </p>
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<button className="px-4 py-1.5 bg-[#e58625] text-white rounded-[20px] font-serif text-[12px] hover:bg-[#d47920] transition-colors"> <input
Upload Now ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif"
onChange={handleFileChange}
className="hidden"
/>
<button
onClick={handleUploadClick}
disabled={isUploading}
className="px-4 py-1.5 bg-[#e58625] text-white rounded-[20px] font-serif text-[12px] hover:bg-[#d47920] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isUploading ? `Uploading ${uploadProgress}%` : 'Upload Now'}
</button> </button>
<button className="px-4 py-1.5 border border-[#00293D]/30 text-[#00293D] rounded-[20px] font-serif text-[12px] hover:bg-[#00293d]/5 transition-colors"> <button
onClick={handleDeleteAvatar}
disabled={isUploading || !avatarUrl}
className="px-4 py-1.5 border border-[#00293D]/30 text-[#00293D] rounded-[20px] font-serif text-[12px] hover:bg-[#00293d]/5 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Delete Delete
</button> </button>
</div> </div>
@@ -175,15 +414,17 @@ export function ProfileSettingsForm({
<div className="mt-8 flex justify-end gap-3"> <div className="mt-8 flex justify-end gap-3">
<button <button
onClick={handleCancel} onClick={handleCancel}
className="px-8 py-3 border border-[#00293D]/30 text-[#00293D] rounded-[10px] font-serif font-medium text-[14px] hover:bg-[#00293d]/5 transition-colors" disabled={isSaving}
className="px-8 py-3 border border-[#00293D]/30 text-[#00293D] rounded-[10px] font-serif font-medium text-[14px] hover:bg-[#00293d]/5 transition-colors disabled:opacity-50"
> >
Cancel Cancel
</button> </button>
<button <button
onClick={handleSave} onClick={handleSave}
className="px-8 py-3 bg-[#e58625] text-white rounded-[10px] font-serif font-medium text-[14px] hover:bg-[#d47920] transition-colors" disabled={isSaving}
className="px-8 py-3 bg-[#e58625] text-white rounded-[10px] font-serif font-medium text-[14px] hover:bg-[#d47920] transition-colors disabled:opacity-50"
> >
Save Changes {isSaving ? 'Saving...' : 'Save Changes'}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -1,8 +1,12 @@
'use client'; 'use client';
import { useState, useEffect } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { useSession } from 'next-auth/react';
import { agentsService } from '@/services/agents.service';
import { uploadService } from '@/services/upload.service';
interface NavItem { interface NavItem {
label: string; label: string;
@@ -11,19 +15,50 @@ interface NavItem {
} }
interface SettingsSidebarProps { interface SettingsSidebarProps {
profileImage?: string;
name?: string;
title?: string;
basePath?: '/agent/settings' | '/user/settings'; basePath?: '/agent/settings' | '/user/settings';
} }
export function SettingsSidebar({ export function SettingsSidebar({
profileImage = '/assets/icons/user-placeholder-icon.svg',
name = 'Brain Neeland',
title = 'Top Real Estate Agent',
basePath = '/agent/settings', basePath = '/agent/settings',
}: SettingsSidebarProps) { }: SettingsSidebarProps) {
const pathname = usePathname(); const pathname = usePathname();
const { data: session } = useSession();
const [profileData, setProfileData] = useState({
name: session?.user?.name || 'User',
title: 'Real Estate Agent',
avatarUrl: session?.user?.image || null,
});
useEffect(() => {
const fetchProfile = async () => {
try {
const profile = await agentsService.getMyProfile();
let avatarUrl = session?.user?.image || null;
if (profile.avatar) {
try {
avatarUrl = await uploadService.getPresignedDownloadUrl(profile.avatar);
} catch {
avatarUrl = profile.avatar;
}
}
setProfileData({
name: `${profile.firstName || ''} ${profile.lastName || ''}`.trim() || session?.user?.name || 'User',
title: profile.agentType?.name || 'Real Estate Agent',
avatarUrl,
});
} catch (err) {
console.error('Failed to fetch profile for sidebar:', err);
// Keep session data as fallback
}
};
if (session) {
fetchProfile();
}
}, [session]);
const navItems: NavItem[] = [ const navItems: NavItem[] = [
{ {
@@ -53,21 +88,29 @@ export function SettingsSidebar({
{/* Profile Card */} {/* Profile Card */}
<div className="bg-white rounded-[15px] border border-[#00293D]/20 p-5"> <div className="bg-white rounded-[15px] border border-[#00293D]/20 p-5">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="w-[70px] h-[70px] rounded-full overflow-hidden border-2 border-[#00293D]/20 flex-shrink-0"> <div className="w-[70px] h-[70px] rounded-full overflow-hidden border-2 border-[#00293D]/20 flex-shrink-0 bg-gray-100">
<Image {profileData.avatarUrl ? (
src={profileImage} <img
alt={name} src={profileData.avatarUrl}
width={70} alt={profileData.name}
height={70} className="w-full h-full object-cover"
className="w-full h-full object-cover" />
/> ) : (
<Image
src="/assets/icons/user-placeholder-icon.svg"
alt={profileData.name}
width={70}
height={70}
className="w-full h-full object-cover"
/>
)}
</div> </div>
<div> <div>
<h3 className="font-fractul font-bold text-[16px] leading-[19px] text-[#00293D]"> <h3 className="font-fractul font-bold text-[16px] leading-[19px] text-[#00293D]">
{name} {profileData.name}
</h3> </h3>
<p className="font-serif font-normal text-[13px] leading-[17px] text-[#00293D]/60 mt-1"> <p className="font-serif font-normal text-[13px] leading-[17px] text-[#00293D]/60 mt-1">
{title} {profileData.title}
</p> </p>
</div> </div>
</div> </div>