feat: add agent search and selection functionality with a 3-item limit to the CMS professional editor
This commit is contained in:
@@ -1,9 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import {
|
import {
|
||||||
cmsService,
|
cmsService,
|
||||||
|
usersService,
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
CmsContentRecord,
|
CmsContentRecord,
|
||||||
HeroContent,
|
HeroContent,
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
TestimonialsContent,
|
TestimonialsContent,
|
||||||
TestimonialItem,
|
TestimonialItem,
|
||||||
StatItem,
|
StatItem,
|
||||||
|
User,
|
||||||
} from '@/services';
|
} from '@/services';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -103,6 +105,14 @@ export default function CmsPage() {
|
|||||||
const [modalError, setModalError] = useState('');
|
const [modalError, setModalError] = useState('');
|
||||||
const [professionalsTab, setProfessionalsTab] = useState<'agents' | 'lenders'>('agents');
|
const [professionalsTab, setProfessionalsTab] = useState<'agents' | 'lenders'>('agents');
|
||||||
|
|
||||||
|
// Agent search
|
||||||
|
const [agentSearchQuery, setAgentSearchQuery] = useState('');
|
||||||
|
const [agentSearchResults, setAgentSearchResults] = useState<User[]>([]);
|
||||||
|
const [isSearchingAgents, setIsSearchingAgents] = useState(false);
|
||||||
|
const [showAgentDropdown, setShowAgentDropdown] = useState(false);
|
||||||
|
const agentSearchTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const agentSearchRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Fetch all CMS records for "landing" page
|
// Fetch all CMS records for "landing" page
|
||||||
const fetchRecords = useCallback(async () => {
|
const fetchRecords = useCallback(async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -133,6 +143,57 @@ export default function CmsPage() {
|
|||||||
}
|
}
|
||||||
}, [notification]);
|
}, [notification]);
|
||||||
|
|
||||||
|
// Close agent search dropdown on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
if (agentSearchRef.current && !agentSearchRef.current.contains(e.target as Node)) {
|
||||||
|
setShowAgentDropdown(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Agent search with debounce
|
||||||
|
function handleAgentSearch(query: string) {
|
||||||
|
setAgentSearchQuery(query);
|
||||||
|
if (agentSearchTimeout.current) clearTimeout(agentSearchTimeout.current);
|
||||||
|
if (query.length < 2) {
|
||||||
|
setAgentSearchResults([]);
|
||||||
|
setShowAgentDropdown(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsSearchingAgents(true);
|
||||||
|
setShowAgentDropdown(true);
|
||||||
|
agentSearchTimeout.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const res = await usersService.getUsers({ role: 'AGENT', search: query, limit: 10 });
|
||||||
|
setAgentSearchResults(res.users);
|
||||||
|
} catch {
|
||||||
|
setAgentSearchResults([]);
|
||||||
|
} finally {
|
||||||
|
setIsSearchingAgents(false);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAgent(user: User, onChange: (items: ProfessionalItem[]) => void, currentItems: ProfessionalItem[]) {
|
||||||
|
if (currentItems.length >= 3) return;
|
||||||
|
const profile = user.profile;
|
||||||
|
const agent = user.agentProfile;
|
||||||
|
const name = profile ? `${profile.firstName} ${profile.lastName}`.trim() : '';
|
||||||
|
const subtitle = agent?.headline || agent?.agentType?.name || '';
|
||||||
|
const location = profile ? [profile.city, profile.state].filter(Boolean).join(', ') : '';
|
||||||
|
const experience = agent?.yearsOfExperience ? `${agent.yearsOfExperience}+ years in real estate.` : '';
|
||||||
|
const imageUrl = profile?.avatar || '';
|
||||||
|
|
||||||
|
const newItem: ProfessionalItem = { name, subtitle, location, experience, expertise: [], imageUrl };
|
||||||
|
onChange([...currentItems, newItem]);
|
||||||
|
setAgentSearchQuery('');
|
||||||
|
setAgentSearchResults([]);
|
||||||
|
setShowAgentDropdown(false);
|
||||||
|
}
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
function recordFor(sectionKey: string): CmsContentRecord | undefined {
|
function recordFor(sectionKey: string): CmsContentRecord | undefined {
|
||||||
return records.find((r) => r.sectionKey === sectionKey);
|
return records.find((r) => r.sectionKey === sectionKey);
|
||||||
@@ -144,6 +205,9 @@ export default function CmsPage() {
|
|||||||
setEditContent(existing ? JSON.parse(JSON.stringify(existing.content)) : defaultContentFor(sectionKey));
|
setEditContent(existing ? JSON.parse(JSON.stringify(existing.content)) : defaultContentFor(sectionKey));
|
||||||
setModalError('');
|
setModalError('');
|
||||||
setProfessionalsTab('agents');
|
setProfessionalsTab('agents');
|
||||||
|
setAgentSearchQuery('');
|
||||||
|
setAgentSearchResults([]);
|
||||||
|
setShowAgentDropdown(false);
|
||||||
setEditingSection(sectionKey);
|
setEditingSection(sectionKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,15 +334,82 @@ export default function CmsPage() {
|
|||||||
onChange(items.filter((_, i) => i !== index));
|
onChange(items.filter((_, i) => i !== index));
|
||||||
}
|
}
|
||||||
function addItem() {
|
function addItem() {
|
||||||
|
if (items.length >= 3) return;
|
||||||
onChange([...items, emptyProfessionalItem()]);
|
onChange([...items, emptyProfessionalItem()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const atMax = items.length >= 3;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
{/* Agent search */}
|
||||||
|
{!atMax && <div ref={agentSearchRef} className="relative mb-4">
|
||||||
|
<label className="block text-sm font-medium text-[#00293d] mb-1">Search agents from database</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={inputCls}
|
||||||
|
value={agentSearchQuery}
|
||||||
|
onChange={(e) => handleAgentSearch(e.target.value)}
|
||||||
|
onFocus={() => { if (agentSearchResults.length > 0) setShowAgentDropdown(true); }}
|
||||||
|
placeholder="Search agents by name or email..."
|
||||||
|
/>
|
||||||
|
{isSearchingAgents && (
|
||||||
|
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-[#f5a623]"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showAgentDropdown && agentSearchQuery.length >= 2 && (
|
||||||
|
<div className="absolute z-10 w-full mt-1 bg-white border border-[#e5e7eb] rounded-lg shadow-lg max-h-60 overflow-y-auto">
|
||||||
|
{isSearchingAgents && agentSearchResults.length === 0 ? (
|
||||||
|
<div className="px-4 py-3 text-sm text-[#666666]">Searching...</div>
|
||||||
|
) : agentSearchResults.length === 0 ? (
|
||||||
|
<div className="px-4 py-3 text-sm text-[#666666]">No agents found</div>
|
||||||
|
) : (
|
||||||
|
agentSearchResults.map((user) => (
|
||||||
|
<button
|
||||||
|
key={user.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectAgent(user, onChange, items)}
|
||||||
|
className="w-full text-left px-4 py-2.5 hover:bg-[#f5f9f8] transition-colors border-b border-[#e5e7eb] last:border-b-0"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-[#f5a623]/10 flex items-center justify-center flex-shrink-0 relative overflow-hidden">
|
||||||
|
<span className="text-xs font-semibold text-[#f5a623]">
|
||||||
|
{user.profile?.firstName?.[0] || ''}{user.profile?.lastName?.[0] || ''}
|
||||||
|
</span>
|
||||||
|
{user.profile?.avatar && (
|
||||||
|
<img src={user.profile.avatar} alt="" className="absolute inset-0 w-full h-full rounded-full object-cover" onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-medium text-[#00293d] truncate">
|
||||||
|
{user.profile ? `${user.profile.firstName} ${user.profile.lastName}` : 'No Name'}
|
||||||
|
<span className="ml-2 text-xs text-[#666666] font-normal">{user.email}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[#666666] truncate">
|
||||||
|
{[user.profile?.city, user.profile?.state].filter(Boolean).join(', ') || 'No location'}
|
||||||
|
{user.agentProfile?.agentType?.name && (
|
||||||
|
<span className="ml-2 px-1.5 py-0.5 bg-[#f5a623]/10 text-[#f5a623] rounded text-[10px] font-medium">
|
||||||
|
{user.agentProfile.agentType.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>}
|
||||||
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<label className="block text-sm font-medium text-[#00293d]">{label} ({items.length})</label>
|
<label className="block text-sm font-medium text-[#00293d]">{label} ({items.length}/3)</label>
|
||||||
<button type="button" onClick={addItem} className="px-3 py-1 text-xs bg-[#f5a623] hover:bg-[#e09620] text-white rounded-lg transition-colors">+ Add {label.slice(0, -1)}</button>
|
<button type="button" onClick={addItem} disabled={atMax} className="px-3 py-1 text-xs bg-[#f5a623] hover:bg-[#e09620] text-white rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed">+ Add {label.slice(0, -1)}</button>
|
||||||
</div>
|
</div>
|
||||||
|
{atMax && <p className="text-xs text-[#666666] mb-2">Maximum of 3 {label.toLowerCase()} reached.</p>}
|
||||||
{items.length === 0 && <p className="text-sm text-[#666666] italic">No {label.toLowerCase()} added yet.</p>}
|
{items.length === 0 && <p className="text-sm text-[#666666] italic">No {label.toLowerCase()} added yet.</p>}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{items.map((item, idx) => (
|
{items.map((item, idx) => (
|
||||||
|
|||||||
Reference in New Issue
Block a user