feat: add SEO metadata and profile schema
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useState, useEffect } from "react";
|
||||
import Image from "next/image";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
// Import shared components
|
||||
import {
|
||||
@@ -14,43 +14,68 @@ import {
|
||||
TestimonialsSection,
|
||||
StatusButtons,
|
||||
ContactInfo,
|
||||
} from '@/components/profile';
|
||||
import { ConnectRequestModal } from '@/components/modals';
|
||||
import { MobileBackButton } from '@/components/layout/MobileBackButton';
|
||||
import { agentsService, AgentProfile, FieldValueResponse } from '@/services/agents.service';
|
||||
import { connectionRequestsService, ConnectionStatus } from '@/services/connection-requests.service';
|
||||
import { uploadService } from '@/services/upload.service';
|
||||
import { testimonialsService, Testimonial } from '@/services/testimonials.service';
|
||||
import { mapFieldValuesToExperience, mapFieldValuesToSpecializationFields, mapFieldValuesToProfileCard, mapFieldValuesToContactInfo, mapFieldValuesToAvailability, mapFieldValuesToWorkEnvironment, mapFieldValuesToPersonalTagline, ExperienceData, SpecializationFieldsData, ProfileCardData, ContactInfoData, AvailabilityData, WorkEnvironmentData, PersonalTaglineData } from '@/utils/profileDataMapper';
|
||||
} from "@/components/profile";
|
||||
import { ConnectRequestModal } from "@/components/modals";
|
||||
import { MobileBackButton } from "@/components/layout/MobileBackButton";
|
||||
import {
|
||||
agentsService,
|
||||
AgentProfile,
|
||||
FieldValueResponse,
|
||||
} from "@/services/agents.service";
|
||||
import {
|
||||
connectionRequestsService,
|
||||
ConnectionStatus,
|
||||
} from "@/services/connection-requests.service";
|
||||
import { uploadService } from "@/services/upload.service";
|
||||
import {
|
||||
testimonialsService,
|
||||
Testimonial,
|
||||
} from "@/services/testimonials.service";
|
||||
import {
|
||||
mapFieldValuesToExperience,
|
||||
mapFieldValuesToSpecializationFields,
|
||||
mapFieldValuesToProfileCard,
|
||||
mapFieldValuesToContactInfo,
|
||||
mapFieldValuesToAvailability,
|
||||
mapFieldValuesToWorkEnvironment,
|
||||
mapFieldValuesToPersonalTagline,
|
||||
ExperienceData,
|
||||
SpecializationFieldsData,
|
||||
ProfileCardData,
|
||||
ContactInfoData,
|
||||
AvailabilityData,
|
||||
WorkEnvironmentData,
|
||||
PersonalTaglineData,
|
||||
} from "@/utils/profileDataMapper";
|
||||
|
||||
// Default work environment data
|
||||
const defaultWorkEnvironmentData: WorkEnvironmentData = {
|
||||
label: 'Preferred Work Environment',
|
||||
content: '',
|
||||
label: "Preferred Work Environment",
|
||||
content: "",
|
||||
};
|
||||
|
||||
// Default personal tagline data
|
||||
const defaultPersonalTaglineData: PersonalTaglineData = {
|
||||
label: 'Personal Tagline',
|
||||
content: '',
|
||||
label: "Personal Tagline",
|
||||
content: "",
|
||||
};
|
||||
|
||||
// Default experience data when no field values are available
|
||||
const defaultExperience: ExperienceData = {
|
||||
years: '-',
|
||||
yearsLabel: 'Years in Experience',
|
||||
contracts: '-',
|
||||
contractsLabel: 'Number of contracts closed',
|
||||
years: "-",
|
||||
yearsLabel: "Years in Experience",
|
||||
contracts: "-",
|
||||
contractsLabel: "Number of contracts closed",
|
||||
licensingAreas: [],
|
||||
licensingAreasLabel: 'Licensing & Areas',
|
||||
licensingAreasLabel: "Licensing & Areas",
|
||||
expertiseYears: [],
|
||||
expertiseYearsLabel: 'Areas in expertise & Years',
|
||||
expertiseYearsLabel: "Areas in expertise & Years",
|
||||
certifications: [],
|
||||
certificationsLabel: 'Certifications',
|
||||
agencyName: '',
|
||||
agencyDesignation: '',
|
||||
certificationsLabel: "Certifications",
|
||||
agencyName: "",
|
||||
agencyDesignation: "",
|
||||
agencyShowDesignation: false,
|
||||
agencySectionLabel: 'Real Estate Agency & Designation',
|
||||
agencySectionLabel: "Real Estate Agency & Designation",
|
||||
};
|
||||
|
||||
// Default specialization fields data when no field values are available
|
||||
@@ -60,7 +85,7 @@ const defaultSpecializationFieldsData: SpecializationFieldsData = {
|
||||
|
||||
// Default profile card data when no field values are available
|
||||
const defaultProfileCardData: ProfileCardData = {
|
||||
bio: '',
|
||||
bio: "",
|
||||
expertise: [],
|
||||
serviceAreas: [],
|
||||
city: null,
|
||||
@@ -75,9 +100,9 @@ const defaultContactInfoData: ContactInfoData = {
|
||||
|
||||
// Default availability data when no field values are available
|
||||
const defaultAvailabilityData: AvailabilityData = {
|
||||
type: '',
|
||||
type: "",
|
||||
schedule: [],
|
||||
label: 'Availability',
|
||||
label: "Availability",
|
||||
};
|
||||
|
||||
export default function AgentProfileView() {
|
||||
@@ -87,31 +112,46 @@ export default function AgentProfileView() {
|
||||
|
||||
const [agentProfile, setAgentProfile] = useState<AgentProfile | null>(null);
|
||||
const [fieldValues, setFieldValues] = useState<FieldValueResponse[]>([]);
|
||||
const [experienceData, setExperienceData] = useState<ExperienceData>(defaultExperience);
|
||||
const [specializationFieldsData, setSpecializationFieldsData] = useState<SpecializationFieldsData>(defaultSpecializationFieldsData);
|
||||
const [profileCardData, setProfileCardData] = useState<ProfileCardData>(defaultProfileCardData);
|
||||
const [contactInfoData, setContactInfoData] = useState<ContactInfoData>(defaultContactInfoData);
|
||||
const [availabilityData, setAvailabilityData] = useState<AvailabilityData>(defaultAvailabilityData);
|
||||
const [workEnvironmentData, setWorkEnvironmentData] = useState<WorkEnvironmentData>(defaultWorkEnvironmentData);
|
||||
const [personalTaglineData, setPersonalTaglineData] = useState<PersonalTaglineData>(defaultPersonalTaglineData);
|
||||
const [experienceData, setExperienceData] =
|
||||
useState<ExperienceData>(defaultExperience);
|
||||
const [specializationFieldsData, setSpecializationFieldsData] =
|
||||
useState<SpecializationFieldsData>(defaultSpecializationFieldsData);
|
||||
const [profileCardData, setProfileCardData] = useState<ProfileCardData>(
|
||||
defaultProfileCardData,
|
||||
);
|
||||
const [contactInfoData, setContactInfoData] = useState<ContactInfoData>(
|
||||
defaultContactInfoData,
|
||||
);
|
||||
const [availabilityData, setAvailabilityData] = useState<AvailabilityData>(
|
||||
defaultAvailabilityData,
|
||||
);
|
||||
const [workEnvironmentData, setWorkEnvironmentData] =
|
||||
useState<WorkEnvironmentData>(defaultWorkEnvironmentData);
|
||||
const [personalTaglineData, setPersonalTaglineData] =
|
||||
useState<PersonalTaglineData>(defaultPersonalTaglineData);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
|
||||
|
||||
const [testimonials, setTestimonials] = useState<{ id: string; text: string; author: string; role: string; rating: number }[]>([]);
|
||||
const [testimonials, setTestimonials] = useState<
|
||||
{ id: string; text: string; author: string; role: string; rating: number }[]
|
||||
>([]);
|
||||
|
||||
// Connect modal state
|
||||
const [showConnectModal, setShowConnectModal] = useState(false);
|
||||
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus | null>(null);
|
||||
const [connectionRequestId, setConnectionRequestId] = useState<string | null>(null);
|
||||
const [connectionStatus, setConnectionStatus] =
|
||||
useState<ConnectionStatus | null>(null);
|
||||
const [connectionRequestId, setConnectionRequestId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// 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('/');
|
||||
return !avatar.startsWith("http") && !avatar.startsWith("/");
|
||||
};
|
||||
|
||||
// Fetch agent profile and field values on mount
|
||||
@@ -124,41 +164,58 @@ export default function AgentProfileView() {
|
||||
setError(null);
|
||||
|
||||
// Fetch profile, field values, and testimonials in parallel
|
||||
const [profile, fieldValuesResponse, testimonialsData] = await Promise.all([
|
||||
agentsService.getAgentById(id),
|
||||
agentsService.getFieldValuesByAgentId(id),
|
||||
testimonialsService.getAgentTestimonials(id).catch(() => [] as Testimonial[]),
|
||||
]);
|
||||
const [profile, fieldValuesResponse, testimonialsData] =
|
||||
await Promise.all([
|
||||
agentsService.getAgentById(id),
|
||||
agentsService.getFieldValuesByAgentId(id),
|
||||
testimonialsService
|
||||
.getAgentTestimonials(id)
|
||||
.catch(() => [] as Testimonial[]),
|
||||
]);
|
||||
|
||||
setAgentProfile(profile);
|
||||
setFieldValues(fieldValuesResponse.fieldValues);
|
||||
|
||||
// Map field values to experience data structure
|
||||
const mappedExperience = mapFieldValuesToExperience(fieldValuesResponse.fieldValues);
|
||||
const mappedExperience = mapFieldValuesToExperience(
|
||||
fieldValuesResponse.fieldValues,
|
||||
);
|
||||
setExperienceData(mappedExperience);
|
||||
|
||||
// Map field values to specialization fields data (only fields from "Specialization" section)
|
||||
const mappedSpecializationFields = mapFieldValuesToSpecializationFields(fieldValuesResponse.fieldValues);
|
||||
const mappedSpecializationFields = mapFieldValuesToSpecializationFields(
|
||||
fieldValuesResponse.fieldValues,
|
||||
);
|
||||
setSpecializationFieldsData(mappedSpecializationFields);
|
||||
|
||||
// Map field values to profile card data (bio, expertise, location)
|
||||
const mappedProfileCard = mapFieldValuesToProfileCard(fieldValuesResponse.fieldValues);
|
||||
const mappedProfileCard = mapFieldValuesToProfileCard(
|
||||
fieldValuesResponse.fieldValues,
|
||||
);
|
||||
setProfileCardData(mappedProfileCard);
|
||||
|
||||
// Map field values to contact info data (email, phone)
|
||||
const mappedContactInfo = mapFieldValuesToContactInfo(fieldValuesResponse.fieldValues);
|
||||
const mappedContactInfo = mapFieldValuesToContactInfo(
|
||||
fieldValuesResponse.fieldValues,
|
||||
);
|
||||
setContactInfoData(mappedContactInfo);
|
||||
|
||||
// Map field values to availability data
|
||||
const mappedAvailability = mapFieldValuesToAvailability(fieldValuesResponse.fieldValues);
|
||||
const mappedAvailability = mapFieldValuesToAvailability(
|
||||
fieldValuesResponse.fieldValues,
|
||||
);
|
||||
setAvailabilityData(mappedAvailability);
|
||||
|
||||
// Map field values to work environment data
|
||||
const mappedWorkEnv = mapFieldValuesToWorkEnvironment(fieldValuesResponse.fieldValues);
|
||||
const mappedWorkEnv = mapFieldValuesToWorkEnvironment(
|
||||
fieldValuesResponse.fieldValues,
|
||||
);
|
||||
setWorkEnvironmentData(mappedWorkEnv);
|
||||
|
||||
// Map field values to personal tagline data
|
||||
const mappedTagline = mapFieldValuesToPersonalTagline(fieldValuesResponse.fieldValues);
|
||||
const mappedTagline = mapFieldValuesToPersonalTagline(
|
||||
fieldValuesResponse.fieldValues,
|
||||
);
|
||||
setPersonalTaglineData(mappedTagline);
|
||||
|
||||
// Map testimonials for TestimonialsSection
|
||||
@@ -169,29 +226,33 @@ export default function AgentProfileView() {
|
||||
author: t.authorName,
|
||||
role: t.authorRole,
|
||||
rating: t.rating,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
|
||||
// If avatar is an S3 key, fetch presigned URL
|
||||
if (profile.avatar && isS3Key(profile.avatar)) {
|
||||
try {
|
||||
const presignedUrl = await uploadService.getPresignedDownloadUrl(profile.avatar);
|
||||
const presignedUrl = await uploadService.getPresignedDownloadUrl(
|
||||
profile.avatar,
|
||||
);
|
||||
setAvatarUrl(presignedUrl);
|
||||
} catch (avatarErr) {
|
||||
console.error('Failed to get avatar URL:', avatarErr);
|
||||
console.error("Failed to get avatar URL:", avatarErr);
|
||||
}
|
||||
} else if (profile.avatar) {
|
||||
setAvatarUrl(profile.avatar);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to fetch profile:', err);
|
||||
console.error("Failed to fetch profile:", err);
|
||||
const status = err?.response?.status;
|
||||
if (status === 403) {
|
||||
setError('This profile is private. You don\u2019t have permission to view it.');
|
||||
setError(
|
||||
"This profile is private. You don\u2019t have permission to view it.",
|
||||
);
|
||||
} else if (status === 404) {
|
||||
setError('Profile not found.');
|
||||
setError("Profile not found.");
|
||||
} else {
|
||||
setError('Failed to load profile data');
|
||||
setError("Failed to load profile data");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -207,11 +268,12 @@ export default function AgentProfileView() {
|
||||
if (!id || !session) return;
|
||||
|
||||
try {
|
||||
const statusResponse = await connectionRequestsService.getConnectionStatus(id);
|
||||
const statusResponse =
|
||||
await connectionRequestsService.getConnectionStatus(id);
|
||||
setConnectionStatus(statusResponse?.status || null);
|
||||
setConnectionRequestId(statusResponse?.id || null);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch connection status:', err);
|
||||
console.error("Failed to fetch connection status:", err);
|
||||
// Non-critical error, don't show to user
|
||||
}
|
||||
};
|
||||
@@ -221,7 +283,7 @@ export default function AgentProfileView() {
|
||||
|
||||
// Handle connection request success
|
||||
const handleConnectionSuccess = () => {
|
||||
setConnectionStatus('PENDING');
|
||||
setConnectionStatus("PENDING");
|
||||
};
|
||||
|
||||
// Handle unlink/disconnect
|
||||
@@ -233,7 +295,7 @@ export default function AgentProfileView() {
|
||||
setConnectionStatus(null);
|
||||
setConnectionRequestId(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to unlink connection:', err);
|
||||
console.error("Failed to unlink connection:", err);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -249,12 +311,12 @@ export default function AgentProfileView() {
|
||||
}
|
||||
|
||||
// Check for null, undefined, or empty string
|
||||
if (!agentProfile?.avatar || agentProfile.avatar.trim() === '') {
|
||||
if (!agentProfile?.avatar || agentProfile.avatar.trim() === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// For relative paths (local assets), return as-is
|
||||
if (agentProfile.avatar.startsWith('/')) {
|
||||
if (agentProfile.avatar.startsWith("/")) {
|
||||
return agentProfile.avatar;
|
||||
}
|
||||
|
||||
@@ -264,12 +326,15 @@ export default function AgentProfileView() {
|
||||
|
||||
// Format member since date
|
||||
const formatMemberSince = (dateString?: string) => {
|
||||
if (!dateString) return 'Member';
|
||||
if (!dateString) return "Member";
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return 'Member';
|
||||
return "Member";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -279,35 +344,64 @@ export default function AgentProfileView() {
|
||||
<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>
|
||||
<p className="text-[14px] font-serif text-[#00293D]/70">
|
||||
Loading profile...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isPermissionError = error?.includes('permission') || error?.includes('private');
|
||||
const isPermissionError =
|
||||
error?.includes("permission") || error?.includes("private");
|
||||
|
||||
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 rounded-full flex items-center justify-center mx-auto mb-4 ${isPermissionError ? 'bg-[#e58625]/10' : 'bg-red-100'}`}>
|
||||
<div
|
||||
className={`w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4 ${isPermissionError ? "bg-[#e58625]/10" : "bg-red-100"}`}
|
||||
>
|
||||
{isPermissionError ? (
|
||||
<svg className="w-8 h-8 text-[#e58625]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
<svg
|
||||
className="w-8 h-8 text-[#e58625]"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<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
|
||||
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">
|
||||
{isPermissionError ? 'Profile Not Available' : 'Unable to Load Profile'}
|
||||
{isPermissionError
|
||||
? "Profile Not Available"
|
||||
: "Unable to Load Profile"}
|
||||
</h2>
|
||||
<p className="text-[14px] font-serif text-[#00293D]/70 mb-4">{error || 'Profile not found'}</p>
|
||||
<p className="text-[14px] font-serif text-[#00293D]/70 mb-4">
|
||||
{error || "Profile not found"}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="px-6 py-2 bg-[#E58625] rounded-full text-[14px] font-semibold font-serif text-white hover:bg-[#E58625]/90 transition-colors"
|
||||
@@ -319,164 +413,265 @@ export default function AgentProfileView() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const profileImage = getProfileImageUrl();
|
||||
const profileSchema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Person",
|
||||
|
||||
name: `${agentProfile.firstName} ${agentProfile.lastName}`.trim(),
|
||||
|
||||
jobTitle: agentProfile.agentType?.name || "Real Estate Professional",
|
||||
|
||||
description:
|
||||
profileCardData.bio ||
|
||||
agentProfile.bio ||
|
||||
"Verified real estate professional on RE-Quest.",
|
||||
|
||||
image: profileImage
|
||||
? profileImage.startsWith("http")
|
||||
? profileImage
|
||||
: `https://re-quest.com${profileImage}`
|
||||
: undefined,
|
||||
|
||||
email: agentProfile.email || agentProfile.user?.email || undefined,
|
||||
|
||||
telephone: agentProfile.phone || contactInfoData.phone || undefined,
|
||||
|
||||
url: `https://re-quest.com/user/profile/${id}`,
|
||||
mainEntityOfPage: {
|
||||
"@type": "WebPage",
|
||||
"@id": `https://re-quest.com/user/profile/${id}`,
|
||||
},
|
||||
address: {
|
||||
"@type": "PostalAddress",
|
||||
addressLocality: profileCardData.city || undefined,
|
||||
|
||||
addressRegion: profileCardData.state || undefined,
|
||||
},
|
||||
|
||||
memberOf: {
|
||||
"@type": "Organization",
|
||||
name: "RE-Quest",
|
||||
url: "https://re-quest.com",
|
||||
},
|
||||
|
||||
sameAs: [`https://re-quest.com/user/profile/${id}`],
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 lg:px-8 pt-2 pb-6 space-y-6">
|
||||
<MobileBackButton label="Back" fallbackHref="/user/profiles" alwaysShow />
|
||||
{/* 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 bg-[#e8e8e8] relative">
|
||||
{/* Shimmer while loading, initials only on error/no image */}
|
||||
{imageError || !getProfileImageUrl() ? (
|
||||
<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}
|
||||
{getProfileImageUrl() && (
|
||||
<>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
ref={(el) => { if (el?.complete) { if (el.naturalWidth > 0) setImageLoaded(true); else setImageError(true); } }}
|
||||
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>
|
||||
</div>
|
||||
|
||||
{/* Status Buttons - Public view shows availability status with Connect/Pending/Unlink button */}
|
||||
<StatusButtons
|
||||
isAvailable={agentProfile.isAvailable ?? true}
|
||||
connectionStatus={connectionStatus}
|
||||
onConnectClick={() => setShowConnectModal(true)}
|
||||
onUnlinkClick={handleUnlink}
|
||||
/>
|
||||
|
||||
{/* 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 - No edit button for user view */}
|
||||
<ProfileCard
|
||||
firstName={agentProfile.firstName}
|
||||
lastName={agentProfile.lastName}
|
||||
isVerified={agentProfile.isVerified}
|
||||
title={agentProfile.agentType?.name || 'Real Estate Agent'}
|
||||
location={profileCardData.city && profileCardData.state
|
||||
? `${profileCardData.state}, ${profileCardData.city}`
|
||||
: profileCardData.state || profileCardData.city || agentProfile.serviceAreas?.[0] || '-'}
|
||||
memberSince={formatMemberSince((agentProfile as unknown as { createdAt?: string }).createdAt)}
|
||||
bio={profileCardData.bio || agentProfile.bio || ''}
|
||||
expertise={profileCardData.expertise.length > 0 ? profileCardData.expertise : (agentProfile.specializations || [])}
|
||||
showEditButton={false}
|
||||
messageHref={`/user/message?agentProfileId=${agentProfile.id}`}
|
||||
connectionStatus={connectionStatus}
|
||||
isAvailable={agentProfile.isAvailable ?? true}
|
||||
onConnectClick={() => setShowConnectModal(true)}
|
||||
onUnlinkClick={handleUnlink}
|
||||
/>
|
||||
|
||||
{/* 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={availabilityData.label}
|
||||
icon={
|
||||
<Image
|
||||
src="/assets/icons/availability-clock-icon.svg"
|
||||
alt={availabilityData.label}
|
||||
width={28}
|
||||
height={31}
|
||||
/>
|
||||
}
|
||||
content={
|
||||
<div>
|
||||
<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={workEnvironmentData.label}
|
||||
icon={
|
||||
<Image
|
||||
src="/assets/icons/work-environment-icon.svg"
|
||||
alt={workEnvironmentData.label}
|
||||
width={28}
|
||||
height={28}
|
||||
/>
|
||||
}
|
||||
content={
|
||||
workEnvironmentData.content ? (
|
||||
<p className="font-serif font-semibold text-[14px] leading-[19px] text-[#00293D]">{workEnvironmentData.content}</p>
|
||||
) : (
|
||||
<p className="font-normal font-serif text-[14px] leading-[19px] text-[#00293D]/60">Not specified</p>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<InfoCard
|
||||
title={personalTaglineData.label}
|
||||
icon={
|
||||
<Image
|
||||
src="/assets/icons/testimonial-star-icon.svg"
|
||||
alt={personalTaglineData.label}
|
||||
width={28}
|
||||
height={28}
|
||||
/>
|
||||
}
|
||||
content={
|
||||
personalTaglineData.content ? (
|
||||
<p className="text-[14px] font-semibold font-serif leading-[19px] text-center text-[#00293D]">“{personalTaglineData.content.length > 150 ? personalTaglineData.content.substring(0, 150) + '...' : personalTaglineData.content}”</p>
|
||||
) : (
|
||||
<p className="font-normal font-serif text-[14px] leading-[19px] text-[#00293D]/60">Not specified</p>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Specialization Section */}
|
||||
<SpecializationSection fieldsData={specializationFieldsData} />
|
||||
|
||||
{/* Testimonials Section */}
|
||||
<TestimonialsSection testimonials={testimonials} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connect Request Modal */}
|
||||
<ConnectRequestModal
|
||||
isOpen={showConnectModal}
|
||||
onClose={() => setShowConnectModal(false)}
|
||||
agentProfileId={id}
|
||||
agentName={`${agentProfile.firstName || ''} ${agentProfile.lastName || ''}`.trim() || 'Agent'}
|
||||
existingStatus={connectionStatus}
|
||||
onSuccess={handleConnectionSuccess}
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(profileSchema),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="max-w-7xl mx-auto px-4 lg:px-8 pt-2 pb-6 space-y-6">
|
||||
<MobileBackButton
|
||||
label="Back"
|
||||
fallbackHref="/user/profiles"
|
||||
alwaysShow
|
||||
/>
|
||||
{/* 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 bg-[#e8e8e8] relative">
|
||||
{/* Shimmer while loading, initials only on error/no image */}
|
||||
{imageError || !getProfileImageUrl() ? (
|
||||
<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}
|
||||
{getProfileImageUrl() && (
|
||||
<>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
ref={(el) => {
|
||||
if (el?.complete) {
|
||||
if (el.naturalWidth > 0) setImageLoaded(true);
|
||||
else setImageError(true);
|
||||
}
|
||||
}}
|
||||
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>
|
||||
</div>
|
||||
|
||||
{/* Status Buttons - Public view shows availability status with Connect/Pending/Unlink button */}
|
||||
<StatusButtons
|
||||
isAvailable={agentProfile.isAvailable ?? true}
|
||||
connectionStatus={connectionStatus}
|
||||
onConnectClick={() => setShowConnectModal(true)}
|
||||
onUnlinkClick={handleUnlink}
|
||||
/>
|
||||
|
||||
{/* 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 - No edit button for user view */}
|
||||
<ProfileCard
|
||||
firstName={agentProfile.firstName}
|
||||
lastName={agentProfile.lastName}
|
||||
isVerified={agentProfile.isVerified}
|
||||
title={agentProfile.agentType?.name || "Real Estate Agent"}
|
||||
location={
|
||||
profileCardData.city && profileCardData.state
|
||||
? `${profileCardData.state}, ${profileCardData.city}`
|
||||
: profileCardData.state ||
|
||||
profileCardData.city ||
|
||||
agentProfile.serviceAreas?.[0] ||
|
||||
"-"
|
||||
}
|
||||
memberSince={formatMemberSince(
|
||||
(agentProfile as unknown as { createdAt?: string }).createdAt,
|
||||
)}
|
||||
bio={profileCardData.bio || agentProfile.bio || ""}
|
||||
expertise={
|
||||
profileCardData.expertise.length > 0
|
||||
? profileCardData.expertise
|
||||
: agentProfile.specializations || []
|
||||
}
|
||||
showEditButton={false}
|
||||
messageHref={`/user/message?agentProfileId=${agentProfile.id}`}
|
||||
connectionStatus={connectionStatus}
|
||||
isAvailable={agentProfile.isAvailable ?? true}
|
||||
onConnectClick={() => setShowConnectModal(true)}
|
||||
onUnlinkClick={handleUnlink}
|
||||
/>
|
||||
|
||||
{/* 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={availabilityData.label}
|
||||
icon={
|
||||
<Image
|
||||
src="/assets/icons/availability-clock-icon.svg"
|
||||
alt={availabilityData.label}
|
||||
width={28}
|
||||
height={31}
|
||||
/>
|
||||
}
|
||||
content={
|
||||
<div>
|
||||
<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={workEnvironmentData.label}
|
||||
icon={
|
||||
<Image
|
||||
src="/assets/icons/work-environment-icon.svg"
|
||||
alt={workEnvironmentData.label}
|
||||
width={28}
|
||||
height={28}
|
||||
/>
|
||||
}
|
||||
content={
|
||||
workEnvironmentData.content ? (
|
||||
<p className="font-serif font-semibold text-[14px] leading-[19px] text-[#00293D]">
|
||||
{workEnvironmentData.content}
|
||||
</p>
|
||||
) : (
|
||||
<p className="font-normal font-serif text-[14px] leading-[19px] text-[#00293D]/60">
|
||||
Not specified
|
||||
</p>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<InfoCard
|
||||
title={personalTaglineData.label}
|
||||
icon={
|
||||
<Image
|
||||
src="/assets/icons/testimonial-star-icon.svg"
|
||||
alt={personalTaglineData.label}
|
||||
width={28}
|
||||
height={28}
|
||||
/>
|
||||
}
|
||||
content={
|
||||
personalTaglineData.content ? (
|
||||
<p className="text-[14px] font-semibold font-serif leading-[19px] text-center text-[#00293D]">
|
||||
“
|
||||
{personalTaglineData.content.length > 150
|
||||
? personalTaglineData.content.substring(0, 150) + "..."
|
||||
: personalTaglineData.content}
|
||||
”
|
||||
</p>
|
||||
) : (
|
||||
<p className="font-normal font-serif text-[14px] leading-[19px] text-[#00293D]/60">
|
||||
Not specified
|
||||
</p>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Specialization Section */}
|
||||
<SpecializationSection fieldsData={specializationFieldsData} />
|
||||
|
||||
{/* Testimonials Section */}
|
||||
<TestimonialsSection testimonials={testimonials} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connect Request Modal */}
|
||||
<ConnectRequestModal
|
||||
isOpen={showConnectModal}
|
||||
onClose={() => setShowConnectModal(false)}
|
||||
agentProfileId={id}
|
||||
agentName={
|
||||
`${agentProfile.firstName || ""} ${agentProfile.lastName || ""}`.trim() ||
|
||||
"Agent"
|
||||
}
|
||||
existingStatus={connectionStatus}
|
||||
onSuccess={handleConnectionSuccess}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user