Files
frontend/src/components/home/TopProfessionals.tsx

375 lines
13 KiB
TypeScript
Raw Normal View History

2026-01-11 22:09:41 +05:30
'use client';
import { useState, useEffect } from 'react';
2026-01-11 22:09:41 +05:30
import Link from 'next/link';
import Image from 'next/image';
import { uploadService } from '@/services/upload.service';
import type { TopProfessionalsContent } from '@/types/cms';
2026-01-11 22:09:41 +05:30
interface ProfessionalCardProps {
name: string;
subtitle: string;
location: string;
experience: string;
expertise: string[];
imageUrl: string;
isVerified: boolean;
rating: number | null;
}
function ProfessionalCard({
name,
subtitle,
location,
experience,
expertise,
imageUrl,
isVerified,
rating,
}: ProfessionalCardProps) {
const starCount = rating ? Math.round(rating) : 0;
const [imgLoaded, setImgLoaded] = useState(false);
2026-03-27 15:47:29 +05:30
const [imgError, setImgError] = useState(false);
const placeholder = '/assets/icons/user-placeholder-icon.svg';
const isPlaceholder = imageUrl === placeholder;
const INITIAL_TAG_COUNT = 4;
return (
<div className="bg-white rounded-[15px] overflow-hidden shadow-[0px_4px_20px_rgba(0,0,0,0.08)] h-full flex flex-col">
{/* Image */}
<div className="relative h-[226px] w-full overflow-hidden bg-gray-100">
{/* Shimmer while loading, initials only on error */}
{imgError || isPlaceholder ? (
<div className="absolute inset-0 bg-[#c4d9d4] flex items-center justify-center">
<span className="font-bold text-[#00293d] text-[72px]">{name?.[0]?.toUpperCase() || '?'}</span>
</div>
) : !imgLoaded ? (
<div className="absolute inset-0 shimmer-loading" />
) : null}
2026-03-27 15:47:29 +05:30
{!isPlaceholder && !imgError && (
<Image
src={imageUrl}
alt={name}
fill
className={`object-cover transition-opacity duration-300 ${imgLoaded ? 'opacity-100' : 'opacity-0'}`}
onLoad={() => setImgLoaded(true)}
onError={() => setImgError(true)}
/>
)}
</div>
{/* Content */}
<div className="p-4 flex-1 flex flex-col">
{/* Name */}
<h3 className="font-fractul font-bold text-[14px] leading-normal text-[#00293d]">
{name}
</h3>
{/* Subtitle */}
<p className="font-serif font-normal text-[10px] leading-normal text-[#00293d] mt-1">
{subtitle}
</p>
{/* Verified Badge */}
{isVerified && (
<div className="flex items-center gap-1.5 mt-2">
<Image
src="/assets/icons/verified-badge-blue.svg"
alt="Verified"
width={14}
height={14}
/>
<span className="font-serif font-medium text-[14px] leading-normal text-[#638559]">
&ldquo;Verified local agent&rdquo;
</span>
</div>
)}
{/* Location */}
{location && (
<div className="mt-3">
<p className="font-fractul font-bold text-[14px] leading-normal text-[#00293d]">
Location:
</p>
<div className="flex items-center gap-1.5 mt-1">
<Image
src="/assets/icons/location-pin-orange.svg"
alt="Location"
width={12}
height={15}
/>
<span className="font-serif font-normal text-[10px] leading-normal text-[#00293d]">
{location}
</span>
</div>
</div>
)}
{/* Expertise */}
{expertise.length > 0 && (
<div className="mt-3">
<p className="font-fractul font-bold text-[14px] leading-normal text-[#00293d]">
Expertise:
</p>
<div className="flex flex-wrap gap-1.5 mt-2 items-center">
{expertise.slice(0, INITIAL_TAG_COUNT).map((tag, index) => (
<span
key={index}
className="border-[0.7px] border-[#00293d] rounded-[15px] px-[5px] h-[24px] flex items-center justify-center font-serif font-normal text-[13px] leading-[100%] text-[#00293d]"
>
{tag}
</span>
))}
{expertise.length > INITIAL_TAG_COUNT && (
<span className="bg-[#e58625]/15 text-[#e58625] rounded-[15px] px-2 h-[24px] flex items-center justify-center font-serif font-semibold text-[12px] leading-[100%]">
+{expertise.length - INITIAL_TAG_COUNT} more
</span>
)}
</div>
</div>
)}
{/* Experience */}
{experience && (
<p className="mt-3 text-[14px] leading-normal text-[#00293d]">
<span className="font-fractul font-bold">Experience:</span>
<span className="font-serif font-normal"> {experience}</span>
</p>
)}
{/* Star Rating */}
{starCount > 0 && (
<div className="flex gap-1 mt-3">
{[...Array(5)].map((_, i) => (
<Image
key={i}
src={i < starCount ? '/assets/icons/star-yellow.svg' : '/assets/icons/star-empty-icon.svg'}
alt="Star"
width={18}
height={17}
/>
))}
</div>
)}
</div>
</div>
);
}
// Helper to resolve avatar URL
async function resolveAvatarUrl(avatar: string | null): Promise<string> {
if (!avatar) return '/assets/icons/user-placeholder-icon.svg';
if (avatar.startsWith('http') || avatar.startsWith('/')) return avatar;
try {
return await uploadService.getPresignedDownloadUrl(avatar);
} catch {
return '/assets/icons/user-placeholder-icon.svg';
}
}
interface DisplayProfessional {
id: string;
name: string;
subtitle: string;
location: string;
experience: string;
expertise: string[];
imageUrl: string;
isVerified: boolean;
rating: number | null;
slug: string;
}
const defaultContent: TopProfessionalsContent = {
title: '\u201cMeet top real estate professionals\u201d',
ctaText: 'Discover 5,000+ Top Real Estate Agents in Our Network.',
ctaButtonText: 'Browse Experts',
agents: [],
lenders: [],
};
export function TopProfessionals({ content }: { content?: TopProfessionalsContent }) {
const data = content ?? defaultContent;
2026-01-11 22:09:41 +05:30
const [activeTab, setActiveTab] = useState<'agents' | 'lenders'>('agents');
const [agents, setAgents] = useState<DisplayProfessional[]>([]);
const [lenders, setLenders] = useState<DisplayProfessional[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const loadProfessionals = async () => {
try {
// Map CMS data to display format, resolving avatar URLs
const mapCmsEntries = async (
entries: TopProfessionalsContent['agents'],
): Promise<DisplayProfessional[]> => {
return Promise.all(
entries.map(async (entry, index) => {
const imageUrl = await resolveAvatarUrl(entry.imageUrl || null);
return {
id: `cms-${index}`,
name: entry.name,
subtitle: entry.subtitle || '',
location: entry.location || '',
experience: entry.experience || '',
expertise: entry.expertise || [],
imageUrl,
isVerified: false,
rating: null,
slug: '',
};
}),
);
};
const [mappedAgents, mappedLenders] = await Promise.all([
mapCmsEntries(data.agents || []),
mapCmsEntries(data.lenders || []),
]);
setAgents(mappedAgents);
setLenders(mappedLenders);
} catch (err) {
console.error('Failed to load top professionals:', err);
} finally {
setLoading(false);
}
};
loadProfessionals();
}, [data.agents, data.lenders]);
const displayData = activeTab === 'agents' ? agents : lenders;
2026-01-11 22:09:41 +05:30
return (
<section className="py-10 md:py-12 bg-white">
2026-01-11 22:09:41 +05:30
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Section Header */}
<div className="text-center mb-10">
<h2 className="font-fractul font-bold text-[32px] md:text-[40px] leading-[1.2] text-[#00293d]">
{data.title}
2026-01-11 22:09:41 +05:30
</h2>
</div>
{/* Tabs Container */}
<div className="flex justify-center mb-10">
<div className="border border-[#00293d]/10 rounded-[5px] p-5 inline-flex items-center">
{/* Agents Tab */}
<button
onClick={() => setActiveTab('agents')}
className={`flex items-center gap-2 px-10 py-3 rounded-[5px] font-serif font-semibold text-[17px] transition-all ${
activeTab === 'agents'
? 'bg-[#00293d] text-[#f0f5fc]'
: 'border border-[#00293d]/20 text-[#00293d] hover:bg-gray-50'
}`}
>
<Image
src={activeTab === 'agents' ? '/assets/icons/agents-tab-icon-orange.svg' : '/assets/icons/agents-tab-icon.svg'}
alt=""
width={20}
height={20}
/>
Agents
</button>
{/* Divider */}
<div className="h-[44px] w-px bg-[#00293d]/20 mx-4" />
{/* Lenders Tab */}
<button
onClick={() => setActiveTab('lenders')}
className={`flex items-center gap-2 px-10 py-3 rounded-[5px] font-serif font-semibold text-[17px] transition-all ${
activeTab === 'lenders'
? 'bg-[#00293d] text-[#f0f5fc]'
: 'border border-[#00293d]/20 text-[#00293d] hover:bg-gray-50'
}`}
>
<Image
src={activeTab === 'lenders' ? '/assets/icons/lenders-tab-icon-orange.svg' : '/assets/icons/lenders-tab-icon.svg'}
alt=""
width={20}
height={20}
/>
Lenders
</button>
</div>
2026-01-11 22:09:41 +05:30
</div>
{/* Cards Grid - 3 cards + CTA sidebar */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{loading ? (
// Loading skeleton cards
<>
{[1, 2, 3].map((i) => (
<div key={i} className="bg-white rounded-[15px] overflow-hidden shadow-[0px_4px_20px_rgba(0,0,0,0.08)] animate-pulse">
<div className="h-[226px] bg-gray-200" />
<div className="p-4 space-y-3">
<div className="h-4 bg-gray-200 rounded w-3/4" />
<div className="h-3 bg-gray-200 rounded w-1/2" />
<div className="h-4 bg-gray-200 rounded w-2/3" />
<div className="flex gap-1.5">
<div className="h-6 bg-gray-200 rounded-full w-16" />
<div className="h-6 bg-gray-200 rounded-full w-16" />
<div className="h-6 bg-gray-200 rounded-full w-16" />
</div>
</div>
</div>
))}
</>
) : displayData.length > 0 ? (
displayData.map((professional) => (
<ProfessionalCard
key={professional.id}
name={professional.name}
subtitle={professional.subtitle}
location={professional.location}
experience={professional.experience}
expertise={professional.expertise}
imageUrl={professional.imageUrl}
isVerified={professional.isVerified}
rating={professional.rating}
/>
))
) : (
// No data message
<div className="col-span-3 flex items-center justify-center py-12">
<p className="font-serif text-[14px] text-[#00293d]/60">
No {activeTab === 'agents' ? 'agents' : 'lenders'} available yet.
</p>
</div>
)}
2026-01-11 22:09:41 +05:30
{/* CTA Sidebar Card */}
<div
className="rounded-[15px] p-6 flex flex-col items-center justify-center text-center min-h-[400px]"
style={{
background: 'linear-gradient(180deg, #C5D6D6 0%, #FFFFFF 100%)',
}}
>
{/* Building Icon */}
<div className="mb-4">
<Image
src="/assets/icons/building-icon.svg"
alt="Building"
width={50}
height={38}
/>
</div>
{/* Text */}
<p className="font-fractul font-normal text-[14px] leading-[1.4] text-[#00293d] mb-6">
{data.ctaText}
2026-01-11 22:09:41 +05:30
</p>
{/* Browse Experts Button */}
<Link href="/user/profiles">
<button className="bg-[#e58625] hover:bg-[#d47820] text-white font-fractul font-bold text-[16px] leading-[19px] px-8 py-3 rounded-[15px] transition-colors">
{data.ctaButtonText}
</button>
2026-01-11 22:09:41 +05:30
</Link>
</div>
</div>
</div>
</section>
);
}