533 lines
22 KiB
TypeScript
533 lines
22 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { useSession } from 'next-auth/react';
|
|
import Image from 'next/image';
|
|
import Link from 'next/link';
|
|
|
|
// Import shared components
|
|
import {
|
|
InfoCard,
|
|
ExperienceSection,
|
|
ProfileCard,
|
|
SpecializationSection,
|
|
TestimonialsSection,
|
|
StatusButtons,
|
|
ContactInfo,
|
|
PhotoUploadModal,
|
|
} from '@/components/profile';
|
|
import { agentsService, AgentProfile, FieldValueResponse } from '@/services/agents.service';
|
|
import { connectionRequestsService } from '@/services/connection-requests.service';
|
|
import { uploadService } from '@/services/upload.service';
|
|
import { usersService } from '@/services/users.service';
|
|
import { testimonialsService, Testimonial } from '@/services/testimonials.service';
|
|
import { mapFieldValuesToExperience, mapFieldValuesToProfileCard, mapFieldValuesToAvailability, mapFieldValuesToSpecializationFields, mapFieldValuesToContactInfo, ExperienceData, ProfileCardData, AvailabilityData, SpecializationFieldsData, ContactInfoData } from '@/utils/profileDataMapper';
|
|
|
|
// Mock data for sections not yet available from API
|
|
const mockData = {
|
|
preferredWorkEnvironment: 'Preferred work environment includes on-site property visits, in-depth market research, client consultations, property tours, and active involvement in negotiations to ensure the best outcomes',
|
|
};
|
|
|
|
// Default experience data when no field values are available
|
|
const defaultExperience: ExperienceData = {
|
|
years: '-',
|
|
contracts: '-',
|
|
licensingAreas: [],
|
|
expertiseYears: [],
|
|
certifications: [],
|
|
};
|
|
|
|
// Default profile card data when no field values are available
|
|
const defaultProfileCardData: ProfileCardData = {
|
|
bio: '',
|
|
expertise: [],
|
|
serviceAreas: [],
|
|
city: null,
|
|
state: null,
|
|
};
|
|
|
|
// Default availability data when no field values are available
|
|
const defaultAvailabilityData: AvailabilityData = {
|
|
type: '',
|
|
schedule: [],
|
|
};
|
|
|
|
// Default specialization fields data when no field values are available
|
|
const defaultSpecializationFieldsData: SpecializationFieldsData = {
|
|
fields: [],
|
|
};
|
|
|
|
// Default contact info data when no field values are available
|
|
const defaultContactInfoData: ContactInfoData = {
|
|
email: null,
|
|
phone: null,
|
|
};
|
|
|
|
export default function AgentDashboard() {
|
|
const { data: session, status: sessionStatus } = useSession();
|
|
const [agentProfile, setAgentProfile] = useState<AgentProfile | null>(null);
|
|
const [fieldValues, setFieldValues] = useState<FieldValueResponse[]>([]);
|
|
const [experienceData, setExperienceData] = useState<ExperienceData>(defaultExperience);
|
|
const [profileCardData, setProfileCardData] = useState<ProfileCardData>(defaultProfileCardData);
|
|
const [availabilityData, setAvailabilityData] = useState<AvailabilityData>(defaultAvailabilityData);
|
|
const [specializationFieldsData, setSpecializationFieldsData] = useState<SpecializationFieldsData>(defaultSpecializationFieldsData);
|
|
const [contactInfoData, setContactInfoData] = useState<ContactInfoData>(defaultContactInfoData);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [isPhotoModalOpen, setIsPhotoModalOpen] = useState(false);
|
|
const [imageError, setImageError] = useState(false);
|
|
const [imageLoaded, setImageLoaded] = useState(false);
|
|
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
|
|
const [isAvailable, setIsAvailable] = useState(true);
|
|
const [isTogglingAvailability, setIsTogglingAvailability] = useState(false);
|
|
const [pendingRequestCount, setPendingRequestCount] = useState(0);
|
|
const [testimonials, setTestimonials] = useState<{ id: string; text: string; author: string; role: string; rating: number }[]>([]);
|
|
const [isSubmittingVerification, setIsSubmittingVerification] = useState(false);
|
|
|
|
const defaultImage = '/assets/demo_images/8f017153a7b4a239a4f0691234ef97dd55092282.jpg';
|
|
|
|
// Helper to check if avatar is an S3 key (not a full URL or local path)
|
|
const isS3Key = (avatar: string | null | undefined): boolean => {
|
|
if (!avatar) return false;
|
|
// S3 keys don't start with http or /
|
|
return !avatar.startsWith('http') && !avatar.startsWith('/');
|
|
};
|
|
|
|
// Fetch agent profile and field values when session is ready
|
|
useEffect(() => {
|
|
// Wait until session is authenticated and tokens are available
|
|
if (sessionStatus !== 'authenticated') return;
|
|
const token = typeof window !== 'undefined' ? localStorage.getItem('accessToken') : null;
|
|
if (!token) return;
|
|
|
|
const fetchData = async () => {
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
// Fetch profile, field values, pending request count, and testimonials in parallel
|
|
const [profile, fieldValuesResponse, requestCounts, testimonialsData] = await Promise.all([
|
|
agentsService.getMyProfile(),
|
|
agentsService.getFieldValues(),
|
|
connectionRequestsService.getRequestCounts().catch(() => null),
|
|
testimonialsService.getMyTestimonials('latest').catch(() => [] as Testimonial[]),
|
|
]);
|
|
|
|
setAgentProfile(profile);
|
|
setFieldValues(fieldValuesResponse.fieldValues);
|
|
setIsAvailable(profile.isAvailable ?? true);
|
|
if (requestCounts) {
|
|
setPendingRequestCount(requestCounts.pending);
|
|
}
|
|
|
|
// Map testimonials for TestimonialsSection (latest 3)
|
|
setTestimonials(
|
|
testimonialsData.slice(0, 3).map((t) => ({
|
|
id: t.id,
|
|
text: t.text,
|
|
author: t.authorName,
|
|
role: t.authorRole,
|
|
rating: t.rating,
|
|
}))
|
|
);
|
|
|
|
// Map field values to experience data structure
|
|
const mappedExperience = mapFieldValuesToExperience(fieldValuesResponse.fieldValues);
|
|
setExperienceData(mappedExperience);
|
|
|
|
// Map field values to profile card data (bio, expertise)
|
|
const mappedProfileCard = mapFieldValuesToProfileCard(fieldValuesResponse.fieldValues);
|
|
setProfileCardData(mappedProfileCard);
|
|
|
|
// Map field values to availability data
|
|
const mappedAvailability = mapFieldValuesToAvailability(fieldValuesResponse.fieldValues);
|
|
setAvailabilityData(mappedAvailability);
|
|
|
|
// Map field values to specialization fields data (only fields from "Specialization" section)
|
|
const mappedSpecializationFields = mapFieldValuesToSpecializationFields(fieldValuesResponse.fieldValues);
|
|
setSpecializationFieldsData(mappedSpecializationFields);
|
|
|
|
// Map field values to contact info data (email, phone)
|
|
const mappedContactInfo = mapFieldValuesToContactInfo(fieldValuesResponse.fieldValues);
|
|
setContactInfoData(mappedContactInfo);
|
|
|
|
// If avatar is an S3 key, fetch presigned URL
|
|
if (profile.avatar && isS3Key(profile.avatar)) {
|
|
try {
|
|
const presignedUrl = await uploadService.getPresignedDownloadUrl(profile.avatar);
|
|
setAvatarUrl(presignedUrl);
|
|
} catch (avatarErr) {
|
|
console.error('Failed to get avatar URL:', avatarErr);
|
|
// Don't fail the whole page, just use default image
|
|
}
|
|
} else if (profile.avatar) {
|
|
// It's already a full URL or local path
|
|
setAvatarUrl(profile.avatar);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to fetch profile:', err);
|
|
setError('Failed to load profile data');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchData();
|
|
}, [sessionStatus]);
|
|
|
|
// Handle availability toggle
|
|
const handleToggleAvailability = async () => {
|
|
if (isTogglingAvailability) return;
|
|
|
|
try {
|
|
setIsTogglingAvailability(true);
|
|
const newAvailability = !isAvailable;
|
|
|
|
// Update on server
|
|
const updatedProfile = await agentsService.updateProfile({ isAvailable: newAvailability });
|
|
|
|
// Update local state
|
|
setIsAvailable(newAvailability);
|
|
setAgentProfile(updatedProfile);
|
|
} catch (err) {
|
|
console.error('Failed to update availability:', err);
|
|
// Revert on error - state stays the same
|
|
} finally {
|
|
setIsTogglingAvailability(false);
|
|
}
|
|
};
|
|
|
|
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 key
|
|
const updatedProfile = await agentsService.updateProfile({ avatar: uploadedFile.url });
|
|
setImageError(false); // Reset error state for new image
|
|
setAgentProfile(updatedProfile);
|
|
|
|
// Fetch new presigned URL for the uploaded avatar
|
|
if (uploadedFile.url && isS3Key(uploadedFile.url)) {
|
|
try {
|
|
const presignedUrl = await uploadService.getPresignedDownloadUrl(uploadedFile.url);
|
|
setAvatarUrl(presignedUrl);
|
|
} catch (err) {
|
|
console.error('Failed to get presigned URL for new avatar:', err);
|
|
}
|
|
}
|
|
|
|
// Notify header and other components to refresh avatar
|
|
window.dispatchEvent(new Event('profile-updated'));
|
|
};
|
|
|
|
const getProfileImageUrl = () => {
|
|
// If image failed to load, return default
|
|
if (imageError) {
|
|
return defaultImage;
|
|
}
|
|
|
|
// If we have a presigned URL from S3, use it
|
|
if (avatarUrl) {
|
|
return avatarUrl;
|
|
}
|
|
|
|
// Check for null, undefined, or empty string
|
|
if (!agentProfile?.avatar || agentProfile.avatar.trim() === '') {
|
|
return defaultImage;
|
|
}
|
|
|
|
// For relative paths (local assets), return as-is
|
|
if (agentProfile.avatar.startsWith('/')) {
|
|
return agentProfile.avatar;
|
|
}
|
|
|
|
// Default fallback
|
|
return defaultImage;
|
|
};
|
|
|
|
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';
|
|
}
|
|
};
|
|
|
|
// Check if agent has uploaded documents
|
|
const hasDocuments = fieldValues.some(
|
|
(fv) => fv.sectionSlug === 'upload-documents' && fv.value && (Array.isArray(fv.value) ? fv.value.length > 0 : true)
|
|
);
|
|
|
|
const handleSubmitVerification = async () => {
|
|
setIsSubmittingVerification(true);
|
|
try {
|
|
await usersService.submitForVerification();
|
|
// Update local state
|
|
if (agentProfile) {
|
|
setAgentProfile({ ...agentProfile, verificationStatus: 'PENDING_REVIEW', verificationNote: null } as AgentProfile);
|
|
}
|
|
} catch (err: any) {
|
|
const data = err?.response?.data;
|
|
if (data?.missingFields && Array.isArray(data.missingFields)) {
|
|
const fieldList = data.missingFields.map((f: any) => `• ${f.sectionName} → ${f.fieldName}`).join('\n');
|
|
alert(`Please fill in all required fields:\n\n${fieldList}`);
|
|
} else {
|
|
alert(data?.message || 'Failed to submit for verification');
|
|
}
|
|
} finally {
|
|
setIsSubmittingVerification(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Verification Status Banner */}
|
|
{agentProfile?.verificationStatus === 'REJECTED' && (
|
|
<div className="bg-red-50 border border-red-200 rounded-[15px] p-4">
|
|
<div className="flex items-start gap-3">
|
|
<Image src="/assets/icons/caution-icon.svg" alt="Warning" width={20} height={20} className="mt-0.5 flex-shrink-0" />
|
|
<div className="flex-1">
|
|
<p className="font-fractul font-bold text-[14px] text-red-800">Profile Verification Rejected</p>
|
|
{agentProfile.verificationNote && (
|
|
<p className="font-serif text-[13px] text-red-700 mt-1">Reason: {agentProfile.verificationNote}</p>
|
|
)}
|
|
<p className="font-serif text-[13px] text-red-600 mt-2">Please update your profile and documents, then resubmit for verification.</p>
|
|
<div className="flex gap-2 mt-2">
|
|
<Link href="/agent/edit" className="inline-block px-4 py-1.5 border border-[#e58625] text-[#e58625] rounded-[10px] font-serif text-[13px] hover:bg-[#e58625]/5 transition-colors">
|
|
Edit Profile
|
|
</Link>
|
|
<button
|
|
onClick={handleSubmitVerification}
|
|
disabled={isSubmittingVerification}
|
|
className="px-4 py-1.5 bg-[#e58625] text-white rounded-[10px] font-serif text-[13px] hover:bg-[#d47920] transition-colors disabled:opacity-50"
|
|
>
|
|
{isSubmittingVerification ? 'Submitting...' : 'Submit for Review'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{agentProfile?.verificationStatus === 'PENDING_REVIEW' && (
|
|
<div className="bg-yellow-50 border border-yellow-200 rounded-[15px] p-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-5 h-5 rounded-full bg-yellow-400 flex-shrink-0" />
|
|
<p className="font-fractul font-bold text-[14px] text-yellow-800">Profile verification is under review. You will be notified once approved.</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{agentProfile?.verificationStatus === 'NONE' && (
|
|
<div className="bg-blue-50 border border-blue-200 rounded-[15px] p-4">
|
|
<div className="flex items-center gap-3">
|
|
<Image src="/assets/icons/caution-icon.svg" alt="Info" width={20} height={20} className="flex-shrink-0" />
|
|
<div className="flex-1">
|
|
<p className="font-fractul font-bold text-[14px] text-blue-800">Complete your profile and upload verification documents to get verified.</p>
|
|
<div className="flex gap-2 mt-2">
|
|
<Link href="/agent/edit" className="inline-block px-4 py-1.5 border border-[#e58625] text-[#e58625] rounded-[10px] font-serif text-[13px] hover:bg-[#e58625]/5 transition-colors">
|
|
Complete Profile
|
|
</Link>
|
|
<button
|
|
onClick={handleSubmitVerification}
|
|
disabled={isSubmittingVerification}
|
|
className="px-4 py-1.5 bg-[#e58625] text-white rounded-[10px] font-serif text-[13px] hover:bg-[#d47920] transition-colors disabled:opacity-50"
|
|
>
|
|
{isSubmittingVerification ? 'Submitting...' : 'Submit for Review'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{/* Main Layout - Responsive: Column on mobile, Row on desktop */}
|
|
<div className="flex flex-col lg:flex-row gap-6 lg:items-stretch">
|
|
{/* Left Sidebar - Status & Contact */}
|
|
<div className="w-full lg:w-[280px] flex-shrink-0 space-y-4 flex flex-col items-center lg:items-start">
|
|
{/* Profile Image */}
|
|
<div className="relative w-[200px] lg:w-[260px]">
|
|
<div className="w-[200px] h-[200px] lg:w-[260px] lg:h-[260px] rounded-[15px] overflow-hidden relative">
|
|
{/* Shimmer while loading, initials only on error */}
|
|
{imageError ? (
|
|
<div className="absolute inset-0 bg-[#c4d9d4] flex items-center justify-center rounded-[15px]">
|
|
<span className="font-bold text-[#00293d] text-[80px]">{agentProfile?.firstName?.[0]?.toUpperCase() || '?'}</span>
|
|
</div>
|
|
) : !imageLoaded ? (
|
|
<div className="absolute inset-0 shimmer-loading rounded-[15px]" />
|
|
) : null}
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={getProfileImageUrl()}
|
|
alt="Profile"
|
|
className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-300 ${imageLoaded ? 'opacity-100' : 'opacity-0'}`}
|
|
onLoad={() => setImageLoaded(true)}
|
|
onError={() => setImageError(true)}
|
|
/>
|
|
{/* Gradient Overlay */}
|
|
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-black/20 to-transparent pointer-events-none" />
|
|
</div>
|
|
{/* Edit Icon - Top Right Outside */}
|
|
<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
|
|
src="/assets/icons/edit-icon.svg"
|
|
alt="Edit profile image"
|
|
width={20}
|
|
height={20}
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Status Buttons - Toggle for agent to set availability */}
|
|
<StatusButtons
|
|
isAvailable={isAvailable}
|
|
showToggle={true}
|
|
onToggleAvailability={handleToggleAvailability}
|
|
/>
|
|
|
|
{/* Contact Info */}
|
|
<ContactInfo
|
|
email={agentProfile.email || agentProfile.user?.email || contactInfoData.email || ''}
|
|
phone={agentProfile.phone || contactInfoData.phone || ''}
|
|
/>
|
|
</div>
|
|
|
|
{/* Right Content - Profile Info + Experience + All Sections */}
|
|
<div className="flex-1 space-y-4">
|
|
{/* Profile Card */}
|
|
<ProfileCard
|
|
firstName={agentProfile.firstName}
|
|
lastName={agentProfile.lastName}
|
|
isVerified={agentProfile.isVerified}
|
|
title={agentProfile.agentType?.name || 'Real Estate Agent'}
|
|
location={
|
|
profileCardData.city && profileCardData.state
|
|
? `${profileCardData.city}, ${profileCardData.state}`
|
|
: profileCardData.city || profileCardData.state || profileCardData.serviceAreas?.[0] || 'United States'
|
|
}
|
|
memberSince={formatMemberSince((agentProfile as unknown as { createdAt?: string }).createdAt)}
|
|
bio={profileCardData.bio || agentProfile.bio || ''}
|
|
expertise={profileCardData.expertise}
|
|
showEditButton={true}
|
|
editHref="/agent/edit"
|
|
messageHref="/agent/message"
|
|
requestsHref="/agent/network"
|
|
pendingRequestCount={pendingRequestCount}
|
|
/>
|
|
|
|
{/* Experience Section - Dynamic data from profile fields */}
|
|
<ExperienceSection experience={experienceData} />
|
|
|
|
{/* Info Cards Section */}
|
|
<div className="flex flex-col lg:grid lg:grid-cols-3 gap-6">
|
|
<InfoCard
|
|
title="Availability"
|
|
icon={
|
|
<Image
|
|
src="/assets/icons/availability-clock-icon.svg"
|
|
alt="Availability"
|
|
width={28}
|
|
height={31}
|
|
/>
|
|
}
|
|
content={
|
|
<div>
|
|
{availabilityData.type && (
|
|
<p className="font-semibold font-serif text-[14px] leading-[19px] text-[#00293D] mb-2">{availabilityData.type}</p>
|
|
)}
|
|
<div className="space-y-1">
|
|
{availabilityData.schedule.length > 0 ? (
|
|
availabilityData.schedule.map((item, index) => (
|
|
<p key={index} className="font-normal font-serif text-[14px] leading-[19px] text-[#00293D]">{item}</p>
|
|
))
|
|
) : (
|
|
<p className="font-normal font-serif text-[14px] leading-[19px] text-[#00293D]/60">Not specified</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
}
|
|
/>
|
|
<InfoCard
|
|
title=""
|
|
icon={
|
|
<Image
|
|
src="/assets/icons/work-environment-icon.svg"
|
|
alt="Work environment"
|
|
width={28}
|
|
height={28}
|
|
/>
|
|
}
|
|
content={<p className="font-serif font-semibold text-[14px] leading-[19px] text-[#00293D]">{mockData.preferredWorkEnvironment}</p>}
|
|
/>
|
|
{testimonials.length > 0 && (
|
|
<InfoCard
|
|
title=""
|
|
icon={
|
|
<Image
|
|
src="/assets/icons/testimonial-star-icon.svg"
|
|
alt="Testimonial"
|
|
width={28}
|
|
height={28}
|
|
/>
|
|
}
|
|
content={<p className="text-[14px] font-semibold font-serif leading-[19px] text-center text-[#00293D]">“{testimonials[0].text.length > 150 ? testimonials[0].text.substring(0, 150) + '...' : testimonials[0].text}”</p>}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* Specialization Section */}
|
|
<SpecializationSection fieldsData={specializationFieldsData} />
|
|
|
|
{/* Testimonials Section */}
|
|
<TestimonialsSection testimonials={testimonials} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Photo Upload Modal */}
|
|
<PhotoUploadModal
|
|
isOpen={isPhotoModalOpen}
|
|
onClose={() => setIsPhotoModalOpen(false)}
|
|
onUpload={handlePhotoUpload}
|
|
currentPhoto={agentProfile.avatar}
|
|
currentPhotoUrl={avatarUrl}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|