feat: Implement useImageStatus hook and enhance image loading status handling across various components to address cached images and loading timeouts.

This commit is contained in:
pradeepkumar
2026-03-18 11:54:40 +05:30
parent f3c4e31924
commit 772e1024cc
17 changed files with 211 additions and 93 deletions

View File

@@ -49,6 +49,7 @@ function ProfessionalCard({
fill
className={`object-cover transition-opacity duration-300 ${isPlaceholder || imgLoaded ? 'opacity-100' : 'opacity-0'}`}
onLoad={() => setImgLoaded(true)}
onError={() => setImgLoaded(true)}
/>
</div>

View File

@@ -1,14 +1,10 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useState, useEffect, useRef } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { useSession } from 'next-auth/react';
import { agentsService } from '@/services/agents.service';
import { usersService } from '@/services/users.service';
import { uploadService } from '@/services/upload.service';
import { notificationsApiService } from '@/services/notifications-api.service';
import { useHeaderData } from '@/components/providers/header-provider';
const navLinks = [
{ label: 'Education', href: '/education' },
@@ -18,16 +14,13 @@ const navLinks = [
export function CommonHeader() {
const { data: session } = useSession();
const { profileImage, profileName, avatarLoaded, setAvatarLoaded, notificationCount } = useHeaderData();
const [showProfileMenu, setShowProfileMenu] = useState(false);
const [showGuestMenu, setShowGuestMenu] = useState(false);
const [showMobileMenu, setShowMobileMenu] = useState(false);
const profileMenuRef = useRef<HTMLDivElement>(null);
const guestMenuRef = useRef<HTMLDivElement>(null);
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
useEffect(() => {
@@ -52,86 +45,6 @@ export function CommonHeader() {
};
}, [showProfileMenu, showGuestMenu, showMobileMenu]);
// Fetch profile data (image and name) from backend based on user role
const fetchProfileData = useCallback(async () => {
try {
const role = (session?.user as any)?.role;
let avatar: string | null = null;
let name: string | null = null;
if (role === 'AGENT') {
const profile = await agentsService.getMyProfile();
avatar = profile.avatar;
name = profile.firstName ? `${profile.firstName} ${profile.lastName || ''}`.trim() : null;
} else if (role === 'USER') {
const profile = await usersService.getMyProfile();
avatar = profile.avatar;
name = profile.firstName ? `${profile.firstName} ${profile.lastName || ''}`.trim() : null;
}
if (name) {
setProfileName(name);
}
if (avatar) {
setAvatarLoaded(false);
try {
const avatarUrl = await uploadService.getPresignedDownloadUrl(avatar);
setProfileImage(avatarUrl);
} catch {
setProfileImage(avatar);
}
}
} catch (err) {
console.error('Failed to fetch profile data:', err);
}
}, [session]);
useEffect(() => {
if (session) {
fetchProfileData();
}
}, [session, fetchProfileData]);
// Fetch notification unread count
const fetchUnreadCount = useCallback(async () => {
try {
const count = await notificationsApiService.getUnreadCount();
setNotificationCount(count);
} catch {
// Silently fail - user may not be authenticated yet
}
}, []);
useEffect(() => {
if (session) {
fetchUnreadCount();
// Poll every 30 seconds for new notifications
const interval = setInterval(fetchUnreadCount, 30000);
// Also refresh when a foreground notification arrives
const handleNotification = () => fetchUnreadCount();
window.addEventListener('notification-received', handleNotification);
return () => {
clearInterval(interval);
window.removeEventListener('notification-received', handleNotification);
};
}
}, [session, fetchUnreadCount]);
// Listen for profile update events to refresh the header
useEffect(() => {
const handleProfileUpdate = () => {
if (session) {
fetchProfileData();
}
};
window.addEventListener('profile-updated', handleProfileUpdate);
return () => {
window.removeEventListener('profile-updated', handleProfileUpdate);
};
}, [session, fetchProfileData]);
// Use fetched profile name, fallback to session name
const userName = profileName || session?.user?.name;
const userEmail = session?.user?.email;
@@ -207,6 +120,7 @@ export function CommonHeader() {
)}
{userImage && (
<img
ref={(el) => { if (el?.complete && el.naturalWidth > 0) setAvatarLoaded(true); }}
src={userImage}
alt="Profile"
className={`w-full h-full object-cover transition-opacity duration-300 ${avatarLoaded ? 'opacity-100' : 'opacity-0'}`}
@@ -237,6 +151,7 @@ export function CommonHeader() {
)}
{userImage && (
<img
ref={(el) => { if (el?.complete && el.naturalWidth > 0) setAvatarLoaded(true); }}
src={userImage}
alt="Profile"
className={`w-full h-full object-cover transition-opacity duration-300 ${avatarLoaded ? 'opacity-100' : 'opacity-0'}`}

View File

@@ -128,6 +128,7 @@ export function ChatHeader({
)}
{avatar && !isPlaceholderAvatar && (
<img
ref={(el) => { if (el?.complete && el.naturalWidth > 0) setAvatarLoaded(true); }}
src={avatar}
alt={name}
className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-300 ${avatarLoaded ? 'opacity-100' : 'opacity-0'}`}

View File

@@ -94,6 +94,7 @@ function AvatarWithPlaceholder({ src, alt, size }: { src: string; alt: string; s
)}
{src && !isPlaceholder && (
<img
ref={(el) => { if (el?.complete && el.naturalWidth > 0) setLoaded(true); }}
src={src}
alt={alt}
className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-300 ${loaded ? 'opacity-100' : 'opacity-0'}`}

View File

@@ -0,0 +1,126 @@
'use client';
import { createContext, useContext, useState, useEffect, useRef, useCallback } from 'react';
import { useSession } from 'next-auth/react';
import { agentsService } from '@/services/agents.service';
import { usersService } from '@/services/users.service';
import { uploadService } from '@/services/upload.service';
import { notificationsApiService } from '@/services/notifications-api.service';
interface HeaderData {
profileImage: string | null;
profileName: string | null;
avatarLoaded: boolean;
setAvatarLoaded: (loaded: boolean) => void;
notificationCount: number;
}
const HeaderContext = createContext<HeaderData>({
profileImage: null,
profileName: null,
avatarLoaded: false,
setAvatarLoaded: () => {},
notificationCount: 0,
});
export function useHeaderData() {
return useContext(HeaderContext);
}
export function HeaderProvider({ children }: { children: React.ReactNode }) {
const { data: session, status } = useSession();
const [profileImage, setProfileImage] = useState<string | null>(null);
const [profileName, setProfileName] = useState<string | null>(null);
const [avatarLoaded, setAvatarLoaded] = useState(false);
const [notificationCount, setNotificationCount] = useState(0);
const lastAvatarKeyRef = useRef<string | null>(null);
const hasFetchedRef = useRef(false);
const fetchProfileData = useCallback(async (forceRefresh = false) => {
try {
const role = (session?.user as any)?.role;
let avatar: string | null = null;
let name: string | null = null;
if (role === 'AGENT') {
const profile = await agentsService.getMyProfile();
avatar = profile.avatar;
name = profile.firstName ? `${profile.firstName} ${profile.lastName || ''}`.trim() : null;
} else if (role === 'USER') {
const profile = await usersService.getMyProfile();
avatar = profile.avatar;
name = profile.firstName ? `${profile.firstName} ${profile.lastName || ''}`.trim() : null;
}
if (name) setProfileName(name);
if (avatar && (avatar !== lastAvatarKeyRef.current || forceRefresh)) {
lastAvatarKeyRef.current = avatar;
setAvatarLoaded(false);
try {
const avatarUrl = await uploadService.getPresignedDownloadUrl(avatar);
setProfileImage(avatarUrl);
} catch {
setProfileImage(avatar);
}
}
} catch (err) {
console.error('Failed to fetch profile data:', err);
}
}, [session]);
// Fetch once on auth
useEffect(() => {
if (status === 'authenticated' && session?.user && !hasFetchedRef.current) {
hasFetchedRef.current = true;
fetchProfileData();
}
if (status === 'unauthenticated') {
hasFetchedRef.current = false;
setProfileImage(null);
setProfileName(null);
setAvatarLoaded(false);
lastAvatarKeyRef.current = null;
}
}, [status, session, fetchProfileData]);
// Listen for profile-updated events
useEffect(() => {
const handleProfileUpdate = () => {
lastAvatarKeyRef.current = null;
fetchProfileData(true);
};
window.addEventListener('profile-updated', handleProfileUpdate);
return () => window.removeEventListener('profile-updated', handleProfileUpdate);
}, [fetchProfileData]);
// Notification count polling
useEffect(() => {
if (status !== 'authenticated') return;
const fetchCount = async () => {
try {
const count = await notificationsApiService.getUnreadCount();
setNotificationCount(count);
} catch {
// Silently fail
}
};
fetchCount();
const interval = setInterval(fetchCount, 30000);
const handleNotification = () => fetchCount();
window.addEventListener('notification-received', handleNotification);
return () => {
clearInterval(interval);
window.removeEventListener('notification-received', handleNotification);
};
}, [status]);
return (
<HeaderContext.Provider value={{ profileImage, profileName, avatarLoaded, setAvatarLoaded, notificationCount }}>
{children}
</HeaderContext.Provider>
);
}

View File

@@ -2,6 +2,7 @@
import { SessionProvider as NextAuthSessionProvider, useSession } from "next-auth/react";
import { useEffect, useRef } from "react";
import { HeaderProvider } from "./header-provider";
// Component that syncs tokens from NextAuth session to localStorage
function TokenSync() {
@@ -48,6 +49,8 @@ function TokenSync() {
if (loggingOut) {
localStorage.removeItem("accessToken");
localStorage.removeItem("refreshToken");
// Logout is complete - clear the flag so API calls aren't blocked on public pages
localStorage.removeItem("isLoggingOut");
}
hasInitialized.current = false;
}
@@ -60,7 +63,9 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
return (
<NextAuthSessionProvider>
<TokenSync />
{children}
<HeaderProvider>
{children}
</HeaderProvider>
</NextAuthSessionProvider>
);
}

View File

@@ -394,6 +394,7 @@ export function ProfileSettingsForm({
)}
{avatarUrl && (
<img
ref={(el) => { if (el?.complete && el.naturalWidth > 0) setAvatarLoaded(true); }}
src={avatarUrl}
alt="Profile"
className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-300 ${avatarLoaded ? 'opacity-100' : 'opacity-0'}`}

View File

@@ -142,6 +142,7 @@ export function SettingsSidebar({
)}
{profileData.avatarUrl && (
<img
ref={(el) => { if (el?.complete && el.naturalWidth > 0) setAvatarLoaded(true); }}
src={profileData.avatarUrl}
alt={profileData.name}
className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-300 ${avatarLoaded ? 'opacity-100' : 'opacity-0'}`}