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 [isPhotoModalOpen, setIsPhotoModalOpen] = useState(false);
const [imageError, setImageError] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false);
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
const [isAvailable, setIsAvailable] = useState(true);
const [isTogglingAvailability, setIsTogglingAvailability] = useState(false);
@@ -334,16 +335,21 @@ export default function AgentDashboard() {
{/* Profile Image */}
<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">
{/* Initials base layer */}
<div className="absolute inset-0 bg-[#c4d9d4] flex items-center justify-center rounded-[15px]">
<span className="font-bold text-[#00293d] text-[80px]">{agentProfile?.firstName?.[0]?.toUpperCase() || '?'}</span>
</div>
{/* Shimmer while loading, initials only on error */}
{imageError ? (
<div className="absolute inset-0 bg-[#c4d9d4] flex items-center justify-center rounded-[15px]">
<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 */}
<img
src={getProfileImageUrl()}
alt="Profile"
className="absolute inset-0 w-full h-full object-cover"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-300 ${imageLoaded ? 'opacity-100' : 'opacity-0'}`}
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
/>
{/* Gradient Overlay */}
<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 */}
<div className="flex-shrink-0">
<div className="w-[80px] h-[80px] rounded-full overflow-hidden relative bg-[#e8e8e8]">
{/* Initials base layer */}
<div className="absolute inset-0 rounded-full bg-[#c4d9d4] flex items-center justify-center">
<span className="font-bold text-[#00293d] text-[32px]">{name?.[0]?.toUpperCase() || '?'}</span>
</div>
{/* Shimmer while loading, initials only on error */}
{avatarError || isPlaceholder ? (
<div className="absolute inset-0 rounded-full bg-[#c4d9d4] flex items-center justify-center">
<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 && (
<img
ref={(el) => { if (el?.complete && el.naturalWidth > 0) setAvatarLoaded(true); }}

View File

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

View File

@@ -286,15 +286,16 @@ export default function AgentProfileView() {
{/* Profile Image */}
<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">
{/* Initials base layer */}
<div className="absolute inset-0 bg-[#c4d9d4] flex items-center justify-center rounded-[15px]">
<span className="font-bold text-[#00293d] text-[80px]">{agentProfile?.firstName?.[0]?.toUpperCase() || '?'}</span>
</div>
{/* Shimmer while loading, initials only on error/no image */}
{imageError || !getProfileImageUrl() ? (
<div className="absolute inset-0 bg-[#c4d9d4] flex items-center justify-center rounded-[15px]">
<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() && (
<>
{!imageLoaded && (
<div className="absolute inset-0 shimmer-loading rounded-[15px]" />
)}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
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">
{/* Image */}
<div className="relative h-[226px] w-full overflow-hidden bg-gray-100">
{/* Initials base layer */}
<div className="absolute inset-0 bg-[#c4d9d4] flex items-center justify-center">
<span className="font-bold text-[#00293d] text-[72px]">{name?.[0]?.toUpperCase() || '?'}</span>
</div>
{/* Shimmer while loading, initials only on error */}
{imgError || isPlaceholder ? (
<div className="absolute inset-0 bg-[#c4d9d4] flex items-center justify-center">
<span className="font-bold text-[#00293d] text-[72px]">{name?.[0]?.toUpperCase() || '?'}</span>
</div>
) : !imgLoaded ? (
<div className="absolute inset-0 shimmer-loading" />
) : null}
{!isPlaceholder && !imgError && (
<Image
src={imageUrl}

View File

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

View File

@@ -152,10 +152,14 @@ export function ChatHeader({
{/* Avatar */}
<div className="relative flex-shrink-0">
<div className="w-[50px] h-[50px] rounded-full overflow-hidden border border-[#00293D]/20 relative">
{/* Initials base layer */}
<div className="absolute inset-0 rounded-full bg-[#c4d9d4] flex items-center justify-center">
<span className="font-bold text-[#00293d] text-[20px]">{name?.[0]?.toUpperCase() || '?'}</span>
</div>
{/* Shimmer while loading, initials only on error */}
{avatarError || isPlaceholderAvatar || !avatar ? (
<div className="absolute inset-0 rounded-full bg-[#c4d9d4] flex items-center justify-center">
<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 && (
<img
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 (
<>
{/* Initials base layer - always rendered behind image */}
<div className="absolute inset-0 rounded-full bg-[#c4d9d4] flex items-center justify-center">
<span className="font-bold text-[#00293d]" style={{ fontSize: size * 0.4 }}>{initial}</span>
</div>
{/* Shimmer while loading, initials only on error/no image */}
{imgError || isPlaceholder ? (
<div className="absolute inset-0 rounded-full bg-[#c4d9d4] flex items-center justify-center">
<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 && (
<img
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 (
<>
{/* Error Message */}
@@ -245,118 +255,63 @@ export function PrivacyForm({ onSave, onDeleteAccount }: PrivacyFormProps) {
)}
{/* Privacy Settings */}
<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">
Privacy Settings
</h2>
<p className="font-serif text-[12px] text-[#00293D]/60">
Control your privacy and visibility preferences
</p>
</div>
{visibleSettings.length > 0 && (
<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">
Privacy Settings
</h2>
<p className="font-serif text-[12px] text-[#00293D]/60">
Control your privacy and visibility preferences
</p>
</div>
<div className="space-y-6">
{privacySettings.map((item) => (
<div
key={item.id}
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>
<h3 className="font-serif font-medium text-[14px] text-[#00293D]">
{item.label}
</h3>
<p className="font-serif text-[12px] text-[#00293D]/60">
{item.description}
</p>
<div className="space-y-6">
{visibleSettings.map((item) => (
<div
key={item.id}
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>
<h3 className="font-serif font-medium text-[14px] text-[#00293D]">
{item.label}
</h3>
<p className="font-serif text-[12px] text-[#00293D]/60">
{item.description}
</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>
<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>
{/* 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 className="flex items-center justify-between py-3 border-b border-[#00293d]/10">
<div>
<h3 className="font-serif font-medium text-[14px] text-[#00293D]">
Personalized Ads
</h3>
<p className="font-serif text-[12px] text-[#00293D]/60">
See ads tailored to your interests
</p>
</div>
<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')}
/>
{/* Save Button */}
<div className="flex justify-end mt-6">
<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>
</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>
<h3 className="font-serif font-medium text-[14px] text-[#00293D]">
@@ -375,17 +330,6 @@ export function PrivacyForm({ onSave, onDeleteAccount }: PrivacyFormProps) {
</button>
</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 { profileImage, profileName, profileTitle, avatarLoaded, setAvatarLoaded } = useHeaderData();
const [localAvatarLoaded, setLocalAvatarLoaded] = useState(false);
const [avatarError, setAvatarError] = useState(false);
// Use header context's avatarLoaded or local state
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="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">
{(!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" />
)}
{profileImage && (
) : null}
{profileImage && !avatarError && (
<img
ref={(el) => { if (el?.complete && el.naturalWidth > 0) { setAvatarLoaded(true); setLocalAvatarLoaded(true); } }}
src={profileImage}
alt={profileName || 'User'}
className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-300 ${isAvatarLoaded ? 'opacity-100' : 'opacity-0'}`}
onLoad={() => { setAvatarLoaded(true); setLocalAvatarLoaded(true); }}
onError={() => setAvatarError(true)}
/>
)}
</div>
<div>
<h3 className="font-fractul font-bold text-[16px] leading-[19px] text-[#00293D]">
{profileName || 'User'}
</h3>
<p className="font-serif font-normal text-[13px] leading-[17px] text-[#00293D]/60 mt-1">
{profileTitle || (isAgent ? 'Real Estate Agent' : 'Member')}
</p>
{profileName ? (
<h3 className="font-fractul font-bold text-[16px] leading-[19px] text-[#00293D]">
{profileName}
</h3>
) : (
<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>