feat: Add About Us page CMS functionality with dedicated sections and image upload support.

This commit is contained in:
pradeepkumar
2026-02-24 03:30:35 +05:30
parent f435173566
commit 956ccede2d
4 changed files with 385 additions and 27 deletions

View File

@@ -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 */}

View File

@@ -59,6 +59,53 @@ export interface TestimonialsContent {
testimonials: TestimonialItem[];
}
export interface AboutHeroContent {
badge: string;
headline: string;
description: string;
ctaButtonText: string;
bannerImageUrl: string;
bannerOverlayText: string;
}
export interface AboutStatItem {
value: string;
label: string;
}
export interface AboutStatsContent {
stats: AboutStatItem[];
}
export interface AboutFeatureItem {
iconPath: string;
title: string;
description: string;
}
export interface AboutFeaturesContent {
badge: string;
features: AboutFeatureItem[];
}
export interface AboutTeamMember {
name: string;
role: string;
imageUrl: string;
}
export interface AboutTeamContent {
title: string;
subtitle: string;
members: AboutTeamMember[];
}
export interface AboutCtaContent {
title: string;
description: string;
buttonText: string;
}
export interface CmsContentRecord {
id: string;
pageSlug: string;

View File

@@ -72,4 +72,12 @@ export type {
TestimonialsContent,
TestimonialItem,
StatItem,
AboutHeroContent,
AboutStatItem,
AboutStatsContent,
AboutFeatureItem,
AboutFeaturesContent,
AboutTeamMember,
AboutTeamContent,
AboutCtaContent,
} from './cms.service';

View File

@@ -1,8 +1,36 @@
import api from './api';
interface PresignedUrlResponse {
uploadUrl: string;
publicUrl: string;
key: string;
}
class UploadService {
private basePath = '/upload';
/**
* Get a presigned upload URL for S3
*/
async getPresignedUploadUrl(filename: string, contentType: string, folder: string): Promise<PresignedUrlResponse> {
const response = await api.post<{ data: PresignedUrlResponse }>(
`${this.basePath}/presigned-url`,
{ filename, contentType, folder }
);
return response.data.data;
}
/**
* Upload a file to S3 using a presigned URL
*/
async uploadFileToS3(uploadUrl: string, file: File): Promise<void> {
await fetch(uploadUrl, {
method: 'PUT',
body: file,
headers: { 'Content-Type': file.type },
});
}
/**
* Get a presigned download URL for an S3 key
*/