s3 fix
This commit is contained in:
@@ -15,8 +15,9 @@ import {
|
||||
ContactInfo,
|
||||
PhotoUploadModal,
|
||||
} from '@/components/profile';
|
||||
import { agentsService, AgentProfile } from '@/services/agents.service';
|
||||
import { agentsService, AgentProfile, FieldValueResponse } from '@/services/agents.service';
|
||||
import { uploadService } from '@/services/upload.service';
|
||||
import { mapFieldValuesToExperience, ExperienceData } from '@/utils/profileDataMapper';
|
||||
|
||||
// Mock data for sections not yet available from API
|
||||
const mockData = {
|
||||
@@ -73,24 +74,68 @@ const mockData = {
|
||||
],
|
||||
};
|
||||
|
||||
// Default experience data when no field values are available
|
||||
const defaultExperience: ExperienceData = {
|
||||
years: '-',
|
||||
contracts: '-',
|
||||
licensingAreas: [],
|
||||
expertiseYears: [],
|
||||
certifications: [],
|
||||
};
|
||||
|
||||
export default function AgentDashboard() {
|
||||
const { data: session } = useSession();
|
||||
const [agentProfile, setAgentProfile] = useState<AgentProfile | null>(null);
|
||||
const [fieldValues, setFieldValues] = useState<FieldValueResponse[]>([]);
|
||||
const [experienceData, setExperienceData] = useState<ExperienceData>(defaultExperience);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPhotoModalOpen, setIsPhotoModalOpen] = useState(false);
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
|
||||
|
||||
const defaultImage = '/assets/demo_images/8f017153a7b4a239a4f0691234ef97dd55092282.jpg';
|
||||
|
||||
// Fetch agent profile on mount
|
||||
// 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 on mount
|
||||
useEffect(() => {
|
||||
const fetchProfile = async () => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const profile = await agentsService.getMyProfile();
|
||||
|
||||
// Fetch profile and field values in parallel
|
||||
const [profile, fieldValuesResponse] = await Promise.all([
|
||||
agentsService.getMyProfile(),
|
||||
agentsService.getFieldValues(),
|
||||
]);
|
||||
|
||||
setAgentProfile(profile);
|
||||
setFieldValues(fieldValuesResponse.fieldValues);
|
||||
|
||||
// Map field values to experience data structure
|
||||
const mappedExperience = mapFieldValuesToExperience(fieldValuesResponse.fieldValues);
|
||||
setExperienceData(mappedExperience);
|
||||
|
||||
// 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');
|
||||
@@ -99,17 +144,27 @@ export default function AgentDashboard() {
|
||||
}
|
||||
};
|
||||
|
||||
fetchProfile();
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
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
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getProfileImageUrl = () => {
|
||||
@@ -118,18 +173,23 @@ export default function AgentDashboard() {
|
||||
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;
|
||||
}
|
||||
|
||||
// S3 URLs are full URLs starting with http
|
||||
if (agentProfile.avatar.startsWith('http')) {
|
||||
// For relative paths (local assets), return as-is
|
||||
if (agentProfile.avatar.startsWith('/')) {
|
||||
return agentProfile.avatar;
|
||||
}
|
||||
|
||||
// For relative paths (local assets), return as-is
|
||||
return agentProfile.avatar;
|
||||
// Default fallback
|
||||
return defaultImage;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
@@ -240,8 +300,8 @@ export default function AgentDashboard() {
|
||||
requestsHref="/agent/network"
|
||||
/>
|
||||
|
||||
{/* Experience Section */}
|
||||
<ExperienceSection experience={mockData.experience} />
|
||||
{/* 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">
|
||||
@@ -306,6 +366,7 @@ export default function AgentDashboard() {
|
||||
onClose={() => setIsPhotoModalOpen(false)}
|
||||
onUpload={handlePhotoUpload}
|
||||
currentPhoto={agentProfile.avatar}
|
||||
currentPhotoUrl={avatarUrl}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
@@ -13,35 +14,12 @@ import {
|
||||
StatusButtons,
|
||||
ContactInfo,
|
||||
} from '@/components/profile';
|
||||
import { agentsService, AgentProfile, FieldValueResponse } from '@/services/agents.service';
|
||||
import { getProxyImageUrl } from '@/lib/imageProxy';
|
||||
import { mapFieldValuesToExperience, ExperienceData } from '@/utils/profileDataMapper';
|
||||
|
||||
// Mock agent data - in production, this would come from API based on id
|
||||
const getAgentData = (id: string) => ({
|
||||
id,
|
||||
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: {
|
||||
years: '10+ years',
|
||||
contracts: '10+ Contracts',
|
||||
licensingAreas: ['Colorado Springs', 'Monument', 'Falcon', 'Castle Rock', 'Fountain', 'Momentum'],
|
||||
expertiseYears: [
|
||||
{ area: 'Colorado', years: '10 yrs' },
|
||||
{ area: 'Falcon', years: '10 yrs' },
|
||||
{ area: 'Colorado', years: '10 yrs' },
|
||||
{ area: 'Colorado', years: '10 yrs' },
|
||||
],
|
||||
certifications: [
|
||||
{ name: 'Certified Residential Specialist (CRS)', org: 'RESIDENTIAL REAL ESTATE COUNCIL' },
|
||||
{ name: 'Certified Residential Specialist (CRS)', org: 'RESIDENTIAL REAL ESTATE COUNCIL' },
|
||||
],
|
||||
},
|
||||
// Mock data for sections not yet available from API
|
||||
const mockData = {
|
||||
availability: {
|
||||
type: 'Full-time',
|
||||
days: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
|
||||
@@ -78,15 +56,129 @@ const getAgentData = (id: string) => ({
|
||||
rating: 5,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
// Default experience data when no field values are available
|
||||
const defaultExperience: ExperienceData = {
|
||||
years: '-',
|
||||
contracts: '-',
|
||||
licensingAreas: [],
|
||||
expertiseYears: [],
|
||||
certifications: [],
|
||||
};
|
||||
|
||||
export default function AgentProfileView() {
|
||||
const params = useParams();
|
||||
const id = params.id as string;
|
||||
|
||||
// In production, fetch agent data based on id
|
||||
const agentData = getAgentData(id);
|
||||
const [firstName, lastName] = agentData.name.split(' ');
|
||||
const [agentProfile, setAgentProfile] = useState<AgentProfile | null>(null);
|
||||
const [fieldValues, setFieldValues] = useState<FieldValueResponse[]>([]);
|
||||
const [experienceData, setExperienceData] = useState<ExperienceData>(defaultExperience);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
const defaultImage = '/assets/demo_images/8f017153a7b4a239a4f0691234ef97dd55092282.jpg';
|
||||
|
||||
// Fetch agent profile and field values on mount
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Fetch profile and field values in parallel
|
||||
const [profile, fieldValuesResponse] = await Promise.all([
|
||||
agentsService.getAgentById(id),
|
||||
agentsService.getFieldValuesByAgentId(id),
|
||||
]);
|
||||
|
||||
setAgentProfile(profile);
|
||||
setFieldValues(fieldValuesResponse.fieldValues);
|
||||
|
||||
// Map field values to experience data structure
|
||||
const mappedExperience = mapFieldValuesToExperience(fieldValuesResponse.fieldValues);
|
||||
setExperienceData(mappedExperience);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch profile:', err);
|
||||
setError('Failed to load profile data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [id]);
|
||||
|
||||
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 need to go through the backend proxy
|
||||
if (agentProfile.avatar.startsWith('http')) {
|
||||
return getProxyImageUrl(agentProfile.avatar);
|
||||
}
|
||||
|
||||
// For relative paths (local assets), return as-is
|
||||
return agentProfile.avatar;
|
||||
};
|
||||
|
||||
// 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';
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 lg:px-8 py-6">
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !agentProfile) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 lg:px-8 py-6">
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 lg:px-8 py-6 space-y-6">
|
||||
@@ -97,12 +189,16 @@ export default function AgentProfileView() {
|
||||
{/* 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">
|
||||
<Image
|
||||
src={agentData.profileImage}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getProfileImageUrl()}
|
||||
alt="Profile"
|
||||
width={260}
|
||||
height={260}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.src = defaultImage;
|
||||
setImageError(true);
|
||||
}}
|
||||
/>
|
||||
{/* Gradient Overlay */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-black/20 to-transparent pointer-events-none" />
|
||||
@@ -113,27 +209,30 @@ export default function AgentProfileView() {
|
||||
<StatusButtons />
|
||||
|
||||
{/* Contact Info */}
|
||||
<ContactInfo email={agentData.email} phone={agentData.phone} />
|
||||
<ContactInfo
|
||||
email={agentProfile.email || agentProfile.user?.email || ''}
|
||||
phone={agentProfile.phone || ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right Content - Profile Info + Experience + All Sections */}
|
||||
<div className="flex-1 space-y-4">
|
||||
{/* Profile Card - No edit button for user view */}
|
||||
<ProfileCard
|
||||
firstName={firstName}
|
||||
lastName={lastName}
|
||||
isVerified={agentData.isVerified}
|
||||
title={agentData.title}
|
||||
location={agentData.location}
|
||||
memberSince={agentData.memberSince}
|
||||
bio={agentData.bio}
|
||||
expertise={agentData.expertise}
|
||||
firstName={agentProfile.firstName}
|
||||
lastName={agentProfile.lastName}
|
||||
isVerified={agentProfile.isVerified}
|
||||
title={agentProfile.agentType?.name || 'Real Estate Agent'}
|
||||
location={agentProfile.serviceAreas?.[0] || 'United States'}
|
||||
memberSince={formatMemberSince((agentProfile as unknown as { createdAt?: string }).createdAt)}
|
||||
bio={agentProfile.bio || ''}
|
||||
expertise={agentProfile.specializations || []}
|
||||
showEditButton={false}
|
||||
messageHref="/user/message"
|
||||
/>
|
||||
|
||||
{/* Experience Section */}
|
||||
<ExperienceSection experience={agentData.experience} />
|
||||
{/* 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">
|
||||
@@ -149,9 +248,9 @@ export default function AgentProfileView() {
|
||||
}
|
||||
content={
|
||||
<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">
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
@@ -168,7 +267,7 @@ export default function AgentProfileView() {
|
||||
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
|
||||
title=""
|
||||
@@ -180,15 +279,15 @@ export default function AgentProfileView() {
|
||||
height={28}
|
||||
/>
|
||||
}
|
||||
content={<p className="text-[14px] font-semibold font-serif leading-[19px] text-center text-[#00293D]">“{agentData.testimonialHighlight}”</p>}
|
||||
content={<p className="text-[14px] font-semibold font-serif leading-[19px] text-center text-[#00293D]">“{mockData.testimonialHighlight}”</p>}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Specialization Section */}
|
||||
<SpecializationSection specialization={agentData.specialization} />
|
||||
<SpecializationSection specialization={mockData.specialization} />
|
||||
|
||||
{/* Testimonials Section */}
|
||||
<TestimonialsSection testimonials={agentData.testimonials} />
|
||||
<TestimonialsSection testimonials={mockData.testimonials} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Tag } from './Tag';
|
||||
|
||||
interface ExperienceData {
|
||||
@@ -14,7 +15,25 @@ interface ExperienceSectionProps {
|
||||
experience: ExperienceData;
|
||||
}
|
||||
|
||||
const INITIAL_DISPLAY_COUNT = 6;
|
||||
const EXPERTISE_INITIAL_COUNT = 4;
|
||||
|
||||
export function ExperienceSection({ experience }: ExperienceSectionProps) {
|
||||
const [showAllLicensing, setShowAllLicensing] = useState(false);
|
||||
const [showAllExpertise, setShowAllExpertise] = useState(false);
|
||||
|
||||
// Calculate how many more items are hidden
|
||||
const licensingRemaining = experience.licensingAreas.length - INITIAL_DISPLAY_COUNT;
|
||||
const expertiseRemaining = experience.expertiseYears.length - EXPERTISE_INITIAL_COUNT;
|
||||
|
||||
// Get displayed items
|
||||
const displayedLicensing = showAllLicensing
|
||||
? experience.licensingAreas
|
||||
: experience.licensingAreas.slice(0, INITIAL_DISPLAY_COUNT);
|
||||
const displayedExpertise = showAllExpertise
|
||||
? experience.expertiseYears
|
||||
: experience.expertiseYears.slice(0, EXPERTISE_INITIAL_COUNT);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-[20px] p-4 lg:p-5">
|
||||
<h2 className="text-[20px] font-bold text-[#00293D] font-fractul leading-[24px] mb-4 text-center lg:text-left">Experience</h2>
|
||||
@@ -24,26 +43,51 @@ export function ExperienceSection({ experience }: ExperienceSectionProps) {
|
||||
<div>
|
||||
<p className="font-fractul font-bold text-[14px] leading-[17px] text-[#00293D] mb-2">Years in Experience</p>
|
||||
<ul className="list-disc list-inside">
|
||||
<li className="font-serif font-medium text-[15px] leading-[21px] text-[#00293D]">{experience.years}</li>
|
||||
<li className="font-serif font-medium text-[15px] leading-[21px] text-[#00293D]">
|
||||
{experience.years !== '-' ? experience.years : <span className="text-[#00293D]/40">Not specified</span>}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{/* Horizontal divider */}
|
||||
<div className="w-full h-0 border-t-[0.5px] border-solid border-[#00293D]/20" />
|
||||
<div>
|
||||
<p className="font-fractul font-bold text-[14px] leading-[17px] text-[#00293D] mb-2">Number of contract closed</p>
|
||||
<p className="font-fractul font-bold text-[14px] leading-[17px] text-[#00293D] mb-2">Number of contracts closed</p>
|
||||
<ul className="list-disc list-inside">
|
||||
<li className="font-serif font-medium text-[15px] leading-[21px] text-[#00293D]">{experience.contracts}</li>
|
||||
<li className="font-serif font-medium text-[15px] leading-[21px] text-[#00293D]">
|
||||
{experience.contracts !== '-' ? experience.contracts : <span className="text-[#00293D]/40">Not specified</span>}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-fractul font-bold text-[14px] leading-[17px] text-[#00293D] mb-3">Licensing & Areas</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{experience.licensingAreas.map((area, idx) => (
|
||||
<Tag key={idx} variant="light">
|
||||
{area}
|
||||
</Tag>
|
||||
))}
|
||||
<span className="inline-flex items-center justify-center h-[28px] px-3 rounded-[15px] border border-[#00293d]/10 text-[15px] font-light font-serif text-[#00293d] cursor-pointer">+3 More</span>
|
||||
{experience.licensingAreas.length > 0 ? (
|
||||
<>
|
||||
{displayedLicensing.map((area, idx) => (
|
||||
<Tag key={idx} variant="light">
|
||||
{area}
|
||||
</Tag>
|
||||
))}
|
||||
{!showAllLicensing && licensingRemaining > 0 && (
|
||||
<button
|
||||
onClick={() => setShowAllLicensing(true)}
|
||||
className="inline-flex items-center justify-center h-[28px] px-3 rounded-[15px] border border-[#00293d]/10 text-[15px] font-light font-serif text-[#00293d] cursor-pointer hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
+{licensingRemaining} More
|
||||
</button>
|
||||
)}
|
||||
{showAllLicensing && experience.licensingAreas.length > INITIAL_DISPLAY_COUNT && (
|
||||
<button
|
||||
onClick={() => setShowAllLicensing(false)}
|
||||
className="inline-flex items-center justify-center h-[28px] px-3 rounded-[15px] border border-[#00293d]/10 text-[15px] font-light font-serif text-[#00293d] cursor-pointer hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Show Less
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-[14px] font-serif text-[#00293D]/40">No licensed areas added</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -57,24 +101,51 @@ export function ExperienceSection({ experience }: ExperienceSectionProps) {
|
||||
<div>
|
||||
<p className="font-fractul font-bold text-[14px] leading-[17px] text-[#00293D] mb-3">Areas in expertise & Years</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{experience.expertiseYears.slice(0, 4).map((item, idx) => (
|
||||
<Tag key={idx} variant="light">
|
||||
{item.area} – {item.years}
|
||||
</Tag>
|
||||
))}
|
||||
<span className="inline-flex items-center justify-center h-[28px] px-3 rounded-[15px] border border-[#00293d]/10 text-[15px] font-light font-serif text-[#00293d] cursor-pointer">+3 More</span>
|
||||
{experience.expertiseYears.length > 0 ? (
|
||||
<>
|
||||
{displayedExpertise.map((item, idx) => (
|
||||
<Tag key={idx} variant="light">
|
||||
{item.years ? `${item.area} – ${item.years}` : item.area}
|
||||
</Tag>
|
||||
))}
|
||||
{!showAllExpertise && expertiseRemaining > 0 && (
|
||||
<button
|
||||
onClick={() => setShowAllExpertise(true)}
|
||||
className="inline-flex items-center justify-center h-[28px] px-3 rounded-[15px] border border-[#00293d]/10 text-[15px] font-light font-serif text-[#00293d] cursor-pointer hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
+{expertiseRemaining} More
|
||||
</button>
|
||||
)}
|
||||
{showAllExpertise && experience.expertiseYears.length > EXPERTISE_INITIAL_COUNT && (
|
||||
<button
|
||||
onClick={() => setShowAllExpertise(false)}
|
||||
className="inline-flex items-center justify-center h-[28px] px-3 rounded-[15px] border border-[#00293d]/10 text-[15px] font-light font-serif text-[#00293d] cursor-pointer hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Show Less
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-[14px] font-serif text-[#00293D]/40">No expertise areas added</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-fractul font-bold text-[14px] leading-[17px] text-[#00293D] mb-3">Certifications</p>
|
||||
<ul className="space-y-3">
|
||||
{experience.certifications.map((cert, idx) => (
|
||||
<li key={idx}>
|
||||
<span className="font-serif font-normal text-[14px] leading-[19px] text-[#00293D]">{cert.name}</span>
|
||||
<p className="font-serif font-normal text-[14px] leading-[19px] text-[#00293D] opacity-50 ml-3">{cert.org}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{experience.certifications.length > 0 ? (
|
||||
<ul className="space-y-3">
|
||||
{experience.certifications.map((cert, idx) => (
|
||||
<li key={idx}>
|
||||
<span className="font-serif font-normal text-[14px] leading-[19px] text-[#00293D]">{cert.name}</span>
|
||||
{cert.org && (
|
||||
<p className="font-serif font-normal text-[14px] leading-[19px] text-[#00293D] opacity-50 ml-3">{cert.org}</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<span className="text-[14px] font-serif text-[#00293D]/40">No certifications added</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,9 +8,10 @@ interface PhotoUploadModalProps {
|
||||
onClose: () => void;
|
||||
onUpload: (file: File) => Promise<void>;
|
||||
currentPhoto?: string | null;
|
||||
currentPhotoUrl?: string | null; // Presigned URL for display
|
||||
}
|
||||
|
||||
export function PhotoUploadModal({ isOpen, onClose, onUpload, currentPhoto }: PhotoUploadModalProps) {
|
||||
export function PhotoUploadModal({ isOpen, onClose, onUpload, currentPhoto, currentPhotoUrl }: PhotoUploadModalProps) {
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
@@ -170,20 +171,14 @@ export function PhotoUploadModal({ isOpen, onClose, onUpload, currentPhoto }: Ph
|
||||
}`}
|
||||
>
|
||||
{/* Current Photo Preview */}
|
||||
{currentPhoto && (
|
||||
{(currentPhotoUrl || 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
|
||||
}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={currentPhotoUrl || currentPhoto || ''}
|
||||
alt="Current photo"
|
||||
fill
|
||||
className="object-cover"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
22
src/lib/imageProxy.ts
Normal file
22
src/lib/imageProxy.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Convert S3 URL to proxy URL through backend API
|
||||
* This is needed because S3 bucket might not have public read access
|
||||
*/
|
||||
export function getProxyImageUrl(url: string | null | undefined): string {
|
||||
if (!url) {
|
||||
return '/assets/demo_images/8f017153a7b4a239a4f0691234ef97dd55092282.jpg';
|
||||
}
|
||||
|
||||
// If it's already a local URL or data URL, return as-is
|
||||
if (url.startsWith('/') || url.startsWith('data:')) {
|
||||
return url;
|
||||
}
|
||||
|
||||
// If it's an S3/external URL, proxy through backend
|
||||
if (url.startsWith('http')) {
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1';
|
||||
return `${apiUrl}/upload/image?url=${encodeURIComponent(url)}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
@@ -101,6 +101,13 @@ class AgentsService {
|
||||
);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async getFieldValuesByAgentId(id: string): Promise<GetFieldValuesResponse> {
|
||||
const response = await api.get<ApiResponse<GetFieldValuesResponse>>(
|
||||
`${this.basePath}/${id}/field-values`
|
||||
);
|
||||
return response.data.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const agentsService = new AgentsService();
|
||||
|
||||
@@ -149,6 +149,7 @@ class UploadService {
|
||||
|
||||
/**
|
||||
* Upload avatar image (uses agent profile ID as filename for auto-replacement)
|
||||
* Returns the S3 key (not full URL) for storage in database
|
||||
*/
|
||||
async uploadAvatar(
|
||||
file: File,
|
||||
@@ -162,21 +163,32 @@ class UploadService {
|
||||
contentType: file.type,
|
||||
}
|
||||
);
|
||||
const { uploadUrl, publicUrl, key } = response.data.data;
|
||||
const { uploadUrl, key } = response.data.data;
|
||||
|
||||
// Step 2: Upload directly to S3
|
||||
await this.uploadToS3(uploadUrl, file, onProgress);
|
||||
|
||||
// Step 3: Return uploaded file info
|
||||
// Step 3: Return uploaded file info with KEY (not full URL) for database storage
|
||||
return {
|
||||
id: key,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
url: publicUrl,
|
||||
url: key, // Store the S3 key, not the full URL
|
||||
uploadedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a presigned download URL for an S3 key
|
||||
*/
|
||||
async getPresignedDownloadUrl(key: string): Promise<string> {
|
||||
const response = await api.get<{ data: { url: string } }>(
|
||||
`${this.basePath}/presigned-download-url`,
|
||||
{ params: { key } }
|
||||
);
|
||||
return response.data.data.url;
|
||||
}
|
||||
}
|
||||
|
||||
export const uploadService = new UploadService();
|
||||
|
||||
126
src/utils/profileDataMapper.ts
Normal file
126
src/utils/profileDataMapper.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { FieldValueResponse } from '@/services/agents.service';
|
||||
|
||||
// Experience data structure expected by ExperienceSection component
|
||||
export interface ExperienceData {
|
||||
years: string;
|
||||
contracts: string;
|
||||
licensingAreas: string[];
|
||||
expertiseYears: { area: string; years: string }[];
|
||||
certifications: { name: string; org: string }[];
|
||||
}
|
||||
|
||||
// Certification entry from repeater field
|
||||
interface CertificationEntry {
|
||||
certification_name?: string;
|
||||
issuing_organization?: string;
|
||||
issue_date?: string;
|
||||
expiration_date?: string;
|
||||
}
|
||||
|
||||
// Helper to get field value by slug
|
||||
function getFieldValue(fieldValues: FieldValueResponse[], slug: string): unknown {
|
||||
const field = fieldValues.find(f => f.fieldSlug === slug);
|
||||
return field?.value;
|
||||
}
|
||||
|
||||
// Helper to get label from value for select/radio fields
|
||||
function mapYearsValueToLabel(value: string | undefined): string {
|
||||
if (!value) return '-';
|
||||
|
||||
const mapping: Record<string, string> = {
|
||||
'<2': 'Less than 2 years',
|
||||
'2+': '2+ years',
|
||||
'5+': '5+ years',
|
||||
'10+': '10+ years',
|
||||
};
|
||||
|
||||
return mapping[value] || value;
|
||||
}
|
||||
|
||||
// Helper to format contracts count
|
||||
function formatContractsCount(value: unknown): string {
|
||||
if (!value) return '-';
|
||||
|
||||
const num = typeof value === 'number' ? value : parseInt(String(value), 10);
|
||||
if (isNaN(num)) return '-';
|
||||
|
||||
if (num >= 100) return '100+ Contracts';
|
||||
if (num >= 50) return '50+ Contracts';
|
||||
if (num >= 20) return '20+ Contracts';
|
||||
if (num >= 10) return '10+ Contracts';
|
||||
if (num >= 5) return '5+ Contracts';
|
||||
return `${num} Contract${num !== 1 ? 's' : ''}`;
|
||||
}
|
||||
|
||||
// Parse expertise areas with years (format: "Area Name - X yrs" or just "Area Name")
|
||||
function parseExpertiseAreas(value: unknown): { area: string; years: string }[] {
|
||||
if (!value || !Array.isArray(value)) return [];
|
||||
|
||||
return value.map((item: string) => {
|
||||
// Check if it contains a year indicator
|
||||
const yearMatch = item.match(/^(.+?)\s*[-–]\s*(\d+)\s*(yrs?|years?)$/i);
|
||||
if (yearMatch) {
|
||||
return { area: yearMatch[1].trim(), years: `${yearMatch[2]} yrs` };
|
||||
}
|
||||
// Just area name without years
|
||||
return { area: item.trim(), years: '' };
|
||||
});
|
||||
}
|
||||
|
||||
// Parse certification entries from repeater field
|
||||
function parseCertifications(value: unknown): { name: string; org: string }[] {
|
||||
if (!value) return [];
|
||||
|
||||
// If it's an array of certification entries
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.filter((entry: CertificationEntry) => entry?.certification_name)
|
||||
.map((entry: CertificationEntry) => ({
|
||||
name: entry.certification_name || '',
|
||||
org: entry.issuing_organization || '',
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps field values from API response to ExperienceData structure
|
||||
*/
|
||||
export function mapFieldValuesToExperience(fieldValues: FieldValueResponse[]): ExperienceData {
|
||||
// Get years in business (could be years_in_business from Professional or Lender experience)
|
||||
const yearsInBusiness = getFieldValue(fieldValues, 'years_in_business') as string | undefined;
|
||||
|
||||
// Get contracts completed
|
||||
const contractsCompleted = getFieldValue(fieldValues, 'contracts_completed');
|
||||
|
||||
// Get licensing areas (could be licensed_areas from Professional or Lender)
|
||||
const licensedAreas = getFieldValue(fieldValues, 'licensed_areas') as string[] | undefined;
|
||||
|
||||
// Get expertise areas with years
|
||||
const expertiseAreas = getFieldValue(fieldValues, 'expertise_areas') as string[] | undefined;
|
||||
|
||||
// Get certification entries from repeater
|
||||
const certificationEntries = getFieldValue(fieldValues, 'certification_entries');
|
||||
|
||||
return {
|
||||
years: mapYearsValueToLabel(yearsInBusiness),
|
||||
contracts: formatContractsCount(contractsCompleted),
|
||||
licensingAreas: licensedAreas || [],
|
||||
expertiseYears: parseExpertiseAreas(expertiseAreas),
|
||||
certifications: parseCertifications(certificationEntries),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if experience data has any meaningful content
|
||||
*/
|
||||
export function hasExperienceData(experience: ExperienceData): boolean {
|
||||
return (
|
||||
experience.years !== '-' ||
|
||||
experience.contracts !== '-' ||
|
||||
experience.licensingAreas.length > 0 ||
|
||||
experience.expertiseYears.length > 0 ||
|
||||
experience.certifications.length > 0
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user