'use client'; import { useEffect, useState, useCallback, useRef } from 'react'; import { useRouter } from 'next/navigation'; import { api, cmsService, usersService, uploadService, getErrorMessage, CmsContentRecord, HeroContent, FeaturesContent, FeatureItem, FeaturedAgentItem, TopProfessionalsContent, ProfessionalItem, TestimonialsContent, TestimonialItem, StatItem, AboutHeroContent, AboutStatsContent, AboutStatItem, AboutFeaturesContent, AboutFeatureItem, AboutTeamContent, AboutTeamCard, AboutCtaContent, FaqContent, FaqCategoryItem, FaqItem, User, } from '@/services'; // --------------------------------------------------------------------------- // Section metadata // --------------------------------------------------------------------------- interface SectionMeta { pageSlug: string; sectionKey: string; label: string; description: string; } const LANDING_SECTIONS: SectionMeta[] = [ { pageSlug: 'landing', sectionKey: 'hero', label: 'Hero', description: 'Main headline, description and call-to-action' }, { pageSlug: 'landing', sectionKey: 'features', label: 'Features', description: 'Feature highlights with icons' }, { pageSlug: 'landing', sectionKey: 'topProfessionals', label: 'Top Professionals', description: 'Agents and lenders showcase' }, { pageSlug: 'landing', sectionKey: 'testimonials', label: 'Testimonials', description: 'Reviews, stats and social proof' }, ]; const ABOUT_SECTIONS: SectionMeta[] = [ { pageSlug: 'about', sectionKey: 'hero', label: 'Hero & Banner', description: 'Headline, description, CTA and banner image' }, { pageSlug: 'about', sectionKey: 'stats', label: 'Stats', description: 'Key metrics like verified agents count' }, { pageSlug: 'about', sectionKey: 'features', label: 'Why Choose Us', description: 'Feature cards with icons' }, { pageSlug: 'about', sectionKey: 'team', label: 'Team', description: 'Team members with photos' }, { pageSlug: 'about', sectionKey: 'cta', label: 'Call to Action', description: 'Bottom CTA section' }, ]; const FAQ_SECTIONS: SectionMeta[] = [ { pageSlug: 'faq', sectionKey: 'faqContent', label: 'FAQ Content', description: 'Categories, questions and answers' }, ]; const CONTACT_SECTIONS: SectionMeta[] = [ { pageSlug: 'contact', sectionKey: 'contactDetails', label: 'Contact Details', description: 'Email, phone, office address and map' }, { pageSlug: 'contact', sectionKey: 'cta', label: 'Call to Action', description: 'Bottom CTA section text and button' }, ]; // --------------------------------------------------------------------------- // Default content factories // --------------------------------------------------------------------------- function defaultHero(): HeroContent { return { headline: '', description: '', ctaButtonText: '', helperText: '' }; } function defaultFeatures(): FeaturesContent { return { title: '', subtitle: '', features: [], featuredAgents: [] }; } function emptyFeaturedAgent(): FeaturedAgentItem { return { name: '', role: '', rating: '', location: '', experience: '', imageUrl: '' }; } function defaultTopProfessionals(): TopProfessionalsContent { return { title: '', ctaText: '', ctaButtonText: '', agents: [], lenders: [] }; } function defaultTestimonials(): TestimonialsContent { return { title: '', subtitle: '', ratingInfo: '', stats: [], testimonials: [] }; } function defaultContentFor(pageSlug: string, sectionKey: string): unknown { if (pageSlug === 'about') { switch (sectionKey) { case 'hero': return { badge: '', headline: '', description: '', ctaButtonText: '', bannerImageUrl: '', bannerOverlayText: '' } as AboutHeroContent; case 'stats': return { stats: [] } as AboutStatsContent; case 'features': return { badge: '', features: [] } as AboutFeaturesContent; case 'team': return { title: '', subtitle: '', intro: '', cards: [] } as AboutTeamContent; case 'cta': return { title: '', description: '', buttonText: '' } as AboutCtaContent; } } if (pageSlug === 'faq') { switch (sectionKey) { case 'faqContent': return { pageTitle: '', categories: [], faqs: [], supportTitle: '', supportDescription: '' } as FaqContent; } } if (pageSlug === 'contact') { switch (sectionKey) { case 'contactDetails': return { title: '', description: '', email: '', phone: '', phoneHours: '', officeAddress: '', officeCity: '', mapUrl: '', directionsUrl: '' }; case 'cta': return { title: '', description: '', buttonText: '', buttonLink: '' }; } } switch (sectionKey) { case 'hero': return defaultHero(); case 'features': return defaultFeatures(); case 'topProfessionals': return defaultTopProfessionals(); case 'testimonials': return defaultTestimonials(); default: return {}; } } function emptyFeatureItem(): FeatureItem { return { iconPath: '', title: '', description: '' }; } function emptyProfessionalItem(): ProfessionalItem { return { name: '', subtitle: '', location: '', experience: '', expertise: [], imageUrl: '' }; } function emptyTestimonialItem(): TestimonialItem { return { rating: 5, title: '', content: '', author: '', role: '' }; } function emptyStatItem(): StatItem { return { iconPath: '', boldText: '', normalText: '' }; } // --------------------------------------------------------------------------- // Shared input class // --------------------------------------------------------------------------- const inputCls = 'w-full px-3 py-2 border border-[#e5e7eb] rounded-xl focus:outline-none focus:border-[#f5a623] text-[#00293d] bg-white placeholder:text-[#666666]'; const textareaCls = inputCls; // Expertise input keeps its own raw-text state so the user can type commas // without the display being destructively re-derived from the parsed array // (which was dropping trailing commas mid-typing and merging new tokens into // the previous one). function ExpertiseInput({ value, onChange, }: { value: string[]; onChange: (next: string[]) => void; }) { const [raw, setRaw] = useState(() => value.join(', ')); useEffect(() => { const currentParsed = raw.split(',').map((s) => s.trim()).filter(Boolean); const matches = currentParsed.length === value.length && currentParsed.every((v, i) => v === value[i]); if (!matches) setRaw(value.join(', ')); // eslint-disable-next-line react-hooks/exhaustive-deps }, [value]); return ( { const next = e.target.value; setRaw(next); onChange(next.split(',').map((s) => s.trim()).filter(Boolean)); }} onBlur={() => { const parsed = raw.split(',').map((s) => s.trim()).filter(Boolean); setRaw(parsed.join(', ')); onChange(parsed); }} placeholder="Expertise (comma-separated)" /> ); } // --------------------------------------------------------------------------- // Page component // --------------------------------------------------------------------------- export default function CmsPage() { const router = useRouter(); const [activePage, setActivePage] = useState<'landing' | 'about' | 'faq' | 'contact'>('landing'); const [records, setRecords] = useState([]); const [aboutRecords, setAboutRecords] = useState([]); const [faqRecords, setFaqRecords] = useState([]); const [contactRecords, setContactRecords] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(''); const [notification, setNotification] = useState<{ type: 'success' | 'error'; message: string } | null>(null); // Modal const [editingSection, setEditingSection] = useState(null); const [editContent, setEditContent] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); const [modalError, setModalError] = useState(''); const [professionalsTab, setProfessionalsTab] = useState<'agents' | 'lenders'>('agents'); const [featuresTab, setFeaturesTab] = useState<'features' | 'agents'>('features'); const [isUploadingImage, setIsUploadingImage] = useState(false); // Agent search const [agentSearchQuery, setAgentSearchQuery] = useState(''); const [agentSearchResults, setAgentSearchResults] = useState([]); const [isSearchingAgents, setIsSearchingAgents] = useState(false); const [showAgentDropdown, setShowAgentDropdown] = useState(false); const agentSearchTimeout = useRef(null); const agentSearchRef = useRef(null); // Fetch all CMS records const fetchRecords = useCallback(async () => { setIsLoading(true); setError(''); try { const [landingData, aboutData, faqData, contactData] = await Promise.all([ cmsService.getByPage('landing'), cmsService.getByPage('about'), cmsService.getByPage('faq'), cmsService.getByPage('contact'), ]); setRecords(landingData); setAboutRecords(aboutData); setFaqRecords(faqData); setContactRecords(contactData); } catch (err) { const msg = getErrorMessage(err); setError(msg); if (msg.includes('Unauthorized')) { router.push('/login'); } } finally { setIsLoading(false); } }, [router]); useEffect(() => { fetchRecords(); }, [fetchRecords]); // Auto-dismiss notifications after 4 seconds useEffect(() => { if (notification) { const timer = setTimeout(() => setNotification(null), 4000); return () => clearTimeout(timer); } }, [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 — searches only verified agents 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, verificationStatus: 'APPROVED' }); // Resolve avatar presigned URLs for dropdown display only // Store resolved URLs separately so original S3 keys are preserved for CMS storage const usersWithDisplayAvatars = await Promise.all( res.users.map(async (user) => { if (user.profile?.avatar && !user.profile.avatar.startsWith('http')) { try { const displayUrl = await uploadService.getPresignedDownloadUrl(user.profile.avatar); return { ...user, _displayAvatar: displayUrl }; } catch { return { ...user, _displayAvatar: undefined }; } } return { ...user, _displayAvatar: user.profile?.avatar || undefined }; }) ); setAgentSearchResults(usersWithDisplayAvatars as any); } catch { setAgentSearchResults([]); } finally { setIsSearchingAgents(false); } }, 300); } async function selectAgent(user: User, onChange: (items: ProfessionalItem[]) => void, currentItems: ProfessionalItem[]) { if (currentItems.length >= 3) return; const agent = user.agentProfile; const profile = user.profile; const name = `${profile?.firstName || ''} ${profile?.lastName || ''}`.trim() || user.email; const subtitle = agent?.agentType?.name || agent?.headline || ''; const imageUrl = profile?.avatar || ''; // Helper to format snake_case to Title Case const formatLabel = (v: string) => v.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join(' '); // Helper to stringify a field value const valueToString = (val: any): string => { if (val == null) return ''; if (Array.isArray(val)) { return val.map((v) => (typeof v === 'object' ? (v.label || v.name || JSON.stringify(v)) : formatLabel(String(v)))).join(', '); } if (typeof val === 'object') return val.label || val.name || JSON.stringify(val); return formatLabel(String(val)); }; // Fetch full agent profile with field values for location/experience/expertise let location = [profile?.city, profile?.state].filter(Boolean).join(', '); let experience = agent?.yearsOfExperience ? `${agent.yearsOfExperience}+ years in real estate.` : ''; let expertise: string[] = []; // Specific slugs to look for (matches profile field seed) const EXPERIENCE_SLUGS = ['years_in_business', 'years_of_experience']; const EXPERTISE_SLUGS = ['about_me_expertise']; if (agent?.id) { try { const res = await api.get(`/agents/${agent.id}/field-values`); const fieldValues = res.data?.data?.fieldValues || res.data?.data || []; for (const fv of fieldValues) { const slug: string = (fv.fieldSlug || fv.field?.slug || '').toLowerCase(); const value = fv.valueLabel ?? fv.value ?? fv.jsonValue ?? fv.textValue; if (value == null || value === '') continue; // Location: city/state fields if ((slug === 'city' || slug === 'state') && !location) { location = Array.isArray(value) ? value.join(', ') : String(value); } // Experience: only specific known slugs (backend already resolves option labels) if (!experience && EXPERIENCE_SLUGS.includes(slug)) { experience = Array.isArray(value) ? value.join(', ') : String(value); } // Expertise: only specific known slugs (backend already resolves option labels) if (EXPERTISE_SLUGS.includes(slug)) { if (Array.isArray(value)) { expertise.push(...value.map((v: any) => typeof v === 'object' ? (v.label || v.name || String(v)) : String(v))); } else if (typeof value === 'string' && value.trim()) { expertise.push(value); } } } } catch { // Use basic profile data as fallback } } const newItem: ProfessionalItem = { id: agent?.id, name, subtitle, location, experience, expertise, imageUrl }; onChange([...currentItems, newItem]); setAgentSearchQuery(''); setAgentSearchResults([]); setShowAgentDropdown(false); } // Select agent for Featured Agent Cards (different format) async function selectFeaturedAgent(user: User) { const data = editContent as FeaturesContent; if ((data.featuredAgents || []).length >= 3) return; const agent = user.agentProfile; const profile = user.profile; const name = `${profile?.firstName || ''} ${profile?.lastName || ''}`.trim() || user.email; const role = agent?.agentType?.name || agent?.headline || ''; const imageUrl = profile?.avatar || ''; // Helper to format snake_case to Title Case const formatLabel = (v: string) => v.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join(' '); const valueToString = (val: any): string => { if (val == null) return ''; if (Array.isArray(val)) { return val.map((v) => (typeof v === 'object' ? (v.label || v.name || JSON.stringify(v)) : formatLabel(String(v)))).join(', '); } if (typeof val === 'object') return val.label || val.name || JSON.stringify(val); return formatLabel(String(val)); }; let location = [profile?.city, profile?.state].filter(Boolean).join(', '); let experience = agent?.yearsOfExperience ? `${agent.yearsOfExperience}+ years` : ''; const rating = agent?.averageRating ? Number(agent.averageRating).toFixed(1) : ''; const EXPERIENCE_SLUGS = ['years_in_business', 'years_of_experience']; if (agent?.id) { try { const res = await api.get(`/agents/${agent.id}/field-values`); const fieldValues = res.data?.data?.fieldValues || res.data?.data || []; for (const fv of fieldValues) { const slug: string = (fv.fieldSlug || fv.field?.slug || '').toLowerCase(); const value = fv.valueLabel ?? fv.value ?? fv.jsonValue ?? fv.textValue; if (value == null || value === '') continue; if ((slug === 'city' || slug === 'state') && !location) { location = Array.isArray(value) ? value.join(', ') : String(value); } if (!experience && EXPERIENCE_SLUGS.includes(slug)) { experience = Array.isArray(value) ? value.join(', ') : String(value); } } } catch { // Use basic profile data as fallback } } const newAgent: FeaturedAgentItem = { id: agent?.id, name, role, rating, location, experience, imageUrl }; setEditContent({ ...data, featuredAgents: [...(data.featuredAgents || []), newAgent] }); setAgentSearchQuery(''); setAgentSearchResults([]); setShowAgentDropdown(false); } // Helpers function recordFor(pageSlug: string, sectionKey: string): CmsContentRecord | undefined { const source = pageSlug === 'about' ? aboutRecords : pageSlug === 'faq' ? faqRecords : pageSlug === 'contact' ? contactRecords : records; return source.find((r) => r.sectionKey === sectionKey); } // Open modal function openEditor(section: SectionMeta) { const existing = recordFor(section.pageSlug, section.sectionKey); setEditContent(existing ? JSON.parse(JSON.stringify(existing.content)) : defaultContentFor(section.pageSlug, section.sectionKey)); setModalError(''); setProfessionalsTab('agents'); setAgentSearchQuery(''); setAgentSearchResults([]); setShowAgentDropdown(false); setEditingSection(section); } function closeEditor() { setEditingSection(null); setEditContent(null); setModalError(''); } // Save function validateContent(): string | null { if (!editContent) return null; const c = editContent as any; // Validate Featured Agents (all fields required) if (Array.isArray(c.featuredAgents)) { for (let i = 0; i < c.featuredAgents.length; i++) { const a = c.featuredAgents[i]; if (!a.name?.trim()) return `Featured Agent #${i + 1}: Name is required`; if (!a.role?.trim()) return `Featured Agent #${i + 1}: Role is required`; if (!a.rating?.toString().trim()) return `Featured Agent #${i + 1}: Rating is required`; if (!a.location?.trim()) return `Featured Agent #${i + 1}: Location is required`; if (!a.experience?.trim()) return `Featured Agent #${i + 1}: Experience is required`; if (!a.imageUrl?.trim()) return `Featured Agent #${i + 1}: Photo is required`; } } // Validate Features if (Array.isArray(c.features)) { for (let i = 0; i < c.features.length; i++) { const f = c.features[i]; if (!f.title?.trim()) return `Feature #${i + 1}: Title is required`; if (!f.description?.trim()) return `Feature #${i + 1}: Description is required`; if (!f.iconPath?.trim()) return `Feature #${i + 1}: Icon is required`; } } // Validate Top Professionals agents (all fields required) if (Array.isArray(c.agents)) { for (let i = 0; i < c.agents.length; i++) { const a = c.agents[i]; if (!a.name?.trim()) return `Agent #${i + 1}: Name is required`; if (!a.subtitle?.trim()) return `Agent #${i + 1}: Subtitle is required`; if (!a.location?.trim()) return `Agent #${i + 1}: Location is required`; if (!a.experience?.trim()) return `Agent #${i + 1}: Experience is required`; if (!Array.isArray(a.expertise) || a.expertise.length === 0 || a.expertise.every((e: string) => !e?.trim())) { return `Agent #${i + 1}: At least one expertise is required`; } if (!a.imageUrl?.trim()) return `Agent #${i + 1}: Photo is required`; } } // Validate Top Professionals lenders (all fields required) if (Array.isArray(c.lenders)) { for (let i = 0; i < c.lenders.length; i++) { const l = c.lenders[i]; if (!l.name?.trim()) return `Lender #${i + 1}: Name is required`; if (!l.subtitle?.trim()) return `Lender #${i + 1}: Subtitle is required`; if (!l.location?.trim()) return `Lender #${i + 1}: Location is required`; if (!l.experience?.trim()) return `Lender #${i + 1}: Experience is required`; if (!Array.isArray(l.expertise) || l.expertise.length === 0 || l.expertise.every((e: string) => !e?.trim())) { return `Lender #${i + 1}: At least one expertise is required`; } if (!l.imageUrl?.trim()) return `Lender #${i + 1}: Photo is required`; } } // Validate testimonials if (Array.isArray(c.testimonials)) { for (let i = 0; i < c.testimonials.length; i++) { const t = c.testimonials[i]; if (!t.author?.trim()) return `Testimonial #${i + 1}: Author is required`; if (!t.content?.trim()) return `Testimonial #${i + 1}: Content is required`; if (!t.title?.trim()) return `Testimonial #${i + 1}: Title is required`; // role is optional } } return null; } async function handleSave() { if (!editingSection || editContent == null) return; // Validate before saving const validationError = validateContent(); if (validationError) { setModalError(validationError); return; } setIsSubmitting(true); setModalError(''); try { await cmsService.upsert({ pageSlug: editingSection.pageSlug, sectionKey: editingSection.sectionKey, content: editContent, isPublished: true, }); closeEditor(); fetchRecords(); setNotification({ type: 'success', message: 'Section saved successfully.' }); } catch (err) { setModalError(getErrorMessage(err)); } finally { setIsSubmitting(false); } } // Generic image upload — returns the S3 key for permanent storage async function handleImageUpload(file: File): Promise { setIsUploadingImage(true); setModalError(''); try { const { uploadUrl, key } = await uploadService.getPresignedUploadUrl(file.name, file.type, 'properties'); await uploadService.uploadFileToS3(uploadUrl, file); return key; } catch (err) { setModalError(getErrorMessage(err)); return ''; } finally { setIsUploadingImage(false); } } // Resolve an S3 key or URL for display — returns a viewable URL async function resolveImageUrl(value: string): Promise { if (!value) return ''; // Already a full URL or local asset path — use directly if (value.startsWith('http') || value.startsWith('/')) return value; // Treat as S3 key — get presigned download URL try { return await uploadService.getPresignedDownloadUrl(value); } catch { return value; } } // --------------------------------------------------------------------------- // Section-specific form renderers // --------------------------------------------------------------------------- function renderHeroForm() { const data = editContent as HeroContent; const update = (patch: Partial) => setEditContent({ ...data, ...patch }); return (