feat: Enhance agent profiles with dynamic fields, granular location data, and add a verified badge asset.

This commit is contained in:
pradeepkumar
2026-02-01 22:45:56 +05:30
parent ad2d2dfbf4
commit bb7706d152
5 changed files with 204 additions and 97 deletions

View File

@@ -226,7 +226,7 @@ export default function AgentProfileView() {
lastName={agentProfile.lastName}
isVerified={agentProfile.isVerified}
title={agentProfile.agentType?.name || 'Real Estate Agent'}
location={agentProfile.serviceAreas?.[0] || 'United States'}
location={agentProfile.serviceAreas?.[0] || '-'}
memberSince={formatMemberSince((agentProfile as unknown as { createdAt?: string }).createdAt)}
bio={agentProfile.bio || ''}
expertise={agentProfile.specializations || []}

View File

@@ -27,7 +27,7 @@ function FilterSection({ title, options, selectedOptions, onToggle, showMore }:
<h3 className="font-fractul font-semibold text-[14px] text-[#00293d]">{title}</h3>
<button className="text-[#00293d]">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M2 4L6 8L10 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M2 4L6 8L10 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
@@ -62,12 +62,10 @@ interface ProfileCardProps {
function ProfileCard({ profile }: ProfileCardProps) {
const [showFullBio, setShowFullBio] = useState(false);
const [showAllTags, setShowAllTags] = useState(false);
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
const defaultImage = '/assets/demo_images/8f017153a7b4a239a4f0691234ef97dd55092282.jpg';
const truncatedBio = profile.bio && profile.bio.length > 150
? profile.bio.slice(0, 150) + '...'
: profile.bio || '';
// Fetch presigned URL for avatar if it's an S3 key
useEffect(() => {
@@ -103,11 +101,102 @@ function ProfileCard({ profile }: ProfileCardProps) {
}
};
// Get location string - prefer city/state/country over serviceAreas
const getLocation = () => {
if (profile.city && profile.state) {
return `${profile.city}, ${profile.state}`;
}
if (profile.city && profile.country) {
return `${profile.city}, ${profile.country}`;
}
if (profile.state && profile.country) {
return `${profile.state}, ${profile.country}`;
}
if (profile.city) {
return profile.city;
}
if (profile.state) {
return profile.state;
}
if (profile.country) {
return profile.country;
}
if (profile.serviceAreas && profile.serviceAreas.length > 0) {
return profile.serviceAreas[0];
}
return null;
};
// Extract expertise tags from fieldValues
const getExpertiseTags = (): string[] => {
const tags: string[] = [];
// Add from fieldValues (dynamic profile fields like client_specialization, property_type, loan_type)
if (profile.fieldValues && profile.fieldValues.length > 0) {
profile.fieldValues.forEach((fv) => {
const value = fv.jsonValue;
if (Array.isArray(value)) {
// For multi-select fields, add all selected values
value.forEach((v) => {
if (typeof v === 'string' && v.trim()) {
// Convert snake_case to Title Case for display
const displayValue = v
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
tags.push(displayValue);
}
});
} else if (typeof value === 'string' && value.trim()) {
tags.push(value);
}
});
}
// Add from specializations array (legacy field)
if (profile.specializations && profile.specializations.length > 0) {
profile.specializations.forEach((spec) => {
if (!tags.includes(spec)) {
tags.push(spec);
}
});
}
// Add from languages array
if (profile.languages && profile.languages.length > 0) {
profile.languages.forEach((lang) => {
if (!tags.includes(lang)) {
tags.push(lang);
}
});
}
return tags;
};
// Get description from fieldValues (description field) or fallback to profile.bio
const getDescription = (): string | null => {
if (profile.fieldValues && profile.fieldValues.length > 0) {
const descField = profile.fieldValues.find(fv => fv.field.slug === 'description');
if (descField && descField.textValue) {
return descField.textValue;
}
}
return profile.bio;
};
const location = getLocation();
const expertiseTags = getExpertiseTags();
const description = getDescription();
const truncatedDescription = description && description.length > 200
? description.slice(0, 200) + '...'
: description || '';
return (
<div className="bg-white rounded-[15px] p-4 flex gap-4 shadow-[0px_4px_20px_rgba(0,0,0,0.08)]">
{/* Profile Image */}
<div className="flex-shrink-0">
<div className="relative w-[120px] h-[140px] rounded-[10px] overflow-hidden">
<div className="bg-white rounded-[20px] p-5 flex gap-5 shadow-[0px_10px_20px_rgba(217,217,217,0.5)]">
{/* Profile Image & View Profile Button */}
<div className="flex-shrink-0 flex flex-col items-center">
<div className="relative w-[200px] h-[200px] rounded-[15px] overflow-hidden">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={getProfileImageUrl()}
@@ -119,8 +208,8 @@ function ProfileCard({ profile }: ProfileCardProps) {
}}
/>
</div>
<Link href={`/user/profile/${profile.id}`}>
<button className="w-full mt-3 px-6 py-2.5 bg-[#e58625] hover:bg-[#d47720] text-[#00293d] font-fractul font-bold text-[14px] rounded-[15px] transition-colors">
<Link href={`/user/profile/${profile.id}`} className="mt-4">
<button className="w-[113px] py-2.5 bg-[#e58625] hover:bg-[#d47720] text-[#00293d] font-fractul font-bold text-[14px] rounded-[15px] transition-colors">
View Profile
</button>
</Link>
@@ -128,107 +217,113 @@ function ProfileCard({ profile }: ProfileCardProps) {
{/* Profile Info */}
<div className="flex-1 min-w-0">
{/* Header */}
<div className="flex items-start justify-between mb-1">
{/* Header Row - Name, Verified, Match Badge */}
<div className="flex items-start justify-between">
<div>
{/* Name and Verified Badge */}
<div className="flex items-center gap-2">
<h3 className="font-fractul font-bold text-[16px] text-[#00293d]">
{profile.firstName} {profile.lastName}
<h3 className="font-fractul text-[18px] text-[#00293d]">
<span className="font-bold">{profile.firstName}</span>{' '}
<span className="font-normal">{profile.lastName}</span>
</h3>
{profile.isVerified && (
<span className="flex items-center gap-1 text-[#e58625] font-serif text-[12px]">
<>
<Image
src="/assets/icons/verified-badge-blue.svg"
src="/assets/icons/verified-expert-badge.svg"
alt="Verified"
width={14}
height={14}
height={15}
/>
(Verified Expert)
</span>
<span className="font-serif font-medium text-[14px] text-[#638559]">
(Verified Expert)
</span>
</>
)}
</div>
<p className="font-serif text-[13px] text-[#00293d] mt-0.5">
{/* Agent Type */}
<p className="font-serif text-[14px] text-[#00293d] mt-1">
{profile.agentType?.name || 'Real Estate Professional'}
</p>
</div>
{profile.rating && (
<div className="bg-[#e58625] text-white px-3 py-1 rounded-full font-fractul font-bold text-[12px]">
{profile.rating.toFixed(1)} Rating
</div>
)}
{/* Match Badge */}
<div className="bg-[#7d917d] text-white px-4 py-1.5 rounded-full font-serif text-[14px] whitespace-nowrap">
95% Match
</div>
</div>
{/* Location & Member Since */}
<div className="flex items-center gap-4 mt-2">
{profile.serviceAreas && profile.serviceAreas.length > 0 && (
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-4 mt-3">
{location && (
<div className="flex items-center gap-2">
<Image
src="/assets/icons/location-pin-orange.svg"
src="/assets/icons/location-icon.svg"
alt="Location"
width={12}
height={15}
width={14}
height={16}
/>
<span className="font-serif text-[12px] text-[#00293d]">{profile.serviceAreas[0]}</span>
<span className="font-serif font-bold text-[14px] text-[#00293d]">
{location}
</span>
</div>
)}
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-2">
<Image
src="/assets/icons/calendar-icon.svg"
alt="Member Since"
width={14}
height={14}
width={18}
height={18}
/>
<span className="font-serif text-[12px] text-[#00293d]">
<span className="font-serif font-medium text-[13px] text-[#00293d]">
Member Since {formatMemberSince(profile.createdAt)}
</span>
</div>
</div>
{/* Bio */}
{profile.bio && (
<p className="font-serif text-[13px] text-[#00293d] mt-3 leading-relaxed">
{showFullBio ? profile.bio : truncatedBio}
{profile.bio.length > 150 && (
<button
onClick={() => setShowFullBio(!showFullBio)}
className="text-[#e58625] ml-1 hover:underline font-medium"
>
{showFullBio ? 'Show Less' : 'Show More.'}
</button>
)}
</p>
{/* Description */}
{description && (
<div className="mt-3">
<p className="font-serif text-[13px] text-[#00293d] leading-[20px]">
{showFullBio ? description : truncatedDescription}
{description.length > 200 && (
<>
{' '}
<button
onClick={() => setShowFullBio(!showFullBio)}
className="font-serif font-bold text-[13px] text-[#00293d] underline"
>
{showFullBio ? 'Show Less.' : 'Show More.'}
</button>
</>
)}
</p>
</div>
)}
{/* Specializations */}
{profile.specializations && profile.specializations.length > 0 && (
{/* Expertise Tags */}
{expertiseTags.length > 0 && (
<div className="mt-3">
<p className="font-fractul font-semibold text-[12px] text-[#00293d] mb-2">Expertise:</p>
<div className="flex flex-wrap gap-1.5">
{profile.specializations.map((tag, index) => (
<p className="font-fractul font-bold text-[14px] text-[#00293d] mb-2">Expertise:</p>
<div className="flex flex-wrap gap-2 items-center">
{(showAllTags ? expertiseTags : expertiseTags.slice(0, 8)).map((tag, index) => (
<span
key={index}
className="border border-[#00293d]/30 rounded-full px-3 py-1 font-serif text-[11px] text-[#00293d]"
className="border border-[#00293d] rounded-[15px] px-3 py-1 font-serif text-[14px] text-[#00293d]"
>
{tag}
</span>
))}
{expertiseTags.length > 8 && (
<button
onClick={() => setShowAllTags(!showAllTags)}
className="font-serif font-bold text-[14px] text-[#00293d] underline ml-1"
>
{showAllTags ? 'Show Less' : `+${expertiseTags.length - 8} more`}
</button>
)}
</div>
</div>
)}
{/* Languages */}
{profile.languages && profile.languages.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{profile.languages.map((lang, index) => (
<span
key={index}
className="border border-[#00293d]/30 rounded-full px-3 py-1 font-serif text-[11px] text-[#00293d]"
>
{lang}
</span>
))}
</div>
)}
</div>
</div>
);
@@ -398,17 +493,15 @@ function ProfilesPageContent() {
<div className="space-y-2">
<button
onClick={() => setListingType('agents')}
className={`block font-serif text-[14px] ${
listingType === 'agents' ? 'text-[#00293d] font-medium' : 'text-[#00293d]/70'
}`}
className={`block font-serif text-[14px] ${listingType === 'agents' ? 'text-[#00293d] font-medium' : 'text-[#00293d]/70'
}`}
>
Agents
</button>
<button
onClick={() => setListingType('lenders')}
className={`block font-serif text-[14px] ${
listingType === 'lenders' ? 'text-[#00293d] font-medium' : 'text-[#00293d]/70'
}`}
className={`block font-serif text-[14px] ${listingType === 'lenders' ? 'text-[#00293d] font-medium' : 'text-[#00293d]/70'
}`}
>
Lenders
</button>
@@ -424,7 +517,7 @@ function ProfilesPageContent() {
className="text-[#00293d] hover:opacity-70"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M3 6H21M7 12H17M11 18H13" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
<path d="M3 6H21M7 12H17M11 18H13" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</button>
</div>
@@ -471,7 +564,7 @@ function ProfilesPageContent() {
className="absolute right-2 top-1/2 -translate-y-1/2 w-[36px] h-[36px] bg-[#e58625] rounded-[8px] flex items-center justify-center"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M21 21L16.65 16.65M19 11C19 15.4183 15.4183 19 11 19C6.58172 19 3 15.4183 3 11C3 6.58172 6.58172 3 11 3C15.4183 3 19 6.58172 19 11Z" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M21 21L16.65 16.65M19 11C19 15.4183 15.4183 19 11 19C6.58172 19 3 15.4183 3 11C3 6.58172 6.58172 3 11 3C15.4183 3 19 6.58172 19 11Z" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
@@ -481,11 +574,10 @@ function ProfilesPageContent() {
<div className="flex flex-wrap gap-2 mb-6">
<button
onClick={() => handleTypeChange(null)}
className={`px-4 py-2 rounded-full font-serif text-[13px] border transition-colors ${
activeTypeId === null
className={`px-4 py-2 rounded-full font-serif text-[13px] border transition-colors ${activeTypeId === null
? 'bg-[#00293d] text-white border-[#00293d]'
: 'bg-white text-[#00293d] border-[#00293d]/20 hover:border-[#00293d]/40'
}`}
}`}
>
All Types
</button>
@@ -493,24 +585,16 @@ function ProfilesPageContent() {
<button
key={type.id}
onClick={() => handleTypeChange(type.id)}
className={`px-4 py-2 rounded-full font-serif text-[13px] border transition-colors ${
activeTypeId === type.id
className={`px-4 py-2 rounded-full font-serif text-[13px] border transition-colors ${activeTypeId === type.id
? 'bg-[#00293d] text-white border-[#00293d]'
: 'bg-white text-[#00293d] border-[#00293d]/20 hover:border-[#00293d]/40'
}`}
}`}
>
{type.name}
</button>
))}
</div>
{/* Results Count */}
{!loading && (
<p className="font-serif text-[13px] text-[#00293d]/70 mb-4">
{totalResults} {totalResults === 1 ? 'agent' : 'agents'} found
</p>
)}
{/* Loading State */}
{loading && (
<div className="flex items-center justify-center py-12">

View File

@@ -29,12 +29,12 @@ export function ContactInfo({ email, phone }: ContactInfoProps) {
return (
<div className="w-full max-w-[354px] lg:max-w-none border border-[#00293d]/10 rounded-[15px] p-4">
<div className="flex items-center gap-2 mb-2">
<span className="font-bold text-[#00293d] text-sm">Email:</span>
<span className="text-[#00293d] text-sm font-serif">
<span className="font-bold text-[#00293d] text-sm flex-shrink-0">Email:</span>
<span className="text-[#00293d] text-sm font-serif truncate min-w-0 flex-1">
{showEmail ? email : maskEmail(email)}
</span>
<button
className="p-1 hover:bg-gray-100 rounded ml-auto"
className="p-1 hover:bg-gray-100 rounded flex-shrink-0"
onClick={() => setShowEmail(!showEmail)}
>
<Image
@@ -46,12 +46,12 @@ export function ContactInfo({ email, phone }: ContactInfoProps) {
</button>
</div>
<div className="flex items-center gap-2">
<span className="font-bold text-[#00293d] text-sm">Ph.No:</span>
<span className="text-[#00293d] text-sm font-serif">
<span className="font-bold text-[#00293d] text-sm flex-shrink-0">Ph.No:</span>
<span className="text-[#00293d] text-sm font-serif truncate min-w-0 flex-1">
{showPhone ? phone : maskPhone(phone)}
</span>
<button
className="p-1 hover:bg-gray-100 rounded ml-auto"
className="p-1 hover:bg-gray-100 rounded flex-shrink-0"
onClick={() => setShowPhone(!showPhone)}
>
<Image

View File

@@ -83,6 +83,22 @@ export interface SearchAgentsParams {
filters?: Record<string, string[]>;
}
// Field value from search response
export interface AgentFieldValue {
id: string;
fieldId: string;
textValue: string | null;
numberValue: number | null;
booleanValue: boolean | null;
jsonValue: unknown;
dateValue: string | null;
field: {
slug: string;
name: string;
fieldType: string;
};
}
// Public agent listing response
export interface PublicAgentProfile {
id: string;
@@ -98,6 +114,9 @@ export interface PublicAgentProfile {
specializations: string[];
languages: string[];
serviceAreas: string[];
city: string | null;
state: string | null;
country: string | null;
rating: number | null;
reviewCount: number;
isVerified: boolean;
@@ -105,6 +124,7 @@ export interface PublicAgentProfile {
agentTypeId: string | null;
agentType: AgentType | null;
createdAt?: string;
fieldValues?: AgentFieldValue[];
}
export interface SearchAgentsResponse {