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';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
@@ -26,17 +27,33 @@ export function ConnectionCard({
|
||||
connectedAt,
|
||||
onMessage,
|
||||
}: ConnectionCardProps) {
|
||||
const [avatarLoaded, setAvatarLoaded] = useState(false);
|
||||
const placeholder = '/assets/icons/user-placeholder-icon.svg';
|
||||
const isPlaceholder = !avatar || avatar === placeholder;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 py-4">
|
||||
{/* Avatar */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-[80px] h-[80px] rounded-full overflow-hidden relative">
|
||||
{(!avatar || !avatarLoaded || isPlaceholder) && (
|
||||
<Image
|
||||
src={avatar}
|
||||
src={placeholder}
|
||||
alt={name}
|
||||
width={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>
|
||||
|
||||
{/* Content */}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
|
||||
interface InvitationCardProps {
|
||||
@@ -40,17 +41,33 @@ export function InvitationCard({
|
||||
onAccept,
|
||||
onIgnore,
|
||||
}: InvitationCardProps) {
|
||||
const [avatarLoaded, setAvatarLoaded] = useState(false);
|
||||
const placeholder = '/assets/icons/user-placeholder-icon.svg';
|
||||
const isPlaceholder = !avatar || avatar === placeholder;
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col sm:flex-row items-start sm:items-center gap-4 py-4 ${isProcessing ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||
{/* Avatar */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-[80px] h-[80px] rounded-full overflow-hidden relative">
|
||||
{(!avatar || !avatarLoaded || isPlaceholder) && (
|
||||
<Image
|
||||
src={avatar}
|
||||
src={placeholder}
|
||||
alt={name}
|
||||
width={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>
|
||||
|
||||
{/* Content */}
|
||||
|
||||
@@ -111,9 +111,6 @@ body {
|
||||
}
|
||||
|
||||
/* Smooth scrolling */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Selection styling */
|
||||
::selection {
|
||||
|
||||
@@ -130,7 +130,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} ${sourceSerif4.variable} ${fractul.variable} antialiased`}
|
||||
>
|
||||
|
||||
@@ -26,6 +26,7 @@ export function CommonHeader() {
|
||||
const mobileMenuRef = useRef<HTMLDivElement>(null);
|
||||
const [profileImage, setProfileImage] = useState<string | null>(null);
|
||||
const [profileName, setProfileName] = useState<string | null>(null);
|
||||
const [avatarLoaded, setAvatarLoaded] = useState(false);
|
||||
const [notificationCount, setNotificationCount] = useState(0);
|
||||
|
||||
// Close dropdowns when clicking outside
|
||||
@@ -73,6 +74,7 @@ export function CommonHeader() {
|
||||
}
|
||||
|
||||
if (avatar) {
|
||||
setAvatarLoaded(false);
|
||||
try {
|
||||
const avatarUrl = await uploadService.getPresignedDownloadUrl(avatar);
|
||||
setProfileImage(avatarUrl);
|
||||
@@ -150,6 +152,7 @@ export function CommonHeader() {
|
||||
alt="RE-QuestN"
|
||||
width={150}
|
||||
height={40}
|
||||
priority
|
||||
className="h-8 md:h-10 w-auto"
|
||||
/>
|
||||
</Link>
|
||||
@@ -198,23 +201,25 @@ export function CommonHeader() {
|
||||
className="flex items-center gap-2 md:gap-3 hover:opacity-80 transition-opacity cursor-pointer"
|
||||
>
|
||||
{/* 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">
|
||||
{userImage ? (
|
||||
<img
|
||||
src={userImage}
|
||||
alt="Profile"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<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 || !avatarLoaded) && (
|
||||
<Image
|
||||
src="/assets/icons/user-placeholder-icon.svg"
|
||||
alt="Profile"
|
||||
width={35}
|
||||
height={35}
|
||||
className="object-cover"
|
||||
className="absolute inset-0 object-cover"
|
||||
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>
|
||||
{/* Greeting Text - hidden on mobile */}
|
||||
<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="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">
|
||||
{userImage ? (
|
||||
<img
|
||||
src={userImage}
|
||||
alt="Profile"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-[42px] h-[42px] rounded-full overflow-hidden flex-shrink-0 bg-gray-100 relative">
|
||||
{(!userImage || !avatarLoaded) && (
|
||||
<Image
|
||||
src="/assets/icons/user-placeholder-icon.svg"
|
||||
alt="Profile"
|
||||
width={42}
|
||||
height={42}
|
||||
className="object-cover"
|
||||
className="absolute inset-0 object-cover"
|
||||
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 className="flex flex-col min-w-0 overflow-hidden">
|
||||
<p className="font-fractul font-medium text-[16px] leading-[20px] text-black">
|
||||
|
||||
@@ -33,6 +33,10 @@ export function ChatHeader({
|
||||
}: ChatHeaderProps) {
|
||||
const [isMenuOpen, setIsMenuOpen] = 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[] = [
|
||||
{
|
||||
@@ -118,15 +122,24 @@ export function ChatHeader({
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Avatar */}
|
||||
<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
|
||||
src={avatar}
|
||||
src={placeholder}
|
||||
alt={name}
|
||||
width={50}
|
||||
height={50}
|
||||
className="object-cover rounded-full"
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
{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>
|
||||
{isOnline && (
|
||||
<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`;
|
||||
}
|
||||
|
||||
// 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
|
||||
interface ConnectedAgent {
|
||||
agentProfileId: string;
|
||||
@@ -589,14 +627,11 @@ export function MessagingPage() {
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div className="relative flex-shrink-0">
|
||||
<div className="w-[50px] h-[50px] rounded-full overflow-hidden border border-[#00293D]/20">
|
||||
<Image
|
||||
<div className="w-[50px] h-[50px] rounded-full overflow-hidden border border-[#00293D]/20 relative">
|
||||
<AvatarWithPlaceholder
|
||||
src={getValidAvatarUrl(agent.avatarUrl)}
|
||||
alt={agent.name}
|
||||
width={50}
|
||||
height={50}
|
||||
className="object-cover rounded-full"
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
size={50}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -659,14 +694,11 @@ export function MessagingPage() {
|
||||
|
||||
{/* Avatar with online indicator */}
|
||||
<div className="relative flex-shrink-0">
|
||||
<div className="w-[70px] h-[70px] rounded-full overflow-hidden border border-[#00293D]/20">
|
||||
<Image
|
||||
<div className="w-[70px] h-[70px] rounded-full overflow-hidden border border-[#00293D]/20 relative">
|
||||
<AvatarWithPlaceholder
|
||||
src={getValidAvatarUrl(conversationAvatarUrls[conversation.id])}
|
||||
alt={conversation.otherParty?.name || 'User'}
|
||||
width={70}
|
||||
height={70}
|
||||
className="object-cover rounded-full"
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
size={70}
|
||||
/>
|
||||
</div>
|
||||
{conversation.otherParty?.isOnline && (
|
||||
|
||||
@@ -50,6 +50,14 @@ export function ProfileCard({
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
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
|
||||
const handleUnlinkClick = async () => {
|
||||
@@ -113,26 +121,35 @@ export function ProfileCard({
|
||||
)}
|
||||
</div>
|
||||
<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">
|
||||
<Image
|
||||
src="/assets/icons/location-pin-icon.svg"
|
||||
alt="Location"
|
||||
width={21}
|
||||
height={19}
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
<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 className="flex items-center gap-1">
|
||||
<span className="flex items-center gap-1 flex-shrink-0">
|
||||
<Image
|
||||
src="/assets/icons/calendar-icon.svg"
|
||||
alt="Calendar"
|
||||
width={23}
|
||||
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}
|
||||
</span>
|
||||
</span>
|
||||
@@ -260,6 +277,33 @@ export function ProfileCard({
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,12 +46,18 @@ export function ProfileSettingsForm({
|
||||
});
|
||||
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
|
||||
const [avatarLoaded, setAvatarLoaded] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = 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
|
||||
const [originalData, setOriginalData] = useState<typeof formData | null>(null);
|
||||
// Track if initial profile fetch is done to avoid re-showing loading spinner
|
||||
@@ -382,20 +388,22 @@ export function ProfileSettingsForm({
|
||||
{/* Profile Photo Section */}
|
||||
<div className="mb-8">
|
||||
<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">
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt="Profile"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-[60px] h-[60px] rounded-full overflow-hidden border border-[#00293D]/20 flex-shrink-0 bg-gray-100 relative">
|
||||
{(!avatarUrl || !avatarLoaded) && (
|
||||
<Image
|
||||
src="/assets/icons/user-placeholder-icon.svg"
|
||||
alt="Profile"
|
||||
width={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>
|
||||
|
||||
@@ -25,6 +25,8 @@ export function SettingsSidebar({
|
||||
const pathname = usePathname();
|
||||
const { data: session } = useSession();
|
||||
|
||||
const [avatarLoaded, setAvatarLoaded] = useState(false);
|
||||
|
||||
const [profileData, setProfileData] = useState({
|
||||
name: session?.user?.name || 'User',
|
||||
title: 'Real Estate Agent',
|
||||
@@ -65,6 +67,7 @@ export function SettingsSidebar({
|
||||
}
|
||||
|
||||
setProfileData({ name, title, avatarUrl });
|
||||
setAvatarLoaded(false);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch profile for sidebar:', err);
|
||||
}
|
||||
@@ -133,20 +136,22 @@ export function SettingsSidebar({
|
||||
{/* Profile Card */}
|
||||
<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">
|
||||
{profileData.avatarUrl ? (
|
||||
<img
|
||||
src={profileData.avatarUrl}
|
||||
alt={profileData.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-[70px] h-[70px] rounded-full overflow-hidden border-2 border-[#00293D]/20 flex-shrink-0 bg-gray-100 relative">
|
||||
{(!profileData.avatarUrl || !avatarLoaded) && (
|
||||
<Image
|
||||
src="/assets/icons/user-placeholder-icon.svg"
|
||||
alt={profileData.name}
|
||||
width={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>
|
||||
|
||||
@@ -360,8 +360,9 @@ export function mapFieldValuesToSpecializationFields(fieldValues: FieldValueResp
|
||||
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 seenSlugs = new Set<string>();
|
||||
|
||||
for (const field of specializationFields) {
|
||||
const { fieldSlug, fieldName, value } = field;
|
||||
@@ -369,6 +370,10 @@ export function mapFieldValuesToSpecializationFields(fieldValues: FieldValueResp
|
||||
// Skip if no field info or no value
|
||||
if (!fieldSlug || !fieldName) continue;
|
||||
|
||||
// Skip duplicate fieldSlugs (API may return duplicates)
|
||||
if (seenSlugs.has(fieldSlug)) continue;
|
||||
seenSlugs.add(fieldSlug);
|
||||
|
||||
const values = valueToStringArray(value);
|
||||
if (values.length === 0) continue;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user