From 772e1024cc6ab9871b1ebbc4e54ae7cfc2df8641 Mon Sep 17 00:00:00 2001 From: pradeepkumar Date: Wed, 18 Mar 2026 11:54:40 +0530 Subject: [PATCH] feat: Implement `useImageStatus` hook and enhance image loading status handling across various components to address cached images and loading timeouts. --- .../network/component/ConnectionCard.tsx | 1 + .../network/component/InvitationCard.tsx | 1 + src/app/(auth)/login/page.tsx | 8 +- src/app/(user)/user/profile/[id]/page.tsx | 1 + src/app/(user)/user/profiles/page.tsx | 1 + src/app/about/page.tsx | 3 + src/app/logout/page.tsx | 3 + src/components/home/TopProfessionals.tsx | 1 + src/components/layout/CommonHeader.tsx | 95 +------------ src/components/message/ChatHeader.tsx | 1 + src/components/message/MessagingPage.tsx | 1 + src/components/providers/header-provider.tsx | 126 ++++++++++++++++++ src/components/providers/session-provider.tsx | 7 +- .../settings/ProfileSettingsForm.tsx | 1 + src/components/settings/SettingsSidebar.tsx | 1 + src/hooks/useImageStatus.ts | 45 +++++++ src/services/api.ts | 8 +- 17 files changed, 211 insertions(+), 93 deletions(-) create mode 100644 src/components/providers/header-provider.tsx create mode 100644 src/hooks/useImageStatus.ts diff --git a/src/app/(agent)/agent/network/component/ConnectionCard.tsx b/src/app/(agent)/agent/network/component/ConnectionCard.tsx index 9021aeb..4f112b3 100644 --- a/src/app/(agent)/agent/network/component/ConnectionCard.tsx +++ b/src/app/(agent)/agent/network/component/ConnectionCard.tsx @@ -41,6 +41,7 @@ export function ConnectionCard({ )} {avatar && !isPlaceholder && ( { if (el?.complete && el.naturalWidth > 0) setAvatarLoaded(true); }} src={avatar} alt={name} className={`absolute inset-0 w-full h-full object-cover rounded-full transition-opacity duration-300 ${avatarLoaded ? 'opacity-100' : 'opacity-0'}`} diff --git a/src/app/(agent)/agent/network/component/InvitationCard.tsx b/src/app/(agent)/agent/network/component/InvitationCard.tsx index 14a2dff..721007f 100644 --- a/src/app/(agent)/agent/network/component/InvitationCard.tsx +++ b/src/app/(agent)/agent/network/component/InvitationCard.tsx @@ -55,6 +55,7 @@ export function InvitationCard({ )} {avatar && !isPlaceholder && ( { if (el?.complete && el.naturalWidth > 0) setAvatarLoaded(true); }} src={avatar} alt={name} className={`absolute inset-0 w-full h-full object-cover rounded-full transition-opacity duration-300 ${avatarLoaded ? 'opacity-100' : 'opacity-0'}`} diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 651075d..5e89efe 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { signIn } from 'next-auth/react'; import Link from 'next/link'; @@ -15,6 +15,12 @@ export default function LoginPage() { const [loading, setLoading] = useState(false); const [userType, setUserType] = useState<'USER' | 'ADMIN'>('USER'); + // Clear logout flags on mount so API calls aren't blocked after redirect from logout + useEffect(() => { + localStorage.removeItem('isLoggingOut'); + resetLogoutState(); + }, []); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); diff --git a/src/app/(user)/user/profile/[id]/page.tsx b/src/app/(user)/user/profile/[id]/page.tsx index 4974b04..1bcd097 100644 --- a/src/app/(user)/user/profile/[id]/page.tsx +++ b/src/app/(user)/user/profile/[id]/page.tsx @@ -291,6 +291,7 @@ export default function AgentProfileView() { )} {/* eslint-disable-next-line @next/next/no-img-element */} { if (el?.complete) { if (el.naturalWidth > 0) setImageLoaded(true); else setImageError(true); } }} src={getProfileImageUrl()!} alt="Profile" className={`w-full h-full object-cover transition-opacity duration-300 ${imageLoaded ? 'opacity-100' : 'opacity-0'}`} diff --git a/src/app/(user)/user/profiles/page.tsx b/src/app/(user)/user/profiles/page.tsx index 0c67920..30114b4 100644 --- a/src/app/(user)/user/profiles/page.tsx +++ b/src/app/(user)/user/profiles/page.tsx @@ -77,6 +77,7 @@ function ProfileImage({ src, alt, className }: { src: string | null; alt: string {src && status !== 'error' ? ( /* eslint-disable-next-line @next/next/no-img-element */ { if (el?.complete) setStatus(el.naturalWidth > 0 ? 'loaded' : 'error'); }} src={src} alt={alt} className={`w-full h-full object-cover transition-opacity duration-300 ${status === 'loaded' ? 'opacity-100' : 'opacity-0'}`} diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index a0b87ae..a837681 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -360,6 +360,7 @@ function SmartImage({ )} {/* eslint-disable-next-line @next/next/no-img-element */} { if (el?.complete) setStatus(el.naturalWidth > 0 ? 'loaded' : 'error'); }} src={src} alt={alt} className={`${className || ''} transition-opacity duration-300 ${status === 'loaded' ? 'opacity-100' : 'opacity-0'}`} @@ -393,6 +394,7 @@ function FeatureCard({ feature }: { feature: AboutFeaturesContent['features'][nu {feature.iconPath && iconStatus !== 'error' && ( /* eslint-disable-next-line @next/next/no-img-element */ { if (el?.complete) setIconStatus(el.naturalWidth > 0 ? 'loaded' : 'error'); }} src={feature.iconPath} alt="" className={`w-[35px] h-[35px] object-contain transition-opacity duration-300 ${iconStatus === 'loaded' ? 'opacity-100' : 'opacity-0'}`} @@ -434,6 +436,7 @@ function TeamCard({ member }: { member: AboutTeamContent['members'][number] }) { {member.imageUrl && imgStatus !== 'error' && ( /* eslint-disable-next-line @next/next/no-img-element */ { if (el?.complete) setImgStatus(el.naturalWidth > 0 ? 'loaded' : 'error'); }} src={member.imageUrl} alt={member.name} className={`w-full h-full object-cover transition-opacity duration-300 ${imgStatus === 'loaded' ? 'opacity-100' : 'opacity-0'}`} diff --git a/src/app/logout/page.tsx b/src/app/logout/page.tsx index abe3254..f94f4f3 100644 --- a/src/app/logout/page.tsx +++ b/src/app/logout/page.tsx @@ -35,6 +35,9 @@ export default function LogoutPage() { // Delete cookies again after signOut in case signOut recreated any await clearAuthCookies(); + // Clear the logout flag before redirecting so it doesn't block API calls on public pages + localStorage.removeItem('isLoggingOut'); + // Redirect to login immediately window.location.href = '/login'; }; diff --git a/src/components/home/TopProfessionals.tsx b/src/components/home/TopProfessionals.tsx index a580e08..cdc69a8 100644 --- a/src/components/home/TopProfessionals.tsx +++ b/src/components/home/TopProfessionals.tsx @@ -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)} /> diff --git a/src/components/layout/CommonHeader.tsx b/src/components/layout/CommonHeader.tsx index 295b734..068ed8c 100644 --- a/src/components/layout/CommonHeader.tsx +++ b/src/components/layout/CommonHeader.tsx @@ -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(null); const guestMenuRef = useRef(null); const mobileMenuRef = useRef(null); - const [profileImage, setProfileImage] = useState(null); - const [profileName, setProfileName] = useState(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 && ( { 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 && ( { 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'}`} diff --git a/src/components/message/ChatHeader.tsx b/src/components/message/ChatHeader.tsx index b39205a..36a20db 100644 --- a/src/components/message/ChatHeader.tsx +++ b/src/components/message/ChatHeader.tsx @@ -128,6 +128,7 @@ export function ChatHeader({ )} {avatar && !isPlaceholderAvatar && ( { 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'}`} diff --git a/src/components/message/MessagingPage.tsx b/src/components/message/MessagingPage.tsx index c408b28..3a6383c 100644 --- a/src/components/message/MessagingPage.tsx +++ b/src/components/message/MessagingPage.tsx @@ -94,6 +94,7 @@ function AvatarWithPlaceholder({ src, alt, size }: { src: string; alt: string; s )} {src && !isPlaceholder && ( { 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'}`} diff --git a/src/components/providers/header-provider.tsx b/src/components/providers/header-provider.tsx new file mode 100644 index 0000000..e7012a2 --- /dev/null +++ b/src/components/providers/header-provider.tsx @@ -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({ + 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(null); + const [profileName, setProfileName] = useState(null); + const [avatarLoaded, setAvatarLoaded] = useState(false); + const [notificationCount, setNotificationCount] = useState(0); + const lastAvatarKeyRef = useRef(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 ( + + {children} + + ); +} diff --git a/src/components/providers/session-provider.tsx b/src/components/providers/session-provider.tsx index 9b140fe..75ca7ed 100644 --- a/src/components/providers/session-provider.tsx +++ b/src/components/providers/session-provider.tsx @@ -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 ( - {children} + + {children} + ); } diff --git a/src/components/settings/ProfileSettingsForm.tsx b/src/components/settings/ProfileSettingsForm.tsx index 0536b36..ed54441 100644 --- a/src/components/settings/ProfileSettingsForm.tsx +++ b/src/components/settings/ProfileSettingsForm.tsx @@ -394,6 +394,7 @@ export function ProfileSettingsForm({ )} {avatarUrl && ( { 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'}`} diff --git a/src/components/settings/SettingsSidebar.tsx b/src/components/settings/SettingsSidebar.tsx index 97d7526..2f96569 100644 --- a/src/components/settings/SettingsSidebar.tsx +++ b/src/components/settings/SettingsSidebar.tsx @@ -142,6 +142,7 @@ export function SettingsSidebar({ )} {profileData.avatarUrl && ( { 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'}`} diff --git a/src/hooks/useImageStatus.ts b/src/hooks/useImageStatus.ts new file mode 100644 index 0000000..ce95def --- /dev/null +++ b/src/hooks/useImageStatus.ts @@ -0,0 +1,45 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; + +/** + * Hook to reliably track image loading status. + * Handles the browser cache race condition where `onLoad` fires + * before React attaches the event handler, leaving images stuck + * in a loading/shimmer state. + * + * Also includes a timeout fallback to prevent infinite loading states. + */ +export function useImageStatus(src: string | null | undefined) { + const [status, setStatus] = useState<'loading' | 'loaded' | 'error'>( + src ? 'loading' : 'error' + ); + const timeoutRef = useRef(null); + + // Reset when src changes + useEffect(() => { + setStatus(src ? 'loading' : 'error'); + }, [src]); + + // Timeout fallback - if image doesn't load within 15s, mark as error + useEffect(() => { + if (status !== 'loading' || !src) return; + timeoutRef.current = setTimeout(() => { + setStatus((prev) => (prev === 'loading' ? 'error' : prev)); + }, 15000); + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, [src, status]); + + // Ref callback - handles cached images where onLoad may have + // already fired before the handler was attached + const setImgRef = useCallback((el: HTMLImageElement | null) => { + if (el?.complete) { + setStatus(el.naturalWidth > 0 ? 'loaded' : 'error'); + } + }, []); + + const onLoad = useCallback(() => setStatus('loaded'), []); + const onError = useCallback(() => setStatus('error'), []); + + return { status, ref: setImgRef, onLoad, onError }; +} diff --git a/src/services/api.ts b/src/services/api.ts index e7fb789..a86e7f2 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -77,8 +77,11 @@ api.interceptors.request.use( (config: InternalAxiosRequestConfig) => { // Skip API calls during logout to prevent refresh loops // Check both module flag and localStorage flag (logout page sets localStorage directly) + // But allow public endpoints (agent search, CMS, etc.) to work even during/after logout if (isLoggingOut || (typeof window !== 'undefined' && localStorage.getItem('isLoggingOut') === 'true')) { - return Promise.reject(new axios.Cancel('Logging out')); + if (!isPublicEndpoint(config.url)) { + return Promise.reject(new axios.Cancel('Logging out')); + } } // Add auth token if available if (typeof window !== 'undefined') { @@ -105,8 +108,11 @@ const publicEndpoints = [ '/auth/2fa/verify', '/auth/2fa/verify-backup', '/agent-types', + '/agents', '/cms', + '/profile-fields/filterable', '/stripe/plans', + '/upload/presigned-download-url', ]; // Check if URL is a public endpoint