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 { import {
cmsService, cmsService,
usersService, usersService,
uploadService,
getErrorMessage, getErrorMessage,
CmsContentRecord, CmsContentRecord,
HeroContent, HeroContent,
@@ -15,6 +16,14 @@ import {
TestimonialsContent, TestimonialsContent,
TestimonialItem, TestimonialItem,
StatItem, StatItem,
AboutHeroContent,
AboutStatsContent,
AboutStatItem,
AboutFeaturesContent,
AboutFeatureItem,
AboutTeamContent,
AboutTeamMember,
AboutCtaContent,
User, User,
} from '@/services'; } from '@/services';
@@ -23,16 +32,25 @@ import {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
interface SectionMeta { interface SectionMeta {
pageSlug: string;
sectionKey: string; sectionKey: string;
label: string; label: string;
description: string; description: string;
} }
const SECTIONS: SectionMeta[] = [ const LANDING_SECTIONS: SectionMeta[] = [
{ sectionKey: 'hero', label: 'Hero', description: 'Main headline, description and call-to-action' }, { pageSlug: 'landing', sectionKey: 'hero', label: 'Hero', description: 'Main headline, description and call-to-action' },
{ sectionKey: 'features', label: 'Features', description: 'Feature highlights with icons' }, { pageSlug: 'landing', sectionKey: 'features', label: 'Features', description: 'Feature highlights with icons' },
{ sectionKey: 'topProfessionals', label: 'Top Professionals', description: 'Agents and lenders showcase' }, { pageSlug: 'landing', sectionKey: 'topProfessionals', label: 'Top Professionals', description: 'Agents and lenders showcase' },
{ sectionKey: 'testimonials', label: 'Testimonials', description: 'Reviews, stats and social proof' }, { 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: [] }; 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) { switch (sectionKey) {
case 'hero': return defaultHero(); case 'hero': return defaultHero();
case 'features': return defaultFeatures(); case 'features': return defaultFeatures();
@@ -93,17 +120,20 @@ const textareaCls = inputCls;
export default function CmsPage() { export default function CmsPage() {
const router = useRouter(); const router = useRouter();
const [activePage, setActivePage] = useState<'landing' | 'about'>('landing');
const [records, setRecords] = useState<CmsContentRecord[]>([]); const [records, setRecords] = useState<CmsContentRecord[]>([]);
const [aboutRecords, setAboutRecords] = useState<CmsContentRecord[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [notification, setNotification] = useState<{ type: 'success' | 'error'; message: string } | null>(null); const [notification, setNotification] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
// Modal // Modal
const [editingSection, setEditingSection] = useState<string | null>(null); const [editingSection, setEditingSection] = useState<SectionMeta | null>(null);
const [editContent, setEditContent] = useState<unknown>(null); const [editContent, setEditContent] = useState<unknown>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [modalError, setModalError] = useState(''); const [modalError, setModalError] = useState('');
const [professionalsTab, setProfessionalsTab] = useState<'agents' | 'lenders'>('agents'); const [professionalsTab, setProfessionalsTab] = useState<'agents' | 'lenders'>('agents');
const [isUploadingImage, setIsUploadingImage] = useState(false);
// Agent search // Agent search
const [agentSearchQuery, setAgentSearchQuery] = useState(''); const [agentSearchQuery, setAgentSearchQuery] = useState('');
@@ -113,13 +143,17 @@ export default function CmsPage() {
const agentSearchTimeout = useRef<NodeJS.Timeout | null>(null); const agentSearchTimeout = useRef<NodeJS.Timeout | null>(null);
const agentSearchRef = useRef<HTMLDivElement>(null); const agentSearchRef = useRef<HTMLDivElement>(null);
// Fetch all CMS records for "landing" page // Fetch all CMS records
const fetchRecords = useCallback(async () => { const fetchRecords = useCallback(async () => {
setIsLoading(true); setIsLoading(true);
setError(''); setError('');
try { try {
const data = await cmsService.getByPage('landing'); const [landingData, aboutData] = await Promise.all([
setRecords(data); cmsService.getByPage('landing'),
cmsService.getByPage('about'),
]);
setRecords(landingData);
setAboutRecords(aboutData);
} catch (err) { } catch (err) {
const msg = getErrorMessage(err); const msg = getErrorMessage(err);
setError(msg); setError(msg);
@@ -195,20 +229,21 @@ export default function CmsPage() {
} }
// Helpers // Helpers
function recordFor(sectionKey: string): CmsContentRecord | undefined { function recordFor(pageSlug: string, sectionKey: string): CmsContentRecord | undefined {
return records.find((r) => r.sectionKey === sectionKey); const source = pageSlug === 'about' ? aboutRecords : records;
return source.find((r) => r.sectionKey === sectionKey);
} }
// Open modal // Open modal
function openEditor(sectionKey: string) { function openEditor(section: SectionMeta) {
const existing = recordFor(sectionKey); const existing = recordFor(section.pageSlug, section.sectionKey);
setEditContent(existing ? JSON.parse(JSON.stringify(existing.content)) : defaultContentFor(sectionKey)); setEditContent(existing ? JSON.parse(JSON.stringify(existing.content)) : defaultContentFor(section.pageSlug, section.sectionKey));
setModalError(''); setModalError('');
setProfessionalsTab('agents'); setProfessionalsTab('agents');
setAgentSearchQuery(''); setAgentSearchQuery('');
setAgentSearchResults([]); setAgentSearchResults([]);
setShowAgentDropdown(false); setShowAgentDropdown(false);
setEditingSection(sectionKey); setEditingSection(section);
} }
function closeEditor() { function closeEditor() {
@@ -224,8 +259,8 @@ export default function CmsPage() {
setModalError(''); setModalError('');
try { try {
await cmsService.upsert({ await cmsService.upsert({
pageSlug: 'landing', pageSlug: editingSection.pageSlug,
sectionKey: editingSection, sectionKey: editingSection.sectionKey,
content: editContent, content: editContent,
isPublished: true, 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 // 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() { function renderModalContent() {
if (!editingSection || editContent == null) return null; 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 'hero': return renderHeroForm();
case 'features': return renderFeaturesForm(); case 'features': return renderFeaturesForm();
case 'topProfessionals': return renderTopProfessionalsForm(); case 'topProfessionals': return renderTopProfessionalsForm();
@@ -597,14 +851,35 @@ export default function CmsPage() {
// Render // Render
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const editingSectionMeta = SECTIONS.find((s) => s.sectionKey === editingSection); const currentSections = activePage === 'landing' ? LANDING_SECTIONS : ABOUT_SECTIONS;
const currentRecords = activePage === 'landing' ? records : aboutRecords;
return ( return (
<div> <div>
{/* Page header */} {/* Page header */}
<div className="mb-6"> <div className="mb-6">
<h1 className="text-2xl font-bold text-[#00293d] font-fractul">Content Management</h1> <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> </div>
{/* Notification toast */} {/* Notification toast */}
@@ -629,13 +904,13 @@ export default function CmsPage() {
) : ( ) : (
/* Section cards grid */ /* Section cards grid */
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{SECTIONS.map((section) => { {currentSections.map((section) => {
const record = recordFor(section.sectionKey); const record = recordFor(section.pageSlug, section.sectionKey);
const hasContent = !!record; const hasContent = !!record;
const isPublished = record?.isPublished ?? false; const isPublished = record?.isPublished ?? false;
return ( 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 className="px-6 py-4 border-b border-[#e5e7eb] flex items-center justify-between">
<div> <div>
<h2 className="text-lg font-semibold text-[#00293d]">{section.label}</h2> <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"> <div className="mt-4 flex justify-end">
<button <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" 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"> <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 */} {/* Modal header */}
<div className="px-6 py-4 border-b border-[#e5e7eb] flex-shrink-0"> <div className="px-6 py-4 border-b border-[#e5e7eb] flex-shrink-0">
<h3 className="text-lg font-semibold text-[#00293d]"> <h3 className="text-lg font-semibold text-[#00293d]">
Edit {editingSectionMeta?.label ?? 'Section'} Edit {editingSection.label}
</h3> </h3>
<p className="text-sm text-[#666666]">{editingSectionMeta?.description}</p> <p className="text-sm text-[#666666]">{editingSection.description}</p>
</div> </div>
{/* Modal body - scrollable */} {/* Modal body - scrollable */}

View File

@@ -59,6 +59,53 @@ export interface TestimonialsContent {
testimonials: TestimonialItem[]; 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 { export interface CmsContentRecord {
id: string; id: string;
pageSlug: string; pageSlug: string;

View File

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

View File

@@ -1,8 +1,36 @@
import api from './api'; import api from './api';
interface PresignedUrlResponse {
uploadUrl: string;
publicUrl: string;
key: string;
}
class UploadService { class UploadService {
private basePath = '/upload'; 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 * Get a presigned download URL for an S3 key
*/ */