Files
frontend/src/app/(user)/user/profile/[id]/page.tsx

678 lines
23 KiB
TypeScript
Raw Normal View History

"use client";
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 {
InfoCard,
ExperienceSection,
ProfileCard,
SpecializationSection,
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";
// Default work environment data
const defaultWorkEnvironmentData: WorkEnvironmentData = {
label: "Preferred Work Environment",
content: "",
2026-01-24 23:10:02 +05:30
};
// Default personal tagline data
const defaultPersonalTaglineData: PersonalTaglineData = {
label: "Personal Tagline",
content: "",
};
2026-01-24 23:10:02 +05:30
// Default experience data when no field values are available
const defaultExperience: ExperienceData = {
years: "-",
yearsLabel: "Years in Experience",
contracts: "-",
contractsLabel: "Number of contracts closed",
2026-01-24 23:10:02 +05:30
licensingAreas: [],
licensingAreasLabel: "Licensing & Areas",
2026-01-24 23:10:02 +05:30
expertiseYears: [],
expertiseYearsLabel: "Areas in expertise & Years",
2026-01-24 23:10:02 +05:30
certifications: [],
certificationsLabel: "Certifications",
agencyName: "",
agencyDesignation: "",
agencyShowDesignation: false,
agencySectionLabel: "Real Estate Agency & Designation",
2026-01-24 23:10:02 +05:30
};
2026-01-25 01:15:02 +05:30
// Default specialization fields data when no field values are available
const defaultSpecializationFieldsData: SpecializationFieldsData = {
fields: [],
};
// Default profile card data when no field values are available
const defaultProfileCardData: ProfileCardData = {
bio: "",
expertise: [],
serviceAreas: [],
city: null,
state: null,
};
// Default contact info data when no field values are available
const defaultContactInfoData: ContactInfoData = {
email: null,
phone: null,
};
// Default availability data when no field values are available
const defaultAvailabilityData: AvailabilityData = {
type: "",
schedule: [],
label: "Availability",
};
export default function AgentProfileView() {
const params = useParams();
const id = params.id as string;
const { data: session } = useSession();
2026-01-24 23:10:02 +05:30
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);
2026-01-24 23:10:02 +05:30
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 }[]
>([]);
// Connect modal state
const [showConnectModal, setShowConnectModal] = useState(false);
const [connectionStatus, setConnectionStatus] =
useState<ConnectionStatus | null>(null);
const [connectionRequestId, setConnectionRequestId] = useState<string | null>(
null,
);
2026-01-24 23:10:02 +05:30
// 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("/");
};
2026-01-24 23:10:02 +05:30
// Fetch agent profile and field values on mount
useEffect(() => {
const fetchData = async () => {
if (!id) return;
try {
setLoading(true);
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[]),
]);
2026-01-24 23:10:02 +05:30
setAgentProfile(profile);
setFieldValues(fieldValuesResponse.fieldValues);
// Map field values to experience data structure
const mappedExperience = mapFieldValuesToExperience(
fieldValuesResponse.fieldValues,
);
2026-01-24 23:10:02 +05:30
setExperienceData(mappedExperience);
2026-01-25 01:15:02 +05:30
// Map field values to specialization fields data (only fields from "Specialization" section)
const mappedSpecializationFields = mapFieldValuesToSpecializationFields(
fieldValuesResponse.fieldValues,
);
2026-01-25 01:15:02 +05:30
setSpecializationFieldsData(mappedSpecializationFields);
// Map field values to profile card data (bio, expertise, location)
const mappedProfileCard = mapFieldValuesToProfileCard(
fieldValuesResponse.fieldValues,
);
setProfileCardData(mappedProfileCard);
// Map field values to contact info data (email, phone)
const mappedContactInfo = mapFieldValuesToContactInfo(
fieldValuesResponse.fieldValues,
);
setContactInfoData(mappedContactInfo);
// Map field values to availability data
const mappedAvailability = mapFieldValuesToAvailability(
fieldValuesResponse.fieldValues,
);
setAvailabilityData(mappedAvailability);
// Map field values to work environment data
const mappedWorkEnv = mapFieldValuesToWorkEnvironment(
fieldValuesResponse.fieldValues,
);
setWorkEnvironmentData(mappedWorkEnv);
// Map field values to personal tagline data
const mappedTagline = mapFieldValuesToPersonalTagline(
fieldValuesResponse.fieldValues,
);
setPersonalTaglineData(mappedTagline);
// Map testimonials for TestimonialsSection
setTestimonials(
testimonialsData.map((t) => ({
id: t.id,
text: t.text,
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,
);
setAvatarUrl(presignedUrl);
} catch (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);
const status = err?.response?.status;
if (status === 403) {
setError(
"This profile is private. You don\u2019t have permission to view it.",
);
} else if (status === 404) {
setError("Profile not found.");
} else {
setError("Failed to load profile data");
}
2026-01-24 23:10:02 +05:30
} finally {
setLoading(false);
}
};
fetchData();
}, [id]);
// Fetch connection status when user is logged in
useEffect(() => {
const fetchConnectionStatus = async () => {
if (!id || !session) return;
try {
const statusResponse =
await connectionRequestsService.getConnectionStatus(id);
setConnectionStatus(statusResponse?.status || null);
setConnectionRequestId(statusResponse?.id || null);
} catch (err) {
console.error("Failed to fetch connection status:", err);
// Non-critical error, don't show to user
}
};
fetchConnectionStatus();
}, [id, session]);
// Handle connection request success
const handleConnectionSuccess = () => {
setConnectionStatus("PENDING");
};
// Handle unlink/disconnect
const handleUnlink = async () => {
if (!connectionRequestId) return;
try {
await connectionRequestsService.cancelRequest(connectionRequestId);
setConnectionStatus(null);
setConnectionRequestId(null);
} catch (err) {
console.error("Failed to unlink connection:", err);
}
};
2026-01-24 23:10:02 +05:30
const getProfileImageUrl = () => {
// If image failed to load, return null
2026-01-24 23:10:02 +05:30
if (imageError) {
return null;
2026-01-24 23:10:02 +05:30
}
// If we have a presigned URL from S3, use it
if (avatarUrl) {
return avatarUrl;
}
2026-01-24 23:10:02 +05:30
// Check for null, undefined, or empty string
if (!agentProfile?.avatar || agentProfile.avatar.trim() === "") {
return null;
2026-01-24 23:10:02 +05:30
}
// For relative paths (local assets), return as-is
if (agentProfile.avatar.startsWith("/")) {
return agentProfile.avatar;
2026-01-24 23:10:02 +05:30
}
// No image available
return null;
2026-01-24 23:10:02 +05:30
};
// Format member since date
const formatMemberSince = (dateString?: string) => {
if (!dateString) return "Member";
2026-01-24 23:10:02 +05:30
try {
const date = new Date(dateString);
return date.toLocaleDateString("en-US", {
month: "long",
year: "numeric",
});
2026-01-24 23:10:02 +05:30
} catch {
return "Member";
2026-01-24 23:10:02 +05:30
}
};
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>
2026-01-24 23:10:02 +05:30
</div>
</div>
</div>
);
}
const isPermissionError =
error?.includes("permission") || error?.includes("private");
2026-01-24 23:10:02 +05:30
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"}`}
>
{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>
) : (
<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>
)}
2026-01-24 23:10:02 +05:30
</div>
<h2 className="text-[18px] font-bold font-serif text-[#00293D] mb-2">
{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>
2026-01-24 23:10:02 +05:30
<button
onClick={() => window.history.back()}
2026-01-24 23:10:02 +05:30
className="px-6 py-2 bg-[#E58625] rounded-full text-[14px] font-semibold font-serif text-white hover:bg-[#E58625]/90 transition-colors"
>
Go Back
2026-01-24 23:10:02 +05:30
</button>
</div>
</div>
</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 (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(profileSchema),
}}
/>
<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 min-w-0 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]">
&ldquo;
{personalTaglineData.content.length > 150
? personalTaglineData.content.substring(0, 150) + "..."
: personalTaglineData.content}
&rdquo;
</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>
</>
);
}