1417 lines
72 KiB
TypeScript
1417 lines
72 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState, useCallback, useRef } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import {
|
|
cmsService,
|
|
usersService,
|
|
uploadService,
|
|
getErrorMessage,
|
|
CmsContentRecord,
|
|
HeroContent,
|
|
FeaturesContent,
|
|
FeatureItem,
|
|
FeaturedAgentItem,
|
|
TopProfessionalsContent,
|
|
ProfessionalItem,
|
|
TestimonialsContent,
|
|
TestimonialItem,
|
|
StatItem,
|
|
AboutHeroContent,
|
|
AboutStatsContent,
|
|
AboutStatItem,
|
|
AboutFeaturesContent,
|
|
AboutFeatureItem,
|
|
AboutTeamContent,
|
|
AboutTeamMember,
|
|
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: '', members: [] } 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;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Page component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export default function CmsPage() {
|
|
const router = useRouter();
|
|
const [activePage, setActivePage] = useState<'landing' | 'about' | 'faq' | 'contact'>('landing');
|
|
const [records, setRecords] = useState<CmsContentRecord[]>([]);
|
|
const [aboutRecords, setAboutRecords] = useState<CmsContentRecord[]>([]);
|
|
const [faqRecords, setFaqRecords] = useState<CmsContentRecord[]>([]);
|
|
const [contactRecords, setContactRecords] = useState<CmsContentRecord[]>([]);
|
|
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<SectionMeta | null>(null);
|
|
const [editContent, setEditContent] = useState<unknown>(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<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
|
|
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 display
|
|
const usersWithAvatars = await Promise.all(
|
|
res.users.map(async (user) => {
|
|
if (user.profile?.avatar && !user.profile.avatar.startsWith('http')) {
|
|
try {
|
|
const avatarUrl = await uploadService.getPresignedDownloadUrl(user.profile.avatar);
|
|
return { ...user, profile: { ...user.profile, avatar: avatarUrl } };
|
|
} catch {
|
|
return user;
|
|
}
|
|
}
|
|
return user;
|
|
})
|
|
);
|
|
setAgentSearchResults(usersWithAvatars);
|
|
} catch {
|
|
setAgentSearchResults([]);
|
|
} finally {
|
|
setIsSearchingAgents(false);
|
|
}
|
|
}, 300);
|
|
}
|
|
|
|
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 location = [profile?.city, profile?.state].filter(Boolean).join(', ');
|
|
const experience = agent?.yearsOfExperience ? `${agent.yearsOfExperience}+ years in real estate.` : '';
|
|
const imageUrl = profile?.avatar || '';
|
|
const isVerified = agent?.isVerified || false;
|
|
|
|
const newItem: ProfessionalItem = { name, subtitle, location, experience, expertise: [], imageUrl };
|
|
onChange([...currentItems, newItem]);
|
|
setAgentSearchQuery('');
|
|
setAgentSearchResults([]);
|
|
setShowAgentDropdown(false);
|
|
}
|
|
|
|
// Select agent for Featured Agent Cards (different format)
|
|
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 location = [profile?.city, profile?.state].filter(Boolean).join(', ');
|
|
const experience = agent?.yearsOfExperience ? `${agent.yearsOfExperience}+ years in real estate.` : '';
|
|
const rating = '';
|
|
const imageUrl = profile?.avatar || '';
|
|
|
|
const newAgent: FeaturedAgentItem = { 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
|
|
async function handleSave() {
|
|
if (!editingSection || editContent == null) 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<string> {
|
|
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<string> {
|
|
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<HeroContent>) => setEditContent({ ...data, ...patch });
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#00293d] mb-1">Headline</label>
|
|
<textarea rows={2} className={textareaCls} value={data.headline} onChange={(e) => update({ headline: e.target.value })} placeholder="Main headline text" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#00293d] mb-1">Description</label>
|
|
<textarea rows={3} className={textareaCls} value={data.description} onChange={(e) => update({ description: e.target.value })} placeholder="Sub-heading description" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#00293d] mb-1">CTA Button Text</label>
|
|
<input type="text" className={inputCls} value={data.ctaButtonText} onChange={(e) => update({ ctaButtonText: e.target.value })} placeholder="e.g. Get Started" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#00293d] mb-1">Helper Text</label>
|
|
<textarea rows={2} className={textareaCls} value={data.helperText} onChange={(e) => update({ helperText: e.target.value })} placeholder="Small text below CTA" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function renderFeaturesForm() {
|
|
const data = editContent as FeaturesContent;
|
|
const update = (patch: Partial<FeaturesContent>) => setEditContent({ ...data, ...patch });
|
|
|
|
function updateFeature(index: number, patch: Partial<FeatureItem>) {
|
|
const features = [...data.features];
|
|
features[index] = { ...features[index], ...patch };
|
|
update({ features });
|
|
}
|
|
function removeFeature(index: number) {
|
|
update({ features: data.features.filter((_, i) => i !== index) });
|
|
}
|
|
function addFeature() {
|
|
update({ features: [...data.features, emptyFeatureItem()] });
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#00293d] mb-1">Title</label>
|
|
<input type="text" className={inputCls} value={data.title} onChange={(e) => update({ title: e.target.value })} placeholder="Section title" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#00293d] mb-1">Subtitle</label>
|
|
<input type="text" className={inputCls} value={data.subtitle} onChange={(e) => update({ subtitle: e.target.value })} placeholder="Section subtitle" />
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="border-b border-[#e5e7eb]">
|
|
<nav className="flex -mb-px">
|
|
<button
|
|
type="button"
|
|
onClick={() => setFeaturesTab('features')}
|
|
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${featuresTab === 'features' ? 'border-[#f5a623] text-[#f5a623]' : 'border-transparent text-[#666666] hover:text-[#00293d]'}`}
|
|
>
|
|
Features ({data.features.length})
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setFeaturesTab('agents')}
|
|
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${featuresTab === 'agents' ? 'border-[#f5a623] text-[#f5a623]' : 'border-transparent text-[#666666] hover:text-[#00293d]'}`}
|
|
>
|
|
Featured Agents ({(data.featuredAgents || []).length})
|
|
</button>
|
|
</nav>
|
|
</div>
|
|
|
|
{/* Features Tab */}
|
|
{featuresTab === 'features' && (
|
|
<div>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<label className="block text-sm font-medium text-[#00293d]">Features ({data.features.length})</label>
|
|
<button type="button" onClick={addFeature} className="px-3 py-1 text-xs bg-[#f5a623] hover:bg-[#e09620] text-white rounded-lg transition-colors">+ Add Feature</button>
|
|
</div>
|
|
{data.features.length === 0 && <p className="text-sm text-[#666666] italic">No features added yet.</p>}
|
|
<div className="space-y-3">
|
|
{data.features.map((feat, idx) => (
|
|
<div key={idx} className="p-3 border border-[#e5e7eb] rounded-lg bg-[#f9fafb] space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs font-semibold text-[#666666]">Feature {idx + 1}</span>
|
|
<button type="button" onClick={() => removeFeature(idx)} className="text-red-500 hover:text-red-700 text-xs font-medium">Remove</button>
|
|
</div>
|
|
{renderImageUploadField('Icon', feat.iconPath, (url) => updateFeature(idx, { iconPath: url }))}
|
|
<input type="text" className={inputCls} value={feat.title} onChange={(e) => updateFeature(idx, { title: e.target.value })} placeholder="Feature title" />
|
|
<textarea rows={2} className={textareaCls} value={feat.description} onChange={(e) => updateFeature(idx, { description: e.target.value })} placeholder="Feature description" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Featured Agents Tab */}
|
|
{featuresTab === 'agents' && (
|
|
<div>
|
|
{/* Search agents from database */}
|
|
{(data.featuredAgents || []).length < 3 && (
|
|
<div ref={agentSearchRef} className="relative mb-4">
|
|
<label className="block text-sm font-medium text-[#00293d] mb-1">Search verified 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 verified agents found</div>
|
|
) : (
|
|
agentSearchResults.map((user) => (
|
|
<button
|
|
key={user.id}
|
|
type="button"
|
|
onClick={() => selectFeaturedAgent(user)}
|
|
className="w-full px-4 py-3 text-left hover:bg-[#f9fafb] border-b border-[#e5e7eb] last:border-b-0"
|
|
>
|
|
<p className="text-sm font-medium text-[#00293d]">
|
|
{user.profile ? `${user.profile.firstName} ${user.profile.lastName}`.trim() : user.email}
|
|
</p>
|
|
<p className="text-xs text-[#666666]">{user.email} {user.agentProfile?.agentType?.name ? `• ${user.agentProfile.agentType.name}` : ''}</p>
|
|
</button>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center justify-between mb-2">
|
|
<label className="block text-sm font-medium text-[#00293d]">Featured Agent Cards ({(data.featuredAgents || []).length}/3)</label>
|
|
{(data.featuredAgents || []).length < 3 && (
|
|
<button type="button" onClick={() => update({ featuredAgents: [...(data.featuredAgents || []), emptyFeaturedAgent()] })} className="px-3 py-1 text-xs bg-[#f5a623] hover:bg-[#e09620] text-white rounded-lg transition-colors">+ Add Agent</button>
|
|
)}
|
|
</div>
|
|
{(data.featuredAgents || []).length >= 3 && <p className="text-xs text-[#666666] mb-2">Maximum of 3 agents reached.</p>}
|
|
<div className="space-y-3">
|
|
{(data.featuredAgents || []).map((agent, idx) => (
|
|
<div key={idx} className="p-3 border border-[#e5e7eb] rounded-lg bg-[#f9fafb] space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs font-semibold text-[#666666]">Agent {idx + 1}</span>
|
|
<button type="button" onClick={() => update({ featuredAgents: (data.featuredAgents || []).filter((_, i) => i !== idx) })} className="text-red-500 hover:text-red-700 text-xs font-medium">Remove</button>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<input type="text" className={inputCls} value={agent.name} onChange={(e) => { const agents = [...(data.featuredAgents || [])]; agents[idx] = { ...agents[idx], name: e.target.value }; update({ featuredAgents: agents }); }} placeholder="Name" />
|
|
<input type="text" className={inputCls} value={agent.role} onChange={(e) => { const agents = [...(data.featuredAgents || [])]; agents[idx] = { ...agents[idx], role: e.target.value }; update({ featuredAgents: agents }); }} placeholder="Role (e.g. Lender)" />
|
|
<input type="text" className={inputCls} value={agent.location} onChange={(e) => { const agents = [...(data.featuredAgents || [])]; agents[idx] = { ...agents[idx], location: e.target.value }; update({ featuredAgents: agents }); }} placeholder="Location" />
|
|
<input type="text" className={inputCls} value={agent.rating} onChange={(e) => { const agents = [...(data.featuredAgents || [])]; agents[idx] = { ...agents[idx], rating: e.target.value }; update({ featuredAgents: agents }); }} placeholder="Rating (e.g. 4.9)" />
|
|
</div>
|
|
<input type="text" className={inputCls} value={agent.experience} onChange={(e) => { const agents = [...(data.featuredAgents || [])]; agents[idx] = { ...agents[idx], experience: e.target.value }; update({ featuredAgents: agents }); }} placeholder="Experience (e.g. 7+ years...)" />
|
|
{renderImageUploadField('Photo', agent.imageUrl, (url) => { const agents = [...(data.featuredAgents || [])]; agents[idx] = { ...agents[idx], imageUrl: url }; update({ featuredAgents: agents }); })}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function renderProfessionalsList(
|
|
items: ProfessionalItem[],
|
|
label: string,
|
|
onChange: (items: ProfessionalItem[]) => void,
|
|
) {
|
|
function updateItem(index: number, patch: Partial<ProfessionalItem>) {
|
|
const updated = [...items];
|
|
updated[index] = { ...updated[index], ...patch };
|
|
onChange(updated);
|
|
}
|
|
function removeItem(index: number) {
|
|
onChange(items.filter((_, i) => i !== index));
|
|
}
|
|
function addItem() {
|
|
if (items.length >= 3) return;
|
|
onChange([...items, emptyProfessionalItem()]);
|
|
}
|
|
|
|
const atMax = items.length >= 3;
|
|
|
|
return (
|
|
<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">
|
|
<label className="block text-sm font-medium text-[#00293d]">{label} ({items.length}/3)</label>
|
|
<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>
|
|
{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>}
|
|
<div className="space-y-3">
|
|
{items.map((item, idx) => (
|
|
<div key={idx} className="p-3 border border-[#e5e7eb] rounded-lg bg-[#f9fafb] space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs font-semibold text-[#666666]">{label.slice(0, -1)} {idx + 1}</span>
|
|
<button type="button" onClick={() => removeItem(idx)} className="text-red-500 hover:text-red-700 text-xs font-medium">Remove</button>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<input type="text" className={inputCls} value={item.name} onChange={(e) => updateItem(idx, { name: e.target.value })} placeholder="Name" />
|
|
<input type="text" className={inputCls} value={item.subtitle} onChange={(e) => updateItem(idx, { subtitle: e.target.value })} placeholder="Subtitle" />
|
|
<input type="text" className={inputCls} value={item.location} onChange={(e) => updateItem(idx, { location: e.target.value })} placeholder="Location" />
|
|
<input type="text" className={inputCls} value={item.experience} onChange={(e) => updateItem(idx, { experience: e.target.value })} placeholder="Experience" />
|
|
</div>
|
|
<input type="text" className={inputCls} value={item.expertise.join(', ')} onChange={(e) => updateItem(idx, { expertise: e.target.value.split(',').map((s) => s.trim()).filter(Boolean) })} placeholder="Expertise (comma-separated)" />
|
|
<input type="text" className={inputCls} value={item.imageUrl} onChange={(e) => updateItem(idx, { imageUrl: e.target.value })} placeholder="Image URL" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function renderTopProfessionalsForm() {
|
|
const data = editContent as TopProfessionalsContent;
|
|
const update = (patch: Partial<TopProfessionalsContent>) => setEditContent({ ...data, ...patch });
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#00293d] mb-1">Title</label>
|
|
<input type="text" className={inputCls} value={data.title} onChange={(e) => update({ title: e.target.value })} placeholder="Section title" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#00293d] mb-1">CTA Text</label>
|
|
<input type="text" className={inputCls} value={data.ctaText} onChange={(e) => update({ ctaText: e.target.value })} placeholder="Call-to-action text" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#00293d] mb-1">CTA Button Text</label>
|
|
<input type="text" className={inputCls} value={data.ctaButtonText} onChange={(e) => update({ ctaButtonText: e.target.value })} placeholder="Button label" />
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="border-b border-[#e5e7eb]">
|
|
<nav className="flex -mb-px">
|
|
<button
|
|
type="button"
|
|
onClick={() => setProfessionalsTab('agents')}
|
|
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${professionalsTab === 'agents' ? 'border-[#f5a623] text-[#f5a623]' : 'border-transparent text-[#666666] hover:text-[#00293d]'}`}
|
|
>
|
|
Agents ({data.agents.length})
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setProfessionalsTab('lenders')}
|
|
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${professionalsTab === 'lenders' ? 'border-[#f5a623] text-[#f5a623]' : 'border-transparent text-[#666666] hover:text-[#00293d]'}`}
|
|
>
|
|
Lenders ({data.lenders.length})
|
|
</button>
|
|
</nav>
|
|
</div>
|
|
|
|
{professionalsTab === 'agents' && renderProfessionalsList(data.agents, 'Agents', (agents) => update({ agents }))}
|
|
{professionalsTab === 'lenders' && renderProfessionalsList(data.lenders, 'Lenders', (lenders) => update({ lenders }))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function renderTestimonialsForm() {
|
|
const data = editContent as TestimonialsContent;
|
|
const update = (patch: Partial<TestimonialsContent>) => setEditContent({ ...data, ...patch });
|
|
|
|
function updateStat(index: number, patch: Partial<StatItem>) {
|
|
const stats = [...data.stats];
|
|
stats[index] = { ...stats[index], ...patch };
|
|
update({ stats });
|
|
}
|
|
function removeStat(index: number) {
|
|
update({ stats: data.stats.filter((_, i) => i !== index) });
|
|
}
|
|
function addStat() {
|
|
update({ stats: [...data.stats, emptyStatItem()] });
|
|
}
|
|
|
|
function updateTestimonial(index: number, patch: Partial<TestimonialItem>) {
|
|
const testimonials = [...data.testimonials];
|
|
testimonials[index] = { ...testimonials[index], ...patch };
|
|
update({ testimonials });
|
|
}
|
|
function removeTestimonial(index: number) {
|
|
update({ testimonials: data.testimonials.filter((_, i) => i !== index) });
|
|
}
|
|
function addTestimonial() {
|
|
update({ testimonials: [...data.testimonials, emptyTestimonialItem()] });
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#00293d] mb-1">Title</label>
|
|
<input type="text" className={inputCls} value={data.title} onChange={(e) => update({ title: e.target.value })} placeholder="Section title" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#00293d] mb-1">Subtitle</label>
|
|
<textarea rows={2} className={textareaCls} value={data.subtitle} onChange={(e) => update({ subtitle: e.target.value })} placeholder="Section subtitle" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#00293d] mb-1">Rating Info</label>
|
|
<input type="text" className={inputCls} value={data.ratingInfo} onChange={(e) => update({ ratingInfo: e.target.value })} placeholder="e.g. 4.9/5 based on 200+ reviews" />
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<label className="block text-sm font-medium text-[#00293d]">Stats ({data.stats.length})</label>
|
|
<button type="button" onClick={addStat} className="px-3 py-1 text-xs bg-[#f5a623] hover:bg-[#e09620] text-white rounded-lg transition-colors">+ Add Stat</button>
|
|
</div>
|
|
{data.stats.length === 0 && <p className="text-sm text-[#666666] italic">No stats added yet.</p>}
|
|
<div className="space-y-3">
|
|
{data.stats.map((stat, idx) => (
|
|
<div key={idx} className="p-3 border border-[#e5e7eb] rounded-lg bg-[#f9fafb] space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs font-semibold text-[#666666]">Stat {idx + 1}</span>
|
|
<button type="button" onClick={() => removeStat(idx)} className="text-red-500 hover:text-red-700 text-xs font-medium">Remove</button>
|
|
</div>
|
|
<input type="text" className={inputCls} value={stat.iconPath} onChange={(e) => updateStat(idx, { iconPath: e.target.value })} placeholder="Icon path" />
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<input type="text" className={inputCls} value={stat.boldText} onChange={(e) => updateStat(idx, { boldText: e.target.value })} placeholder="Bold text (e.g. 500+)" />
|
|
<input type="text" className={inputCls} value={stat.normalText} onChange={(e) => updateStat(idx, { normalText: e.target.value })} placeholder="Normal text (e.g. Properties)" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Testimonials */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<label className="block text-sm font-medium text-[#00293d]">Testimonials ({data.testimonials.length})</label>
|
|
<button type="button" onClick={addTestimonial} className="px-3 py-1 text-xs bg-[#f5a623] hover:bg-[#e09620] text-white rounded-lg transition-colors">+ Add Testimonial</button>
|
|
</div>
|
|
{data.testimonials.length === 0 && <p className="text-sm text-[#666666] italic">No testimonials added yet.</p>}
|
|
<div className="space-y-3">
|
|
{data.testimonials.map((item, idx) => (
|
|
<div key={idx} className="p-3 border border-[#e5e7eb] rounded-lg bg-[#f9fafb] space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs font-semibold text-[#666666]">Testimonial {idx + 1}</span>
|
|
<button type="button" onClick={() => removeTestimonial(idx)} className="text-red-500 hover:text-red-700 text-xs font-medium">Remove</button>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<label className="block text-xs text-[#666666] mb-0.5">Rating</label>
|
|
<input type="number" min={1} max={5} step={0.1} className={inputCls} value={item.rating || ''} onChange={(e) => updateTestimonial(idx, { rating: e.target.value === '' ? 0 : parseFloat(e.target.value) })} />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-[#666666] mb-0.5">Title</label>
|
|
<input type="text" className={inputCls} value={item.title} onChange={(e) => updateTestimonial(idx, { title: e.target.value })} placeholder="Review title" />
|
|
</div>
|
|
</div>
|
|
<textarea rows={2} className={textareaCls} value={item.content} onChange={(e) => updateTestimonial(idx, { content: e.target.value })} placeholder="Testimonial content" />
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<input type="text" className={inputCls} value={item.author} onChange={(e) => updateTestimonial(idx, { author: e.target.value })} placeholder="Author name" />
|
|
<input type="text" className={inputCls} value={item.role} onChange={(e) => updateTestimonial(idx, { role: e.target.value })} placeholder="Author role" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Reusable image upload field with S3 key resolution for preview
|
|
function ImageUploadField({ label, currentUrl, onUrlChange }: { label: string; currentUrl: string; onUrlChange: (url: string) => void }) {
|
|
const [previewUrl, setPreviewUrl] = useState('');
|
|
|
|
useEffect(() => {
|
|
if (!currentUrl) { setPreviewUrl(''); return; }
|
|
// If it's already a full URL or local path, use directly
|
|
if (currentUrl.startsWith('http') || currentUrl.startsWith('/')) {
|
|
setPreviewUrl(currentUrl);
|
|
} else {
|
|
// S3 key — resolve to presigned URL for preview
|
|
resolveImageUrl(currentUrl).then(setPreviewUrl);
|
|
}
|
|
}, [currentUrl]);
|
|
|
|
return (
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#00293d] mb-1">{label}</label>
|
|
{previewUrl && (
|
|
<div className="mb-3 relative rounded-lg overflow-hidden border border-[#e5e7eb]">
|
|
<img src={previewUrl} alt="Preview" className="w-full h-48 object-cover" onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
|
|
<button type="button" onClick={() => onUrlChange('')} className="absolute top-2 right-2 px-2 py-1 bg-red-600 text-white text-xs rounded-lg hover:bg-red-700 transition-colors">Remove</button>
|
|
</div>
|
|
)}
|
|
<div className="flex gap-2 mb-2">
|
|
<label className={`flex-shrink-0 px-4 py-2 text-sm font-medium rounded-lg transition-colors cursor-pointer ${isUploadingImage ? 'bg-gray-300 text-gray-500' : 'bg-[#f5a623] hover:bg-[#e09620] text-white'}`}>
|
|
{isUploadingImage ? 'Uploading...' : 'Upload Image'}
|
|
<input type="file" accept="image/*" className="hidden" disabled={isUploadingImage} onChange={async (e) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) { const url = await handleImageUpload(file); if (url) onUrlChange(url); }
|
|
e.target.value = '';
|
|
}} />
|
|
</label>
|
|
<span className="text-xs text-[#666666] self-center">or enter URL below</span>
|
|
</div>
|
|
<input type="text" className={inputCls} value={currentUrl} onChange={(e) => onUrlChange(e.target.value)} placeholder="S3 key or https://..." />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function renderImageUploadField(label: string, currentUrl: string, onUrlChange: (url: string) => void) {
|
|
return <ImageUploadField label={label} currentUrl={currentUrl} onUrlChange={onUrlChange} />;
|
|
}
|
|
|
|
// --- About Us form renderers ---
|
|
|
|
function renderAboutHeroForm() {
|
|
const data = editContent as AboutHeroContent;
|
|
const update = (patch: Partial<AboutHeroContent>) => setEditContent({ ...data, ...patch });
|
|
return (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#00293d] mb-1">Badge Text</label>
|
|
<input type="text" className={inputCls} value={data.badge} onChange={(e) => update({ badge: e.target.value })} placeholder="e.g. Our Mission" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#00293d] mb-1">Headline</label>
|
|
<textarea rows={2} className={textareaCls} value={data.headline} onChange={(e) => update({ headline: e.target.value })} placeholder="Main headline" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#00293d] mb-1">Description</label>
|
|
<textarea rows={3} className={textareaCls} value={data.description} onChange={(e) => update({ description: e.target.value })} placeholder="Sub-heading description" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#00293d] mb-1">CTA Button Text</label>
|
|
<input type="text" className={inputCls} value={data.ctaButtonText} onChange={(e) => update({ ctaButtonText: e.target.value })} placeholder="e.g. Find Your Agent" />
|
|
</div>
|
|
{renderImageUploadField('Banner Image', data.bannerImageUrl, (url) => update({ bannerImageUrl: url }))}
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#00293d] mb-1">Banner Overlay Text</label>
|
|
<input type="text" className={inputCls} value={data.bannerOverlayText} onChange={(e) => update({ bannerOverlayText: e.target.value })} placeholder="e.g. Building the future of property." />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function renderAboutStatsForm() {
|
|
const data = editContent as AboutStatsContent;
|
|
const update = (patch: Partial<AboutStatsContent>) => setEditContent({ ...data, ...patch });
|
|
function updateStat(index: number, patch: Partial<AboutStatItem>) {
|
|
const stats = [...data.stats];
|
|
stats[index] = { ...stats[index], ...patch };
|
|
update({ stats });
|
|
}
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<label className="block text-sm font-medium text-[#00293d]">Stats ({data.stats.length})</label>
|
|
<button type="button" onClick={() => update({ stats: [...data.stats, { value: '', label: '' }] })} className="px-3 py-1 text-xs bg-[#f5a623] hover:bg-[#e09620] text-white rounded-lg transition-colors">+ Add Stat</button>
|
|
</div>
|
|
{data.stats.length === 0 && <p className="text-sm text-[#666666] italic">No stats added yet.</p>}
|
|
<div className="space-y-3">
|
|
{data.stats.map((stat, idx) => (
|
|
<div key={idx} className="p-3 border border-[#e5e7eb] rounded-lg bg-[#f9fafb] space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs font-semibold text-[#666666]">Stat {idx + 1}</span>
|
|
<button type="button" onClick={() => update({ stats: data.stats.filter((_, i) => i !== idx) })} className="text-red-500 hover:text-red-700 text-xs font-medium">Remove</button>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<input type="text" className={inputCls} value={stat.value} onChange={(e) => updateStat(idx, { value: e.target.value })} placeholder="Value (e.g. 15k+)" />
|
|
<input type="text" className={inputCls} value={stat.label} onChange={(e) => updateStat(idx, { label: e.target.value })} placeholder="Label (e.g. Verified Agents)" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function renderAboutFeaturesForm() {
|
|
const data = editContent as AboutFeaturesContent;
|
|
const update = (patch: Partial<AboutFeaturesContent>) => setEditContent({ ...data, ...patch });
|
|
function updateFeature(index: number, patch: Partial<AboutFeatureItem>) {
|
|
const features = [...data.features];
|
|
features[index] = { ...features[index], ...patch };
|
|
update({ features });
|
|
}
|
|
return (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#00293d] mb-1">Section Badge</label>
|
|
<input type="text" className={inputCls} value={data.badge} onChange={(e) => update({ badge: e.target.value })} placeholder="e.g. Why Choose Us" />
|
|
</div>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<label className="block text-sm font-medium text-[#00293d]">Features ({data.features.length})</label>
|
|
<button type="button" onClick={() => update({ features: [...data.features, { iconPath: '', title: '', description: '' }] })} className="px-3 py-1 text-xs bg-[#f5a623] hover:bg-[#e09620] text-white rounded-lg transition-colors">+ Add Feature</button>
|
|
</div>
|
|
{data.features.length === 0 && <p className="text-sm text-[#666666] italic">No features added yet.</p>}
|
|
<div className="space-y-3">
|
|
{data.features.map((feat, idx) => (
|
|
<div key={idx} className="p-3 border border-[#e5e7eb] rounded-lg bg-[#f9fafb] space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs font-semibold text-[#666666]">Feature {idx + 1}</span>
|
|
<button type="button" onClick={() => update({ features: data.features.filter((_, i) => i !== idx) })} className="text-red-500 hover:text-red-700 text-xs font-medium">Remove</button>
|
|
</div>
|
|
{renderImageUploadField('Icon', feat.iconPath, (url) => updateFeature(idx, { iconPath: url }))}
|
|
<input type="text" className={inputCls} value={feat.title} onChange={(e) => updateFeature(idx, { title: e.target.value })} placeholder="Feature title" />
|
|
<textarea rows={2} className={textareaCls} value={feat.description} onChange={(e) => updateFeature(idx, { description: e.target.value })} placeholder="Feature description" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function renderAboutTeamForm() {
|
|
const data = editContent as AboutTeamContent;
|
|
const update = (patch: Partial<AboutTeamContent>) => setEditContent({ ...data, ...patch });
|
|
function updateMember(index: number, patch: Partial<AboutTeamMember>) {
|
|
const members = [...data.members];
|
|
members[index] = { ...members[index], ...patch };
|
|
update({ members });
|
|
}
|
|
return (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#00293d] mb-1">Section Title</label>
|
|
<input type="text" className={inputCls} value={data.title} onChange={(e) => update({ title: e.target.value })} placeholder="e.g. Meet the minds behind the platform." />
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#00293d] mb-1">Section Subtitle</label>
|
|
<textarea rows={2} className={textareaCls} value={data.subtitle} onChange={(e) => update({ subtitle: e.target.value })} placeholder="Section description" />
|
|
</div>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<label className="block text-sm font-medium text-[#00293d]">Team Members ({data.members.length})</label>
|
|
<button type="button" onClick={() => update({ members: [...data.members, { name: '', role: '', imageUrl: '' }] })} className="px-3 py-1 text-xs bg-[#f5a623] hover:bg-[#e09620] text-white rounded-lg transition-colors">+ Add Member</button>
|
|
</div>
|
|
{data.members.length === 0 && <p className="text-sm text-[#666666] italic">No team members added yet.</p>}
|
|
<div className="space-y-3">
|
|
{data.members.map((member, idx) => (
|
|
<div key={idx} className="p-3 border border-[#e5e7eb] rounded-lg bg-[#f9fafb] space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs font-semibold text-[#666666]">Member {idx + 1}</span>
|
|
<button type="button" onClick={() => update({ members: data.members.filter((_, i) => i !== idx) })} className="text-red-500 hover:text-red-700 text-xs font-medium">Remove</button>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<input type="text" className={inputCls} value={member.name} onChange={(e) => updateMember(idx, { name: e.target.value })} placeholder="Name" />
|
|
<input type="text" className={inputCls} value={member.role} onChange={(e) => updateMember(idx, { role: e.target.value })} placeholder="Role" />
|
|
</div>
|
|
{renderImageUploadField(`Photo`, member.imageUrl, (url) => updateMember(idx, { imageUrl: url }))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function renderAboutCtaForm() {
|
|
const data = editContent as AboutCtaContent;
|
|
const update = (patch: Partial<AboutCtaContent>) => setEditContent({ ...data, ...patch });
|
|
return (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#00293d] mb-1">Title</label>
|
|
<input type="text" className={inputCls} value={data.title} onChange={(e) => update({ title: e.target.value })} placeholder="e.g. Ready to find an agent?" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#00293d] mb-1">Description</label>
|
|
<textarea rows={2} className={textareaCls} value={data.description} onChange={(e) => update({ description: e.target.value })} placeholder="CTA description" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#00293d] mb-1">Button Text</label>
|
|
<input type="text" className={inputCls} value={data.buttonText} onChange={(e) => update({ buttonText: e.target.value })} placeholder="e.g. Start Your Search" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function renderFaqForm() {
|
|
const data = editContent as FaqContent;
|
|
const update = (patch: Partial<FaqContent>) => setEditContent({ ...data, ...patch });
|
|
|
|
function addCategory() {
|
|
const newCat: FaqCategoryItem = { id: '', label: '' };
|
|
update({ categories: [...data.categories, newCat] });
|
|
}
|
|
function updateCategory(index: number, patch: Partial<FaqCategoryItem>) {
|
|
const categories = [...data.categories];
|
|
categories[index] = { ...categories[index], ...patch };
|
|
update({ categories });
|
|
}
|
|
function removeCategory(index: number) {
|
|
update({ categories: data.categories.filter((_, i) => i !== index) });
|
|
}
|
|
|
|
function addFaq() {
|
|
const newFaq: FaqItem = { id: Date.now(), icon: '', question: '', answer: '', category: '' };
|
|
update({ faqs: [...data.faqs, newFaq] });
|
|
}
|
|
function updateFaq(index: number, patch: Partial<FaqItem>) {
|
|
const faqs = [...data.faqs];
|
|
faqs[index] = { ...faqs[index], ...patch };
|
|
update({ faqs });
|
|
}
|
|
function removeFaq(index: number) {
|
|
update({ faqs: data.faqs.filter((_, i) => i !== index) });
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Page Title */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#00293d] mb-1">Page Title</label>
|
|
<input type="text" className={inputCls} value={data.pageTitle} onChange={(e) => update({ pageTitle: e.target.value })} placeholder="e.g. Frequently Asked Questions ?" />
|
|
</div>
|
|
|
|
{/* Support Title */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#00293d] mb-1">Support Title</label>
|
|
<input type="text" className={inputCls} value={data.supportTitle} onChange={(e) => update({ supportTitle: e.target.value })} placeholder="e.g. Still Need Help ?" />
|
|
</div>
|
|
|
|
{/* Support Description */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#00293d] mb-1">Support Description</label>
|
|
<textarea rows={3} className={textareaCls} value={data.supportDescription} onChange={(e) => update({ supportDescription: e.target.value })} placeholder="e.g. Our Support Team Is Available Mon-Fri, 9am-6pm IST" />
|
|
</div>
|
|
|
|
{/* Categories */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<label className="block text-sm font-medium text-[#00293d]">Categories ({data.categories.length})</label>
|
|
<button type="button" onClick={addCategory} className="px-3 py-1 text-xs bg-[#f5a623] hover:bg-[#e09620] text-white rounded-lg transition-colors">+ Add Category</button>
|
|
</div>
|
|
{data.categories.length === 0 && <p className="text-sm text-[#666666] italic">No categories added yet.</p>}
|
|
<div className="space-y-3">
|
|
{data.categories.map((cat, idx) => (
|
|
<div key={idx} className="p-3 border border-[#e5e7eb] rounded-lg bg-[#f9fafb] space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs font-semibold text-[#666666]">Category {idx + 1}</span>
|
|
<button type="button" onClick={() => removeCategory(idx)} className="text-red-500 hover:text-red-700 text-xs font-medium">Remove</button>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<input type="text" className={inputCls} value={cat.id} onChange={(e) => updateCategory(idx, { id: e.target.value })} placeholder="ID (e.g. buying)" />
|
|
<input type="text" className={inputCls} value={cat.label} onChange={(e) => updateCategory(idx, { label: e.target.value })} placeholder="Label (e.g. Buying a Home)" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* FAQ Items */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<label className="block text-sm font-medium text-[#00293d]">FAQ Items ({data.faqs.length})</label>
|
|
<button type="button" onClick={addFaq} className="px-3 py-1 text-xs bg-[#f5a623] hover:bg-[#e09620] text-white rounded-lg transition-colors">+ Add FAQ</button>
|
|
</div>
|
|
{data.faqs.length === 0 && <p className="text-sm text-[#666666] italic">No FAQ items added yet.</p>}
|
|
<div className="space-y-3">
|
|
{data.faqs.map((faq, idx) => (
|
|
<div key={idx} className="p-3 border border-[#e5e7eb] rounded-lg bg-[#f9fafb] space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs font-semibold text-[#666666]">FAQ {idx + 1}</span>
|
|
<button type="button" onClick={() => removeFaq(idx)} className="text-red-500 hover:text-red-700 text-xs font-medium">Remove</button>
|
|
</div>
|
|
{renderImageUploadField('Icon', faq.icon, (url) => updateFaq(idx, { icon: url }))}
|
|
<div>
|
|
<label className="block text-xs text-[#666666] mb-0.5">Question</label>
|
|
<input type="text" className={inputCls} value={faq.question} onChange={(e) => updateFaq(idx, { question: e.target.value })} placeholder="Enter the question" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-[#666666] mb-0.5">Answer</label>
|
|
<textarea rows={3} className={textareaCls} value={faq.answer} onChange={(e) => updateFaq(idx, { answer: e.target.value })} placeholder="Enter the answer" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-[#666666] mb-0.5">Category</label>
|
|
<select
|
|
className={inputCls}
|
|
value={faq.category}
|
|
onChange={(e) => updateFaq(idx, { category: e.target.value })}
|
|
>
|
|
<option value="">Select a category...</option>
|
|
{data.categories.map((cat) => (
|
|
<option key={cat.id} value={cat.id}>{cat.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Contact page editors
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function renderContactDetailsForm() {
|
|
const data = editContent as Record<string, string>;
|
|
const update = (patch: Record<string, string>) => setEditContent({ ...data, ...patch });
|
|
return (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#00293d] mb-1">Page Title</label>
|
|
<input className={inputCls} value={data.title || ''} onChange={e => update({ title: e.target.value })} placeholder="Get In Touch" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#00293d] mb-1">Description</label>
|
|
<textarea className={textareaCls} rows={2} value={data.description || ''} onChange={e => update({ description: e.target.value })} placeholder="Page description" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#00293d] mb-1">Support Email</label>
|
|
<input className={inputCls} value={data.email || ''} onChange={e => update({ email: e.target.value })} placeholder="support@example.com" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#00293d] mb-1">Phone Number</label>
|
|
<input className={inputCls} value={data.phone || ''} onChange={e => update({ phone: e.target.value })} placeholder="1234567890" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#00293d] mb-1">Phone Hours</label>
|
|
<input className={inputCls} value={data.phoneHours || ''} onChange={e => update({ phoneHours: e.target.value })} placeholder="Mon-Fri 9am-6pm" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#00293d] mb-1">Office Address (Line 1)</label>
|
|
<input className={inputCls} value={data.officeAddress || ''} onChange={e => update({ officeAddress: e.target.value })} placeholder="123 Market Street" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#00293d] mb-1">Office Address (Line 2)</label>
|
|
<input className={inputCls} value={data.officeCity || ''} onChange={e => update({ officeCity: e.target.value })} placeholder="New York CA 234737" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#00293d] mb-1">Google Maps Embed URL (optional)</label>
|
|
<input className={inputCls} value={data.mapUrl || ''} onChange={e => update({ mapUrl: e.target.value })} placeholder="https://maps.google.com/..." />
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#00293d] mb-1">Directions URL (optional)</label>
|
|
<input className={inputCls} value={data.directionsUrl || ''} onChange={e => update({ directionsUrl: e.target.value })} placeholder="https://maps.google.com/..." />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function renderContactCtaForm() {
|
|
const data = editContent as Record<string, string>;
|
|
const update = (patch: Record<string, string>) => setEditContent({ ...data, ...patch });
|
|
return (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#00293d] mb-1">Title</label>
|
|
<input className={inputCls} value={data.title || ''} onChange={e => update({ title: e.target.value })} placeholder="Ready to find an agent?" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#00293d] mb-1">Description</label>
|
|
<textarea className={textareaCls} rows={2} value={data.description || ''} onChange={e => update({ description: e.target.value })} placeholder="CTA description" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#00293d] mb-1">Button Text</label>
|
|
<input className={inputCls} value={data.buttonText || ''} onChange={e => update({ buttonText: e.target.value })} placeholder="Start Your Search" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#00293d] mb-1">Button Link</label>
|
|
<input className={inputCls} value={data.buttonLink || ''} onChange={e => update({ buttonLink: e.target.value })} placeholder="/user/profiles" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function renderModalContent() {
|
|
if (!editingSection || editContent == null) return null;
|
|
const key = editingSection.sectionKey;
|
|
const page = editingSection.pageSlug;
|
|
if (page === 'about') {
|
|
switch (key) {
|
|
case 'hero': return renderAboutHeroForm();
|
|
case 'stats': return renderAboutStatsForm();
|
|
case 'features': return renderAboutFeaturesForm();
|
|
case 'team': return renderAboutTeamForm();
|
|
case 'cta': return renderAboutCtaForm();
|
|
}
|
|
}
|
|
if (page === 'faq') {
|
|
switch (key) {
|
|
case 'faqContent': return renderFaqForm();
|
|
}
|
|
}
|
|
if (page === 'contact') {
|
|
switch (key) {
|
|
case 'contactDetails': return renderContactDetailsForm();
|
|
case 'cta': return renderContactCtaForm();
|
|
}
|
|
}
|
|
switch (key) {
|
|
case 'hero': return renderHeroForm();
|
|
case 'features': return renderFeaturesForm();
|
|
case 'topProfessionals': return renderTopProfessionalsForm();
|
|
case 'testimonials': return renderTestimonialsForm();
|
|
default: return <p className="text-[#666666]">Unknown section type.</p>;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Render
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const currentSections = activePage === 'landing' ? LANDING_SECTIONS : activePage === 'about' ? ABOUT_SECTIONS : activePage === 'contact' ? CONTACT_SECTIONS : FAQ_SECTIONS;
|
|
const currentRecords = activePage === 'landing' ? records : activePage === 'about' ? aboutRecords : activePage === 'contact' ? contactRecords : faqRecords;
|
|
|
|
return (
|
|
<div>
|
|
{/* Page header */}
|
|
<div className="mb-6">
|
|
<h1 className="text-2xl font-bold text-[#00293d] font-fractul">Content Management</h1>
|
|
<p className="text-[#666666] font-serif">Manage website page content</p>
|
|
</div>
|
|
|
|
{/* Page tabs */}
|
|
<div className="border-b border-[#e5e7eb] mb-6">
|
|
<nav className="flex -mb-px">
|
|
<button
|
|
type="button"
|
|
onClick={() => setActivePage('landing')}
|
|
className={`px-5 py-3 text-sm font-medium border-b-2 transition-colors ${activePage === 'landing' ? 'border-[#f5a623] text-[#f5a623]' : 'border-transparent text-[#666666] hover:text-[#00293d]'}`}
|
|
>
|
|
Landing Page
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setActivePage('about')}
|
|
className={`px-5 py-3 text-sm font-medium border-b-2 transition-colors ${activePage === 'about' ? 'border-[#f5a623] text-[#f5a623]' : 'border-transparent text-[#666666] hover:text-[#00293d]'}`}
|
|
>
|
|
About Us
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setActivePage('faq')}
|
|
className={`px-5 py-3 text-sm font-medium border-b-2 transition-colors ${activePage === 'faq' ? 'border-[#f5a623] text-[#f5a623]' : 'border-transparent text-[#666666] hover:text-[#00293d]'}`}
|
|
>
|
|
FAQ
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setActivePage('contact')}
|
|
className={`px-5 py-3 text-sm font-medium border-b-2 transition-colors ${activePage === 'contact' ? 'border-[#f5a623] text-[#f5a623]' : 'border-transparent text-[#666666] hover:text-[#00293d]'}`}
|
|
>
|
|
Contact
|
|
</button>
|
|
</nav>
|
|
</div>
|
|
|
|
{/* Notification toast */}
|
|
{notification && (
|
|
<div className={`mb-4 p-3 rounded-lg border text-sm ${notification.type === 'success' ? 'bg-green-50 border-green-200 text-green-700' : 'bg-red-50 border-red-200 text-red-700'}`}>
|
|
{notification.message}
|
|
</div>
|
|
)}
|
|
|
|
{/* Error */}
|
|
{error && (
|
|
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg">
|
|
<p className="text-red-700 text-sm">{error}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Loading */}
|
|
{isLoading ? (
|
|
<div className="flex items-center justify-center py-16">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#f5a623]"></div>
|
|
</div>
|
|
) : (
|
|
/* Section cards grid */
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
{currentSections.map((section) => {
|
|
const record = recordFor(section.pageSlug, section.sectionKey);
|
|
const hasContent = !!record;
|
|
const isPublished = record?.isPublished ?? false;
|
|
|
|
return (
|
|
<div key={`${section.pageSlug}-${section.sectionKey}`} className="bg-white rounded-xl shadow-sm border border-[#e5e7eb]">
|
|
<div className="px-6 py-4 border-b border-[#e5e7eb] flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-[#00293d]">{section.label}</h2>
|
|
<p className="text-sm text-[#666666] font-serif">{section.description}</p>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
{hasContent ? (
|
|
isPublished ? (
|
|
<span className="px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800">Published</span>
|
|
) : (
|
|
<span className="px-2 py-1 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800">Draft</span>
|
|
)
|
|
) : (
|
|
<span className="px-2 py-1 text-xs font-semibold rounded-full bg-gray-100 text-gray-800">No Content</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="px-6 py-4">
|
|
{hasContent ? (
|
|
<p className="text-sm text-[#666666]">
|
|
Last updated: {new Date(record.updatedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
|
</p>
|
|
) : (
|
|
<p className="text-sm text-[#666666] italic">No content configured yet. Click edit to add content.</p>
|
|
)}
|
|
<div className="mt-4 flex justify-end">
|
|
<button
|
|
onClick={() => openEditor(section)}
|
|
className="px-4 py-2 bg-[#f5a623] hover:bg-[#e09620] text-white text-sm font-medium rounded-lg transition-colors inline-flex items-center"
|
|
>
|
|
<svg className="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
|
</svg>
|
|
Edit
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Edit modal */}
|
|
{editingSection && (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
<div className="bg-white rounded-xl shadow-xl w-full max-w-2xl mx-4 max-h-[90vh] flex flex-col">
|
|
{/* Modal header */}
|
|
<div className="px-6 py-4 border-b border-[#e5e7eb] flex-shrink-0">
|
|
<h3 className="text-lg font-semibold text-[#00293d]">
|
|
Edit {editingSection.label}
|
|
</h3>
|
|
<p className="text-sm text-[#666666]">{editingSection.description}</p>
|
|
</div>
|
|
|
|
{/* Modal body - scrollable */}
|
|
<div className="px-6 py-4 overflow-y-auto flex-1">
|
|
{modalError && (
|
|
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg">
|
|
<p className="text-red-700 text-sm">{modalError}</p>
|
|
</div>
|
|
)}
|
|
{renderModalContent()}
|
|
</div>
|
|
|
|
{/* Modal footer */}
|
|
<div className="px-6 py-4 border-t border-[#e5e7eb] flex justify-end space-x-3 flex-shrink-0">
|
|
<button
|
|
type="button"
|
|
onClick={closeEditor}
|
|
className="px-4 py-2 border border-[#e5e7eb] text-[#00293d] rounded-xl hover:bg-[#f5f9f8] transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleSave}
|
|
disabled={isSubmitting}
|
|
className="px-4 py-2 bg-[#f5a623] text-white rounded-xl hover:bg-[#e09620] transition-colors disabled:opacity-50"
|
|
>
|
|
{isSubmitting ? 'Saving...' : 'Save Changes'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|