feat: dynamically load About page content from CMS, replacing hardcoded defaults and adding a skeleton loader.
This commit is contained in:
@@ -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,204 +81,169 @@ export default function AboutPage() {
|
|||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="flex-1 w-full">
|
<main className="flex-1 w-full">
|
||||||
{/* Hero Section */}
|
{loading ? (
|
||||||
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-8">
|
<AboutSkeleton />
|
||||||
<div className="text-center">
|
) : (
|
||||||
{/* Mission Badge */}
|
<>
|
||||||
<div className="inline-flex items-center gap-2 px-4 py-2 border border-[#00293d]/20 rounded-full mb-6">
|
{/* Hero Section */}
|
||||||
<Image
|
{hero && (
|
||||||
src="/assets/icons/verified-badge-orange.svg"
|
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-8">
|
||||||
alt=""
|
<div className="text-center">
|
||||||
width={19}
|
{hero.badge && (
|
||||||
height={19}
|
<div className="inline-flex items-center gap-2 px-4 py-2 border border-[#00293d]/20 rounded-full mb-6">
|
||||||
/>
|
<Image
|
||||||
<span className="font-fractul font-medium text-[14px] text-[#00293d]">
|
src="/assets/icons/verified-badge-orange.svg"
|
||||||
{hero.badge}
|
alt=""
|
||||||
</span>
|
width={19}
|
||||||
</div>
|
height={19}
|
||||||
|
/>
|
||||||
{/* Headline */}
|
<span className="font-fractul font-medium text-[14px] text-[#00293d]">
|
||||||
<h1 className="font-fractul font-bold text-[30px] text-[#00293d] leading-tight mb-6">
|
{hero.badge}
|
||||||
{hero.headline}
|
</span>
|
||||||
</h1>
|
</div>
|
||||||
|
)}
|
||||||
{/* Description */}
|
<h1 className="font-fractul font-bold text-[30px] text-[#00293d] leading-tight mb-6">
|
||||||
<p className="font-serif text-[14px] text-[#00293d] leading-relaxed mb-8 max-w-[600px] mx-auto">
|
{hero.headline}
|
||||||
{hero.description}
|
</h1>
|
||||||
</p>
|
<p className="font-serif text-[14px] text-[#00293d] leading-relaxed mb-8 max-w-[600px] mx-auto">
|
||||||
|
{hero.description}
|
||||||
{/* CTA Button */}
|
</p>
|
||||||
<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}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Hero Image with Overlay Text */}
|
|
||||||
<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]">
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
|
||||||
src={hero.bannerImageUrl || defaultHero.bannerImageUrl}
|
|
||||||
alt="City buildings"
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
{/* Overlay Text */}
|
|
||||||
<div className="absolute bottom-6 left-6 lg:bottom-10 lg:left-10">
|
|
||||||
<h2 className="font-fractul font-medium text-[20px] text-[#00293d]">
|
|
||||||
{hero.bannerOverlayText}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Stats Section */}
|
|
||||||
{stats.stats.length > 0 && (
|
|
||||||
<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">
|
|
||||||
{stats.stats.map((stat, index) => (
|
|
||||||
<div key={index} className="text-center">
|
|
||||||
<p className="font-fractul font-medium text-[14px] text-[#e58625]">
|
|
||||||
{stat.value}
|
|
||||||
</p>
|
|
||||||
<p className="font-serif text-[14px] text-[#00293d]">
|
|
||||||
{stat.label}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Why Choose Us Section */}
|
|
||||||
{(features.badge || 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 Header */}
|
|
||||||
<div className="text-center mb-12">
|
|
||||||
{features.badge && (
|
|
||||||
<h2 className="font-fractul font-bold text-[30px] text-[#00293d] mb-3">
|
|
||||||
{features.badge}
|
|
||||||
</h2>
|
|
||||||
)}
|
|
||||||
<p className="font-serif text-[14px] text-[#00293d] max-w-[518px] mx-auto">
|
|
||||||
Our core principles guide every interaction, ensuring you feel confident and supported at every step.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Feature Cards */}
|
|
||||||
{features.features.length > 0 && (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
||||||
{features.features.map((feature, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="border border-[#00293d]/10 rounded-[15px] p-6 text-center"
|
|
||||||
>
|
>
|
||||||
{feature.iconPath && (
|
{isAgent ? 'Go to Dashboard' : (hero.ctaButtonText || 'Find Your Agent')}
|
||||||
<div className="flex justify-center mb-4">
|
</Link>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
</div>
|
||||||
<img
|
</section>
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Team Section */}
|
{/* Hero Image */}
|
||||||
{team.members.length > 0 && (
|
{hero?.bannerImageUrl && (
|
||||||
<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-8">
|
||||||
{/* Section Header */}
|
<div className="relative rounded-[15px] overflow-hidden h-[411px]">
|
||||||
<div className="text-center mb-12">
|
<SmartImage
|
||||||
<h2 className="font-fractul font-bold text-[30px] text-[#00293d] mb-3">
|
src={hero.bannerImageUrl}
|
||||||
{team.title}
|
alt="City buildings"
|
||||||
</h2>
|
className="w-full h-full object-cover"
|
||||||
<p className="font-serif text-[14px] text-[#00293d] max-w-[471px] mx-auto">
|
shimmerClassName="rounded-[15px]"
|
||||||
{team.subtitle}
|
/>
|
||||||
</p>
|
{hero.bannerOverlayText && (
|
||||||
</div>
|
<div className="absolute bottom-6 left-6 lg:bottom-10 lg:left-10">
|
||||||
|
<h2 className="font-fractul font-medium text-[20px] text-[#00293d]">
|
||||||
|
{hero.bannerOverlayText}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Team Cards */}
|
{/* Stats Section */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 max-w-4xl mx-auto">
|
{stats && stats.stats.length > 0 && (
|
||||||
{team.members.map((member, index) => (
|
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<div key={index} className="border border-[#00293d]/10 rounded-[15px] p-4 text-center">
|
<div className="border-t border-b border-[#00293d]/10 py-6">
|
||||||
<div className="w-full h-[211px] rounded-[15px] overflow-hidden mb-4 border border-[#00293d]/10 bg-gray-100">
|
<div className="flex items-center justify-center gap-16 lg:gap-32">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{stats.stats.map((stat, index) => (
|
||||||
<img
|
<div key={index} className="text-center">
|
||||||
src={member.imageUrl || '/assets/images/professional-1.jpg'}
|
<p className="font-fractul font-medium text-[14px] text-[#e58625]">
|
||||||
alt={member.name}
|
{stat.value}
|
||||||
className="w-full h-full object-cover"
|
</p>
|
||||||
/>
|
<p className="font-serif text-[14px] text-[#00293d]">
|
||||||
|
{stat.label}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-fractul font-bold text-[14px] text-black">
|
</div>
|
||||||
{member.name}
|
</section>
|
||||||
</h3>
|
)}
|
||||||
<p className="font-fractul font-light text-[14px] text-black">
|
|
||||||
{member.role}
|
{/* Why Choose Us Section */}
|
||||||
|
{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">
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
{features.badge && (
|
||||||
|
<h2 className="font-fractul font-bold text-[30px] text-[#00293d] mb-3">
|
||||||
|
{features.badge}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
<p className="font-serif text-[14px] text-[#00293d] max-w-[518px] mx-auto">
|
||||||
|
Our core principles guide every interaction, ensuring you feel confident and supported at every step.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{features.features.map((feature, index) => (
|
||||||
|
<FeatureCard key={index} feature={feature} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Team Section */}
|
||||||
|
{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">
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<h2 className="font-fractul font-bold text-[30px] text-[#00293d] mb-3">
|
||||||
|
{team.title}
|
||||||
|
</h2>
|
||||||
|
<p className="font-serif text-[14px] text-[#00293d] max-w-[471px] mx-auto">
|
||||||
|
{team.subtitle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 max-w-4xl mx-auto">
|
||||||
|
{team.members.map((member, index) => (
|
||||||
|
<TeamCard key={index} member={member} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="border-t border-[#00293d]/10" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Divider */}
|
{/* CTA Section */}
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
||||||
<div className="border-t border-[#00293d]/10" />
|
<div className="flex flex-col lg:flex-row items-center justify-between gap-8">
|
||||||
</div>
|
<div className="lg:max-w-[400px]">
|
||||||
|
<h2 className="font-fractul font-bold text-[30px] text-[#00293d] mb-3">
|
||||||
{/* CTA Section */}
|
{isAgent ? 'Manage your profile' : (cta?.title || 'Ready to find a agent')}
|
||||||
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
</h2>
|
||||||
<div className="flex flex-col lg:flex-row items-center justify-between gap-8">
|
<p className="font-serif font-bold text-[14px] text-[#00293d] mb-6">
|
||||||
<div className="lg:max-w-[400px]">
|
{isAgent
|
||||||
<h2 className="font-fractul font-bold text-[30px] text-[#00293d] mb-3">
|
? 'Keep your profile updated and connect with potential clients.'
|
||||||
{isAgent ? 'Manage your profile' : 'Ready to find a agent'}
|
: (cta?.description || 'Search and connect with verified real estate agents who match your needs, location, and goals.')}
|
||||||
</h2>
|
</p>
|
||||||
<p className="font-serif font-bold text-[14px] text-[#00293d] mb-6">
|
<div className="flex items-center gap-4">
|
||||||
{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.'}
|
<Link
|
||||||
</p>
|
href={isAgent ? '/agent/dashboard' : '/user/profiles'}
|
||||||
<div className="flex items-center gap-4">
|
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"
|
||||||
<Link
|
>
|
||||||
href={isAgent ? '/agent/dashboard' : '/user/profiles'}
|
{isAgent ? 'Go to Dashboard' : (cta?.buttonText || 'Find Agents Now')}
|
||||||
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"
|
</Link>
|
||||||
>
|
<Link
|
||||||
{isAgent ? 'Go to Dashboard' : 'Find Agents Now'}
|
href="/contact"
|
||||||
</Link>
|
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"
|
||||||
<Link
|
>
|
||||||
href="/contact"
|
Get Help
|
||||||
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"
|
</Link>
|
||||||
>
|
</div>
|
||||||
Get Help
|
</div>
|
||||||
</Link>
|
<div className="w-[200px] lg:w-[280px]">
|
||||||
|
<Image
|
||||||
|
src="/assets/images/agent-illustration.png"
|
||||||
|
alt="Find an agent"
|
||||||
|
width={280}
|
||||||
|
height={241}
|
||||||
|
className="w-full h-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
<div className="w-[200px] lg:w-[280px]">
|
</>
|
||||||
<Image
|
)}
|
||||||
src="/assets/images/agent-illustration.png"
|
|
||||||
alt="Find an agent"
|
|
||||||
width={280}
|
|
||||||
height={241}
|
|
||||||
className="w-full h-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user