feat: implement avatar loading shimmers and restrict privacy settings visibility by user role
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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); }}
|
||||
|
||||
@@ -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); }}
|
||||
|
||||
@@ -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); } }}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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); }}
|
||||
|
||||
@@ -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); }}
|
||||
|
||||
@@ -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); }}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user