feat: Enhance agent profiles with dynamic fields, granular location data, and add a verified badge asset.
This commit is contained in:
3
public/assets/icons/verified-expert-badge.svg
Normal file
3
public/assets/icons/verified-expert-badge.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="14" height="15" viewBox="0 0 14 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.3732 0.596262C10.2351 0.372368 10.0317 0.197732 9.79127 0.096662C9.55083 -0.00440788 9.28532 -0.0268823 9.0317 0.0323677L7.2317 0.451986C7.04723 0.495017 6.85555 0.495017 6.67108 0.451986L4.87107 0.0323677C4.61746 -0.0268823 4.35195 -0.00440788 4.1115 0.096662C3.87106 0.197732 3.66765 0.372368 3.52958 0.596262L2.54849 2.18533C2.44838 2.34789 2.31323 2.48505 2.15305 2.58767L0.58731 3.58338C0.367083 3.72338 0.195262 3.92948 0.0956966 4.17309C-0.00386835 4.4167 -0.0262384 4.68572 0.0316916 4.94282L0.445152 6.77167C0.487397 6.95856 0.487397 7.15273 0.445152 7.33962L0.0316916 9.16745C-0.0264635 9.4247 -0.00420607 9.69394 0.0953696 9.93776C0.194945 10.1816 0.366892 10.3879 0.58731 10.5279L2.15305 11.5236C2.31323 11.6252 2.44838 11.7624 2.54949 11.9249L3.53058 13.514C3.8129 13.9722 4.3515 14.1988 4.87107 14.0779L6.67108 13.6583C6.85555 13.6153 7.04723 13.6153 7.2317 13.6583L9.0327 14.0779C9.28617 14.1369 9.55147 14.1143 9.7917 14.0133C10.0319 13.9122 10.2352 13.7377 10.3732 13.514L11.3543 11.9249C11.4544 11.7624 11.5895 11.6252 11.7497 11.5236L13.3165 10.5279C13.5369 10.3876 13.7088 10.1811 13.8082 9.93713C13.9076 9.69312 13.9296 9.42373 13.8711 9.16644L13.4586 7.33962C13.4162 7.15241 13.4162 6.95787 13.4586 6.77065L13.8721 4.94282C13.9303 4.68568 13.9082 4.41651 13.8088 4.17271C13.7094 3.9289 13.5377 3.72256 13.3175 3.58236L11.7507 2.58666C11.5908 2.48487 11.4556 2.34766 11.3553 2.18533L10.3732 0.596262ZM9.86963 4.7894C9.93155 4.67384 9.9469 4.53842 9.91243 4.41161C9.87797 4.28481 9.79638 4.17652 9.68485 4.10952C9.57331 4.04252 9.44052 4.02204 9.31441 4.05239C9.18829 4.08274 9.0787 4.16155 9.00868 4.27224L6.39076 8.76917L4.81001 7.23294C4.76311 7.18407 4.707 7.14529 4.64501 7.11891C4.58303 7.09252 4.51645 7.07908 4.44923 7.07937C4.38202 7.07967 4.31555 7.09371 4.25379 7.12064C4.19204 7.14757 4.13626 7.18685 4.08979 7.23613C4.04331 7.28541 4.0071 7.34369 3.98329 7.40748C3.95949 7.47128 3.94859 7.53929 3.95125 7.60745C3.9539 7.67561 3.97006 7.74253 3.99875 7.80422C4.02744 7.86591 4.06807 7.92111 4.11824 7.96651L6.1545 9.94675C6.209 9.99963 6.27457 10.0393 6.34631 10.0629C6.41805 10.0865 6.49411 10.0933 6.56881 10.0828C6.6435 10.0724 6.71491 10.0449 6.77769 10.0025C6.84047 9.96015 6.893 9.90391 6.93137 9.83803L9.86963 4.7894Z" fill="#1DA1F2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -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 || []}
|
||||
|
||||
@@ -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,105 +217,111 @@ 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}
|
||||
/>
|
||||
<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
|
||||
|
||||
{/* 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 && (
|
||||
{/* 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="text-[#e58625] ml-1 hover:underline font-medium"
|
||||
className="font-serif font-bold text-[13px] text-[#00293d] underline"
|
||||
>
|
||||
{showFullBio ? 'Show Less' : 'Show More.'}
|
||||
{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>
|
||||
))}
|
||||
</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]"
|
||||
{expertiseTags.length > 8 && (
|
||||
<button
|
||||
onClick={() => setShowAllTags(!showAllTags)}
|
||||
className="font-serif font-bold text-[14px] text-[#00293d] underline ml-1"
|
||||
>
|
||||
{lang}
|
||||
</span>
|
||||
))}
|
||||
{showAllTags ? 'Show Less' : `+${expertiseTags.length - 8} more`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -398,16 +493,14 @@ 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
|
||||
@@ -481,8 +574,7 @@ 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'
|
||||
}`}
|
||||
@@ -493,8 +585,7 @@ 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'
|
||||
}`}
|
||||
@@ -504,13 +595,6 @@ function ProfilesPageContent() {
|
||||
))}
|
||||
</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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user