feat: Improve avatar loading experience by displaying placeholders until images are fully loaded across various components.

This commit is contained in:
pradeepkumar
2026-03-13 22:45:11 +05:30
parent b339dd865c
commit 7b5c670159
11 changed files with 225 additions and 80 deletions

View File

@@ -1,5 +1,6 @@
'use client';
import { useState } from 'react';
import Image from 'next/image';
import Link from 'next/link';
@@ -26,17 +27,33 @@ export function ConnectionCard({
connectedAt,
onMessage,
}: ConnectionCardProps) {
const [avatarLoaded, setAvatarLoaded] = useState(false);
const placeholder = '/assets/icons/user-placeholder-icon.svg';
const isPlaceholder = !avatar || avatar === placeholder;
return (
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 py-4">
{/* Avatar */}
<div className="flex-shrink-0">
<Image
src={avatar}
alt={name}
width={80}
height={80}
className="rounded-full object-cover w-[80px] h-[80px]"
/>
<div className="w-[80px] h-[80px] rounded-full overflow-hidden relative">
{(!avatar || !avatarLoaded || isPlaceholder) && (
<Image
src={placeholder}
alt={name}
width={80}
height={80}
className="absolute inset-0 w-full h-full object-cover"
/>
)}
{avatar && !isPlaceholder && (
<img
src={avatar}
alt={name}
className={`absolute inset-0 w-full h-full object-cover rounded-full transition-opacity duration-200 ${avatarLoaded ? 'opacity-100' : 'opacity-0'}`}
onLoad={() => setAvatarLoaded(true)}
/>
)}
</div>
</div>
{/* Content */}

View File

@@ -1,5 +1,6 @@
'use client';
import { useState } from 'react';
import Image from 'next/image';
interface InvitationCardProps {
@@ -40,17 +41,33 @@ export function InvitationCard({
onAccept,
onIgnore,
}: InvitationCardProps) {
const [avatarLoaded, setAvatarLoaded] = useState(false);
const placeholder = '/assets/icons/user-placeholder-icon.svg';
const isPlaceholder = !avatar || avatar === placeholder;
return (
<div className={`flex flex-col sm:flex-row items-start sm:items-center gap-4 py-4 ${isProcessing ? 'opacity-50 pointer-events-none' : ''}`}>
{/* Avatar */}
<div className="flex-shrink-0">
<Image
src={avatar}
alt={name}
width={80}
height={80}
className="rounded-full object-cover w-[80px] h-[80px]"
/>
<div className="w-[80px] h-[80px] rounded-full overflow-hidden relative">
{(!avatar || !avatarLoaded || isPlaceholder) && (
<Image
src={placeholder}
alt={name}
width={80}
height={80}
className="absolute inset-0 w-full h-full object-cover"
/>
)}
{avatar && !isPlaceholder && (
<img
src={avatar}
alt={name}
className={`absolute inset-0 w-full h-full object-cover rounded-full transition-opacity duration-200 ${avatarLoaded ? 'opacity-100' : 'opacity-0'}`}
onLoad={() => setAvatarLoaded(true)}
/>
)}
</div>
</div>
{/* Content */}

View File

@@ -111,9 +111,6 @@ body {
}
/* Smooth scrolling */
html {
scroll-behavior: smooth;
}
/* Selection styling */
::selection {

View File

@@ -130,7 +130,7 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<html lang="en" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} ${sourceSerif4.variable} ${fractul.variable} antialiased`}
>

View File

@@ -26,6 +26,7 @@ export function CommonHeader() {
const mobileMenuRef = useRef<HTMLDivElement>(null);
const [profileImage, setProfileImage] = useState<string | null>(null);
const [profileName, setProfileName] = useState<string | null>(null);
const [avatarLoaded, setAvatarLoaded] = useState(false);
const [notificationCount, setNotificationCount] = useState(0);
// Close dropdowns when clicking outside
@@ -73,6 +74,7 @@ export function CommonHeader() {
}
if (avatar) {
setAvatarLoaded(false);
try {
const avatarUrl = await uploadService.getPresignedDownloadUrl(avatar);
setProfileImage(avatarUrl);
@@ -150,6 +152,7 @@ export function CommonHeader() {
alt="RE-QuestN"
width={150}
height={40}
priority
className="h-8 md:h-10 w-auto"
/>
</Link>
@@ -198,23 +201,25 @@ export function CommonHeader() {
className="flex items-center gap-2 md:gap-3 hover:opacity-80 transition-opacity cursor-pointer"
>
{/* Avatar */}
<div className="w-[32px] h-[32px] md:w-[35px] md:h-[35px] rounded-full overflow-hidden border-2 border-[#e58625] bg-gray-100 flex-shrink-0">
{userImage ? (
<img
src={userImage}
alt="Profile"
className="w-full h-full object-cover"
/>
) : (
<div className="w-[32px] h-[32px] md:w-[35px] md:h-[35px] rounded-full overflow-hidden border-2 border-[#e58625] bg-gray-100 flex-shrink-0 relative">
{(!userImage || !avatarLoaded) && (
<Image
src="/assets/icons/user-placeholder-icon.svg"
alt="Profile"
width={35}
height={35}
className="object-cover"
className="absolute inset-0 object-cover"
style={{ width: '100%', height: '100%' }}
/>
)}
{userImage && (
<img
src={userImage}
alt="Profile"
className={`w-full h-full object-cover ${avatarLoaded ? 'opacity-100' : 'opacity-0'}`}
onLoad={() => setAvatarLoaded(true)}
/>
)}
</div>
{/* Greeting Text - hidden on mobile */}
<div className="hidden sm:flex flex-col text-left">
@@ -233,23 +238,25 @@ export function CommonHeader() {
<div className="absolute -top-2 right-6 w-0 h-0 border-l-[8px] border-l-transparent border-r-[8px] border-r-transparent border-b-[8px] border-b-white"></div>
<div className="flex items-center gap-3 px-4 py-4 border-b border-black/10">
<div className="w-[42px] h-[42px] rounded-full overflow-hidden flex-shrink-0 bg-gray-100">
{userImage ? (
<img
src={userImage}
alt="Profile"
className="w-full h-full object-cover"
/>
) : (
<div className="w-[42px] h-[42px] rounded-full overflow-hidden flex-shrink-0 bg-gray-100 relative">
{(!userImage || !avatarLoaded) && (
<Image
src="/assets/icons/user-placeholder-icon.svg"
alt="Profile"
width={42}
height={42}
className="object-cover"
className="absolute inset-0 object-cover"
style={{ width: '100%', height: '100%' }}
/>
)}
{userImage && (
<img
src={userImage}
alt="Profile"
className={`w-full h-full object-cover ${avatarLoaded ? 'opacity-100' : 'opacity-0'}`}
onLoad={() => setAvatarLoaded(true)}
/>
)}
</div>
<div className="flex flex-col min-w-0 overflow-hidden">
<p className="font-fractul font-medium text-[16px] leading-[20px] text-black">

View File

@@ -33,6 +33,10 @@ export function ChatHeader({
}: ChatHeaderProps) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [avatarLoaded, setAvatarLoaded] = useState(false);
const placeholder = '/assets/icons/user-placeholder-icon.svg';
const isPlaceholderAvatar = !avatar || avatar === placeholder;
const menuItems: DropdownMenuItem[] = [
{
@@ -118,15 +122,24 @@ export function ChatHeader({
<div className="flex items-start gap-3">
{/* Avatar */}
<div className="relative flex-shrink-0">
<div className="w-[50px] h-[50px] rounded-full overflow-hidden border border-[#00293D]/20">
<Image
src={avatar}
alt={name}
width={50}
height={50}
className="object-cover rounded-full"
style={{ width: '100%', height: '100%' }}
/>
<div className="w-[50px] h-[50px] rounded-full overflow-hidden border border-[#00293D]/20 relative">
{(!avatar || !avatarLoaded || isPlaceholderAvatar) && (
<Image
src={placeholder}
alt={name}
width={50}
height={50}
className="absolute inset-0 w-full h-full object-cover"
/>
)}
{avatar && !isPlaceholderAvatar && (
<img
src={avatar}
alt={name}
className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-200 ${avatarLoaded ? 'opacity-100' : 'opacity-0'}`}
onLoad={() => setAvatarLoaded(true)}
/>
)}
</div>
{isOnline && (
<div className="absolute bottom-0 right-0 w-3 h-3 bg-green-500 rounded-full border-2 border-white" />

View File

@@ -80,6 +80,44 @@ function formatLastSeen(lastSeenAt: string | null | undefined, isOnline: boolean
return `Last seen ${diffDays}d ago`;
}
// Avatar component with placeholder-until-loaded pattern
function AvatarWithPlaceholder({ src, alt, size }: { src: string; alt: string; size: number }) {
const [loaded, setLoaded] = useState(false);
const placeholder = '/assets/icons/user-placeholder-icon.svg';
const isPlaceholder = !src || src === placeholder;
return (
<>
{(!src || !loaded) && (
<Image
src={placeholder}
alt={alt}
width={size}
height={size}
className="absolute inset-0 w-full h-full object-cover"
/>
)}
{src && !isPlaceholder && (
<img
src={src}
alt={alt}
className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-200 ${loaded ? 'opacity-100' : 'opacity-0'}`}
onLoad={() => setLoaded(true)}
/>
)}
{src && isPlaceholder && (
<Image
src={placeholder}
alt={alt}
width={size}
height={size}
className="absolute inset-0 w-full h-full object-cover"
/>
)}
</>
);
}
// Connected agent with avatar URL
interface ConnectedAgent {
agentProfileId: string;
@@ -589,14 +627,11 @@ export function MessagingPage() {
>
{/* Avatar */}
<div className="relative flex-shrink-0">
<div className="w-[50px] h-[50px] rounded-full overflow-hidden border border-[#00293D]/20">
<Image
<div className="w-[50px] h-[50px] rounded-full overflow-hidden border border-[#00293D]/20 relative">
<AvatarWithPlaceholder
src={getValidAvatarUrl(agent.avatarUrl)}
alt={agent.name}
width={50}
height={50}
className="object-cover rounded-full"
style={{ width: '100%', height: '100%' }}
size={50}
/>
</div>
</div>
@@ -659,14 +694,11 @@ export function MessagingPage() {
{/* Avatar with online indicator */}
<div className="relative flex-shrink-0">
<div className="w-[70px] h-[70px] rounded-full overflow-hidden border border-[#00293D]/20">
<Image
<div className="w-[70px] h-[70px] rounded-full overflow-hidden border border-[#00293D]/20 relative">
<AvatarWithPlaceholder
src={getValidAvatarUrl(conversationAvatarUrls[conversation.id])}
alt={conversation.otherParty?.name || 'User'}
width={70}
height={70}
className="object-cover rounded-full"
style={{ width: '100%', height: '100%' }}
size={70}
/>
</div>
{conversation.otherParty?.isOnline && (

View File

@@ -50,6 +50,14 @@ export function ProfileCard({
const router = useRouter();
const pathname = usePathname();
const [isUnlinking, setIsUnlinking] = useState(false);
const [showLocationModal, setShowLocationModal] = useState(false);
// Split location into parts and limit display
const locationParts = location.split(',').map(s => s.trim()).filter(Boolean);
const maxVisibleLocations = 4;
const visibleLocations = locationParts.slice(0, maxVisibleLocations).join(', ');
const hasMoreLocations = locationParts.length > maxVisibleLocations;
const remainingCount = locationParts.length - maxVisibleLocations;
// Handle unlink click
const handleUnlinkClick = async () => {
@@ -113,26 +121,35 @@ export function ProfileCard({
)}
</div>
<p className="text-[#00293d] text-sm mb-2 font-fractul">{title}</p>
<div className="flex items-center justify-center lg:justify-start gap-4 text-sm text-[#00293d] font-fractul">
<div className="flex flex-wrap items-center justify-center lg:justify-start gap-x-4 gap-y-1 text-sm text-[#00293d] font-fractul">
<span className="flex items-center gap-1">
<Image
src="/assets/icons/location-pin-icon.svg"
alt="Location"
width={21}
height={19}
className="flex-shrink-0"
/>
<span className="text-[14px] font-bold text-[#00293D] font-serif leading-[19px]">
{location}
{visibleLocations}
{hasMoreLocations && (
<button
onClick={() => setShowLocationModal(true)}
className="ml-1 text-[#e58625] hover:underline cursor-pointer"
>
+{remainingCount} more
</button>
)}
</span>
</span>
<span className="flex items-center gap-1">
<span className="flex items-center gap-1 flex-shrink-0">
<Image
src="/assets/icons/calendar-icon.svg"
alt="Calendar"
width={23}
height={21}
/>
<span className="text-[13px] font-medium text-[#00293D] font-serif leading-[18px]">
<span className="text-[13px] font-medium text-[#00293D] font-serif leading-[18px] whitespace-nowrap">
Member Since {memberSince}
</span>
</span>
@@ -260,6 +277,33 @@ export function ProfileCard({
))}
</div>
</div>
{/* Location Modal */}
{showLocationModal && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" onClick={() => setShowLocationModal(false)}>
<div className="bg-white rounded-[20px] p-6 max-w-[400px] w-full max-h-[80vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="font-fractul font-bold text-[18px] text-[#00293D]">Service Areas</h3>
<button
onClick={() => setShowLocationModal(false)}
className="text-[#00293D]/50 hover:text-[#00293D] text-[20px] leading-none cursor-pointer"
>
&times;
</button>
</div>
<div className="flex flex-wrap gap-2">
{locationParts.map((loc, idx) => (
<span
key={idx}
className="px-3 py-1.5 bg-[#e8e8e8] rounded-[10px] text-[13px] font-serif text-[#00293D]"
>
{loc}
</span>
))}
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -46,12 +46,18 @@ export function ProfileSettingsForm({
});
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
const [avatarLoaded, setAvatarLoaded] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
// Reset avatar loaded state when URL changes (e.g., after upload)
useEffect(() => {
setAvatarLoaded(false);
}, [avatarUrl]);
// Store original data for cancel/reset functionality
const [originalData, setOriginalData] = useState<typeof formData | null>(null);
// Track if initial profile fetch is done to avoid re-showing loading spinner
@@ -382,20 +388,22 @@ export function ProfileSettingsForm({
{/* Profile Photo Section */}
<div className="mb-8">
<div className="flex items-start gap-4">
<div className="w-[60px] h-[60px] rounded-full overflow-hidden border border-[#00293D]/20 flex-shrink-0 bg-gray-100">
{avatarUrl ? (
<img
src={avatarUrl}
alt="Profile"
className="w-full h-full object-cover"
/>
) : (
<div className="w-[60px] h-[60px] rounded-full overflow-hidden border border-[#00293D]/20 flex-shrink-0 bg-gray-100 relative">
{(!avatarUrl || !avatarLoaded) && (
<Image
src="/assets/icons/user-placeholder-icon.svg"
alt="Profile"
width={60}
height={60}
className="w-full h-full object-cover"
className="absolute inset-0 w-full h-full object-cover"
/>
)}
{avatarUrl && (
<img
src={avatarUrl}
alt="Profile"
className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-200 ${avatarLoaded ? 'opacity-100' : 'opacity-0'}`}
onLoad={() => setAvatarLoaded(true)}
/>
)}
</div>

View File

@@ -25,6 +25,8 @@ export function SettingsSidebar({
const pathname = usePathname();
const { data: session } = useSession();
const [avatarLoaded, setAvatarLoaded] = useState(false);
const [profileData, setProfileData] = useState({
name: session?.user?.name || 'User',
title: 'Real Estate Agent',
@@ -65,6 +67,7 @@ export function SettingsSidebar({
}
setProfileData({ name, title, avatarUrl });
setAvatarLoaded(false);
} catch (err) {
console.error('Failed to fetch profile for sidebar:', err);
}
@@ -133,20 +136,22 @@ export function SettingsSidebar({
{/* Profile Card */}
<div className="bg-white rounded-[15px] border border-[#00293D]/20 p-5">
<div className="flex items-center gap-4">
<div className="w-[70px] h-[70px] rounded-full overflow-hidden border-2 border-[#00293D]/20 flex-shrink-0 bg-gray-100">
{profileData.avatarUrl ? (
<img
src={profileData.avatarUrl}
alt={profileData.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-[70px] h-[70px] rounded-full overflow-hidden border-2 border-[#00293D]/20 flex-shrink-0 bg-gray-100 relative">
{(!profileData.avatarUrl || !avatarLoaded) && (
<Image
src="/assets/icons/user-placeholder-icon.svg"
alt={profileData.name}
width={70}
height={70}
className="w-full h-full object-cover"
className="absolute inset-0 w-full h-full object-cover"
/>
)}
{profileData.avatarUrl && (
<img
src={profileData.avatarUrl}
alt={profileData.name}
className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-200 ${avatarLoaded ? 'opacity-100' : 'opacity-0'}`}
onLoad={() => setAvatarLoaded(true)}
/>
)}
</div>

View File

@@ -360,8 +360,9 @@ export function mapFieldValuesToSpecializationFields(fieldValues: FieldValueResp
return sectionSlug === 'specialization' || sectionSlug.includes('specialization');
});
// Create a card for each field
// Create a card for each field, deduplicating by fieldSlug
const fields: SpecializationFieldItem[] = [];
const seenSlugs = new Set<string>();
for (const field of specializationFields) {
const { fieldSlug, fieldName, value } = field;
@@ -369,6 +370,10 @@ export function mapFieldValuesToSpecializationFields(fieldValues: FieldValueResp
// Skip if no field info or no value
if (!fieldSlug || !fieldName) continue;
// Skip duplicate fieldSlugs (API may return duplicates)
if (seenSlugs.has(fieldSlug)) continue;
seenSlugs.add(fieldSlug);
const values = valueToStringArray(value);
if (values.length === 0) continue;