feat: add CMS page and service to manage website content.
This commit is contained in:
596
src/app/dashboard/cms/page.tsx
Normal file
596
src/app/dashboard/cms/page.tsx
Normal file
@@ -0,0 +1,596 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
cmsService,
|
||||
getErrorMessage,
|
||||
CmsContentRecord,
|
||||
HeroContent,
|
||||
FeaturesContent,
|
||||
FeatureItem,
|
||||
TopProfessionalsContent,
|
||||
ProfessionalItem,
|
||||
TestimonialsContent,
|
||||
TestimonialItem,
|
||||
StatItem,
|
||||
} from '@/services';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SectionMeta {
|
||||
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' },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default content factories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function defaultHero(): HeroContent {
|
||||
return { headline: '', description: '', ctaButtonText: '', helperText: '' };
|
||||
}
|
||||
|
||||
function defaultFeatures(): FeaturesContent {
|
||||
return { title: '', subtitle: '', features: [] };
|
||||
}
|
||||
|
||||
function defaultTopProfessionals(): TopProfessionalsContent {
|
||||
return { title: '', ctaText: '', ctaButtonText: '', agents: [], lenders: [] };
|
||||
}
|
||||
|
||||
function defaultTestimonials(): TestimonialsContent {
|
||||
return { title: '', subtitle: '', ratingInfo: '', stats: [], testimonials: [] };
|
||||
}
|
||||
|
||||
function defaultContentFor(sectionKey: string): unknown {
|
||||
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 [records, setRecords] = 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 [editContent, setEditContent] = useState<unknown>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [modalError, setModalError] = useState('');
|
||||
const [professionalsTab, setProfessionalsTab] = useState<'agents' | 'lenders'>('agents');
|
||||
|
||||
// Fetch all CMS records for "landing" page
|
||||
const fetchRecords = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await cmsService.getByPage('landing');
|
||||
setRecords(data);
|
||||
} 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]);
|
||||
|
||||
// Helpers
|
||||
function recordFor(sectionKey: string): CmsContentRecord | undefined {
|
||||
return records.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));
|
||||
setModalError('');
|
||||
setProfessionalsTab('agents');
|
||||
setEditingSection(sectionKey);
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
setEditingSection(null);
|
||||
setEditContent(null);
|
||||
setModalError('');
|
||||
}
|
||||
|
||||
// Save
|
||||
async function handleSave() {
|
||||
if (!editingSection || editContent == null) return;
|
||||
setIsSubmitting(true);
|
||||
setModalError('');
|
||||
try {
|
||||
await cmsService.upsert({
|
||||
pageSlug: 'landing',
|
||||
sectionKey: editingSection,
|
||||
content: editContent,
|
||||
isPublished: true,
|
||||
});
|
||||
closeEditor();
|
||||
fetchRecords();
|
||||
setNotification({ type: 'success', message: 'Section saved successfully.' });
|
||||
} catch (err) {
|
||||
setModalError(getErrorMessage(err));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</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() {
|
||||
onChange([...items, emptyProfessionalItem()]);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="block text-sm font-medium text-[#00293d]">{label} ({items.length})</label>
|
||||
<button type="button" onClick={addItem} className="px-3 py-1 text-xs bg-[#f5a623] hover:bg-[#e09620] text-white rounded-lg transition-colors">+ Add {label.slice(0, -1)}</button>
|
||||
</div>
|
||||
{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: parseFloat(e.target.value) || 0 })} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
function renderModalContent() {
|
||||
if (!editingSection || editContent == null) return null;
|
||||
switch (editingSection) {
|
||||
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 editingSectionMeta = SECTIONS.find((s) => s.sectionKey === editingSection);
|
||||
|
||||
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>
|
||||
</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">
|
||||
{SECTIONS.map((section) => {
|
||||
const record = recordFor(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 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.sectionKey)}
|
||||
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 {editingSectionMeta?.label ?? 'Section'}
|
||||
</h3>
|
||||
<p className="text-sm text-[#666666]">{editingSectionMeta?.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>
|
||||
);
|
||||
}
|
||||
@@ -60,6 +60,20 @@ const menuItems = [
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Content Management',
|
||||
href: '/dashboard/cms',
|
||||
icon: (
|
||||
<svg className="w-5 h-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>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export default function Sidebar() {
|
||||
|
||||
86
src/services/cms.service.ts
Normal file
86
src/services/cms.service.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import api, { ApiResponse } from './api';
|
||||
|
||||
// TypeScript interfaces for each section's content shape
|
||||
export interface HeroContent {
|
||||
headline: string;
|
||||
description: string;
|
||||
ctaButtonText: string;
|
||||
helperText: string;
|
||||
}
|
||||
|
||||
export interface FeatureItem {
|
||||
iconPath: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface FeaturesContent {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
features: FeatureItem[];
|
||||
}
|
||||
|
||||
export interface ProfessionalItem {
|
||||
name: string;
|
||||
subtitle: string;
|
||||
location: string;
|
||||
experience: string;
|
||||
expertise: string[];
|
||||
imageUrl: string;
|
||||
}
|
||||
|
||||
export interface TopProfessionalsContent {
|
||||
title: string;
|
||||
ctaText: string;
|
||||
ctaButtonText: string;
|
||||
agents: ProfessionalItem[];
|
||||
lenders: ProfessionalItem[];
|
||||
}
|
||||
|
||||
export interface TestimonialItem {
|
||||
rating: number;
|
||||
title: string;
|
||||
content: string;
|
||||
author: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface StatItem {
|
||||
iconPath: string;
|
||||
boldText: string;
|
||||
normalText: string;
|
||||
}
|
||||
|
||||
export interface TestimonialsContent {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
ratingInfo: string;
|
||||
stats: StatItem[];
|
||||
testimonials: TestimonialItem[];
|
||||
}
|
||||
|
||||
export interface CmsContentRecord {
|
||||
id: string;
|
||||
pageSlug: string;
|
||||
sectionKey: string;
|
||||
content: HeroContent | FeaturesContent | TopProfessionalsContent | TestimonialsContent;
|
||||
isPublished: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
class CmsService {
|
||||
private basePath = '/cms';
|
||||
|
||||
async getByPage(pageSlug: string): Promise<CmsContentRecord[]> {
|
||||
const response = await api.get<ApiResponse<CmsContentRecord[]>>(`${this.basePath}/page/${pageSlug}`);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async upsert(data: { pageSlug: string; sectionKey: string; content: unknown; isPublished?: boolean }): Promise<CmsContentRecord> {
|
||||
const response = await api.put<ApiResponse<CmsContentRecord>>(`${this.basePath}/upsert`, data);
|
||||
return response.data.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const cmsService = new CmsService();
|
||||
@@ -59,3 +59,17 @@ export type {
|
||||
|
||||
// Upload Service
|
||||
export { uploadService } from './upload.service';
|
||||
|
||||
// CMS Service
|
||||
export { cmsService } from './cms.service';
|
||||
export type {
|
||||
CmsContentRecord,
|
||||
HeroContent,
|
||||
FeaturesContent,
|
||||
FeatureItem,
|
||||
TopProfessionalsContent,
|
||||
ProfessionalItem,
|
||||
TestimonialsContent,
|
||||
TestimonialItem,
|
||||
StatItem,
|
||||
} from './cms.service';
|
||||
|
||||
Reference in New Issue
Block a user