feat: implement avatar loading shimmers and restrict privacy settings visibility by user role

This commit is contained in:
pradeepkumar
2026-03-28 17:01:37 +05:30
parent 391d717987
commit d0d43eb69b
10 changed files with 151 additions and 161 deletions

View File

@@ -75,6 +75,7 @@ export default function AgentDashboard() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isPhotoModalOpen, setIsPhotoModalOpen] = useState(false); const [isPhotoModalOpen, setIsPhotoModalOpen] = useState(false);
const [imageError, setImageError] = useState(false); const [imageError, setImageError] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false);
const [avatarUrl, setAvatarUrl] = useState<string | null>(null); const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
const [isAvailable, setIsAvailable] = useState(true); const [isAvailable, setIsAvailable] = useState(true);
const [isTogglingAvailability, setIsTogglingAvailability] = useState(false); const [isTogglingAvailability, setIsTogglingAvailability] = useState(false);
@@ -334,16 +335,21 @@ export default function AgentDashboard() {
{/* Profile Image */} {/* Profile Image */}
<div className="relative w-[200px] lg:w-[260px]"> <div className="relative w-[200px] lg:w-[260px]">
<div className="w-[200px] h-[200px] lg:w-[260px] lg:h-[260px] rounded-[15px] overflow-hidden relative"> <div className="w-[200px] h-[200px] lg:w-[260px] lg:h-[260px] rounded-[15px] overflow-hidden relative">
{/* Initials base layer */} {/* Shimmer while loading, initials only on error */}
<div className="absolute inset-0 bg-[#c4d9d4] flex items-center justify-center rounded-[15px]"> {imageError ? (
<span className="font-bold text-[#00293d] text-[80px]">{agentProfile?.firstName?.[0]?.toUpperCase() || '?'}</span> <div className="absolute inset-0 bg-[#c4d9d4] flex items-center justify-center rounded-[15px]">
</div> <span className="font-bold text-[#00293d] text-[80px]">{agentProfile?.firstName?.[0]?.toUpperCase() || '?'}</span>
</div>
) : !imageLoaded ? (
<div className="absolute inset-0 shimmer-loading rounded-[15px]" />
) : null}
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={getProfileImageUrl()} src={getProfileImageUrl()}
alt="Profile" alt="Profile"
className="absolute inset-0 w-full h-full object-cover" className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-300 ${imageLoaded ? 'opacity-100' : 'opacity-0'}`}
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
/> />
{/* Gradient Overlay */} {/* Gradient Overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-black/20 to-transparent pointer-events-none" /> <div className="absolute inset-0 bg-gradient-to-t from-black/50 via-black/20 to-transparent pointer-events-none" />

View File

@@ -37,10 +37,14 @@ export function ConnectionCard({
{/* Avatar */} {/* Avatar */}
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<div className="w-[80px] h-[80px] rounded-full overflow-hidden relative bg-[#e8e8e8]"> <div className="w-[80px] h-[80px] rounded-full overflow-hidden relative bg-[#e8e8e8]">
{/* Initials base layer */} {/* Shimmer while loading, initials only on error */}
<div className="absolute inset-0 rounded-full bg-[#c4d9d4] flex items-center justify-center"> {avatarError || isPlaceholder ? (
<span className="font-bold text-[#00293d] text-[32px]">{name?.[0]?.toUpperCase() || '?'}</span> <div className="absolute inset-0 rounded-full bg-[#c4d9d4] flex items-center justify-center">
</div> <span className="font-bold text-[#00293d] text-[32px]">{name?.[0]?.toUpperCase() || '?'}</span>
</div>
) : !avatarLoaded ? (
<div className="absolute inset-0 rounded-full shimmer-loading" />
) : null}
{avatar && !isPlaceholder && !avatarError && ( {avatar && !isPlaceholder && !avatarError && (
<img <img
ref={(el) => { if (el?.complete && el.naturalWidth > 0) setAvatarLoaded(true); }} ref={(el) => { if (el?.complete && el.naturalWidth > 0) setAvatarLoaded(true); }}

View File

@@ -51,10 +51,14 @@ export function InvitationCard({
{/* Avatar */} {/* Avatar */}
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<div className="w-[80px] h-[80px] rounded-full overflow-hidden relative bg-[#e8e8e8]"> <div className="w-[80px] h-[80px] rounded-full overflow-hidden relative bg-[#e8e8e8]">
{/* Initials base layer */} {/* Shimmer while loading, initials only on error */}
<div className="absolute inset-0 rounded-full bg-[#c4d9d4] flex items-center justify-center"> {avatarError || isPlaceholder ? (
<span className="font-bold text-[#00293d] text-[32px]">{name?.[0]?.toUpperCase() || '?'}</span> <div className="absolute inset-0 rounded-full bg-[#c4d9d4] flex items-center justify-center">
</div> <span className="font-bold text-[#00293d] text-[32px]">{name?.[0]?.toUpperCase() || '?'}</span>
</div>
) : !avatarLoaded ? (
<div className="absolute inset-0 rounded-full shimmer-loading" />
) : null}
{avatar && !isPlaceholder && !avatarError && ( {avatar && !isPlaceholder && !avatarError && (
<img <img
ref={(el) => { if (el?.complete && el.naturalWidth > 0) setAvatarLoaded(true); }} ref={(el) => { if (el?.complete && el.naturalWidth > 0) setAvatarLoaded(true); }}

View File

@@ -286,15 +286,16 @@ export default function AgentProfileView() {
{/* Profile Image */} {/* Profile Image */}
<div className="relative w-[200px] lg:w-[260px]"> <div className="relative w-[200px] lg:w-[260px]">
<div className="w-[200px] h-[200px] lg:w-[260px] lg:h-[260px] rounded-[15px] overflow-hidden bg-[#e8e8e8] relative"> <div className="w-[200px] h-[200px] lg:w-[260px] lg:h-[260px] rounded-[15px] overflow-hidden bg-[#e8e8e8] relative">
{/* Initials base layer */} {/* Shimmer while loading, initials only on error/no image */}
<div className="absolute inset-0 bg-[#c4d9d4] flex items-center justify-center rounded-[15px]"> {imageError || !getProfileImageUrl() ? (
<span className="font-bold text-[#00293d] text-[80px]">{agentProfile?.firstName?.[0]?.toUpperCase() || '?'}</span> <div className="absolute inset-0 bg-[#c4d9d4] flex items-center justify-center rounded-[15px]">
</div> <span className="font-bold text-[#00293d] text-[80px]">{agentProfile?.firstName?.[0]?.toUpperCase() || '?'}</span>
</div>
) : !imageLoaded ? (
<div className="absolute inset-0 shimmer-loading rounded-[15px]" />
) : null}
{getProfileImageUrl() && ( {getProfileImageUrl() && (
<> <>
{!imageLoaded && (
<div className="absolute inset-0 shimmer-loading rounded-[15px]" />
)}
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
ref={(el) => { if (el?.complete) { if (el.naturalWidth > 0) setImageLoaded(true); else setImageError(true); } }} ref={(el) => { if (el?.complete) { if (el.naturalWidth > 0) setImageLoaded(true); else setImageError(true); } }}

View File

@@ -39,10 +39,14 @@ function ProfessionalCard({
<div className="bg-white rounded-[15px] overflow-hidden shadow-[0px_4px_20px_rgba(0,0,0,0.08)] h-full flex flex-col"> <div className="bg-white rounded-[15px] overflow-hidden shadow-[0px_4px_20px_rgba(0,0,0,0.08)] h-full flex flex-col">
{/* Image */} {/* Image */}
<div className="relative h-[226px] w-full overflow-hidden bg-gray-100"> <div className="relative h-[226px] w-full overflow-hidden bg-gray-100">
{/* Initials base layer */} {/* Shimmer while loading, initials only on error */}
<div className="absolute inset-0 bg-[#c4d9d4] flex items-center justify-center"> {imgError || isPlaceholder ? (
<span className="font-bold text-[#00293d] text-[72px]">{name?.[0]?.toUpperCase() || '?'}</span> <div className="absolute inset-0 bg-[#c4d9d4] flex items-center justify-center">
</div> <span className="font-bold text-[#00293d] text-[72px]">{name?.[0]?.toUpperCase() || '?'}</span>
</div>
) : !imgLoaded ? (
<div className="absolute inset-0 shimmer-loading" />
) : null}
{!isPlaceholder && !imgError && ( {!isPlaceholder && !imgError && (
<Image <Image
src={imageUrl} src={imageUrl}

View File

@@ -118,10 +118,14 @@ export function CommonHeader() {
> >
{/* Avatar */} {/* 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 relative"> <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">
{/* Initials base layer */} {/* Shimmer while loading, initials only on error */}
<div className="absolute inset-0 rounded-full bg-[#c4d9d4] flex items-center justify-center"> {avatarError || !userImage ? (
<span className="font-bold text-[#00293d] text-[14px]">{userName?.[0]?.toUpperCase() || '?'}</span> <div className="absolute inset-0 rounded-full bg-[#c4d9d4] flex items-center justify-center">
</div> <span className="font-bold text-[#00293d] text-[14px]">{userName?.[0]?.toUpperCase() || '?'}</span>
</div>
) : !avatarLoaded ? (
<div className="absolute inset-0 rounded-full shimmer-loading" />
) : null}
{userImage && !avatarError && ( {userImage && !avatarError && (
<img <img
ref={(el) => { if (el?.complete && el.naturalWidth > 0) setAvatarLoaded(true); }} ref={(el) => { if (el?.complete && el.naturalWidth > 0) setAvatarLoaded(true); }}

View File

@@ -152,10 +152,14 @@ export function ChatHeader({
{/* Avatar */} {/* Avatar */}
<div className="relative flex-shrink-0"> <div className="relative flex-shrink-0">
<div className="w-[50px] h-[50px] rounded-full overflow-hidden border border-[#00293D]/20 relative"> <div className="w-[50px] h-[50px] rounded-full overflow-hidden border border-[#00293D]/20 relative">
{/* Initials base layer */} {/* Shimmer while loading, initials only on error */}
<div className="absolute inset-0 rounded-full bg-[#c4d9d4] flex items-center justify-center"> {avatarError || isPlaceholderAvatar || !avatar ? (
<span className="font-bold text-[#00293d] text-[20px]">{name?.[0]?.toUpperCase() || '?'}</span> <div className="absolute inset-0 rounded-full bg-[#c4d9d4] flex items-center justify-center">
</div> <span className="font-bold text-[#00293d] text-[20px]">{name?.[0]?.toUpperCase() || '?'}</span>
</div>
) : !avatarLoaded ? (
<div className="absolute inset-0 rounded-full shimmer-loading" />
) : null}
{avatar && !isPlaceholderAvatar && !avatarError && ( {avatar && !isPlaceholderAvatar && !avatarError && (
<img <img
ref={(el) => { if (el?.complete && el.naturalWidth > 0) setAvatarLoaded(true); }} ref={(el) => { if (el?.complete && el.naturalWidth > 0) setAvatarLoaded(true); }}

View File

@@ -92,10 +92,14 @@ function AvatarWithPlaceholder({ src, alt, size, name }: { src: string; alt: str
return ( return (
<> <>
{/* Initials base layer - always rendered behind image */} {/* Shimmer while loading, initials only on error/no image */}
<div className="absolute inset-0 rounded-full bg-[#c4d9d4] flex items-center justify-center"> {imgError || isPlaceholder ? (
<span className="font-bold text-[#00293d]" style={{ fontSize: size * 0.4 }}>{initial}</span> <div className="absolute inset-0 rounded-full bg-[#c4d9d4] flex items-center justify-center">
</div> <span className="font-bold text-[#00293d]" style={{ fontSize: size * 0.4 }}>{initial}</span>
</div>
) : !loaded ? (
<div className="absolute inset-0 rounded-full shimmer-loading" />
) : null}
{src && !isPlaceholder && !imgError && ( {src && !isPlaceholder && !imgError && (
<img <img
ref={(el) => { if (el?.complete && el.naturalWidth > 0) setLoaded(true); }} ref={(el) => { if (el?.complete && el.naturalWidth > 0) setLoaded(true); }}

View File

@@ -228,6 +228,16 @@ export function PrivacyForm({ onSave, onDeleteAccount }: PrivacyFormProps) {
); );
} }
const role = (session?.user as any)?.role;
const isAgent = role === 'AGENT';
// Filter settings: Profile Visibility only for agents, Activity Status for both
const visibleSettings = privacySettings.filter((item) => {
if (item.id === 'profile_visibility') return isAgent;
if (item.id === 'activity_status') return true;
return false;
});
return ( return (
<> <>
{/* Error Message */} {/* Error Message */}
@@ -245,118 +255,63 @@ export function PrivacyForm({ onSave, onDeleteAccount }: PrivacyFormProps) {
)} )}
{/* Privacy Settings */} {/* Privacy Settings */}
<div className="border border-[#00293d]/10 rounded-[15px] bg-white p-6 mb-4"> {visibleSettings.length > 0 && (
<div className="mb-6"> <div className="border border-[#00293d]/10 rounded-[15px] bg-white p-6 mb-4">
<h2 className="font-fractul font-bold text-[16px] text-[#00293D] mb-2"> <div className="mb-6">
Privacy Settings <h2 className="font-fractul font-bold text-[16px] text-[#00293D] mb-2">
</h2> Privacy Settings
<p className="font-serif text-[12px] text-[#00293D]/60"> </h2>
Control your privacy and visibility preferences <p className="font-serif text-[12px] text-[#00293D]/60">
</p> Control your privacy and visibility preferences
</div> </p>
</div>
<div className="space-y-6"> <div className="space-y-6">
{privacySettings.map((item) => ( {visibleSettings.map((item) => (
<div <div
key={item.id} key={item.id}
className="pb-4 border-b border-[#00293d]/10 last:border-0" className="pb-4 border-b border-[#00293d]/10 last:border-0"
> >
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div> <div>
<h3 className="font-serif font-medium text-[14px] text-[#00293D]"> <h3 className="font-serif font-medium text-[14px] text-[#00293D]">
{item.label} {item.label}
</h3> </h3>
<p className="font-serif text-[12px] text-[#00293D]/60"> <p className="font-serif text-[12px] text-[#00293D]/60">
{item.description} {item.description}
</p> </p>
</div>
<select
value={item.value}
onChange={(e) => handlePrivacyChange(item.id, e.target.value)}
className="h-[36px] px-4 border border-[#00293D]/20 rounded-[10px] text-[13px] font-serif text-[#00293D] focus:outline-none focus:border-[#E58625] bg-white cursor-pointer"
>
{item.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div> </div>
<select
value={item.value}
onChange={(e) => handlePrivacyChange(item.id, e.target.value)}
className="h-[36px] px-4 border border-[#00293D]/20 rounded-[10px] text-[13px] font-serif text-[#00293D] focus:outline-none focus:border-[#E58625] bg-white cursor-pointer"
>
{item.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div> </div>
</div> ))}
))}
</div>
</div>
{/* Data & Personalization */}
<div className="border border-[#00293d]/10 rounded-[15px] bg-white p-6 mb-4">
<div className="mb-6">
<h2 className="font-fractul font-bold text-[16px] text-[#00293D] mb-2">
Data & Personalization
</h2>
<p className="font-serif text-[12px] text-[#00293D]/60">
Manage how your data is used
</p>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between py-3 border-b border-[#00293d]/10">
<div>
<h3 className="font-serif font-medium text-[14px] text-[#00293D]">
Share Analytics
</h3>
<p className="font-serif text-[12px] text-[#00293D]/60">
Help improve our service by sharing usage data
</p>
</div>
<ToggleSwitch
enabled={dataSettings.shareAnalytics}
onToggle={() => handleDataSettingChange('shareAnalytics')}
/>
</div> </div>
<div className="flex items-center justify-between py-3 border-b border-[#00293d]/10"> {/* Save Button */}
<div> <div className="flex justify-end mt-6">
<h3 className="font-serif font-medium text-[14px] text-[#00293D]"> <button
Personalized Ads onClick={handleSave}
</h3> disabled={isSaving}
<p className="font-serif text-[12px] text-[#00293D]/60"> className="px-8 py-3 bg-[#e58625] text-white rounded-[15px] font-fractul font-medium text-[14px] hover:bg-[#d47920] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
See ads tailored to your interests >
</p> {isSaving ? 'Saving...' : 'Save Changes'}
</div> </button>
<ToggleSwitch
enabled={dataSettings.personalizedAds}
onToggle={() => handleDataSettingChange('personalizedAds')}
/>
</div>
<div className="flex items-center justify-between py-3">
<div>
<h3 className="font-serif font-medium text-[14px] text-[#00293D]">
Third-Party Data Sharing
</h3>
<p className="font-serif text-[12px] text-[#00293D]/60">
Allow sharing data with third-party partners
</p>
</div>
<ToggleSwitch
enabled={dataSettings.thirdPartySharing}
onToggle={() => handleDataSettingChange('thirdPartySharing')}
/>
</div> </div>
</div> </div>
</div> )}
{/* Danger Zone */}
<div className="border border-red-200 rounded-[15px] bg-red-50/50 p-6 mb-4">
<div className="mb-4">
<h2 className="font-fractul font-bold text-[16px] text-red-600 mb-2">
Danger Zone
</h2>
<p className="font-serif text-[12px] text-red-600/70">
Irreversible and destructive actions
</p>
</div>
{/* Delete Account */}
<div className="border border-[#00293d]/10 rounded-[15px] bg-white p-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div> <div>
<h3 className="font-serif font-medium text-[14px] text-[#00293D]"> <h3 className="font-serif font-medium text-[14px] text-[#00293D]">
@@ -375,17 +330,6 @@ export function PrivacyForm({ onSave, onDeleteAccount }: PrivacyFormProps) {
</button> </button>
</div> </div>
</div> </div>
{/* Save Button */}
<div className="flex justify-end">
<button
onClick={handleSave}
disabled={isSaving}
className="px-8 py-3 bg-[#e58625] text-white rounded-[15px] font-fractul font-medium text-[14px] hover:bg-[#d47920] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSaving ? 'Saving...' : 'Save Changes'}
</button>
</div>
</> </>
); );
} }

View File

@@ -22,6 +22,7 @@ export function SettingsSidebar({
const pathname = usePathname(); const pathname = usePathname();
const { profileImage, profileName, profileTitle, avatarLoaded, setAvatarLoaded } = useHeaderData(); const { profileImage, profileName, profileTitle, avatarLoaded, setAvatarLoaded } = useHeaderData();
const [localAvatarLoaded, setLocalAvatarLoaded] = useState(false); const [localAvatarLoaded, setLocalAvatarLoaded] = useState(false);
const [avatarError, setAvatarError] = useState(false);
// Use header context's avatarLoaded or local state // Use header context's avatarLoaded or local state
const isAvatarLoaded = avatarLoaded || localAvatarLoaded; const isAvatarLoaded = avatarLoaded || localAvatarLoaded;
@@ -71,26 +72,40 @@ export function SettingsSidebar({
<div className="bg-white rounded-[15px] border border-[#00293D]/20 p-5"> <div className="bg-white rounded-[15px] border border-[#00293D]/20 p-5">
<div className="flex items-center gap-4"> <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 relative"> <div className="w-[70px] h-[70px] rounded-full overflow-hidden border-2 border-[#00293D]/20 flex-shrink-0 bg-gray-100 relative">
{(!profileImage || !isAvatarLoaded) && ( {/* Shimmer while loading, initials only on error */}
{avatarError || !profileImage ? (
<div className="absolute inset-0 rounded-full bg-[#c4d9d4] flex items-center justify-center">
<span className="font-bold text-[#00293d] text-[28px]">{profileName?.[0]?.toUpperCase() || '?'}</span>
</div>
) : !isAvatarLoaded ? (
<div className="absolute inset-0 rounded-full shimmer-loading" /> <div className="absolute inset-0 rounded-full shimmer-loading" />
)} ) : null}
{profileImage && ( {profileImage && !avatarError && (
<img <img
ref={(el) => { if (el?.complete && el.naturalWidth > 0) { setAvatarLoaded(true); setLocalAvatarLoaded(true); } }} ref={(el) => { if (el?.complete && el.naturalWidth > 0) { setAvatarLoaded(true); setLocalAvatarLoaded(true); } }}
src={profileImage} src={profileImage}
alt={profileName || 'User'} alt={profileName || 'User'}
className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-300 ${isAvatarLoaded ? 'opacity-100' : 'opacity-0'}`} className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-300 ${isAvatarLoaded ? 'opacity-100' : 'opacity-0'}`}
onLoad={() => { setAvatarLoaded(true); setLocalAvatarLoaded(true); }} onLoad={() => { setAvatarLoaded(true); setLocalAvatarLoaded(true); }}
onError={() => setAvatarError(true)}
/> />
)} )}
</div> </div>
<div> <div>
<h3 className="font-fractul font-bold text-[16px] leading-[19px] text-[#00293D]"> {profileName ? (
{profileName || 'User'} <h3 className="font-fractul font-bold text-[16px] leading-[19px] text-[#00293D]">
</h3> {profileName}
<p className="font-serif font-normal text-[13px] leading-[17px] text-[#00293D]/60 mt-1"> </h3>
{profileTitle || (isAgent ? 'Real Estate Agent' : 'Member')} ) : (
</p> <div className="h-[19px] w-[120px] rounded shimmer-loading" />
)}
{profileTitle ? (
<p className="font-serif font-normal text-[13px] leading-[17px] text-[#00293D]/60 mt-1">
{profileTitle}
</p>
) : (
<div className="h-[17px] w-[80px] rounded shimmer-loading mt-1" />
)}
</div> </div>
</div> </div>
</div> </div>