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:
@@ -41,6 +41,7 @@ export function ConnectionCard({
|
||||
)}
|
||||
{avatar && !isPlaceholder && (
|
||||
<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 rounded-full transition-opacity duration-300 ${avatarLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||
|
||||
@@ -55,6 +55,7 @@ export function InvitationCard({
|
||||
)}
|
||||
{avatar && !isPlaceholder && (
|
||||
<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 rounded-full transition-opacity duration-300 ${avatarLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -291,6 +291,7 @@ export default function AgentProfileView() {
|
||||
)}
|
||||
{/* 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={`w-full h-full object-cover transition-opacity duration-300 ${imageLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||
|
||||
@@ -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 */
|
||||
<img
|
||||
ref={(el) => { 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'}`}
|
||||
|
||||
@@ -360,6 +360,7 @@ function SmartImage({
|
||||
)}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
ref={(el) => { 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 */
|
||||
<img
|
||||
ref={(el) => { 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 */
|
||||
<img
|
||||
ref={(el) => { 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'}`}
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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'}`}
|
||||
|
||||
@@ -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'}`}
|
||||
|
||||
@@ -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'}`}
|
||||
|
||||
126
src/components/providers/header-provider.tsx
Normal file
126
src/components/providers/header-provider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
<HeaderProvider>
|
||||
{children}
|
||||
</HeaderProvider>
|
||||
</NextAuthSessionProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'}`}
|
||||
|
||||
@@ -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'}`}
|
||||
|
||||
45
src/hooks/useImageStatus.ts
Normal file
45
src/hooks/useImageStatus.ts
Normal file
@@ -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<NodeJS.Timeout | null>(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 };
|
||||
}
|
||||
@@ -77,9 +77,12 @@ 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')) {
|
||||
if (!isPublicEndpoint(config.url)) {
|
||||
return Promise.reject(new axios.Cancel('Logging out'));
|
||||
}
|
||||
}
|
||||
// Add auth token if available
|
||||
if (typeof window !== 'undefined') {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user