This commit is contained in:
pradeepkumar
2026-01-24 22:19:30 +05:30
parent 7d6ef356e2
commit a0559a17f1
6 changed files with 454 additions and 40 deletions

View File

@@ -11,6 +11,14 @@ const nextConfig: NextConfig = {
protocol: "https", protocol: "https",
hostname: "**", hostname: "**",
}, },
{
protocol: "http",
hostname: "localhost",
},
{
protocol: "http",
hostname: "127.0.0.1",
},
], ],
}, },
}; };

View File

@@ -1,5 +1,6 @@
'use client'; 'use client';
import { useState, useEffect } from 'react';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import Image from 'next/image'; import Image from 'next/image';
@@ -12,20 +13,13 @@ import {
TestimonialsSection, TestimonialsSection,
StatusButtons, StatusButtons,
ContactInfo, ContactInfo,
PhotoUploadModal,
} from '@/components/profile'; } from '@/components/profile';
import { agentsService, AgentProfile } from '@/services/agents.service';
import { uploadService } from '@/services/upload.service';
// Mock agent data - in production, this would come from API // Mock data for sections not yet available from API
const agentData = { const mockData = {
name: 'Brian Neeland',
isVerified: true,
title: 'Licensed Real Estate Agent',
location: 'Colorado',
memberSince: 'March 2020',
bio: 'Brian brings eight years of hands-on experience helping clients confidently buy, sell, and invest in real estate. He combines strong analytical skills with deep market knowledge to identify the right opportunities and guide clients through every step of the process. With a data-driven approach and clear communication, Brian ensures smooth transactions and informed decisions tailored to each client\'s goals.',
expertise: ['Buyers', 'Sellers', 'Divorce', 'Luxury', 'Retirement', 'Historical', 'condo', 'Rural', 'FHA', 'Conventional', 'USDA', 'Retirement'],
email: 'brian@gmail.com',
phone: '+91*******7493',
profileImage: '/assets/demo_images/8f017153a7b4a239a4f0691234ef97dd55092282.jpg',
experience: { experience: {
years: '10+ years', years: '10+ years',
contracts: '10+ Contracts', contracts: '10+ Contracts',
@@ -81,7 +75,106 @@ const agentData = {
export default function AgentDashboard() { export default function AgentDashboard() {
const { data: session } = useSession(); const { data: session } = useSession();
const [firstName, lastName] = agentData.name.split(' '); const [agentProfile, setAgentProfile] = useState<AgentProfile | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isPhotoModalOpen, setIsPhotoModalOpen] = useState(false);
const [imageError, setImageError] = useState(false);
const defaultImage = '/assets/demo_images/8f017153a7b4a239a4f0691234ef97dd55092282.jpg';
// Fetch agent profile on mount
useEffect(() => {
const fetchProfile = async () => {
try {
setLoading(true);
setError(null);
const profile = await agentsService.getMyProfile();
setAgentProfile(profile);
} catch (err) {
console.error('Failed to fetch profile:', err);
setError('Failed to load profile data');
} finally {
setLoading(false);
}
};
fetchProfile();
}, []);
const handlePhotoUpload = async (file: File) => {
// Upload avatar to S3 (uses agent ID as filename for auto-replacement)
const uploadedFile = await uploadService.uploadAvatar(file);
// Update agent profile with the S3 URL
const updatedProfile = await agentsService.updateProfile({ avatar: uploadedFile.url });
setImageError(false); // Reset error state for new image
setAgentProfile(updatedProfile);
};
const getProfileImageUrl = () => {
// If image failed to load, return default
if (imageError) {
return defaultImage;
}
// Check for null, undefined, or empty string
if (!agentProfile?.avatar || agentProfile.avatar.trim() === '') {
return defaultImage;
}
// S3 URLs are full URLs starting with http
if (agentProfile.avatar.startsWith('http')) {
return agentProfile.avatar;
}
// For relative paths (local assets), return as-is
return agentProfile.avatar;
};
if (loading) {
return (
<div className="flex items-center justify-center h-[calc(100vh-180px)]">
<div className="flex flex-col items-center gap-4">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#E58625]"></div>
<p className="text-[14px] font-serif text-[#00293D]/70">Loading profile...</p>
</div>
</div>
);
}
if (error || !agentProfile) {
return (
<div className="flex items-center justify-center h-[calc(100vh-180px)]">
<div className="bg-white rounded-[20px] p-8 shadow-[0px_10px_20px_rgba(217,217,217,0.5)] max-w-md text-center">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h2 className="text-[18px] font-bold font-serif text-[#00293D] mb-2">Unable to Load Profile</h2>
<p className="text-[14px] font-serif text-[#00293D]/70 mb-4">{error || 'Profile not found'}</p>
<button
onClick={() => window.location.reload()}
className="px-6 py-2 bg-[#E58625] rounded-full text-[14px] font-semibold font-serif text-white hover:bg-[#E58625]/90 transition-colors"
>
Try Again
</button>
</div>
</div>
);
}
// Format member since date
const formatMemberSince = (dateString?: string) => {
if (!dateString) return 'Member';
try {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
} catch {
return 'Member';
}
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -92,18 +185,24 @@ export default function AgentDashboard() {
{/* Profile Image */} {/* Profile Image */}
<div className="relative w-[200px] lg:w-[260px]"> <div className="relative w-[200px] lg:w-[260px]">
<div className="w-[200px] h-[200px] lg:w-[260px] lg:h-[260px] rounded-[15px] overflow-hidden"> <div className="w-[200px] h-[200px] lg:w-[260px] lg:h-[260px] rounded-[15px] overflow-hidden">
<Image {/* eslint-disable-next-line @next/next/no-img-element */}
src={agentData.profileImage} <img
src={getProfileImageUrl()}
alt="Profile" alt="Profile"
width={260}
height={260}
className="w-full h-full object-cover" className="w-full h-full object-cover"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = defaultImage;
}}
/> />
{/* Gradient Overlay */} {/* Gradient Overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-black/20 to-transparent pointer-events-none" /> <div className="absolute inset-0 bg-gradient-to-t from-black/50 via-black/20 to-transparent pointer-events-none" />
</div> </div>
{/* Edit Icon - Top Right Outside */} {/* Edit Icon - Top Right Outside */}
<button className="absolute -top-3 -right-3 w-9 h-9 bg-white rounded-[10px] shadow-md flex items-center justify-center hover:bg-gray-50 transition-colors"> <button
onClick={() => setIsPhotoModalOpen(true)}
className="absolute -top-3 -right-3 w-9 h-9 bg-white rounded-[10px] shadow-md flex items-center justify-center hover:bg-gray-50 transition-colors"
>
<Image <Image
src="/assets/icons/edit-icon.svg" src="/assets/icons/edit-icon.svg"
alt="Edit profile image" alt="Edit profile image"
@@ -117,21 +216,24 @@ export default function AgentDashboard() {
<StatusButtons /> <StatusButtons />
{/* Contact Info */} {/* Contact Info */}
<ContactInfo email={agentData.email} phone={agentData.phone} /> <ContactInfo
email={agentProfile.email || agentProfile.user?.email || ''}
phone={agentProfile.phone || ''}
/>
</div> </div>
{/* Right Content - Profile Info + Experience + All Sections */} {/* Right Content - Profile Info + Experience + All Sections */}
<div className="flex-1 space-y-4"> <div className="flex-1 space-y-4">
{/* Profile Card */} {/* Profile Card */}
<ProfileCard <ProfileCard
firstName={firstName} firstName={agentProfile.firstName}
lastName={lastName} lastName={agentProfile.lastName}
isVerified={agentData.isVerified} isVerified={agentProfile.isVerified}
title={agentData.title} title={agentProfile.agentType?.name || 'Real Estate Agent'}
location={agentData.location} location={agentProfile.serviceAreas?.[0] || 'United States'}
memberSince={agentData.memberSince} memberSince={formatMemberSince((agentProfile as unknown as { createdAt?: string }).createdAt)}
bio={agentData.bio} bio={agentProfile.bio || ''}
expertise={agentData.expertise} expertise={agentProfile.specializations || []}
showEditButton={true} showEditButton={true}
editHref="/agent/edit" editHref="/agent/edit"
messageHref="/agent/message" messageHref="/agent/message"
@@ -139,7 +241,7 @@ export default function AgentDashboard() {
/> />
{/* Experience Section */} {/* Experience Section */}
<ExperienceSection experience={agentData.experience} /> <ExperienceSection experience={mockData.experience} />
{/* Info Cards Section */} {/* Info Cards Section */}
<div className="flex flex-col lg:grid lg:grid-cols-3 gap-6"> <div className="flex flex-col lg:grid lg:grid-cols-3 gap-6">
@@ -155,9 +257,9 @@ export default function AgentDashboard() {
} }
content={ content={
<div> <div>
<p className="font-semibold font-serif text-[14px] leading-[19px] text-[#00293D] mb-2">{agentData.availability.type}</p> <p className="font-semibold font-serif text-[14px] leading-[19px] text-[#00293D] mb-2">{mockData.availability.type}</p>
<div className="space-y-1"> <div className="space-y-1">
{agentData.availability.days.map((day) => ( {mockData.availability.days.map((day) => (
<p key={day} className="font-normal font-serif text-[14px] leading-[19px] text-[#00293D]">{day}</p> <p key={day} className="font-normal font-serif text-[14px] leading-[19px] text-[#00293D]">{day}</p>
))} ))}
</div> </div>
@@ -174,7 +276,7 @@ export default function AgentDashboard() {
height={28} height={28}
/> />
} }
content={<p className="font-serif font-semibold text-[14px] leading-[19px] text-[#00293D]">{agentData.preferredWorkEnvironment}</p>} content={<p className="font-serif font-semibold text-[14px] leading-[19px] text-[#00293D]">{mockData.preferredWorkEnvironment}</p>}
/> />
<InfoCard <InfoCard
title="" title=""
@@ -186,17 +288,25 @@ export default function AgentDashboard() {
height={28} height={28}
/> />
} }
content={<p className="text-[14px] font-semibold font-serif leading-[19px] text-center text-[#00293D]">&ldquo;{agentData.testimonialHighlight}&rdquo;</p>} content={<p className="text-[14px] font-semibold font-serif leading-[19px] text-center text-[#00293D]">&ldquo;{mockData.testimonialHighlight}&rdquo;</p>}
/> />
</div> </div>
{/* Specialization Section */} {/* Specialization Section */}
<SpecializationSection specialization={agentData.specialization} /> <SpecializationSection specialization={mockData.specialization} />
{/* Testimonials Section */} {/* Testimonials Section */}
<TestimonialsSection testimonials={agentData.testimonials} /> <TestimonialsSection testimonials={mockData.testimonials} />
</div> </div>
</div> </div>
{/* Photo Upload Modal */}
<PhotoUploadModal
isOpen={isPhotoModalOpen}
onClose={() => setIsPhotoModalOpen(false)}
onUpload={handlePhotoUpload}
currentPhoto={agentProfile.avatar}
/>
</div> </div>
); );
} }

View File

@@ -53,6 +53,9 @@ export default function EditProfilePage() {
setSections(sortedSections); setSections(sortedSections);
// Debug: Log all sections and their isRepeatable status
console.log('All sections:', sortedSections.map(s => ({ slug: s.slug, name: s.name, isRepeatable: s.isRepeatable })));
// Initialize form data with existing profile data + default values from fields // Initialize form data with existing profile data + default values from fields
const initialData: Record<string, unknown> = {}; const initialData: Record<string, unknown> = {};
@@ -100,7 +103,6 @@ export default function EditProfilePage() {
// Fetch existing field values from the API // Fetch existing field values from the API
try { try {
const fieldValuesResponse = await agentsService.getFieldValues(); const fieldValuesResponse = await agentsService.getFieldValues();
console.log('Field values from API:', fieldValuesResponse.fieldValues);
if (fieldValuesResponse.fieldValues) { if (fieldValuesResponse.fieldValues) {
fieldValuesResponse.fieldValues.forEach(fv => { fieldValuesResponse.fieldValues.forEach(fv => {
// Only set if value exists and is not null/undefined // Only set if value exists and is not null/undefined
@@ -114,8 +116,6 @@ export default function EditProfilePage() {
console.log('No existing field values found, using defaults'); console.log('No existing field values found, using defaults');
} }
console.log('initialData after field values:', initialData);
setFormData(initialData); setFormData(initialData);
// Initialize repeatable sections data // Initialize repeatable sections data
@@ -124,9 +124,7 @@ export default function EditProfilePage() {
if (section.isRepeatable) { if (section.isRepeatable) {
// Check if there's existing data for this section (stored as `${sectionSlug}_entries`) // Check if there's existing data for this section (stored as `${sectionSlug}_entries`)
const entriesKey = `${section.slug}_entries`; const entriesKey = `${section.slug}_entries`;
console.log(`Checking repeatable section: ${section.slug}, entriesKey: ${entriesKey}`);
const existingEntries = initialData[entriesKey]; const existingEntries = initialData[entriesKey];
console.log(`Existing entries for ${entriesKey}:`, existingEntries);
if (Array.isArray(existingEntries) && existingEntries.length > 0) { if (Array.isArray(existingEntries) && existingEntries.length > 0) {
initialRepeatableData[section.slug] = existingEntries as RepeatableEntryData[]; initialRepeatableData[section.slug] = existingEntries as RepeatableEntryData[];
} else { } else {
@@ -135,7 +133,22 @@ export default function EditProfilePage() {
} }
} }
}); });
console.log('initialRepeatableData:', initialRepeatableData);
// Also check for any _entries fields that might not match a repeatable section
// This handles cases where data was saved but section isn't marked as repeatable
Object.keys(initialData).forEach(key => {
if (key.endsWith('_entries') && Array.isArray(initialData[key])) {
const sectionSlug = key.replace('_entries', '');
if (!initialRepeatableData[sectionSlug]) {
// Find the section by slug
const section = sortedSections.find(s => s.slug === sectionSlug);
if (section) {
initialRepeatableData[sectionSlug] = initialData[key] as RepeatableEntryData[];
}
}
}
});
setRepeatableData(initialRepeatableData); setRepeatableData(initialRepeatableData);
} catch (err: unknown) { } catch (err: unknown) {

View File

@@ -0,0 +1,251 @@
'use client';
import { useState, useRef, useCallback } from 'react';
import Image from 'next/image';
interface PhotoUploadModalProps {
isOpen: boolean;
onClose: () => void;
onUpload: (file: File) => Promise<void>;
currentPhoto?: string | null;
}
export function PhotoUploadModal({ isOpen, onClose, onUpload, currentPhoto }: PhotoUploadModalProps) {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const validateFile = (file: File): string | null => {
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
const maxSize = 5 * 1024 * 1024; // 5MB
if (!allowedTypes.includes(file.type)) {
return 'Only JPG, PNG, GIF, and WebP images are allowed';
}
if (file.size > maxSize) {
return 'File size must be less than 5MB';
}
return null;
};
const handleFileSelect = (file: File) => {
const validationError = validateFile(file);
if (validationError) {
setError(validationError);
return;
}
setError(null);
setSelectedFile(file);
// Create preview URL
const reader = new FileReader();
reader.onload = (e) => {
setPreviewUrl(e.target?.result as string);
};
reader.readAsDataURL(file);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
handleFileSelect(file);
}
};
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files?.[0];
if (file) {
handleFileSelect(file);
}
}, []);
const handleUpload = async () => {
if (!selectedFile) return;
try {
setIsUploading(true);
setError(null);
await onUpload(selectedFile);
handleClose();
} catch (err) {
console.error('Upload failed:', err);
setError('Failed to upload photo. Please try again.');
} finally {
setIsUploading(false);
}
};
const handleClose = () => {
setSelectedFile(null);
setPreviewUrl(null);
setError(null);
setIsDragging(false);
onClose();
};
const handleBrowseClick = () => {
fileInputRef.current?.click();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/30" onClick={handleClose} />
{/* Modal */}
<div className="relative bg-white rounded-[20px] w-[500px] max-w-[90vw] overflow-hidden shadow-xl">
{/* Header */}
<div className="px-8 py-5 border-b border-[#d9d9d9] flex items-center justify-between">
<h2 className="font-fractul font-bold text-[20px] text-[#00293d]">Upload Profile Photo</h2>
<button onClick={handleClose} className="text-[#00293d] hover:opacity-70">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
</div>
{/* Content */}
<div className="px-8 py-6">
{/* Error Message */}
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-[10px] flex items-center gap-2">
<svg className="w-5 h-5 text-red-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-[14px] font-serif text-red-700">{error}</p>
</div>
)}
{/* Preview or Upload Area */}
{previewUrl ? (
<div className="flex flex-col items-center">
<div className="relative w-[200px] h-[200px] rounded-[15px] overflow-hidden mb-4">
<Image
src={previewUrl}
alt="Preview"
fill
className="object-cover"
/>
</div>
<button
onClick={() => {
setSelectedFile(null);
setPreviewUrl(null);
}}
className="text-[14px] font-serif text-[#e58625] hover:underline"
>
Choose a different photo
</button>
</div>
) : (
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`border-2 border-dashed rounded-[15px] p-8 text-center transition-colors ${
isDragging
? 'border-[#e58625] bg-[#e58625]/5'
: 'border-[#00293d]/20 hover:border-[#e58625]/50'
}`}
>
{/* Current Photo Preview */}
{currentPhoto && (
<div className="flex justify-center mb-4">
<div className="relative w-[100px] h-[100px] rounded-[10px] overflow-hidden opacity-50">
<Image
src={
currentPhoto.startsWith('http')
? currentPhoto
: currentPhoto.startsWith('/uploads')
? `${process.env.NEXT_PUBLIC_API_URL?.replace('/api/v1', '')}${currentPhoto}`
: currentPhoto
}
alt="Current photo"
fill
className="object-cover"
/>
</div>
</div>
)}
<div className="flex flex-col items-center gap-3">
<div className="w-16 h-16 bg-[#e58625]/10 rounded-full flex items-center justify-center">
<svg className="w-8 h-8 text-[#e58625]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<div>
<p className="font-serif font-semibold text-[16px] text-[#00293d] mb-1">
Drag and drop your photo here
</p>
<p className="font-serif text-[14px] text-[#00293d]/60">
or{' '}
<button
onClick={handleBrowseClick}
className="text-[#e58625] hover:underline font-semibold"
>
browse files
</button>
</p>
</div>
<p className="font-serif text-[12px] text-[#00293d]/40">
JPG, PNG, GIF, WebP Max 5MB
</p>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/jpg,image/png,image/gif,image/webp"
onChange={handleInputChange}
className="hidden"
/>
</div>
)}
</div>
{/* Footer */}
<div className="px-8 py-5 border-t border-[#d9d9d9] flex items-center justify-end gap-3">
<button
onClick={handleClose}
disabled={isUploading}
className="px-6 py-3 rounded-[15px] border border-[#00293d]/20 font-serif font-semibold text-[14px] text-[#00293d] hover:bg-gray-50 transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
onClick={handleUpload}
disabled={!selectedFile || isUploading}
className="px-6 py-3 rounded-[15px] bg-[#e58625] font-serif font-semibold text-[14px] text-white hover:bg-[#d47720] transition-colors disabled:opacity-50 flex items-center gap-2"
>
{isUploading && (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
)}
{isUploading ? 'Uploading...' : 'Upload Photo'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -10,3 +10,4 @@ export { TestimonialCard } from './TestimonialCard';
export { TestimonialsSection } from './TestimonialsSection'; export { TestimonialsSection } from './TestimonialsSection';
export { StatusButtons } from './StatusButtons'; export { StatusButtons } from './StatusButtons';
export { ContactInfo } from './ContactInfo'; export { ContactInfo } from './ContactInfo';
export { PhotoUploadModal } from './PhotoUploadModal';

View File

@@ -146,6 +146,37 @@ class UploadService {
const encodedKey = encodeURIComponent(fileKey); const encodedKey = encodeURIComponent(fileKey);
await api.delete(`${this.basePath}/${encodedKey}`); await api.delete(`${this.basePath}/${encodedKey}`);
} }
/**
* Upload avatar image (uses agent profile ID as filename for auto-replacement)
*/
async uploadAvatar(
file: File,
onProgress?: (progress: UploadProgress) => void
): Promise<UploadedFile> {
// Step 1: Get presigned URL from backend (uses agent ID as filename)
const response = await api.post<{ data: PresignedUrlResponse }>(
`${this.basePath}/avatar-presigned-url`,
{
filename: file.name,
contentType: file.type,
}
);
const { uploadUrl, publicUrl, key } = response.data.data;
// Step 2: Upload directly to S3
await this.uploadToS3(uploadUrl, file, onProgress);
// Step 3: Return uploaded file info
return {
id: key,
name: file.name,
size: file.size,
type: file.type,
url: publicUrl,
uploadedAt: new Date().toISOString(),
};
}
} }
export const uploadService = new UploadService(); export const uploadService = new UploadService();