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