|
|
|
|
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
|
|
|
|
|
import {
|
|
|
|
|
cmsService,
|
|
|
|
|
usersService,
|
|
|
|
|
uploadService,
|
|
|
|
|
getErrorMessage,
|
|
|
|
|
CmsContentRecord,
|
|
|
|
|
HeroContent,
|
|
|
|
|
@@ -15,6 +16,14 @@ import {
|
|
|
|
|
TestimonialsContent,
|
|
|
|
|
TestimonialItem,
|
|
|
|
|
StatItem,
|
|
|
|
|
AboutHeroContent,
|
|
|
|
|
AboutStatsContent,
|
|
|
|
|
AboutStatItem,
|
|
|
|
|
AboutFeaturesContent,
|
|
|
|
|
AboutFeatureItem,
|
|
|
|
|
AboutTeamContent,
|
|
|
|
|
AboutTeamMember,
|
|
|
|
|
AboutCtaContent,
|
|
|
|
|
User,
|
|
|
|
|
} from '@/services';
|
|
|
|
|
|
|
|
|
|
@@ -23,16 +32,25 @@ import {
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
interface SectionMeta {
|
|
|
|
|
pageSlug: string;
|
|
|
|
|
sectionKey: string;
|
|
|
|
|
label: string;
|
|
|
|
|
description: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const SECTIONS: SectionMeta[] = [
|
|
|
|
|
{ sectionKey: 'hero', label: 'Hero', description: 'Main headline, description and call-to-action' },
|
|
|
|
|
{ sectionKey: 'features', label: 'Features', description: 'Feature highlights with icons' },
|
|
|
|
|
{ sectionKey: 'topProfessionals', label: 'Top Professionals', description: 'Agents and lenders showcase' },
|
|
|
|
|
{ sectionKey: 'testimonials', label: 'Testimonials', description: 'Reviews, stats and social proof' },
|
|
|
|
|
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' },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
@@ -55,7 +73,16 @@ function defaultTestimonials(): TestimonialsContent {
|
|
|
|
|
return { title: '', subtitle: '', ratingInfo: '', stats: [], testimonials: [] };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function defaultContentFor(sectionKey: string): unknown {
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
switch (sectionKey) {
|
|
|
|
|
case 'hero': return defaultHero();
|
|
|
|
|
case 'features': return defaultFeatures();
|
|
|
|
|
@@ -93,17 +120,20 @@ const textareaCls = inputCls;
|
|
|
|
|
|
|
|
|
|
export default function CmsPage() {
|
|
|
|
|
const router = useRouter();
|
|
|
|
|
const [activePage, setActivePage] = useState<'landing' | 'about'>('landing');
|
|
|
|
|
const [records, setRecords] = useState<CmsContentRecord[]>([]);
|
|
|
|
|
const [aboutRecords, setAboutRecords] = 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<string | null>(null);
|
|
|
|
|
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 [isUploadingImage, setIsUploadingImage] = useState(false);
|
|
|
|
|
|
|
|
|
|
// Agent search
|
|
|
|
|
const [agentSearchQuery, setAgentSearchQuery] = useState('');
|
|
|
|
|
@@ -113,13 +143,17 @@ export default function CmsPage() {
|
|
|
|
|
const agentSearchTimeout = useRef<NodeJS.Timeout | null>(null);
|
|
|
|
|
const agentSearchRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
|
|
|
|
// Fetch all CMS records for "landing" page
|
|
|
|
|
// Fetch all CMS records
|
|
|
|
|
const fetchRecords = useCallback(async () => {
|
|
|
|
|
setIsLoading(true);
|
|
|
|
|
setError('');
|
|
|
|
|
try {
|
|
|
|
|
const data = await cmsService.getByPage('landing');
|
|
|
|
|
setRecords(data);
|
|
|
|
|
const [landingData, aboutData] = await Promise.all([
|
|
|
|
|
cmsService.getByPage('landing'),
|
|
|
|
|
cmsService.getByPage('about'),
|
|
|
|
|
]);
|
|
|
|
|
setRecords(landingData);
|
|
|
|
|
setAboutRecords(aboutData);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
const msg = getErrorMessage(err);
|
|
|
|
|
setError(msg);
|
|
|
|
|
@@ -195,20 +229,21 @@ export default function CmsPage() {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Helpers
|
|
|
|
|
function recordFor(sectionKey: string): CmsContentRecord | undefined {
|
|
|
|
|
return records.find((r) => r.sectionKey === sectionKey);
|
|
|
|
|
function recordFor(pageSlug: string, sectionKey: string): CmsContentRecord | undefined {
|
|
|
|
|
const source = pageSlug === 'about' ? aboutRecords : records;
|
|
|
|
|
return source.find((r) => r.sectionKey === sectionKey);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Open modal
|
|
|
|
|
function openEditor(sectionKey: string) {
|
|
|
|
|
const existing = recordFor(sectionKey);
|
|
|
|
|
setEditContent(existing ? JSON.parse(JSON.stringify(existing.content)) : defaultContentFor(sectionKey));
|
|
|
|
|
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(sectionKey);
|
|
|
|
|
setEditingSection(section);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function closeEditor() {
|
|
|
|
|
@@ -224,8 +259,8 @@ export default function CmsPage() {
|
|
|
|
|
setModalError('');
|
|
|
|
|
try {
|
|
|
|
|
await cmsService.upsert({
|
|
|
|
|
pageSlug: 'landing',
|
|
|
|
|
sectionKey: editingSection,
|
|
|
|
|
pageSlug: editingSection.pageSlug,
|
|
|
|
|
sectionKey: editingSection.sectionKey,
|
|
|
|
|
content: editContent,
|
|
|
|
|
isPublished: true,
|
|
|
|
|
});
|
|
|
|
|
@@ -239,6 +274,23 @@ export default function CmsPage() {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Generic image upload — returns the presigned download URL
|
|
|
|
|
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);
|
|
|
|
|
const downloadUrl = await uploadService.getPresignedDownloadUrl(key);
|
|
|
|
|
return downloadUrl;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setModalError(getErrorMessage(err));
|
|
|
|
|
return '';
|
|
|
|
|
} finally {
|
|
|
|
|
setIsUploadingImage(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Section-specific form renderers
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
@@ -582,9 +634,211 @@ export default function CmsPage() {
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Reusable image upload field
|
|
|
|
|
function renderImageUploadField(label: string, currentUrl: string, onUrlChange: (url: string) => void) {
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-[#00293d] mb-1">{label}</label>
|
|
|
|
|
{currentUrl && (
|
|
|
|
|
<div className="mb-3 relative rounded-lg overflow-hidden border border-[#e5e7eb]">
|
|
|
|
|
<img src={currentUrl} 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="https://..." />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- 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>
|
|
|
|
|
<input type="text" className={inputCls} value={feat.iconPath} onChange={(e) => updateFeature(idx, { iconPath: e.target.value })} placeholder="Icon path (e.g. /assets/icons/icon.svg)" />
|
|
|
|
|
<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 renderModalContent() {
|
|
|
|
|
if (!editingSection || editContent == null) return null;
|
|
|
|
|
switch (editingSection) {
|
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
switch (key) {
|
|
|
|
|
case 'hero': return renderHeroForm();
|
|
|
|
|
case 'features': return renderFeaturesForm();
|
|
|
|
|
case 'topProfessionals': return renderTopProfessionalsForm();
|
|
|
|
|
@@ -597,14 +851,35 @@ export default function CmsPage() {
|
|
|
|
|
// Render
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
const editingSectionMeta = SECTIONS.find((s) => s.sectionKey === editingSection);
|
|
|
|
|
const currentSections = activePage === 'landing' ? LANDING_SECTIONS : ABOUT_SECTIONS;
|
|
|
|
|
const currentRecords = activePage === 'landing' ? records : aboutRecords;
|
|
|
|
|
|
|
|
|
|
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 landing page content sections</p>
|
|
|
|
|
<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>
|
|
|
|
|
</nav>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Notification toast */}
|
|
|
|
|
@@ -629,13 +904,13 @@ export default function CmsPage() {
|
|
|
|
|
) : (
|
|
|
|
|
/* Section cards grid */
|
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
|
|
|
{SECTIONS.map((section) => {
|
|
|
|
|
const record = recordFor(section.sectionKey);
|
|
|
|
|
{currentSections.map((section) => {
|
|
|
|
|
const record = recordFor(section.pageSlug, section.sectionKey);
|
|
|
|
|
const hasContent = !!record;
|
|
|
|
|
const isPublished = record?.isPublished ?? false;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div key={section.sectionKey} className="bg-white rounded-xl shadow-sm border border-[#e5e7eb]">
|
|
|
|
|
<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>
|
|
|
|
|
@@ -663,7 +938,7 @@ export default function CmsPage() {
|
|
|
|
|
)}
|
|
|
|
|
<div className="mt-4 flex justify-end">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => openEditor(section.sectionKey)}
|
|
|
|
|
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">
|
|
|
|
|
@@ -686,9 +961,9 @@ export default function CmsPage() {
|
|
|
|
|
{/* Modal header */}
|
|
|
|
|
<div className="px-6 py-4 border-b border-[#e5e7eb] flex-shrink-0">
|
|
|
|
|
<h3 className="text-lg font-semibold text-[#00293d]">
|
|
|
|
|
Edit {editingSectionMeta?.label ?? 'Section'}
|
|
|
|
|
Edit {editingSection.label}
|
|
|
|
|
</h3>
|
|
|
|
|
<p className="text-sm text-[#666666]">{editingSectionMeta?.description}</p>
|
|
|
|
|
<p className="text-sm text-[#666666]">{editingSection.description}</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Modal body - scrollable */}
|
|
|
|
|
|