feat: Improve avatar loading experience by displaying placeholders until images are fully loaded across various components.
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
@@ -26,17 +27,33 @@ export function ConnectionCard({
|
|||||||
connectedAt,
|
connectedAt,
|
||||||
onMessage,
|
onMessage,
|
||||||
}: ConnectionCardProps) {
|
}: ConnectionCardProps) {
|
||||||
|
const [avatarLoaded, setAvatarLoaded] = useState(false);
|
||||||
|
const placeholder = '/assets/icons/user-placeholder-icon.svg';
|
||||||
|
const isPlaceholder = !avatar || avatar === placeholder;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 py-4">
|
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 py-4">
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
|
<div className="w-[80px] h-[80px] rounded-full overflow-hidden relative">
|
||||||
|
{(!avatar || !avatarLoaded || isPlaceholder) && (
|
||||||
<Image
|
<Image
|
||||||
src={avatar}
|
src={placeholder}
|
||||||
alt={name}
|
alt={name}
|
||||||
width={80}
|
width={80}
|
||||||
height={80}
|
height={80}
|
||||||
className="rounded-full object-cover w-[80px] h-[80px]"
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
{avatar && !isPlaceholder && (
|
||||||
|
<img
|
||||||
|
src={avatar}
|
||||||
|
alt={name}
|
||||||
|
className={`absolute inset-0 w-full h-full object-cover rounded-full transition-opacity duration-200 ${avatarLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||||
|
onLoad={() => setAvatarLoaded(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
|
||||||
interface InvitationCardProps {
|
interface InvitationCardProps {
|
||||||
@@ -40,17 +41,33 @@ export function InvitationCard({
|
|||||||
onAccept,
|
onAccept,
|
||||||
onIgnore,
|
onIgnore,
|
||||||
}: InvitationCardProps) {
|
}: InvitationCardProps) {
|
||||||
|
const [avatarLoaded, setAvatarLoaded] = useState(false);
|
||||||
|
const placeholder = '/assets/icons/user-placeholder-icon.svg';
|
||||||
|
const isPlaceholder = !avatar || avatar === placeholder;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex flex-col sm:flex-row items-start sm:items-center gap-4 py-4 ${isProcessing ? 'opacity-50 pointer-events-none' : ''}`}>
|
<div className={`flex flex-col sm:flex-row items-start sm:items-center gap-4 py-4 ${isProcessing ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
|
<div className="w-[80px] h-[80px] rounded-full overflow-hidden relative">
|
||||||
|
{(!avatar || !avatarLoaded || isPlaceholder) && (
|
||||||
<Image
|
<Image
|
||||||
src={avatar}
|
src={placeholder}
|
||||||
alt={name}
|
alt={name}
|
||||||
width={80}
|
width={80}
|
||||||
height={80}
|
height={80}
|
||||||
className="rounded-full object-cover w-[80px] h-[80px]"
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
{avatar && !isPlaceholder && (
|
||||||
|
<img
|
||||||
|
src={avatar}
|
||||||
|
alt={name}
|
||||||
|
className={`absolute inset-0 w-full h-full object-cover rounded-full transition-opacity duration-200 ${avatarLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||||
|
onLoad={() => setAvatarLoaded(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
|
|||||||
@@ -111,9 +111,6 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Smooth scrolling */
|
/* Smooth scrolling */
|
||||||
html {
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Selection styling */
|
/* Selection styling */
|
||||||
::selection {
|
::selection {
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} ${sourceSerif4.variable} ${fractul.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} ${sourceSerif4.variable} ${fractul.variable} antialiased`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export function CommonHeader() {
|
|||||||
const mobileMenuRef = useRef<HTMLDivElement>(null);
|
const mobileMenuRef = useRef<HTMLDivElement>(null);
|
||||||
const [profileImage, setProfileImage] = useState<string | null>(null);
|
const [profileImage, setProfileImage] = useState<string | null>(null);
|
||||||
const [profileName, setProfileName] = useState<string | null>(null);
|
const [profileName, setProfileName] = useState<string | null>(null);
|
||||||
|
const [avatarLoaded, setAvatarLoaded] = useState(false);
|
||||||
const [notificationCount, setNotificationCount] = useState(0);
|
const [notificationCount, setNotificationCount] = useState(0);
|
||||||
|
|
||||||
// Close dropdowns when clicking outside
|
// Close dropdowns when clicking outside
|
||||||
@@ -73,6 +74,7 @@ export function CommonHeader() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (avatar) {
|
if (avatar) {
|
||||||
|
setAvatarLoaded(false);
|
||||||
try {
|
try {
|
||||||
const avatarUrl = await uploadService.getPresignedDownloadUrl(avatar);
|
const avatarUrl = await uploadService.getPresignedDownloadUrl(avatar);
|
||||||
setProfileImage(avatarUrl);
|
setProfileImage(avatarUrl);
|
||||||
@@ -150,6 +152,7 @@ export function CommonHeader() {
|
|||||||
alt="RE-QuestN"
|
alt="RE-QuestN"
|
||||||
width={150}
|
width={150}
|
||||||
height={40}
|
height={40}
|
||||||
|
priority
|
||||||
className="h-8 md:h-10 w-auto"
|
className="h-8 md:h-10 w-auto"
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -198,23 +201,25 @@ export function CommonHeader() {
|
|||||||
className="flex items-center gap-2 md:gap-3 hover:opacity-80 transition-opacity cursor-pointer"
|
className="flex items-center gap-2 md:gap-3 hover:opacity-80 transition-opacity cursor-pointer"
|
||||||
>
|
>
|
||||||
{/* 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">
|
<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">
|
||||||
{userImage ? (
|
{(!userImage || !avatarLoaded) && (
|
||||||
<img
|
|
||||||
src={userImage}
|
|
||||||
alt="Profile"
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Image
|
<Image
|
||||||
src="/assets/icons/user-placeholder-icon.svg"
|
src="/assets/icons/user-placeholder-icon.svg"
|
||||||
alt="Profile"
|
alt="Profile"
|
||||||
width={35}
|
width={35}
|
||||||
height={35}
|
height={35}
|
||||||
className="object-cover"
|
className="absolute inset-0 object-cover"
|
||||||
style={{ width: '100%', height: '100%' }}
|
style={{ width: '100%', height: '100%' }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{userImage && (
|
||||||
|
<img
|
||||||
|
src={userImage}
|
||||||
|
alt="Profile"
|
||||||
|
className={`w-full h-full object-cover ${avatarLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||||
|
onLoad={() => setAvatarLoaded(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Greeting Text - hidden on mobile */}
|
{/* Greeting Text - hidden on mobile */}
|
||||||
<div className="hidden sm:flex flex-col text-left">
|
<div className="hidden sm:flex flex-col text-left">
|
||||||
@@ -233,23 +238,25 @@ export function CommonHeader() {
|
|||||||
<div className="absolute -top-2 right-6 w-0 h-0 border-l-[8px] border-l-transparent border-r-[8px] border-r-transparent border-b-[8px] border-b-white"></div>
|
<div className="absolute -top-2 right-6 w-0 h-0 border-l-[8px] border-l-transparent border-r-[8px] border-r-transparent border-b-[8px] border-b-white"></div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 px-4 py-4 border-b border-black/10">
|
<div className="flex items-center gap-3 px-4 py-4 border-b border-black/10">
|
||||||
<div className="w-[42px] h-[42px] rounded-full overflow-hidden flex-shrink-0 bg-gray-100">
|
<div className="w-[42px] h-[42px] rounded-full overflow-hidden flex-shrink-0 bg-gray-100 relative">
|
||||||
{userImage ? (
|
{(!userImage || !avatarLoaded) && (
|
||||||
<img
|
|
||||||
src={userImage}
|
|
||||||
alt="Profile"
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Image
|
<Image
|
||||||
src="/assets/icons/user-placeholder-icon.svg"
|
src="/assets/icons/user-placeholder-icon.svg"
|
||||||
alt="Profile"
|
alt="Profile"
|
||||||
width={42}
|
width={42}
|
||||||
height={42}
|
height={42}
|
||||||
className="object-cover"
|
className="absolute inset-0 object-cover"
|
||||||
style={{ width: '100%', height: '100%' }}
|
style={{ width: '100%', height: '100%' }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{userImage && (
|
||||||
|
<img
|
||||||
|
src={userImage}
|
||||||
|
alt="Profile"
|
||||||
|
className={`w-full h-full object-cover ${avatarLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||||
|
onLoad={() => setAvatarLoaded(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col min-w-0 overflow-hidden">
|
<div className="flex flex-col min-w-0 overflow-hidden">
|
||||||
<p className="font-fractul font-medium text-[16px] leading-[20px] text-black">
|
<p className="font-fractul font-medium text-[16px] leading-[20px] text-black">
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ export function ChatHeader({
|
|||||||
}: ChatHeaderProps) {
|
}: ChatHeaderProps) {
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
const [isMuted, setIsMuted] = useState(false);
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
|
const [avatarLoaded, setAvatarLoaded] = useState(false);
|
||||||
|
|
||||||
|
const placeholder = '/assets/icons/user-placeholder-icon.svg';
|
||||||
|
const isPlaceholderAvatar = !avatar || avatar === placeholder;
|
||||||
|
|
||||||
const menuItems: DropdownMenuItem[] = [
|
const menuItems: DropdownMenuItem[] = [
|
||||||
{
|
{
|
||||||
@@ -118,15 +122,24 @@ export function ChatHeader({
|
|||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
{/* 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">
|
<div className="w-[50px] h-[50px] rounded-full overflow-hidden border border-[#00293D]/20 relative">
|
||||||
|
{(!avatar || !avatarLoaded || isPlaceholderAvatar) && (
|
||||||
<Image
|
<Image
|
||||||
src={avatar}
|
src={placeholder}
|
||||||
alt={name}
|
alt={name}
|
||||||
width={50}
|
width={50}
|
||||||
height={50}
|
height={50}
|
||||||
className="object-cover rounded-full"
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
style={{ width: '100%', height: '100%' }}
|
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
{avatar && !isPlaceholderAvatar && (
|
||||||
|
<img
|
||||||
|
src={avatar}
|
||||||
|
alt={name}
|
||||||
|
className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-200 ${avatarLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||||
|
onLoad={() => setAvatarLoaded(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isOnline && (
|
{isOnline && (
|
||||||
<div className="absolute bottom-0 right-0 w-3 h-3 bg-green-500 rounded-full border-2 border-white" />
|
<div className="absolute bottom-0 right-0 w-3 h-3 bg-green-500 rounded-full border-2 border-white" />
|
||||||
|
|||||||
@@ -80,6 +80,44 @@ function formatLastSeen(lastSeenAt: string | null | undefined, isOnline: boolean
|
|||||||
return `Last seen ${diffDays}d ago`;
|
return `Last seen ${diffDays}d ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Avatar component with placeholder-until-loaded pattern
|
||||||
|
function AvatarWithPlaceholder({ src, alt, size }: { src: string; alt: string; size: number }) {
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const placeholder = '/assets/icons/user-placeholder-icon.svg';
|
||||||
|
const isPlaceholder = !src || src === placeholder;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{(!src || !loaded) && (
|
||||||
|
<Image
|
||||||
|
src={placeholder}
|
||||||
|
alt={alt}
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{src && !isPlaceholder && (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-200 ${loaded ? 'opacity-100' : 'opacity-0'}`}
|
||||||
|
onLoad={() => setLoaded(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{src && isPlaceholder && (
|
||||||
|
<Image
|
||||||
|
src={placeholder}
|
||||||
|
alt={alt}
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Connected agent with avatar URL
|
// Connected agent with avatar URL
|
||||||
interface ConnectedAgent {
|
interface ConnectedAgent {
|
||||||
agentProfileId: string;
|
agentProfileId: string;
|
||||||
@@ -589,14 +627,11 @@ export function MessagingPage() {
|
|||||||
>
|
>
|
||||||
{/* 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">
|
<div className="w-[50px] h-[50px] rounded-full overflow-hidden border border-[#00293D]/20 relative">
|
||||||
<Image
|
<AvatarWithPlaceholder
|
||||||
src={getValidAvatarUrl(agent.avatarUrl)}
|
src={getValidAvatarUrl(agent.avatarUrl)}
|
||||||
alt={agent.name}
|
alt={agent.name}
|
||||||
width={50}
|
size={50}
|
||||||
height={50}
|
|
||||||
className="object-cover rounded-full"
|
|
||||||
style={{ width: '100%', height: '100%' }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -659,14 +694,11 @@ export function MessagingPage() {
|
|||||||
|
|
||||||
{/* Avatar with online indicator */}
|
{/* Avatar with online indicator */}
|
||||||
<div className="relative flex-shrink-0">
|
<div className="relative flex-shrink-0">
|
||||||
<div className="w-[70px] h-[70px] rounded-full overflow-hidden border border-[#00293D]/20">
|
<div className="w-[70px] h-[70px] rounded-full overflow-hidden border border-[#00293D]/20 relative">
|
||||||
<Image
|
<AvatarWithPlaceholder
|
||||||
src={getValidAvatarUrl(conversationAvatarUrls[conversation.id])}
|
src={getValidAvatarUrl(conversationAvatarUrls[conversation.id])}
|
||||||
alt={conversation.otherParty?.name || 'User'}
|
alt={conversation.otherParty?.name || 'User'}
|
||||||
width={70}
|
size={70}
|
||||||
height={70}
|
|
||||||
className="object-cover rounded-full"
|
|
||||||
style={{ width: '100%', height: '100%' }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{conversation.otherParty?.isOnline && (
|
{conversation.otherParty?.isOnline && (
|
||||||
|
|||||||
@@ -50,6 +50,14 @@ export function ProfileCard({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [isUnlinking, setIsUnlinking] = useState(false);
|
const [isUnlinking, setIsUnlinking] = useState(false);
|
||||||
|
const [showLocationModal, setShowLocationModal] = useState(false);
|
||||||
|
|
||||||
|
// Split location into parts and limit display
|
||||||
|
const locationParts = location.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
const maxVisibleLocations = 4;
|
||||||
|
const visibleLocations = locationParts.slice(0, maxVisibleLocations).join(', ');
|
||||||
|
const hasMoreLocations = locationParts.length > maxVisibleLocations;
|
||||||
|
const remainingCount = locationParts.length - maxVisibleLocations;
|
||||||
|
|
||||||
// Handle unlink click
|
// Handle unlink click
|
||||||
const handleUnlinkClick = async () => {
|
const handleUnlinkClick = async () => {
|
||||||
@@ -113,26 +121,35 @@ export function ProfileCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[#00293d] text-sm mb-2 font-fractul">{title}</p>
|
<p className="text-[#00293d] text-sm mb-2 font-fractul">{title}</p>
|
||||||
<div className="flex items-center justify-center lg:justify-start gap-4 text-sm text-[#00293d] font-fractul">
|
<div className="flex flex-wrap items-center justify-center lg:justify-start gap-x-4 gap-y-1 text-sm text-[#00293d] font-fractul">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Image
|
<Image
|
||||||
src="/assets/icons/location-pin-icon.svg"
|
src="/assets/icons/location-pin-icon.svg"
|
||||||
alt="Location"
|
alt="Location"
|
||||||
width={21}
|
width={21}
|
||||||
height={19}
|
height={19}
|
||||||
|
className="flex-shrink-0"
|
||||||
/>
|
/>
|
||||||
<span className="text-[14px] font-bold text-[#00293D] font-serif leading-[19px]">
|
<span className="text-[14px] font-bold text-[#00293D] font-serif leading-[19px]">
|
||||||
{location}
|
{visibleLocations}
|
||||||
|
{hasMoreLocations && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowLocationModal(true)}
|
||||||
|
className="ml-1 text-[#e58625] hover:underline cursor-pointer"
|
||||||
|
>
|
||||||
|
+{remainingCount} more
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1 flex-shrink-0">
|
||||||
<Image
|
<Image
|
||||||
src="/assets/icons/calendar-icon.svg"
|
src="/assets/icons/calendar-icon.svg"
|
||||||
alt="Calendar"
|
alt="Calendar"
|
||||||
width={23}
|
width={23}
|
||||||
height={21}
|
height={21}
|
||||||
/>
|
/>
|
||||||
<span className="text-[13px] font-medium text-[#00293D] font-serif leading-[18px]">
|
<span className="text-[13px] font-medium text-[#00293D] font-serif leading-[18px] whitespace-nowrap">
|
||||||
Member Since {memberSince}
|
Member Since {memberSince}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -260,6 +277,33 @@ export function ProfileCard({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Location Modal */}
|
||||||
|
{showLocationModal && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" onClick={() => setShowLocationModal(false)}>
|
||||||
|
<div className="bg-white rounded-[20px] p-6 max-w-[400px] w-full max-h-[80vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="font-fractul font-bold text-[18px] text-[#00293D]">Service Areas</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowLocationModal(false)}
|
||||||
|
className="text-[#00293D]/50 hover:text-[#00293D] text-[20px] leading-none cursor-pointer"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{locationParts.map((loc, idx) => (
|
||||||
|
<span
|
||||||
|
key={idx}
|
||||||
|
className="px-3 py-1.5 bg-[#e8e8e8] rounded-[10px] text-[13px] font-serif text-[#00293D]"
|
||||||
|
>
|
||||||
|
{loc}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,12 +46,18 @@ export function ProfileSettingsForm({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
|
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
|
||||||
|
const [avatarLoaded, setAvatarLoaded] = useState(false);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [uploadProgress, setUploadProgress] = useState(0);
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
|
// Reset avatar loaded state when URL changes (e.g., after upload)
|
||||||
|
useEffect(() => {
|
||||||
|
setAvatarLoaded(false);
|
||||||
|
}, [avatarUrl]);
|
||||||
|
|
||||||
// Store original data for cancel/reset functionality
|
// Store original data for cancel/reset functionality
|
||||||
const [originalData, setOriginalData] = useState<typeof formData | null>(null);
|
const [originalData, setOriginalData] = useState<typeof formData | null>(null);
|
||||||
// Track if initial profile fetch is done to avoid re-showing loading spinner
|
// Track if initial profile fetch is done to avoid re-showing loading spinner
|
||||||
@@ -382,20 +388,22 @@ export function ProfileSettingsForm({
|
|||||||
{/* Profile Photo Section */}
|
{/* Profile Photo Section */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className="w-[60px] h-[60px] rounded-full overflow-hidden border border-[#00293D]/20 flex-shrink-0 bg-gray-100">
|
<div className="w-[60px] h-[60px] rounded-full overflow-hidden border border-[#00293D]/20 flex-shrink-0 bg-gray-100 relative">
|
||||||
{avatarUrl ? (
|
{(!avatarUrl || !avatarLoaded) && (
|
||||||
<img
|
|
||||||
src={avatarUrl}
|
|
||||||
alt="Profile"
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Image
|
<Image
|
||||||
src="/assets/icons/user-placeholder-icon.svg"
|
src="/assets/icons/user-placeholder-icon.svg"
|
||||||
alt="Profile"
|
alt="Profile"
|
||||||
width={60}
|
width={60}
|
||||||
height={60}
|
height={60}
|
||||||
className="w-full h-full object-cover"
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{avatarUrl && (
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt="Profile"
|
||||||
|
className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-200 ${avatarLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||||
|
onLoad={() => setAvatarLoaded(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export function SettingsSidebar({
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
|
||||||
|
const [avatarLoaded, setAvatarLoaded] = useState(false);
|
||||||
|
|
||||||
const [profileData, setProfileData] = useState({
|
const [profileData, setProfileData] = useState({
|
||||||
name: session?.user?.name || 'User',
|
name: session?.user?.name || 'User',
|
||||||
title: 'Real Estate Agent',
|
title: 'Real Estate Agent',
|
||||||
@@ -65,6 +67,7 @@ export function SettingsSidebar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setProfileData({ name, title, avatarUrl });
|
setProfileData({ name, title, avatarUrl });
|
||||||
|
setAvatarLoaded(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch profile for sidebar:', err);
|
console.error('Failed to fetch profile for sidebar:', err);
|
||||||
}
|
}
|
||||||
@@ -133,20 +136,22 @@ export function SettingsSidebar({
|
|||||||
{/* Profile Card */}
|
{/* Profile Card */}
|
||||||
<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">
|
<div className="w-[70px] h-[70px] rounded-full overflow-hidden border-2 border-[#00293D]/20 flex-shrink-0 bg-gray-100 relative">
|
||||||
{profileData.avatarUrl ? (
|
{(!profileData.avatarUrl || !avatarLoaded) && (
|
||||||
<img
|
|
||||||
src={profileData.avatarUrl}
|
|
||||||
alt={profileData.name}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Image
|
<Image
|
||||||
src="/assets/icons/user-placeholder-icon.svg"
|
src="/assets/icons/user-placeholder-icon.svg"
|
||||||
alt={profileData.name}
|
alt={profileData.name}
|
||||||
width={70}
|
width={70}
|
||||||
height={70}
|
height={70}
|
||||||
className="w-full h-full object-cover"
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{profileData.avatarUrl && (
|
||||||
|
<img
|
||||||
|
src={profileData.avatarUrl}
|
||||||
|
alt={profileData.name}
|
||||||
|
className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-200 ${avatarLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||||
|
onLoad={() => setAvatarLoaded(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -360,8 +360,9 @@ export function mapFieldValuesToSpecializationFields(fieldValues: FieldValueResp
|
|||||||
return sectionSlug === 'specialization' || sectionSlug.includes('specialization');
|
return sectionSlug === 'specialization' || sectionSlug.includes('specialization');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create a card for each field
|
// Create a card for each field, deduplicating by fieldSlug
|
||||||
const fields: SpecializationFieldItem[] = [];
|
const fields: SpecializationFieldItem[] = [];
|
||||||
|
const seenSlugs = new Set<string>();
|
||||||
|
|
||||||
for (const field of specializationFields) {
|
for (const field of specializationFields) {
|
||||||
const { fieldSlug, fieldName, value } = field;
|
const { fieldSlug, fieldName, value } = field;
|
||||||
@@ -369,6 +370,10 @@ export function mapFieldValuesToSpecializationFields(fieldValues: FieldValueResp
|
|||||||
// Skip if no field info or no value
|
// Skip if no field info or no value
|
||||||
if (!fieldSlug || !fieldName) continue;
|
if (!fieldSlug || !fieldName) continue;
|
||||||
|
|
||||||
|
// Skip duplicate fieldSlugs (API may return duplicates)
|
||||||
|
if (seenSlugs.has(fieldSlug)) continue;
|
||||||
|
seenSlugs.add(fieldSlug);
|
||||||
|
|
||||||
const values = valueToStringArray(value);
|
const values = valueToStringArray(value);
|
||||||
if (values.length === 0) continue;
|
if (values.length === 0) continue;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user