feat: dynamically load About page content from CMS, replacing hardcoded defaults and adding a skeleton loader.

This commit is contained in:
pradeepkumar
2026-03-12 23:51:48 +05:30
parent a3586021a4
commit 45adaf2deb
2 changed files with 386 additions and 257 deletions

View File

@@ -13,71 +13,8 @@ import type {
AboutFeaturesContent, AboutFeaturesContent,
AboutTeamContent, AboutTeamContent,
AboutCtaContent, AboutCtaContent,
CmsContentRecord,
} from '@/types/cms'; } from '@/types/cms';
// ---------------------------------------------------------------------------
// Hardcoded defaults (used when no CMS content is saved)
// ---------------------------------------------------------------------------
const defaultHero: AboutHeroContent = {
badge: 'Our Mission',
headline: 'Connecting You with Trusted Agents.',
description:
'We believe finding the right home shouldn\u2019t be complicated. Our platform bridges the gap between buyers, sellers, and verified real estate agents for a smooth and transparent experience.',
ctaButtonText: 'Find Your Agent',
bannerImageUrl: '/assets/images/user_home_top.png',
bannerOverlayText: 'Building the future of property.',
};
const defaultStats: AboutStatsContent = {
stats: [
{ value: '15k+', label: 'Verified Agents' },
{ value: '98%', label: 'Customer Satisfaction' },
],
};
const defaultFeatures: AboutFeaturesContent = {
badge: 'Why Choose Us',
features: [
{
iconPath: '/assets/icons/verified-badge-orange.svg',
title: 'Verified Agents',
description:
'All agents on our platform are carefully verified to ensure trust, proven expertise, and consistent service, helping users connect with reliable professionals and enjoy a smooth, confident, and transparent property journey',
},
{
iconPath: '/assets/icons/shield-verified-icon.svg',
title: 'Total Transparency',
description:
'We maintain complete transparency by clearly displaying agent details, reviews, and activity, allowing users to make informed decisions with confidence, clarity, and trust throughout their entire property journey.',
},
{
iconPath: '/assets/icons/home-icon.svg',
title: 'Simple Journey',
description:
'Our platform simplifies every step of the property process, from discovering the right agent to closing with confidence, making the experience smooth, fast, and stress-free for users at every stage.',
},
],
};
const defaultTeam: AboutTeamContent = {
title: 'Meet the minds behind the platform',
subtitle:
'We are a team of passionate innovators, real estate experts, and designers working together to redefine how people connect with trusted agents.',
members: [
{ name: 'Andrew', role: 'Founder & CEO', imageUrl: '/assets/images/professional-1.jpg' },
{ name: 'Thomas', role: 'Co-Founder', imageUrl: '/assets/images/professional-2.jpg' },
{ name: 'Darren', role: 'Advisor', imageUrl: '/assets/images/professional-3.jpg' },
],
};
const defaultCta: AboutCtaContent = {
title: 'Ready to find a agent',
description: 'Search and connect with verified real estate agents who match your needs, location, and goals.',
buttonText: 'Find Agents Now',
};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Page component // Page component
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -86,11 +23,12 @@ export default function AboutPage() {
const { data: session } = useSession(); const { data: session } = useSession();
const isAgent = (session?.user as any)?.role === 'AGENT'; const isAgent = (session?.user as any)?.role === 'AGENT';
const [hero, setHero] = useState<AboutHeroContent>(defaultHero); const [loading, setLoading] = useState(true);
const [stats, setStats] = useState<AboutStatsContent>(defaultStats); const [hero, setHero] = useState<AboutHeroContent | null>(null);
const [features, setFeatures] = useState<AboutFeaturesContent>(defaultFeatures); const [stats, setStats] = useState<AboutStatsContent | null>(null);
const [team, setTeam] = useState<AboutTeamContent>(defaultTeam); const [features, setFeatures] = useState<AboutFeaturesContent | null>(null);
const [cta, setCta] = useState<AboutCtaContent>(defaultCta); const [team, setTeam] = useState<AboutTeamContent | null>(null);
const [cta, setCta] = useState<AboutCtaContent | null>(null);
useEffect(() => { useEffect(() => {
cmsService.getPageContent('about').then(async (sections) => { cmsService.getPageContent('about').then(async (sections) => {
@@ -130,7 +68,8 @@ export default function AboutPage() {
break; break;
} }
} }
}); setLoading(false);
}).catch(() => setLoading(false));
}, []); }, []);
return ( return (
@@ -142,10 +81,15 @@ export default function AboutPage() {
{/* Main Content */} {/* Main Content */}
<main className="flex-1 w-full"> <main className="flex-1 w-full">
{loading ? (
<AboutSkeleton />
) : (
<>
{/* Hero Section */} {/* Hero Section */}
{hero && (
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-8"> <section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-8">
<div className="text-center"> <div className="text-center">
{/* Mission Badge */} {hero.badge && (
<div className="inline-flex items-center gap-2 px-4 py-2 border border-[#00293d]/20 rounded-full mb-6"> <div className="inline-flex items-center gap-2 px-4 py-2 border border-[#00293d]/20 rounded-full mb-6">
<Image <Image
src="/assets/icons/verified-badge-orange.svg" src="/assets/icons/verified-badge-orange.svg"
@@ -157,47 +101,46 @@ export default function AboutPage() {
{hero.badge} {hero.badge}
</span> </span>
</div> </div>
)}
{/* Headline */}
<h1 className="font-fractul font-bold text-[30px] text-[#00293d] leading-tight mb-6"> <h1 className="font-fractul font-bold text-[30px] text-[#00293d] leading-tight mb-6">
{hero.headline} {hero.headline}
</h1> </h1>
{/* Description */}
<p className="font-serif text-[14px] text-[#00293d] leading-relaxed mb-8 max-w-[600px] mx-auto"> <p className="font-serif text-[14px] text-[#00293d] leading-relaxed mb-8 max-w-[600px] mx-auto">
{hero.description} {hero.description}
</p> </p>
{/* CTA Button */}
<Link <Link
href={isAgent ? '/agent/dashboard' : '/user/profiles'} href={isAgent ? '/agent/dashboard' : '/user/profiles'}
className="inline-flex items-center justify-center px-6 h-[46px] border border-[#00293d] rounded-[15px] font-fractul font-bold text-[16px] text-[#00293d] hover:bg-gray-50 transition-colors" className="inline-flex items-center justify-center px-6 h-[46px] border border-[#00293d] rounded-[15px] font-fractul font-bold text-[16px] text-[#00293d] hover:bg-gray-50 transition-colors"
> >
{isAgent ? 'Go to Dashboard' : hero.ctaButtonText} {isAgent ? 'Go to Dashboard' : (hero.ctaButtonText || 'Find Your Agent')}
</Link> </Link>
</div> </div>
</section> </section>
)}
{/* Hero Image with Overlay Text */} {/* Hero Image */}
{hero?.bannerImageUrl && (
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-8"> <section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-8">
<div className="relative rounded-[15px] overflow-hidden h-[411px]"> <div className="relative rounded-[15px] overflow-hidden h-[411px]">
{/* eslint-disable-next-line @next/next/no-img-element */} <SmartImage
<img src={hero.bannerImageUrl}
src={hero.bannerImageUrl || defaultHero.bannerImageUrl}
alt="City buildings" alt="City buildings"
className="w-full h-full object-cover" className="w-full h-full object-cover"
shimmerClassName="rounded-[15px]"
/> />
{/* Overlay Text */} {hero.bannerOverlayText && (
<div className="absolute bottom-6 left-6 lg:bottom-10 lg:left-10"> <div className="absolute bottom-6 left-6 lg:bottom-10 lg:left-10">
<h2 className="font-fractul font-medium text-[20px] text-[#00293d]"> <h2 className="font-fractul font-medium text-[20px] text-[#00293d]">
{hero.bannerOverlayText} {hero.bannerOverlayText}
</h2> </h2>
</div> </div>
)}
</div> </div>
</section> </section>
)}
{/* Stats Section */} {/* Stats Section */}
{stats.stats.length > 0 && ( {stats && stats.stats.length > 0 && (
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="border-t border-b border-[#00293d]/10 py-6"> <div className="border-t border-b border-[#00293d]/10 py-6">
<div className="flex items-center justify-center gap-16 lg:gap-32"> <div className="flex items-center justify-center gap-16 lg:gap-32">
@@ -217,9 +160,8 @@ export default function AboutPage() {
)} )}
{/* Why Choose Us Section */} {/* Why Choose Us Section */}
{(features.badge || features.features.length > 0) && ( {features && features.features.length > 0 && (
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 lg:py-20"> <section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 lg:py-20">
{/* Section Header */}
<div className="text-center mb-12"> <div className="text-center mb-12">
{features.badge && ( {features.badge && (
<h2 className="font-fractul font-bold text-[30px] text-[#00293d] mb-3"> <h2 className="font-fractul font-bold text-[30px] text-[#00293d] mb-3">
@@ -230,42 +172,17 @@ export default function AboutPage() {
Our core principles guide every interaction, ensuring you feel confident and supported at every step. Our core principles guide every interaction, ensuring you feel confident and supported at every step.
</p> </p>
</div> </div>
{/* Feature Cards */}
{features.features.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{features.features.map((feature, index) => ( {features.features.map((feature, index) => (
<div <FeatureCard key={index} feature={feature} />
key={index}
className="border border-[#00293d]/10 rounded-[15px] p-6 text-center"
>
{feature.iconPath && (
<div className="flex justify-center mb-4">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={feature.iconPath}
alt=""
className="w-[35px] h-[35px] object-contain"
/>
</div>
)}
<h3 className="font-fractul font-bold text-[14px] text-[#00293d] mb-3">
{feature.title}
</h3>
<p className="font-serif text-[14px] text-[#00293d] leading-relaxed">
{feature.description}
</p>
</div>
))} ))}
</div> </div>
)}
</section> </section>
)} )}
{/* Team Section */} {/* Team Section */}
{team.members.length > 0 && ( {team && team.members.length > 0 && (
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-16 lg:pb-20"> <section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-16 lg:pb-20">
{/* Section Header */}
<div className="text-center mb-12"> <div className="text-center mb-12">
<h2 className="font-fractul font-bold text-[30px] text-[#00293d] mb-3"> <h2 className="font-fractul font-bold text-[30px] text-[#00293d] mb-3">
{team.title} {team.title}
@@ -274,26 +191,9 @@ export default function AboutPage() {
{team.subtitle} {team.subtitle}
</p> </p>
</div> </div>
{/* Team Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 max-w-4xl mx-auto"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6 max-w-4xl mx-auto">
{team.members.map((member, index) => ( {team.members.map((member, index) => (
<div key={index} className="border border-[#00293d]/10 rounded-[15px] p-4 text-center"> <TeamCard key={index} member={member} />
<div className="w-full h-[211px] rounded-[15px] overflow-hidden mb-4 border border-[#00293d]/10 bg-gray-100">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={member.imageUrl || '/assets/images/professional-1.jpg'}
alt={member.name}
className="w-full h-full object-cover"
/>
</div>
<h3 className="font-fractul font-bold text-[14px] text-black">
{member.name}
</h3>
<p className="font-fractul font-light text-[14px] text-black">
{member.role}
</p>
</div>
))} ))}
</div> </div>
</section> </section>
@@ -309,17 +209,19 @@ export default function AboutPage() {
<div className="flex flex-col lg:flex-row items-center justify-between gap-8"> <div className="flex flex-col lg:flex-row items-center justify-between gap-8">
<div className="lg:max-w-[400px]"> <div className="lg:max-w-[400px]">
<h2 className="font-fractul font-bold text-[30px] text-[#00293d] mb-3"> <h2 className="font-fractul font-bold text-[30px] text-[#00293d] mb-3">
{isAgent ? 'Manage your profile' : 'Ready to find a agent'} {isAgent ? 'Manage your profile' : (cta?.title || 'Ready to find a agent')}
</h2> </h2>
<p className="font-serif font-bold text-[14px] text-[#00293d] mb-6"> <p className="font-serif font-bold text-[14px] text-[#00293d] mb-6">
{isAgent ? 'Keep your profile updated and connect with potential clients.' : 'Search and connect with verified real estate agents who match your needs, location, and goals.'} {isAgent
? 'Keep your profile updated and connect with potential clients.'
: (cta?.description || 'Search and connect with verified real estate agents who match your needs, location, and goals.')}
</p> </p>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Link <Link
href={isAgent ? '/agent/dashboard' : '/user/profiles'} href={isAgent ? '/agent/dashboard' : '/user/profiles'}
className="inline-flex items-center justify-center px-6 h-[46px] bg-[#e58625] rounded-[15px] font-fractul font-bold text-[16px] text-[#00293d] hover:bg-[#d47720] transition-colors" className="inline-flex items-center justify-center px-6 h-[46px] bg-[#e58625] rounded-[15px] font-fractul font-bold text-[16px] text-[#00293d] hover:bg-[#d47720] transition-colors"
> >
{isAgent ? 'Go to Dashboard' : 'Find Agents Now'} {isAgent ? 'Go to Dashboard' : (cta?.buttonText || 'Find Agents Now')}
</Link> </Link>
<Link <Link
href="/contact" href="/contact"
@@ -340,6 +242,8 @@ export default function AboutPage() {
</div> </div>
</div> </div>
</section> </section>
</>
)}
</main> </main>
{/* Footer */} {/* Footer */}
@@ -347,3 +251,203 @@ export default function AboutPage() {
</div> </div>
); );
} }
// ---------------------------------------------------------------------------
// Full page skeleton while CMS loads
// ---------------------------------------------------------------------------
function AboutSkeleton() {
return (
<div className="animate-fade-in">
{/* Hero skeleton */}
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-8">
<div className="flex flex-col items-center gap-4">
<div className="shimmer-loading h-[36px] w-[160px] rounded-full" />
<div className="shimmer-loading h-[36px] w-[350px] rounded-lg" />
<div className="shimmer-loading h-[16px] w-[500px] max-w-full rounded-lg" />
<div className="shimmer-loading h-[16px] w-[400px] max-w-full rounded-lg" />
<div className="shimmer-loading h-[46px] w-[180px] rounded-[15px] mt-2" />
</div>
</section>
{/* Banner skeleton */}
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-8">
<div className="shimmer-loading h-[411px] w-full rounded-[15px]" />
</section>
{/* Stats skeleton */}
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="border-t border-b border-[#00293d]/10 py-6">
<div className="flex items-center justify-center gap-16 lg:gap-32">
<div className="flex flex-col items-center gap-2">
<div className="shimmer-loading h-[16px] w-[60px] rounded-lg" />
<div className="shimmer-loading h-[14px] w-[100px] rounded-lg" />
</div>
<div className="flex flex-col items-center gap-2">
<div className="shimmer-loading h-[16px] w-[50px] rounded-lg" />
<div className="shimmer-loading h-[14px] w-[130px] rounded-lg" />
</div>
</div>
</div>
</section>
{/* Features skeleton */}
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 lg:py-20">
<div className="flex flex-col items-center gap-3 mb-12">
<div className="shimmer-loading h-[36px] w-[220px] rounded-lg" />
<div className="shimmer-loading h-[14px] w-[400px] max-w-full rounded-lg" />
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{[1, 2, 3].map((i) => (
<div key={i} className="border border-[#00293d]/10 rounded-[15px] p-6 flex flex-col items-center gap-3">
<div className="shimmer-loading w-[35px] h-[35px] rounded-full" />
<div className="shimmer-loading h-[14px] w-[120px] rounded-lg" />
<div className="shimmer-loading h-[12px] w-full rounded-lg" />
<div className="shimmer-loading h-[12px] w-[90%] rounded-lg" />
<div className="shimmer-loading h-[12px] w-[80%] rounded-lg" />
</div>
))}
</div>
</section>
{/* Team skeleton */}
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-16 lg:pb-20">
<div className="flex flex-col items-center gap-3 mb-12">
<div className="shimmer-loading h-[36px] w-[350px] max-w-full rounded-lg" />
<div className="shimmer-loading h-[14px] w-[400px] max-w-full rounded-lg" />
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 max-w-4xl mx-auto">
{[1, 2, 3].map((i) => (
<div key={i} className="border border-[#00293d]/10 rounded-[15px] p-4 flex flex-col items-center gap-3">
<div className="shimmer-loading w-full h-[211px] rounded-[15px]" />
<div className="shimmer-loading h-[14px] w-[80px] rounded-lg" />
<div className="shimmer-loading h-[12px] w-[100px] rounded-lg" />
</div>
))}
</div>
</section>
</div>
);
}
// ---------------------------------------------------------------------------
// Smart image — shimmer while loading, hidden on error
// ---------------------------------------------------------------------------
function SmartImage({
src,
alt,
className,
shimmerClassName,
}: {
src: string | undefined | null;
alt: string;
className?: string;
shimmerClassName?: string;
}) {
const [status, setStatus] = useState<'loading' | 'loaded' | 'error'>(src ? 'loading' : 'error');
useEffect(() => {
setStatus(src ? 'loading' : 'error');
}, [src]);
if (!src || status === 'error') return null;
return (
<div className="relative w-full h-full">
{status === 'loading' && (
<div className={`absolute inset-0 shimmer-loading ${shimmerClassName || ''}`} />
)}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
alt={alt}
className={`${className || ''} transition-opacity duration-300 ${status === 'loaded' ? 'opacity-100' : 'opacity-0'}`}
onLoad={() => setStatus('loaded')}
onError={() => setStatus('error')}
/>
</div>
);
}
// ---------------------------------------------------------------------------
// Feature card — shimmer icon while loading
// ---------------------------------------------------------------------------
function FeatureCard({ feature }: { feature: AboutFeaturesContent['features'][number] }) {
const [iconStatus, setIconStatus] = useState<'loading' | 'loaded' | 'error'>(
feature.iconPath ? 'loading' : 'error'
);
useEffect(() => {
setIconStatus(feature.iconPath ? 'loading' : 'error');
}, [feature.iconPath]);
return (
<div className="border border-[#00293d]/10 rounded-[15px] p-6 text-center">
<div className="flex items-center justify-center mb-4 h-[35px]">
<div className="relative w-[35px] h-[35px]">
{iconStatus === 'loading' && (
<div className="absolute inset-0 rounded-full shimmer-loading" />
)}
{feature.iconPath && iconStatus !== 'error' && (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={feature.iconPath}
alt=""
className={`w-[35px] h-[35px] object-contain transition-opacity duration-300 ${iconStatus === 'loaded' ? 'opacity-100' : 'opacity-0'}`}
onLoad={() => setIconStatus('loaded')}
onError={() => setIconStatus('error')}
/>
)}
</div>
</div>
<h3 className="font-fractul font-bold text-[14px] text-[#00293d] mb-3">
{feature.title}
</h3>
<p className="font-serif text-[14px] text-[#00293d] leading-relaxed">
{feature.description}
</p>
</div>
);
}
// ---------------------------------------------------------------------------
// Team card — shimmer image while loading
// ---------------------------------------------------------------------------
function TeamCard({ member }: { member: AboutTeamContent['members'][number] }) {
const [imgStatus, setImgStatus] = useState<'loading' | 'loaded' | 'error'>(
member.imageUrl ? 'loading' : 'error'
);
useEffect(() => {
setImgStatus(member.imageUrl ? 'loading' : 'error');
}, [member.imageUrl]);
return (
<div className="border border-[#00293d]/10 rounded-[15px] p-4 text-center">
<div className="w-full h-[211px] rounded-[15px] overflow-hidden mb-4 border border-[#00293d]/10 bg-gray-50">
{imgStatus === 'loading' && (
<div className="w-full h-full shimmer-loading rounded-[15px]" />
)}
{member.imageUrl && imgStatus !== 'error' && (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={member.imageUrl}
alt={member.name}
className={`w-full h-full object-cover transition-opacity duration-300 ${imgStatus === 'loaded' ? 'opacity-100' : 'opacity-0'}`}
onLoad={() => setImgStatus('loaded')}
onError={() => setImgStatus('error')}
/>
)}
</div>
<h3 className="font-fractul font-bold text-[14px] text-black">
{member.name}
</h3>
<p className="font-fractul font-light text-[14px] text-black">
{member.role}
</p>
</div>
);
}

View File

@@ -1,5 +1,30 @@
@import "tailwindcss"; @import "tailwindcss";
/* Shimmer loading animation */
@keyframes shimmer-slide {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.shimmer-loading {
position: relative;
overflow: hidden;
background-color: #e5e7eb;
}
.shimmer-loading::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.6) 50%,
transparent 100%
);
animation: shimmer-slide 1.5s ease-in-out infinite;
}
:root { :root {
/* RE-Quest Design Tokens */ /* RE-Quest Design Tokens */
--color-primary-dark: #00293d; --color-primary-dark: #00293d;