All implemaentation and 360deg tour
This commit is contained in:
@@ -35,6 +35,7 @@ import Balaji from '../assets/Balaji-Chandrakumar.jpeg';
|
||||
import Ramesh from '../assets/Ramesh-Padmanabhan.jpeg';
|
||||
import Diju from '../assets/Diju.jpeg';
|
||||
import svgPaths from '../imports/svg-kw7r0ellyk';
|
||||
import { useGetAboutUsQuery } from '../redux/services/aboutUsApi';
|
||||
|
||||
// Leadership Orientations Data
|
||||
const leadershipOrientations = [
|
||||
@@ -83,8 +84,8 @@ const benefits = [
|
||||
'We recommend that the Leadership intervention is designed for a period of 12-15 months with multiple touch points which can constitute a combination of classroom, fire side chats, one-on-one sessions, address by an expert, use of profilers, accessing online content on concepts and accomplished leaders\' experiences'
|
||||
];
|
||||
|
||||
// Team Members Data with Full Profiles
|
||||
const teamMembers = [
|
||||
// Team Members Data with Full Profiles (Static - can be kept or also fetched from API if needed)
|
||||
const staticTeamMembers = [
|
||||
{
|
||||
name: 'Mr. K Ramkumar',
|
||||
role: 'Managing Director',
|
||||
@@ -271,14 +272,38 @@ After a career break, she joined KLC as Practice Head. She now co-creates leader
|
||||
}
|
||||
];
|
||||
|
||||
// Loading Skeleton Component
|
||||
const AboutUsSkeleton = () => (
|
||||
<div className="animate-pulse">
|
||||
{/* Hero Section Skeleton */}
|
||||
<section className="relative min-h-[85vh] flex flex-col bg-gray-200">
|
||||
<div className="absolute inset-0 bg-gray-300"></div>
|
||||
<div className="relative z-10 flex-1 flex items-center">
|
||||
<div className="w-full section-margin-x">
|
||||
<div className="max-w-6xl">
|
||||
<div className="h-16 bg-gray-400 rounded-lg w-3/4 mb-8"></div>
|
||||
<div className="h-24 bg-gray-400 rounded-lg w-2/3 mb-8"></div>
|
||||
<div className="h-12 bg-gray-400 rounded-lg w-48"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Add more skeleton sections as needed */}
|
||||
<div className="py-24 text-center text-gray-500">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export function AboutUs() {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [expandedValue, setExpandedValue] = useState<string | null>('context');
|
||||
const [selectedMember, setSelectedMember] = useState<typeof teamMembers[0] | null>(null);
|
||||
const [selectedMember, setSelectedMember] = useState<typeof staticTeamMembers[0] | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
const handleMemberClick = (member: typeof teamMembers[0]) => {
|
||||
// Fetch About Us data from API
|
||||
const { data: aboutUsData, isLoading, isError, error } = useGetAboutUsQuery();
|
||||
|
||||
const handleMemberClick = (member: typeof staticTeamMembers[0]) => {
|
||||
setSelectedMember(member);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
@@ -296,7 +321,6 @@ export function AboutUs() {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
@@ -305,9 +329,8 @@ export function AboutUs() {
|
||||
useEffect(() => {
|
||||
setIsVisible(true);
|
||||
|
||||
// Timeline fill animation on scroll
|
||||
const handleScroll = () => {
|
||||
const timelineSection = document.querySelector('#timeline-fill-line');
|
||||
const timelineSection = document.querySelector('#timeline-fill-line') as HTMLElement | null;
|
||||
const timelineContainer = timelineSection?.parentElement;
|
||||
|
||||
if (!timelineSection || !timelineContainer) return;
|
||||
@@ -315,47 +338,56 @@ export function AboutUs() {
|
||||
const rect = timelineContainer.getBoundingClientRect();
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
// Calculate how much of the timeline is visible
|
||||
const sectionTop = rect.top;
|
||||
const sectionHeight = rect.height;
|
||||
const visibleTop = Math.max(0, windowHeight - sectionTop);
|
||||
const visibleHeight = Math.min(visibleTop, sectionHeight);
|
||||
const scrollProgress = Math.max(0, Math.min(1, visibleHeight / sectionHeight));
|
||||
|
||||
// Calculate the exact position to end at Phase 3 dot
|
||||
const maxFillHeight = 'calc(100% - 1rem)'; // Match the background line limit
|
||||
const maxFillHeight = 'calc(100% - 1rem)';
|
||||
|
||||
// Apply progressive fill that respects the maximum height constraint
|
||||
if (scrollProgress >= 0.9) {
|
||||
// When nearly complete, set to exact end position
|
||||
timelineSection.style.height = maxFillHeight;
|
||||
} else {
|
||||
// Progressive fill up to 90% of the way
|
||||
timelineSection.style.height = `${scrollProgress * 90}%`;
|
||||
}
|
||||
};
|
||||
|
||||
// Add scroll listener
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
|
||||
// Initial call to set the initial state
|
||||
handleScroll();
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Show loading skeleton while fetching data
|
||||
if (isLoading) {
|
||||
return <AboutUsSkeleton />;
|
||||
}
|
||||
|
||||
// Show error state if API call fails
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-red-600 mb-4">Error Loading Page</h2>
|
||||
<p className="text-gray-600 mb-4">Failed to load About Us content. Please try again later.</p>
|
||||
<Button onClick={() => window.location.reload()}>Retry</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ backgroundColor: '#FFFFFF', fontFamily: 'var(--font-family-base)' }}>
|
||||
{/* Hero Section - Our Vision Page Style */}
|
||||
{/* Hero Section - Dynamic from API */}
|
||||
<section className="relative min-h-[85vh] flex flex-col">
|
||||
<div className="absolute inset-0 z-0">
|
||||
<div
|
||||
className="w-full h-full bg-cover bg-center bg-no-repeat"
|
||||
style={{
|
||||
backgroundImage: `url('https://images.unsplash.com/photo-1552664730-d307ca884978?w=1920&h=1080&fit=crop')`
|
||||
backgroundImage: `url('${aboutUsData?.hero_section?.background_image_url || 'https://images.unsplash.com/photo-1552664730-d307ca884978?w=1920&h=1080&fit=crop'}')`
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-black/85 via-black/75 to-black/65"></div>
|
||||
@@ -364,32 +396,22 @@ export function AboutUs() {
|
||||
<div className="relative z-10 flex-1 flex items-center">
|
||||
<div className="w-full section-margin-x">
|
||||
<div className="max-w-6xl">
|
||||
{/* Back Navigation */}
|
||||
{/* <div className="mb-8">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigateTo('/services')}
|
||||
className="text-white hover:text-white hover:bg-white/10 p-2 -ml-2"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to Services
|
||||
</Button>
|
||||
</div> */}
|
||||
|
||||
<div className="mb-8">
|
||||
<h1 className="text-h1-white">
|
||||
Advancing Leadership Through Insight
|
||||
{aboutUsData?.hero_section?.headline || "Advancing Leadership Through Insight"}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<p className="text-body-lg-white mb-8 max-w-3xl">
|
||||
<strong>Founded in 2016 with the vision of being a world class institution in the thought and practice of leadership. We facilitate institutions to build Leadership capacity and capability while helping individuals unleash their potential.</strong>
|
||||
<strong>
|
||||
{aboutUsData?.hero_section?.subtext || "Founded in 2016 with the vision of being a world class institution in the thought and practice of leadership. We facilitate institutions to build Leadership capacity and capability while helping individuals unleash their potential."}
|
||||
</strong>
|
||||
</p>
|
||||
|
||||
<div className="flex justify-start">
|
||||
<PrimaryCTAButton
|
||||
text="Talk to Us"
|
||||
onClick={() => navigateTo('/contact?topic=management-development')}
|
||||
text={aboutUsData?.hero_section?.cta_text || "Talk to Us"}
|
||||
onClick={() => navigateTo(aboutUsData?.hero_section?.cta_destination || '/contact?topic=management-development')}
|
||||
ariaLabel="Talk to us about management development"
|
||||
className="primary-cta-button-blue cta-text-white"
|
||||
/>
|
||||
@@ -399,7 +421,7 @@ export function AboutUs() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Section 1: Our Promise */}
|
||||
{/* Section 1: Our Promise - Dynamic from API */}
|
||||
<section className="py-24 lg:py-32" style={{ backgroundColor: '#FFFFFF' }}>
|
||||
<div className="section-margin-x">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
@@ -410,7 +432,7 @@ export function AboutUs() {
|
||||
viewport={{ once: true }}
|
||||
className="text-center"
|
||||
>
|
||||
<BrandedTag text="Our Promise" />
|
||||
<BrandedTag text={aboutUsData?.our_promise_title || "Our Promise"} />
|
||||
<h2 className="text-h1 mb-8" style={{
|
||||
fontSize: 'clamp(2.5rem, 5vw, 4rem)',
|
||||
lineHeight: '1.1',
|
||||
@@ -423,7 +445,7 @@ export function AboutUs() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Section 2: How We Work */}
|
||||
{/* Section 2: How We Work - Dynamic from API */}
|
||||
<section className="py-24 lg:py-32" style={{ backgroundColor: '#F9F9F9' }}>
|
||||
<div className="section-margin-x">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
@@ -434,109 +456,143 @@ export function AboutUs() {
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<BrandedTag text="How We Work" />
|
||||
<h2 className="text-h2 mb-8">How We Work</h2>
|
||||
<BrandedTag text={aboutUsData?.how_we_work_title || "How We Work"} />
|
||||
<h2 className="text-h2 mb-8">{aboutUsData?.how_we_work_title || "How We Work"}</h2>
|
||||
</motion.div>
|
||||
|
||||
{/* Four Key Points Grid */}
|
||||
{/* Four Key Points Grid - Using API data if available, otherwise fallback to static */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 lg:gap-12">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.1 }}
|
||||
viewport={{ once: true }}
|
||||
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className="w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0"
|
||||
style={{ backgroundColor: 'var(--color-primary)' }}
|
||||
{(aboutUsData?.how_we_work && aboutUsData.how_we_work.length > 0) ? (
|
||||
aboutUsData.how_we_work.map((item, index) => (
|
||||
<motion.div
|
||||
key={item.id}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.1 * (index + 1) }}
|
||||
viewport={{ once: true }}
|
||||
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100"
|
||||
>
|
||||
<Puzzle className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-h4 mb-4">Co-created interventions</h3>
|
||||
<p className="text-body text-muted leading-relaxed">
|
||||
We collaborate with you to design solutions that fit your unique organizational context and strategic objectives.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className="w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0"
|
||||
style={{ backgroundColor: 'var(--color-primary)' }}
|
||||
>
|
||||
{index === 0 && <Puzzle className="w-6 h-6 text-white" />}
|
||||
{index === 1 && <Target className="w-6 h-6 text-white" />}
|
||||
{index === 2 && <BookOpen className="w-6 h-6 text-white" />}
|
||||
{index === 3 && <Zap className="w-6 h-6 text-white" />}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-h4 mb-4">{item.title}</h3>
|
||||
<p className="text-body text-muted leading-relaxed">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))
|
||||
) : (
|
||||
// Fallback to static data if API data is not available
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.1 }}
|
||||
viewport={{ once: true }}
|
||||
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className="w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0"
|
||||
style={{ backgroundColor: 'var(--color-primary)' }}
|
||||
>
|
||||
<Puzzle className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-h4 mb-4">Co-created interventions</h3>
|
||||
<p className="text-body text-muted leading-relaxed">
|
||||
We collaborate with you to design solutions that fit your unique organizational context and strategic objectives.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
viewport={{ once: true }}
|
||||
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className="w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0"
|
||||
style={{ backgroundColor: 'var(--color-primary)' }}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
viewport={{ once: true }}
|
||||
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100"
|
||||
>
|
||||
<Target className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-h4 mb-4">Grounded in business context</h3>
|
||||
<p className="text-body text-muted leading-relaxed">
|
||||
Every solution is tailored to your specific business environment, challenges, and growth objectives.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className="w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0"
|
||||
style={{ backgroundColor: 'var(--color-primary)' }}
|
||||
>
|
||||
<Target className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-h4 mb-4">Grounded in business context</h3>
|
||||
<p className="text-body text-muted leading-relaxed">
|
||||
Every solution is tailored to your specific business environment, challenges, and growth objectives.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.3 }}
|
||||
viewport={{ once: true }}
|
||||
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className="w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0"
|
||||
style={{ backgroundColor: 'var(--color-primary)' }}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.3 }}
|
||||
viewport={{ once: true }}
|
||||
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100"
|
||||
>
|
||||
<BookOpen className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-h4 mb-4">Research-backed, behaviour-anchored</h3>
|
||||
<p className="text-body text-muted leading-relaxed">
|
||||
Our methodologies are rooted in rigorous research and focused on sustainable behavioral transformation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className="w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0"
|
||||
style={{ backgroundColor: 'var(--color-primary)' }}
|
||||
>
|
||||
<BookOpen className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-h4 mb-4">Research-backed, behaviour-anchored</h3>
|
||||
<p className="text-body text-muted leading-relaxed">
|
||||
Our methodologies are rooted in rigorous research and focused on sustainable behavioral transformation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.4 }}
|
||||
viewport={{ once: true }}
|
||||
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className="w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0"
|
||||
style={{ backgroundColor: 'var(--color-primary)' }}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.4 }}
|
||||
viewport={{ once: true }}
|
||||
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100"
|
||||
>
|
||||
<Zap className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-h4 mb-4">Delivered through immersive formats</h3>
|
||||
<p className="text-body text-muted leading-relaxed">
|
||||
Interactive, experiential learning approaches that engage participants and drive lasting impact.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className="w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0"
|
||||
style={{ backgroundColor: 'var(--color-primary)' }}
|
||||
>
|
||||
<Zap className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-h4 mb-4">Delivered through immersive formats</h3>
|
||||
<p className="text-body text-muted leading-relaxed">
|
||||
Interactive, experiential learning approaches that engage participants and drive lasting impact.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Section 3: Who We Are - Updated Statistics */}
|
||||
{/* Section 3: Who We Are - Updated Statistics - Dynamic from API */}
|
||||
<section className="py-24 lg:py-32" style={{ backgroundColor: '#FFFFFF' }}>
|
||||
<div className="section-margin-x">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
@@ -552,7 +608,7 @@ export function AboutUs() {
|
||||
<div className="lg:col-span-1">
|
||||
<div className="branded-tag-system">
|
||||
<div className="dot"></div>
|
||||
<span className="text">Who we are</span>
|
||||
<span className="text">{aboutUsData?.who_we_are_title || "Who we are"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -568,96 +624,122 @@ export function AboutUs() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Updated Statistics Grid */}
|
||||
{/* Updated Statistics Grid - Dynamic from API */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8 lg:gap-12 pt-12 border-t border-gray-200">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.1 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center lg:text-left"
|
||||
>
|
||||
<div className="text-5xl lg:text-6xl font-medium mb-2" style={{
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
lineHeight: '1',
|
||||
color: 'var(--color-primary)'
|
||||
}}>
|
||||
150+
|
||||
</div>
|
||||
<div className="flex items-center justify-center lg:justify-start gap-2 text-body text-muted" style={{ fontFamily: 'var(--font-family-base)' }}>
|
||||
<div className="w-2 h-2 rounded-sm" style={{ backgroundColor: 'var(--color-accent)' }}></div>
|
||||
<span style={{ color: 'var(--color-black)', fontWeight: '500' }}>CORPORATES</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
{(aboutUsData?.stat_section && aboutUsData.stat_section.length > 0) ? (
|
||||
aboutUsData.stat_section.map((stat, index) => (
|
||||
<motion.div
|
||||
key={stat.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.1 * (index + 1) }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center lg:text-left"
|
||||
>
|
||||
<div className="text-5xl lg:text-6xl font-medium mb-2" style={{
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
lineHeight: '1',
|
||||
color: 'var(--color-primary)'
|
||||
}}>
|
||||
{stat.number}{stat.suffix}
|
||||
</div>
|
||||
<div className="flex items-center justify-center lg:justify-start gap-2 text-body text-muted" style={{ fontFamily: 'var(--font-family-base)' }}>
|
||||
<div className="w-2 h-2 rounded-sm" style={{ backgroundColor: 'var(--color-accent)' }}></div>
|
||||
<span style={{ color: 'var(--color-black)', fontWeight: '500' }}>{stat.label.toUpperCase()}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
))
|
||||
) : (
|
||||
// Fallback to static statistics if API data is not available
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.1 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center lg:text-left"
|
||||
>
|
||||
<div className="text-5xl lg:text-6xl font-medium mb-2" style={{
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
lineHeight: '1',
|
||||
color: 'var(--color-primary)'
|
||||
}}>
|
||||
150+
|
||||
</div>
|
||||
<div className="flex items-center justify-center lg:justify-start gap-2 text-body text-muted" style={{ fontFamily: 'var(--font-family-base)' }}>
|
||||
<div className="w-2 h-2 rounded-sm" style={{ backgroundColor: 'var(--color-accent)' }}></div>
|
||||
<span style={{ color: 'var(--color-black)', fontWeight: '500' }}>CORPORATES</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center lg:text-left"
|
||||
>
|
||||
<div className="text-5xl lg:text-6xl font-medium mb-2" style={{
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
lineHeight: '1',
|
||||
color: 'var(--color-primary)'
|
||||
}}>
|
||||
27,000+
|
||||
</div>
|
||||
<div className="flex items-center justify-center lg:justify-start gap-2 text-body text-muted" style={{ fontFamily: 'var(--font-family-base)' }}>
|
||||
<div className="w-2 h-2 rounded-sm" style={{ backgroundColor: 'var(--color-accent)' }}></div>
|
||||
<span style={{ color: 'var(--color-black)', fontWeight: '500' }}>LEADERS</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center lg:text-left"
|
||||
>
|
||||
<div className="text-5xl lg:text-6xl font-medium mb-2" style={{
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
lineHeight: '1',
|
||||
color: 'var(--color-primary)'
|
||||
}}>
|
||||
27,000+
|
||||
</div>
|
||||
<div className="flex items-center justify-center lg:justify-start gap-2 text-body text-muted" style={{ fontFamily: 'var(--font-family-base)' }}>
|
||||
<div className="w-2 h-2 rounded-sm" style={{ backgroundColor: 'var(--color-accent)' }}></div>
|
||||
<span style={{ color: 'var(--color-black)', fontWeight: '500' }}>LEADERS</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.3 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center lg:text-left"
|
||||
>
|
||||
<div className="text-5xl lg:text-6xl font-medium mb-2" style={{
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
lineHeight: '1',
|
||||
color: 'var(--color-primary)'
|
||||
}}>
|
||||
5,000+
|
||||
</div>
|
||||
<div className="flex items-center justify-center lg:justify-start gap-2 text-body text-muted" style={{ fontFamily: 'var(--font-family-base)' }}>
|
||||
<div className="w-2 h-2 rounded-sm" style={{ backgroundColor: 'var(--color-accent)' }}></div>
|
||||
<span style={{ color: 'var(--color-black)', fontWeight: '500' }}>ROOM NIGHTS</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.3 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center lg:text-left"
|
||||
>
|
||||
<div className="text-5xl lg:text-6xl font-medium mb-2" style={{
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
lineHeight: '1',
|
||||
color: 'var(--color-primary)'
|
||||
}}>
|
||||
5,000+
|
||||
</div>
|
||||
<div className="flex items-center justify-center lg:justify-start gap-2 text-body text-muted" style={{ fontFamily: 'var(--font-family-base)' }}>
|
||||
<div className="w-2 h-2 rounded-sm" style={{ backgroundColor: 'var(--color-accent)' }}></div>
|
||||
<span style={{ color: 'var(--color-black)', fontWeight: '500' }}>ROOM NIGHTS</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.4 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center lg:text-left"
|
||||
>
|
||||
<div className="text-3xl lg:text-4xl font-medium mb-2" style={{
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
lineHeight: '1',
|
||||
color: 'var(--color-primary)'
|
||||
}}>
|
||||
India & APAC
|
||||
</div>
|
||||
<div className="flex items-center justify-center lg:justify-start gap-2 text-body text-muted" style={{ fontFamily: 'var(--font-family-base)' }}>
|
||||
<div className="w-2 h-2 rounded-sm" style={{ backgroundColor: 'var(--color-accent)' }}></div>
|
||||
<span style={{ color: 'var(--color-black)', fontWeight: '500' }}>PRESENCE</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.4 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center lg:text-left"
|
||||
>
|
||||
<div className="text-3xl lg:text-4xl font-medium mb-2" style={{
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
lineHeight: '1',
|
||||
color: 'var(--color-primary)'
|
||||
}}>
|
||||
India & APAC
|
||||
</div>
|
||||
<div className="flex items-center justify-center lg:justify-start gap-2 text-body text-muted" style={{ fontFamily: 'var(--font-family-base)' }}>
|
||||
<div className="w-2 h-2 rounded-sm" style={{ backgroundColor: 'var(--color-accent)' }}></div>
|
||||
<span style={{ color: 'var(--color-black)', fontWeight: '500' }}>PRESENCE</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
{/* Section 4: Our Team */}
|
||||
{/* Section 4: Our Team - Dynamic from API */}
|
||||
<section className="py-24 lg:py-32" style={{ backgroundColor: '#F9F9F9' }}>
|
||||
<div className="section-margin-x">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
@@ -669,8 +751,8 @@ export function AboutUs() {
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<BrandedTag text="Our Team" />
|
||||
<h2 className="text-h2 mb-8">Our Team</h2>
|
||||
<BrandedTag text={aboutUsData?.our_team_title || "Our Team"} />
|
||||
<h2 className="text-h2 mb-8">{aboutUsData?.our_team_title || "Our Team"}</h2>
|
||||
<div className="max-w-4xl mx-auto text-center space-y-6">
|
||||
<p className="text-body-lg text-muted leading-relaxed">
|
||||
We have a team of 7 consultants and 4 young consultants. All our senior Consultants are ex-business professionals with experience ranging from 15-30 years in varied business functions and carry a deep understanding of the area they are engaging in. Two of them bring in Board room experience. – Meet them
|
||||
@@ -678,9 +760,9 @@ export function AboutUs() {
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Team Members Grid */}
|
||||
{/* Team Members Grid - Using static team members with full profiles */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8 lg:gap-12">
|
||||
{teamMembers.map((member, index) => (
|
||||
{staticTeamMembers.map((member, index) => (
|
||||
<motion.div
|
||||
key={member.name}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
@@ -725,11 +807,60 @@ export function AboutUs() {
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Alternative: Use API team data if needed */}
|
||||
{/* {aboutUsData?.our_team && aboutUsData.our_team.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8 lg:gap-12">
|
||||
{aboutUsData.our_team.map((member, index) => (
|
||||
<motion.div
|
||||
key={member.id}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: index * 0.1 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-left cursor-pointer"
|
||||
>
|
||||
<div className="relative mb-6 group">
|
||||
<div className="aspect-square rounded-2xl overflow-hidden bg-gray-100 shadow-lg group-hover:shadow-xl transition-all duration-300">
|
||||
<img
|
||||
src={member.photo_url}
|
||||
alt={member.alt_text}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-all duration-300 rounded-2xl flex items-center justify-center">
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<div
|
||||
className="px-4 py-2 rounded-lg text-white text-small"
|
||||
style={{
|
||||
backgroundColor: '#04045B',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
View Profile
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-h4 text-black group-hover:text-primary transition-colors duration-300">
|
||||
{member.name_role.split(' - ')[0]}
|
||||
</h3>
|
||||
<p className="text-body text-muted leading-relaxed">
|
||||
{member.name_role.split(' - ')[1] || ''}
|
||||
</p>
|
||||
<p className="text-small text-muted">{member.bio}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Section 5: Our Methodology */}
|
||||
{/* Section 5: Our Methodology (Static - unchanged) */}
|
||||
<section className="py-16 lg:py-20" style={{ backgroundColor: '#FFFFFF' }}>
|
||||
<div className="section-margin-x">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
@@ -750,19 +881,19 @@ export function AboutUs() {
|
||||
<div
|
||||
className="absolute left-4 top-0 w-0.5 bg-gray-300"
|
||||
style={{
|
||||
height: 'calc(100% - 1rem)', // Adjusted to end exactly at Phase 3 dot
|
||||
height: 'calc(100% - 1rem)',
|
||||
zIndex: 1
|
||||
}}
|
||||
></div>
|
||||
|
||||
{/* Vertical Line Fill - Blue - Animated on Scroll - Ends exactly at Phase 3 dot */}
|
||||
{/* Vertical Line Fill - Blue - Animated on Scroll */}
|
||||
<div
|
||||
id="timeline-fill-line"
|
||||
className="absolute left-4 top-0 w-0.5 transition-all duration-1000 ease-out"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-primary)',
|
||||
height: '0%',
|
||||
maxHeight: 'calc(100% - 1rem)', // Limit to Phase 3 dot position
|
||||
maxHeight: 'calc(100% - 1rem)',
|
||||
zIndex: 2
|
||||
}}
|
||||
></div>
|
||||
@@ -1168,8 +1299,6 @@ export function AboutUs() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
{/* Testimonials Section */}
|
||||
<TestimonialsSection
|
||||
title="What Our Clients Say About Us"
|
||||
|
||||
@@ -1,113 +1,127 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Input } from './ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import { ImageWithFallback } from './figma/ImageWithFallback';
|
||||
import { PrimaryCTAButton } from './PrimaryCTAButton';
|
||||
import { navigateTo } from './Router';
|
||||
import {
|
||||
Search,
|
||||
Calendar,
|
||||
User,
|
||||
ArrowRight,
|
||||
BookOpen,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Filter,
|
||||
Grid,
|
||||
List,
|
||||
SortAsc,
|
||||
Clock,
|
||||
Eye,
|
||||
Star,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Search,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
import { articlesData } from '../data/articlesData';
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import { BlogItem, useGetBlogsQuery } from '../redux/services/blogApi';
|
||||
import { useGetFaqCategoriesQuery } from '../redux/services/faqApi';
|
||||
import { ImageWithFallback } from './figma/ImageWithFallback';
|
||||
import { PrimaryCTAButton } from './PrimaryCTAButton';
|
||||
import { navigateTo } from './Router';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Button } from './ui/button';
|
||||
import { Card, CardContent } from './ui/card';
|
||||
import { Input } from './ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import { FullScreenLoader } from './FullScreenLoader';
|
||||
import { getSlugWithId } from '../utils/urlHelpers';
|
||||
|
||||
// Define category type with ID and name
|
||||
interface CategoryOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
type DateRange =
|
||||
| 'all_time'
|
||||
| 'last_7_days'
|
||||
| 'last_30_days'
|
||||
| 'last_3_months'
|
||||
| 'last_6_months';
|
||||
|
||||
type SortBy =
|
||||
| 'most_recent'
|
||||
| 'oldest_first'
|
||||
| 'title_az';
|
||||
|
||||
export function Articles() {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState('All Categories');
|
||||
const [selectedAuthor, setSelectedAuthor] = useState('All Authors');
|
||||
const [selectedCategory, setSelectedCategory] = useState<CategoryOption>({ id: 'all', name: 'All Categories' });
|
||||
const [selectedReadTime, setSelectedReadTime] = useState('All Read Times');
|
||||
const [selectedDateRange, setSelectedDateRange] = useState('All Time');
|
||||
const [selectedTopic, setSelectedTopic] = useState('All Topics');
|
||||
const [sortBy, setSortBy] = useState('Most Recent');
|
||||
const [selectedDateRange, setSelectedDateRange] = useState<DateRange>('all_time');
|
||||
const [selectedTopic, setSelectedTopic] = useState<{
|
||||
id: string;
|
||||
name: string;
|
||||
}>({
|
||||
id: 'all',
|
||||
name: 'All Topics'
|
||||
});
|
||||
const [sortBy, setSortBy] = useState<SortBy>('most_recent');
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const articlesPerPage = 4;
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const articlesPerPage = 6;
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [allTags, setAllTags] = useState<{ id: string; name: string }[]>([]);
|
||||
|
||||
// Use articlesData instead of the old articles variable
|
||||
const articles = articlesData;
|
||||
|
||||
// Get unique values for filters - FIXED: using articlesData
|
||||
const categories = ['All Categories', ...Array.from(new Set(articlesData.map(article => article.category)))];
|
||||
const authors = ['All Authors', ...Array.from(new Set(articlesData.map(article => article.author)))];
|
||||
const readTimes = ['All Read Times', 'Under 5 min', '5-10 min', 'Over 10 min'];
|
||||
const dateRanges = ['All Time', 'Last 7 days', 'Last 30 days', 'Last 3 months'];
|
||||
const allTags = Array.from(new Set(articlesData.flatMap(article => article.tags)));
|
||||
const sortOptions = [
|
||||
{ value: 'Most Recent', label: 'Most Recent' },
|
||||
{ value: 'oldest', label: 'Oldest First' },
|
||||
{ value: 'title', label: 'Title A-Z' },
|
||||
{ value: 'readTime', label: 'Read Time' },
|
||||
{ value: 'popular', label: 'Most Popular' }
|
||||
];
|
||||
|
||||
// Filter and sort articles - FIXED: using articlesData
|
||||
const filteredArticles = articlesData.filter(article => {
|
||||
const matchesSearch = article.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
article.excerpt.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
article.author.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
article.tags.some(tag => tag.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
const matchesCategory = selectedCategory === 'All Categories' || article.category === selectedCategory;
|
||||
const matchesAuthor = selectedAuthor === 'All Authors' || article.author === selectedAuthor;
|
||||
|
||||
// FIXED: Read time filter - properly parse the read time
|
||||
const readTimeMinutes = parseInt(article.readTime.replace(' min read', '')) || 0;
|
||||
const matchesReadTime = selectedReadTime === 'All Read Times' ||
|
||||
(selectedReadTime === 'Under 5 min' && readTimeMinutes < 5) ||
|
||||
(selectedReadTime === '5-10 min' && readTimeMinutes >= 5 && readTimeMinutes <= 10) ||
|
||||
(selectedReadTime === 'Over 10 min' && readTimeMinutes > 10);
|
||||
|
||||
// Date range filter
|
||||
const articleDate = new Date(article.date);
|
||||
const now = new Date();
|
||||
const matchesDateRange = selectedDateRange === 'All Time' ||
|
||||
(selectedDateRange === 'Last 7 days' && (now.getTime() - articleDate.getTime()) <= 7 * 24 * 60 * 60 * 1000) ||
|
||||
(selectedDateRange === 'Last 30 days' && (now.getTime() - articleDate.getTime()) <= 30 * 24 * 60 * 60 * 1000) ||
|
||||
(selectedDateRange === 'Last 3 months' && (now.getTime() - articleDate.getTime()) <= 90 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Topic filter
|
||||
const matchesTopic = selectedTopic === 'All Topics' ||
|
||||
article.tags.includes(selectedTopic);
|
||||
|
||||
return matchesSearch && matchesCategory && matchesAuthor && matchesReadTime && matchesDateRange && matchesTopic;
|
||||
}).sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'Most Recent':
|
||||
return new Date(b.date).getTime() - new Date(a.date).getTime();
|
||||
case 'oldest':
|
||||
return new Date(a.date).getTime() - new Date(b.date).getTime();
|
||||
case 'title':
|
||||
return a.title.localeCompare(b.title);
|
||||
case 'readTime':
|
||||
// FIXED: Sort by read time - properly parse the values
|
||||
return (parseInt(a.readTime.replace(' min read', '')) || 0) - (parseInt(b.readTime.replace(' min read', '')) || 0);
|
||||
case 'popular':
|
||||
return parseInt(b.views) - parseInt(a.views);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
// Fetch categories for filter dropdown
|
||||
const {
|
||||
data: categoriesData,
|
||||
isLoading: isLoadingCategories
|
||||
} = useGetFaqCategoriesQuery({
|
||||
limit: 100,
|
||||
offset: 0
|
||||
});
|
||||
|
||||
// Paginate results
|
||||
const totalPages = Math.ceil(filteredArticles.length / articlesPerPage);
|
||||
const startIndex = (currentPage - 1) * articlesPerPage;
|
||||
const currentArticles = filteredArticles.slice(startIndex, startIndex + articlesPerPage);
|
||||
// Filter categories to only those for blog and create options with id and name
|
||||
const categories: CategoryOption[] = [
|
||||
{ id: 'all', name: 'All Categories' },
|
||||
...(categoriesData?.data?.items
|
||||
?.filter((category: any) => category.for_blog)
|
||||
.map((category: any) => ({
|
||||
id: category.id,
|
||||
name: category.category_name
|
||||
})) || [])
|
||||
];
|
||||
|
||||
// Debounce search term
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedSearch(searchTerm);
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchTerm]);
|
||||
|
||||
// Fetch blogs from API with all parameters
|
||||
const {
|
||||
data: blogsData,
|
||||
isLoading: isLoadingBlogs,
|
||||
isError: isBlogsError,
|
||||
refetch: refetchBlogs
|
||||
} = useGetBlogsQuery({
|
||||
limit: articlesPerPage,
|
||||
offset: (currentPage - 1) * articlesPerPage,
|
||||
search: debouncedSearch || undefined,
|
||||
content_status: 'publish',
|
||||
content_type: 'BLOG',
|
||||
date_range: selectedDateRange !== 'all_time' ? selectedDateRange : undefined,
|
||||
sort_by: sortBy,
|
||||
content_category_id: selectedCategory.id !== 'all' ? selectedCategory.id : undefined, // Send UUID
|
||||
tag_id: selectedTopic.id !== 'all' ? selectedTopic.id : undefined,
|
||||
});
|
||||
|
||||
|
||||
const sortOptions = [
|
||||
{ value: 'most_recent', label: 'Most Recent' },
|
||||
{ value: 'oldest_first', label: 'Oldest First' },
|
||||
{ value: 'title_az', label: 'Title A-Z' }
|
||||
];
|
||||
|
||||
const readTimes = ['All Read Times', 'Under 5 min', '5-10 min', 'Over 10 min'];
|
||||
const dateRanges = [
|
||||
{ value: 'all_time', label: 'All Time' },
|
||||
{ value: 'last_7_days', label: 'Last 7 days' },
|
||||
{ value: 'last_30_days', label: 'Last 30 days' },
|
||||
{ value: 'last_3_months', label: 'Last 3 months' }
|
||||
];
|
||||
|
||||
// Format date function
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
@@ -116,25 +130,94 @@ export function Articles() {
|
||||
});
|
||||
};
|
||||
|
||||
const clearAllFilters = () => {
|
||||
setSearchTerm('');
|
||||
setSelectedCategory('All Categories');
|
||||
setSelectedAuthor('All Authors');
|
||||
setSelectedReadTime('All Read Times');
|
||||
setSelectedDateRange('All Time');
|
||||
setSelectedTopic('All Topics');
|
||||
setSortBy('Most Recent');
|
||||
// Calculate read time based on content length (approx)
|
||||
const calculateReadTime = (content: string): string => {
|
||||
const wordsPerMinute = 200;
|
||||
const wordCount = content.split(/\s+/).length;
|
||||
const minutes = Math.ceil(wordCount / wordsPerMinute);
|
||||
return `${minutes} min read`;
|
||||
};
|
||||
|
||||
const hasActiveFilters = searchTerm ||
|
||||
selectedCategory !== 'All Categories' ||
|
||||
selectedAuthor !== 'All Authors' ||
|
||||
// Filter articles by read time (client-side only - API doesn't support this)
|
||||
const getReadTimeFilteredArticles = () => {
|
||||
if (selectedReadTime === 'All Read Times' || !blogsData?.data?.items) {
|
||||
return blogsData?.data?.items || [];
|
||||
}
|
||||
|
||||
return (blogsData?.data?.items || []).filter((blog: BlogItem) => {
|
||||
const readTimeMinutes = parseInt(calculateReadTime(blog.content).replace(' min read', '')) || 0;
|
||||
return (selectedReadTime === 'Under 5 min' && readTimeMinutes < 5) ||
|
||||
(selectedReadTime === '5-10 min' && readTimeMinutes >= 5 && readTimeMinutes <= 10) ||
|
||||
(selectedReadTime === 'Over 10 min' && readTimeMinutes > 10);
|
||||
});
|
||||
};
|
||||
|
||||
const finalFilteredArticles = getReadTimeFilteredArticles();
|
||||
|
||||
const totalPages = Math.ceil((blogsData?.data?.pagination?.total || 0) / articlesPerPage);
|
||||
|
||||
const clearAllFilters = () => {
|
||||
setSearchTerm('');
|
||||
setDebouncedSearch('');
|
||||
setSelectedCategory({ id: 'all', name: 'All Categories' });
|
||||
setSelectedReadTime('All Read Times');
|
||||
setSelectedDateRange('all_time');
|
||||
setSelectedTopic({ id: 'all', name: 'All Topics' });
|
||||
setSortBy('most_recent');
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const hasActiveFilters = Boolean(
|
||||
searchTerm ||
|
||||
selectedCategory.id !== 'all' ||
|
||||
selectedReadTime !== 'All Read Times' ||
|
||||
selectedDateRange !== 'All Time' ||
|
||||
selectedTopic !== 'All Topics';
|
||||
selectedDateRange !== 'all_time' ||
|
||||
selectedTopic.id !== 'all'
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!blogsData?.data?.items || allTags.length > 0) return;
|
||||
|
||||
const tags = Array.from(
|
||||
new Map(
|
||||
blogsData.data.items
|
||||
.flatMap((blog: BlogItem) => blog.blog_tags || [])
|
||||
.map(tag => [tag.id, { id: tag.id, name: tag.tag_name }])
|
||||
).values()
|
||||
);
|
||||
|
||||
setAllTags(tags);
|
||||
}, [blogsData]);
|
||||
|
||||
// Handle loading state
|
||||
if (isLoadingBlogs || isLoadingCategories) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-white">
|
||||
<FullScreenLoader text="Loading articles..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle error state
|
||||
if (isBlogsError) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-white">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<X className="w-8 h-8 text-red-600" />
|
||||
</div>
|
||||
<h2 className="text-h3 mb-2">Failed to load articles</h2>
|
||||
<p className="text-gray-600 mb-4">Please try again later</p>
|
||||
<Button onClick={() => refetchBlogs()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ backgroundColor: '#FFFFFF' }}>
|
||||
<div style={{ backgroundColor: '#FFFFFF' }} ref={containerRef}>
|
||||
{/* Hero Section */}
|
||||
<section className="relative py-28 overflow-hidden">
|
||||
<div className="absolute inset-0">
|
||||
@@ -169,18 +252,16 @@ export function Articles() {
|
||||
<div className="section-margin-x">
|
||||
<div className="grid grid-cols-3 gap-8 text-center">
|
||||
<div>
|
||||
{/* FIXED: Using articlesData.length */}
|
||||
<div className="text-h2-white mb-2">{articlesData.length}+</div>
|
||||
<div className="text-h2-white mb-2">{blogsData?.data?.pagination?.total || 0}+</div>
|
||||
<div className="text-small-white">Expert Articles</div>
|
||||
</div>
|
||||
<div>
|
||||
{/* FIXED: Using categories from articlesData */}
|
||||
<div className="text-h2-white mb-2">{categories.length - 1}</div>
|
||||
<div className="text-small-white">Categories</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-h2-white mb-2">25,400</div>
|
||||
<div className="text-small-white">Total Reads</div>
|
||||
<div className="text-h2-white mb-2">{allTags.length}+</div>
|
||||
<div className="text-small-white">Topics</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -197,7 +278,7 @@ export function Articles() {
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search articles, authors, topics..."
|
||||
placeholder="Search articles..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 pr-4 py-3 text-body rounded-lg border border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-200 transition-all duration-200 w-full bg-gray-50"
|
||||
@@ -298,46 +379,37 @@ export function Articles() {
|
||||
<label className="block text-small mb-2 font-medium text-gray-700">
|
||||
Category
|
||||
</label>
|
||||
<Select value={selectedCategory} onValueChange={setSelectedCategory}>
|
||||
<SelectTrigger className="w-full text-small h-9 border-gray-300 hover:border-gray-400 transition-colors" style={{ '&:focus': { borderColor: 'var(--color-primary)' } }}>
|
||||
<SelectValue placeholder="All Categories" />
|
||||
<Select
|
||||
value={selectedCategory.id}
|
||||
onValueChange={(value: string) => {
|
||||
const category = categories.find(c => c.id === value);
|
||||
if (category) {
|
||||
setSelectedCategory(category);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full text-small h-9 border-gray-300 hover:border-gray-400 transition-colors">
|
||||
<SelectValue placeholder="All Categories">
|
||||
{selectedCategory.name}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category} value={category} className="text-small">
|
||||
{category}
|
||||
<SelectItem key={category.id} value={category.id} className="text-small">
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Author Filter */}
|
||||
<div className="filter-section">
|
||||
<label className="block text-small mb-2 font-medium text-gray-700">
|
||||
Author
|
||||
</label>
|
||||
<Select value={selectedAuthor} onValueChange={setSelectedAuthor}>
|
||||
<SelectTrigger className="w-full text-small h-9 border-gray-300 hover:border-gray-400 transition-colors" style={{ '&:focus': { borderColor: 'var(--color-primary)' } }}>
|
||||
<SelectValue placeholder="All Authors" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{authors.map((author) => (
|
||||
<SelectItem key={author} value={author} className="text-small">
|
||||
{author}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Read Time Filter */}
|
||||
{/* Read Time Filter - Client-side only */}
|
||||
<div className="filter-section">
|
||||
<label className="block text-small mb-2 font-medium text-gray-700">
|
||||
Read Time
|
||||
</label>
|
||||
<Select value={selectedReadTime} onValueChange={setSelectedReadTime}>
|
||||
<SelectTrigger className="w-full text-small h-9 border-gray-300 hover:border-gray-400 transition-colors" style={{ '&:focus': { borderColor: 'var(--color-primary)' } }}>
|
||||
<SelectTrigger className="w-full text-small h-9 border-gray-300 hover:border-gray-400 transition-colors">
|
||||
<SelectValue placeholder="All Read Times" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -356,13 +428,13 @@ export function Articles() {
|
||||
Date Range
|
||||
</label>
|
||||
<Select value={selectedDateRange} onValueChange={setSelectedDateRange}>
|
||||
<SelectTrigger className="w-full text-small h-9 border-gray-300 hover:border-gray-400 transition-colors" style={{ '&:focus': { borderColor: 'var(--color-primary)' } }}>
|
||||
<SelectTrigger className="w-full text-small h-9 border-gray-300 hover:border-gray-400 transition-colors">
|
||||
<SelectValue placeholder="All Time" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{dateRanges.map((dateRange) => (
|
||||
<SelectItem key={dateRange} value={dateRange} className="text-small">
|
||||
{dateRange}
|
||||
{dateRanges.map((range) => (
|
||||
<SelectItem key={range.value} value={range.value}>
|
||||
{range.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -374,17 +446,28 @@ export function Articles() {
|
||||
<label className="block text-small mb-2 font-medium text-gray-700">
|
||||
Topic
|
||||
</label>
|
||||
<Select value={selectedTopic} onValueChange={setSelectedTopic}>
|
||||
<SelectTrigger className="w-full text-small h-9 border-gray-300 hover:border-gray-400 transition-colors" style={{ '&:focus': { borderColor: 'var(--color-primary)' } }}>
|
||||
<Select
|
||||
value={selectedTopic.id}
|
||||
onValueChange={(value: string) => {
|
||||
if (value === 'all') {
|
||||
setSelectedTopic({ id: 'all', name: 'All Topics' });
|
||||
return;
|
||||
}
|
||||
const topic = allTags.find(t => t.id === value);
|
||||
if (topic) setSelectedTopic(topic);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full text-small h-9 border-gray-300 hover:border-gray-400 transition-colors">
|
||||
<SelectValue placeholder="All Topics" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All Topics" className="text-small">
|
||||
<SelectItem value="all">
|
||||
All Topics
|
||||
</SelectItem>
|
||||
|
||||
{allTags.map((tag) => (
|
||||
<SelectItem key={tag} value={tag} className="text-small">
|
||||
{tag}
|
||||
<SelectItem key={tag.id} value={tag.id}>
|
||||
{tag.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -399,73 +482,85 @@ export function Articles() {
|
||||
{/* Right Content Area - Scrollable Articles */}
|
||||
<div className="col-span-12 lg:col-span-9">
|
||||
<div className="mb-4 text-small text-muted">
|
||||
Showing {currentArticles.length} of {filteredArticles.length} articles
|
||||
Showing {finalFilteredArticles.length} of {blogsData?.data?.pagination?.total || 0} articles
|
||||
</div>
|
||||
|
||||
{/* Articles Results */}
|
||||
{currentArticles.length === 0 ? (
|
||||
{finalFilteredArticles.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<BookOpen className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-body-lg text-muted">
|
||||
No articles found matching your criteria.
|
||||
</p>
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={clearAllFilters}
|
||||
className="mt-4"
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Grid View */}
|
||||
{viewMode === 'grid' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{currentArticles.map((article) => (
|
||||
{finalFilteredArticles.map((article: BlogItem) => (
|
||||
<Card
|
||||
key={article.id}
|
||||
className="overflow-hidden hover:shadow-lg transition-all duration-300 cursor-pointer group"
|
||||
onClick={() => navigateTo(`/learning/articles/${article.slug}`)}
|
||||
onClick={() => {
|
||||
// Use slug_name to create the URL with full UUID
|
||||
const url = getSlugWithId(article.slug_name, article.id);
|
||||
navigateTo(`/learning/articles/${url}`);
|
||||
}}
|
||||
>
|
||||
<div className="aspect-video w-full bg-gray-100 overflow-hidden relative">
|
||||
<ImageWithFallback
|
||||
src={article.thumbnail}
|
||||
src={article.banner_img || 'https://images.unsplash.com/photo-1481627834876-b7833e8f5570?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxhcnRpY2xlJTIwYmxvZyUyMGNvbnRlbnQlMjBrbm93bGVkZ2V8ZW58MXx8fHwxNzU1ODU0Mjg2fDA&ixlib=rb-4.1.0&q=80&w=1080'}
|
||||
alt={article.title}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
{article.featured && (
|
||||
<div className="absolute top-4 right-4">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-yellow-100 text-yellow-800 border-yellow-200"
|
||||
>
|
||||
Featured
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Badge variant="outline" className="text-small">
|
||||
{article.category}
|
||||
{article.content_category}
|
||||
</Badge>
|
||||
<span className="text-small text-muted">{article.readTime}</span>
|
||||
<span className="text-small text-muted">
|
||||
{calculateReadTime(article.content)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="text-h4 mb-3 group-hover:text-blue-600 transition-colors line-clamp-2">
|
||||
<h3 className="text-h4 mb-3 group-hover:text-[#04045B] transition-colors line-clamp-2">
|
||||
{article.title}
|
||||
</h3>
|
||||
|
||||
<p className="text-small text-muted mb-4 line-clamp-3">
|
||||
{article.excerpt}
|
||||
{article.short_description || article.content.substring(0, 150) + '...'}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ImageWithFallback
|
||||
src={article.authorAvatar}
|
||||
alt={article.author}
|
||||
className="w-6 h-6 rounded-full object-cover"
|
||||
/>
|
||||
<span className="text-small">{article.author}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<div className="text-small text-muted">
|
||||
{formatDate(article.date)}
|
||||
{formatDate(article.updated_at)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Display tags if available */}
|
||||
{article.blog_tags && article.blog_tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-3 pt-3 border-t border-gray-100">
|
||||
{article.blog_tags.slice(0, 3).map((tag, idx) => (
|
||||
<Badge key={idx} variant="secondary" className="text-xs">
|
||||
{tag.tag_name}
|
||||
</Badge>
|
||||
))}
|
||||
{article.blog_tags.length > 3 && (
|
||||
<span className="text-xs text-gray-500">+{article.blog_tags.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
@@ -475,29 +570,23 @@ export function Articles() {
|
||||
{/* List View */}
|
||||
{viewMode === 'list' && (
|
||||
<div className="space-y-6">
|
||||
{currentArticles.map((article) => (
|
||||
{finalFilteredArticles.map((article: BlogItem) => (
|
||||
<Card
|
||||
key={article.id}
|
||||
className="overflow-hidden hover:shadow-lg transition-all duration-300 cursor-pointer group"
|
||||
onClick={() => navigateTo(`/learning/articles/${article.slug}`)}
|
||||
onClick={() => {
|
||||
// Use slug_name to create the URL with full UUID
|
||||
const url = getSlugWithId(article.slug_name, article.id);
|
||||
navigateTo(`/learning/articles/${url}`);
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col md:flex-row">
|
||||
<div className="md:w-80 h-48 md:h-auto bg-gray-100 overflow-hidden relative flex-shrink-0">
|
||||
<ImageWithFallback
|
||||
src={article.thumbnail}
|
||||
src={article.banner_img || 'https://images.unsplash.com/photo-1481627834876-b7833e8f5570?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxhcnRpY2xlJTIwYmxvZyUyMGNvbnRlbnQlMjBrbm93bGVkZ2V8ZW58MXx8fHwxNzU1ODU0Mjg2fDA&ixlib=rb-4.1.0&q=80&w=1080'}
|
||||
alt={article.title}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
{article.featured && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-yellow-100 text-yellow-800 border-yellow-200 text-xs"
|
||||
>
|
||||
Featured
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-6">
|
||||
@@ -505,38 +594,39 @@ export function Articles() {
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Badge variant="outline" className="text-small">
|
||||
{article.category}
|
||||
{article.content_category}
|
||||
</Badge>
|
||||
<span className="text-small text-muted">{article.readTime}</span>
|
||||
<div className="flex items-center gap-1 text-small text-muted">
|
||||
<Eye className="w-3 h-3" />
|
||||
<span>{article.views}</span>
|
||||
</div>
|
||||
<span className="text-small text-muted">
|
||||
{calculateReadTime(article.content)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="text-h4 mb-2 group-hover:text-blue-600 transition-colors">
|
||||
<h3 className="text-h4 mb-2 group-hover:text-[#04045B] transition-colors">
|
||||
{article.title}
|
||||
</h3>
|
||||
|
||||
<p className="text-body text-muted mb-3">
|
||||
{article.excerpt}
|
||||
{article.short_description || article.content.substring(0, 200) + '...'}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ImageWithFallback
|
||||
src={article.authorAvatar}
|
||||
alt={article.author}
|
||||
className="w-6 h-6 rounded-full object-cover"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-small font-medium">{article.author}</span>
|
||||
<span className="text-small text-muted ml-1">• {article.authorTitle}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-small text-muted">
|
||||
{formatDate(article.date)}
|
||||
{formatDate(article.updated_at)}
|
||||
</div>
|
||||
|
||||
{/* Display tags if available */}
|
||||
{article.blog_tags && article.blog_tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{article.blog_tags.slice(0, 2).map((tag, idx) => (
|
||||
<Badge key={idx} variant="secondary" className="text-xs">
|
||||
{tag.tag_name}
|
||||
</Badge>
|
||||
))}
|
||||
{article.blog_tags.length > 2 && (
|
||||
<span className="text-xs text-gray-500">+{article.blog_tags.length - 2}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -592,7 +682,7 @@ export function Articles() {
|
||||
containerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}}
|
||||
className={`min-w-10 ${currentPage === page
|
||||
? 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
? 'bg-[#04045B] text-white hover:bg-[#04045B]'
|
||||
: 'border-gray-300 text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Button } from './ui/button';
|
||||
import { Card, CardContent } from './ui/card';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
|
||||
import { navigateTo } from './Router';
|
||||
import { ImageWithFallback } from './figma/ImageWithFallback';
|
||||
import { CTABannerSection } from './CTABannerSection';
|
||||
import { useCart } from './CartContext';
|
||||
// CRITICAL: Import articlesData from the centralized data file
|
||||
// DO NOT create a local articlesData variable - it will shadow this import
|
||||
// All article data must be maintained in /data/articlesData.ts
|
||||
import {
|
||||
Calendar,
|
||||
Clock,
|
||||
ArrowRight,
|
||||
ChevronUp,
|
||||
Share2,
|
||||
Bookmark,
|
||||
Twitter,
|
||||
Facebook,
|
||||
@@ -24,202 +19,91 @@ import {
|
||||
Heart,
|
||||
Eye,
|
||||
BookOpen,
|
||||
ArrowLeft,
|
||||
Star,
|
||||
MessageCircle,
|
||||
Users,
|
||||
TrendingUp,
|
||||
Award
|
||||
ArrowLeft
|
||||
} from 'lucide-react';
|
||||
import { articlesData } from '../data/articlesData';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
interface BlogPost {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
excerpt: string;
|
||||
content: string;
|
||||
author: string;
|
||||
authorBio: string;
|
||||
authorImage: string;
|
||||
publishedDate: string;
|
||||
updatedDate?: string;
|
||||
readTime: string;
|
||||
views: number;
|
||||
likes: number;
|
||||
category: string;
|
||||
tags: string[];
|
||||
image: string;
|
||||
featured: boolean;
|
||||
}
|
||||
|
||||
interface RelatedPost {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
excerpt: string;
|
||||
author: string;
|
||||
publishedDate: string;
|
||||
readTime: string;
|
||||
category: string;
|
||||
image: string;
|
||||
}
|
||||
import { useGetBlogByIDQuery, useGetBlogsQuery } from '../redux/services/blogApi';
|
||||
import { FullScreenLoader } from './FullScreenLoader';
|
||||
import { extractIdFromSlug, extractSlugFromSlugAndId, getSlugWithId } from '../utils/urlHelpers';
|
||||
|
||||
interface BlogDetailProps {
|
||||
params?: {
|
||||
slug?: string;
|
||||
slugAndId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// NOTE: Using articlesData imported from '../data/articlesData' (line 10)
|
||||
// DO NOT create a local articlesData variable here as it will shadow the import
|
||||
|
||||
// Function to get blog post by slug
|
||||
const getBlogPostBySlug = (slug: string): BlogPost => {
|
||||
console.log('🔍 Searching for slug:', slug);
|
||||
console.log('📋 Available slugs:', articlesData.map(a => ({ id: a.id, slug: a.slug })));
|
||||
|
||||
const article = articlesData.find(article => article.slug === slug);
|
||||
|
||||
if (!article) {
|
||||
console.error('❌ Article not found. Available slugs:', articlesData.map(a => a.slug));
|
||||
return {
|
||||
id: 'not-found',
|
||||
title: 'Article Not Found',
|
||||
slug: 'not-found',
|
||||
excerpt: `The requested article "${slug}" could not be found.`,
|
||||
content: `
|
||||
<p>We're sorry, but the article you're looking for could not be found. It may have been moved or removed.</p>
|
||||
<p>Available articles:</p>
|
||||
<ul>
|
||||
${articlesData.slice(0, 6).map(article =>
|
||||
`<li><a href="/learning/articles/${article.slug}">${article.title}</a></li>`
|
||||
).join('')}
|
||||
</ul>
|
||||
`,
|
||||
author: 'KLC Team',
|
||||
authorBio: 'The Kautilya Leadership Center team',
|
||||
authorImage: 'https://images.unsplash.com/photo-1494790108755-2616b612b47c?w=150&h=150&fit=crop',
|
||||
publishedDate: '2024-01-01',
|
||||
updatedDate: '2024-01-01',
|
||||
readTime: '1 min read',
|
||||
views: 0,
|
||||
likes: 0,
|
||||
category: 'General',
|
||||
tags: [],
|
||||
image: 'https://images.unsplash.com/photo-1552664730-d307ca884978?w=1200&h=600&fit=crop',
|
||||
featured: false
|
||||
};
|
||||
}
|
||||
|
||||
console.log('✅ Article found:', article.title);
|
||||
|
||||
return {
|
||||
id: article.id,
|
||||
title: article.title,
|
||||
slug: article.slug,
|
||||
excerpt: article.excerpt || '',
|
||||
content: article.content || '',
|
||||
author: article.author || 'Unknown Author',
|
||||
authorBio: article.authorBio || `${article.author} is a contributor to Kautilya Leadership Center.`,
|
||||
authorImage: article.authorAvatar || 'https://images.unsplash.com/photo-1494790108755-2616b612b47c?w=150&h=150&fit=crop',
|
||||
publishedDate: article.date || '2024-01-01',
|
||||
updatedDate: article.date || '2024-01-01',
|
||||
readTime: article.readTime || '5 min read',
|
||||
views: parseInt((article.views || '0').replace('k', '00').replace('.', '')) || 0,
|
||||
likes: article.likes || 0,
|
||||
category: article.category || 'General',
|
||||
tags: article.tags || [],
|
||||
image: article.thumbnail || 'https://images.unsplash.com/photo-1552664730-d307ca884978?w=1200&h=600&fit=crop',
|
||||
featured: article.featured || false
|
||||
};
|
||||
};
|
||||
|
||||
// Generate related posts dynamically from articlesData
|
||||
const getRelatedPosts = (currentSlug: string, currentCategory: string): RelatedPost[] => {
|
||||
// Filter articles by same category or exclude current article
|
||||
const related = articlesData
|
||||
.filter(article => article.slug !== currentSlug)
|
||||
.slice(0, 3)
|
||||
.map(article => ({
|
||||
id: article.id,
|
||||
title: article.title,
|
||||
slug: article.slug,
|
||||
excerpt: article.excerpt,
|
||||
author: article.author,
|
||||
publishedDate: article.date,
|
||||
readTime: article.readTime,
|
||||
category: article.category,
|
||||
image: article.thumbnail
|
||||
}));
|
||||
|
||||
return related;
|
||||
};
|
||||
|
||||
const relatedPosts: RelatedPost[] = [
|
||||
{
|
||||
id: '5',
|
||||
title: 'A New Lens on Leadership',
|
||||
slug: 'new-lens-on-leadership',
|
||||
excerpt: 'Your leadership calls, and how you interpret opportunities and threats, are influenced by your lenses, which are unique and personal to you.',
|
||||
author: 'K Ramkumar',
|
||||
publishedDate: '2016-08-16',
|
||||
readTime: '12 min read',
|
||||
category: 'Leadership Philosophy',
|
||||
image: 'https://images.unsplash.com/photo-1560550900-5c10828c40aa?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxsZWFkZXJzaGlwJTIwbGVucyUyMHBlcnNwZWN0aXZlfGVufDF8fHx8MTc1OTk5NTg0N3ww&ixlib=rb-4.1.0&q=80&w=400'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Inspiration – the magic potion to leadership',
|
||||
slug: 'inspiration-magic-potion-leadership',
|
||||
excerpt: 'Explore how inspiration serves as a fundamental catalyst for effective leadership, transforming both leaders and their teams toward extraordinary achievements.',
|
||||
author: 'Ramkumar Krishnaswamy',
|
||||
publishedDate: '2017-10-13',
|
||||
readTime: '7 min read',
|
||||
category: 'Leadership Philosophy',
|
||||
image: 'https://images.unsplash.com/photo-1559027615-cd4628902d4a?w=400&h=300&fit=crop'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Lessons on Leadership Paradox',
|
||||
slug: 'lessons-leadership-paradox',
|
||||
excerpt: 'Uncover the inherent paradoxes in leadership and learn how embracing these contradictions can make you a more effective and authentic leader.',
|
||||
author: 'Ramkumar Krishnaswamy',
|
||||
publishedDate: '2017-08-10',
|
||||
readTime: '7 min read',
|
||||
category: 'Leadership Strategy',
|
||||
image: 'https://images.unsplash.com/photo-1552664730-d307ca884978?w=400&h=300&fit=crop'
|
||||
}
|
||||
];
|
||||
|
||||
export function BlogDetail({ params }: BlogDetailProps) {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const { slugAndId } = useParams<{ slugAndId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [scrollProgress, setScrollProgress] = useState(0);
|
||||
const [showBackToTop, setShowBackToTop] = useState(false);
|
||||
const [isLiked, setIsLiked] = useState(false);
|
||||
const [isBookmarked, setIsBookmarked] = useState(false);
|
||||
const { addToCart } = useCart();
|
||||
|
||||
// Get blog post data based on slug
|
||||
const blogPost = getBlogPostBySlug(slug || "");
|
||||
|
||||
// Extract full ID from URL using the new function
|
||||
const fullId = slugAndId ? extractIdFromSlug(slugAndId) : null;
|
||||
const urlSlug = slugAndId ? extractSlugFromSlugAndId(slugAndId) : '';
|
||||
|
||||
// Fetch blog details by ID directly - no dependency on list API
|
||||
const {
|
||||
data: blogPost,
|
||||
isLoading: isLoadingBlog,
|
||||
isError: isBlogError,
|
||||
refetch: refetchBlog
|
||||
} = useGetBlogByIDQuery(fullId as string, {
|
||||
skip: !fullId,
|
||||
refetchOnMountOrArgChange: true,
|
||||
});
|
||||
|
||||
// Fetch related blogs (excluding current blog)
|
||||
const {
|
||||
data: relatedBlogsData,
|
||||
isLoading: isLoadingRelated
|
||||
} = useGetBlogsQuery({
|
||||
limit: 3,
|
||||
offset: 0,
|
||||
content_status: 'publish',
|
||||
content_type: 'BLOG',
|
||||
}, {
|
||||
skip: !fullId,
|
||||
});
|
||||
|
||||
// SEO: Check if URL slug matches the actual slug_name and redirect if needed
|
||||
useEffect(() => {
|
||||
if (blogPost && fullId) {
|
||||
// Get the expected slug from the blog post
|
||||
const expectedSlug = blogPost.slug_name;
|
||||
// Create the expected URL with proper formatting
|
||||
const expectedUrl = getSlugWithId(expectedSlug, fullId);
|
||||
// Get the current URL slug
|
||||
const currentSlugAndId = slugAndId || '';
|
||||
|
||||
// Compare (case-insensitive)
|
||||
if (currentSlugAndId.toLowerCase() !== expectedUrl.toLowerCase()) {
|
||||
// Redirect to the correct URL
|
||||
navigate(`/learning/articles/${expectedUrl}`, { replace: true });
|
||||
}
|
||||
}
|
||||
}, [blogPost, fullId, slugAndId, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `${blogPost.title} | KLC Blog`;
|
||||
if (blogPost?.title) {
|
||||
document.title = `${blogPost.title} | KLC Blog`;
|
||||
}
|
||||
window.scrollTo(0, 0);
|
||||
|
||||
|
||||
const handleScroll = () => {
|
||||
const winScroll = document.body.scrollTop || document.documentElement.scrollTop;
|
||||
const height = document.documentElement.scrollHeight - document.documentElement.clientHeight;
|
||||
const scrolled = (winScroll / height) * 100;
|
||||
|
||||
|
||||
setScrollProgress(scrolled);
|
||||
setShowBackToTop(winScroll > 300);
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, [blogPost.title]);
|
||||
}, [blogPost?.title]);
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
@@ -231,8 +115,8 @@ export function BlogDetail({ params }: BlogDetailProps) {
|
||||
|
||||
const handleShare = (platform: string) => {
|
||||
const url = window.location.href;
|
||||
const title = blogPost.title;
|
||||
|
||||
const title = blogPost?.title || '';
|
||||
|
||||
const shareUrls = {
|
||||
twitter: `https://twitter.com/intent/tweet?text=${encodeURIComponent(title)}&url=${encodeURIComponent(url)}`,
|
||||
facebook: `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`,
|
||||
@@ -252,13 +136,71 @@ export function BlogDetail({ params }: BlogDetailProps) {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
// Calculate read time based on content length (approx)
|
||||
const calculateReadTime = (content: string): string => {
|
||||
const wordsPerMinute = 200;
|
||||
const wordCount = content.split(/\s+/).length;
|
||||
const minutes = Math.ceil(wordCount / wordsPerMinute);
|
||||
return `${minutes} min read`;
|
||||
};
|
||||
|
||||
// Filter related blogs (exclude current blog)
|
||||
const relatedPosts = relatedBlogsData?.data?.items
|
||||
?.filter((blog: any) => blog.id !== fullId)
|
||||
.map((blog: any) => ({
|
||||
id: blog.id,
|
||||
title: blog.title,
|
||||
slug: blog.slug_name,
|
||||
excerpt: blog.short_description || blog.content.substring(0, 150) + '...',
|
||||
author: blog.author_name || 'KLC Team',
|
||||
publishedDate: blog.updated_at,
|
||||
readTime: calculateReadTime(blog.content),
|
||||
category: blog.content_category,
|
||||
image: blog.banner_img || 'https://images.unsplash.com/photo-1552664730-d307ca884978?w=400&h=300&fit=crop'
|
||||
})) || [];
|
||||
|
||||
// Handle related post click - use full UUID
|
||||
const handleRelatedPostClick = (postSlug: string, postId: string) => {
|
||||
const url = getSlugWithId(postSlug, postId);
|
||||
navigate(`/learning/articles/${url}`);
|
||||
};
|
||||
|
||||
// Handle loading state
|
||||
if (isLoadingBlog) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-white">
|
||||
<FullScreenLoader text="Loading article..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle error state
|
||||
if (isBlogError || !blogPost) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-white">
|
||||
<div className="text-center max-w-md px-4">
|
||||
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<BookOpen className="w-8 h-8 text-red-600" />
|
||||
</div>
|
||||
<h2 className="text-h3 mb-2">Article Not Found</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
The article you're looking for could not be found. It may have been moved or removed.
|
||||
</p>
|
||||
<Button onClick={() => navigate('/learning/articles')}>
|
||||
Back to Articles
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen" style={{ backgroundColor: '#FFFFFF' }}>
|
||||
{/* Scroll Progress Bar */}
|
||||
<div className="fixed top-0 left-0 w-full h-1 z-50" style={{ backgroundColor: 'rgba(0, 0, 0, 0.1)' }}>
|
||||
<div
|
||||
<div
|
||||
className="h-full transition-all duration-150"
|
||||
style={{
|
||||
style={{
|
||||
width: `${scrollProgress}%`,
|
||||
backgroundColor: '#04045B'
|
||||
}}
|
||||
@@ -287,7 +229,7 @@ export function BlogDetail({ params }: BlogDetailProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigateTo('/learning/articles')}
|
||||
onClick={() => navigate('/learning/articles')}
|
||||
className="p-0 h-auto font-medium hover:bg-transparent transition-colors"
|
||||
style={{ color: '#6F6F6F' }}
|
||||
>
|
||||
@@ -295,7 +237,7 @@ export function BlogDetail({ params }: BlogDetailProps) {
|
||||
Back to Articles
|
||||
</Button>
|
||||
<span className="text-[#E5E7EB]">•</span>
|
||||
<span>{blogPost.category}</span>
|
||||
<span>{blogPost.content_category || 'Article'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -307,64 +249,64 @@ export function BlogDetail({ params }: BlogDetailProps) {
|
||||
<header className="mb-16">
|
||||
{/* Category Badge */}
|
||||
<div className="mb-8">
|
||||
<Badge
|
||||
<Badge
|
||||
className="mb-6 text-small px-4 py-2 font-medium border-none"
|
||||
style={{
|
||||
backgroundColor: 'rgba(4, 4, 91, 0.1)',
|
||||
color: '#04045B'
|
||||
}}
|
||||
>
|
||||
{blogPost.category}
|
||||
{blogPost.content_category || 'Article'}
|
||||
</Badge>
|
||||
|
||||
|
||||
{/* Improved Typography Hierarchy */}
|
||||
<h1 className="text-h1 mb-6 leading-tight" style={{ color: '#26231A' }}>
|
||||
{blogPost.title}
|
||||
</h1>
|
||||
|
||||
|
||||
{/* Constrained Width Excerpt for Better Readability */}
|
||||
<div className="max-w-3xl">
|
||||
<p className="text-body-lg leading-relaxed" style={{ color: '#6F6F6F' }}>
|
||||
{blogPost.excerpt}
|
||||
{blogPost.short_description || blogPost.content.substring(0, 200) + '...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Enhanced Meta Bar with Cleaner Spacing */}
|
||||
<div
|
||||
<div
|
||||
className="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-6 p-6 rounded-xl border"
|
||||
style={{ backgroundColor: 'rgba(0, 0, 0, 0.02)', borderColor: 'rgba(0, 0, 0, 0.08)' }}
|
||||
>
|
||||
{/* Author Info with Improved Layout */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="w-14 h-14 ring-2 ring-white shadow-md">
|
||||
<AvatarImage src={blogPost.authorImage} alt={blogPost.author} />
|
||||
<AvatarFallback className="text-subhead font-medium">{blogPost.author.split(' ').map(n => n[0]).join('')}</AvatarFallback>
|
||||
<AvatarImage src="https://images.unsplash.com/photo-1494790108755-2616b612b47c?w=150&h=150&fit=crop" alt="Author" />
|
||||
<AvatarFallback className="text-subhead font-medium">KLC</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="text-subhead font-medium mb-1" style={{ color: '#26231A' }}>
|
||||
{blogPost.author}
|
||||
KLC Team
|
||||
</div>
|
||||
|
||||
|
||||
{/* Cleaner Meta Information with Subtle Dividers */}
|
||||
<div className="flex items-center gap-4 text-small" style={{ color: '#6F6F6F' }}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Calendar className="w-4 h-4" />
|
||||
{formatDate(blogPost.publishedDate)}
|
||||
{formatDate(blogPost.updated_at || new Date().toISOString())}
|
||||
</span>
|
||||
|
||||
|
||||
<div className="w-1 h-1 rounded-full" style={{ backgroundColor: '#E5E7EB' }}></div>
|
||||
|
||||
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="w-4 h-4" />
|
||||
{blogPost.readTime}
|
||||
{calculateReadTime(blogPost.content)}
|
||||
</span>
|
||||
|
||||
|
||||
<div className="w-1 h-1 rounded-full" style={{ backgroundColor: '#E5E7EB' }}></div>
|
||||
|
||||
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Eye className="w-4 h-4" />
|
||||
{blogPost.views.toLocaleString()}
|
||||
0
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -379,9 +321,9 @@ export function BlogDetail({ params }: BlogDetailProps) {
|
||||
className={`transition-colors ${isLiked ? 'text-red-500' : 'text-[#6F6F6F]'}`}
|
||||
>
|
||||
<Heart className={`w-4 h-4 mr-2 ${isLiked ? 'fill-current' : ''}`} />
|
||||
{blogPost.likes + (isLiked ? 1 : 0)}
|
||||
0
|
||||
</Button>
|
||||
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -390,7 +332,7 @@ export function BlogDetail({ params }: BlogDetailProps) {
|
||||
>
|
||||
<Bookmark className={`w-4 h-4 ${isBookmarked ? 'fill-current' : ''}`} />
|
||||
</Button>
|
||||
|
||||
|
||||
{/* Share Options */}
|
||||
<div className="flex items-center gap-1 ml-2 pl-2 border-l border-[#E5E7EB]">
|
||||
<Button variant="ghost" size="sm" onClick={() => handleShare('twitter')} className="text-[#6F6F6F] hover:text-[#04045B]">
|
||||
@@ -409,7 +351,7 @@ export function BlogDetail({ params }: BlogDetailProps) {
|
||||
{/* Featured Image with Better Aspect Ratio */}
|
||||
<div className="aspect-[16/9] rounded-xl overflow-hidden mt-8 shadow-lg">
|
||||
<ImageWithFallback
|
||||
src={blogPost.image}
|
||||
src={blogPost.banner_img || 'https://images.unsplash.com/photo-1552664730-d307ca884978?w=1200&h=600&fit=crop'}
|
||||
alt={blogPost.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
@@ -420,7 +362,7 @@ export function BlogDetail({ params }: BlogDetailProps) {
|
||||
<article className="mb-20">
|
||||
{/* Full Width Container - Uses complete available width */}
|
||||
<div className="w-full">
|
||||
<div
|
||||
<div
|
||||
className="prose prose-xl max-w-none blog-article-content w-full"
|
||||
style={{
|
||||
/* Enhanced Typography Hierarchy using Design System */
|
||||
@@ -447,48 +389,50 @@ export function BlogDetail({ params }: BlogDetailProps) {
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
color: '#26231A',
|
||||
width: '100%'
|
||||
}}
|
||||
} as React.CSSProperties}
|
||||
dangerouslySetInnerHTML={{ __html: blogPost.content }}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{/* Enhanced Tag Pills with Hover States */}
|
||||
<div className="mb-16">
|
||||
<h3 className="text-subhead mb-6 font-medium" style={{ color: '#26231A' }}>
|
||||
Topics covered in this article
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{blogPost.tags.map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
className="transition-all duration-200 text-body px-4 py-2 font-medium"
|
||||
style={{
|
||||
backgroundColor: 'rgba(4, 4, 91, 0.08)',
|
||||
color: '#04045B',
|
||||
border: '1px solid rgba(4, 4, 91, 0.15)'
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{blogPost.blog_tags && blogPost.blog_tags.length > 0 && (
|
||||
<div className="mb-16">
|
||||
<h3 className="text-subhead mb-6 font-medium" style={{ color: '#26231A' }}>
|
||||
Topics covered in this article
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{blogPost.blog_tags.map((tag: any) => (
|
||||
<Badge
|
||||
key={tag.tag_name}
|
||||
className="transition-all duration-200 text-body px-4 py-2 font-medium"
|
||||
style={{
|
||||
backgroundColor: 'rgba(4, 4, 91, 0.08)',
|
||||
color: '#04045B',
|
||||
border: '1px solid rgba(4, 4, 91, 0.15)'
|
||||
}}
|
||||
>
|
||||
{tag.tag_name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Enhanced Author Bio Card */}
|
||||
<Card className="mb-16 shadow-md border-0" style={{ backgroundColor: '#FFFFFF' }}>
|
||||
<CardContent className="p-8">
|
||||
<div className="flex items-start gap-6">
|
||||
<Avatar className="w-20 h-20 ring-4 ring-white shadow-lg">
|
||||
<AvatarImage src={blogPost.authorImage} alt={blogPost.author} />
|
||||
<AvatarFallback className="text-lg font-medium">{blogPost.author.split(' ').map(n => n[0]).join('')}</AvatarFallback>
|
||||
<AvatarImage src="https://images.unsplash.com/photo-1494790108755-2616b612b47c?w=150&h=150&fit=crop" alt="Author" />
|
||||
<AvatarFallback className="text-lg font-medium">KLC</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-h4 mb-3 font-semibold" style={{ color: '#26231A' }}>
|
||||
About {blogPost.author}
|
||||
About KLC Team
|
||||
</h4>
|
||||
<p className="text-body leading-relaxed mb-6" style={{ color: '#6F6F6F' }}>
|
||||
{blogPost.authorBio}
|
||||
The Kautilya Leadership Center team is dedicated to providing cutting-edge insights and research on leadership development, management strategies, and organizational excellence.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -498,95 +442,101 @@ export function BlogDetail({ params }: BlogDetailProps) {
|
||||
</div>
|
||||
|
||||
{/* Related Articles Section with Balanced Grid Layout */}
|
||||
<section className="py-20" style={{ backgroundColor: 'rgba(0, 0, 0, 0.02)' }}>
|
||||
<div className="section-margin-x">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-16">
|
||||
<div className="branded-tag-system mb-6">
|
||||
<div className="dot"></div>
|
||||
<span className="text">Continue Learning</span>
|
||||
{relatedPosts.length > 0 && (
|
||||
<section className="py-20" style={{ backgroundColor: 'rgba(0, 0, 0, 0.02)' }}>
|
||||
<div className="section-margin-x">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-16">
|
||||
<div className="branded-tag-system mb-6">
|
||||
<div className="dot"></div>
|
||||
<span className="text">Continue Learning</span>
|
||||
</div>
|
||||
<h2 className="text-h2 mb-6 font-bold" style={{ color: '#26231A' }}>
|
||||
Explore More Leadership Insights
|
||||
</h2>
|
||||
<p className="text-body-lg max-w-2xl mx-auto" style={{ color: '#6F6F6F' }}>
|
||||
Discover additional strategies and frameworks to enhance your leadership effectiveness
|
||||
</p>
|
||||
</div>
|
||||
<h2 className="text-h2 mb-6 font-bold" style={{ color: '#26231A' }}>
|
||||
Explore More Leadership Insights
|
||||
</h2>
|
||||
<p className="text-body-lg max-w-2xl mx-auto" style={{ color: '#6F6F6F' }}>
|
||||
Discover additional strategies and frameworks to enhance your leadership effectiveness
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Balanced Card Grid with Equal Spacing */}
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{relatedPosts.map((post) => (
|
||||
<Card
|
||||
key={post.id}
|
||||
className="overflow-hidden hover:shadow-xl transition-all duration-300 cursor-pointer group border-0"
|
||||
onClick={() => navigateTo(`/learning/articles/${post.slug}`)}
|
||||
style={{ backgroundColor: '#FFFFFF' }}
|
||||
>
|
||||
<div className="aspect-[16/10] w-full bg-gray-100 overflow-hidden relative">
|
||||
<ImageWithFallback
|
||||
src={post.image}
|
||||
alt={post.title}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
</div>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-small border-none"
|
||||
{/* Balanced Card Grid with Equal Spacing */}
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{relatedPosts.map((post: any) => (
|
||||
<Card
|
||||
key={post.id}
|
||||
className="overflow-hidden hover:shadow-xl transition-all duration-300 cursor-pointer group border-0"
|
||||
onClick={() => {
|
||||
// Use the same pattern as the main articles with full UUID
|
||||
const url = getSlugWithId(post.slug, post.id);
|
||||
navigate(`/learning/articles/${url}`);
|
||||
}}
|
||||
style={{ backgroundColor: '#FFFFFF' }}
|
||||
>
|
||||
<div className="aspect-[16/10] w-full bg-gray-100 overflow-hidden relative">
|
||||
<ImageWithFallback
|
||||
src={post.image}
|
||||
alt={post.title}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
</div>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-small border-none"
|
||||
style={{
|
||||
backgroundColor: 'rgba(4, 4, 91, 0.1)',
|
||||
color: '#04045B'
|
||||
}}
|
||||
>
|
||||
{post.category}
|
||||
</Badge>
|
||||
<span className="text-small" style={{ color: '#6F6F6F' }}>
|
||||
{post.readTime}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3
|
||||
className="mb-3 group-hover:text-blue-600 transition-colors line-clamp-2"
|
||||
style={{
|
||||
backgroundColor: 'rgba(4, 4, 91, 0.1)',
|
||||
color: '#04045B'
|
||||
fontSize: 'var(--font-h4)',
|
||||
fontWeight: 'var(--font-weight-h4)',
|
||||
lineHeight: '1.3',
|
||||
color: '#26231A',
|
||||
fontFamily: 'var(--font-family-base)'
|
||||
}}
|
||||
>
|
||||
{post.category}
|
||||
</Badge>
|
||||
<span className="text-small" style={{ color: '#6F6F6F' }}>
|
||||
{post.readTime}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3
|
||||
className="mb-3 group-hover:text-blue-600 transition-colors line-clamp-2"
|
||||
style={{
|
||||
fontSize: 'var(--font-h4)',
|
||||
fontWeight: 'var(--font-weight-h4)',
|
||||
lineHeight: '1.3',
|
||||
color: '#26231A',
|
||||
fontFamily: 'var(--font-family-base)'
|
||||
}}
|
||||
>
|
||||
{post.title}
|
||||
</h3>
|
||||
|
||||
<p
|
||||
className="mb-4 line-clamp-3"
|
||||
style={{
|
||||
fontSize: 'var(--font-body)',
|
||||
lineHeight: '1.5',
|
||||
color: '#6F6F6F',
|
||||
fontFamily: 'var(--font-family-base)'
|
||||
}}
|
||||
>
|
||||
{post.excerpt}
|
||||
</p>
|
||||
{post.title}
|
||||
</h3>
|
||||
|
||||
<div className="flex items-center justify-between pt-4 border-t" style={{ borderColor: 'rgba(0, 0, 0, 0.05)' }}>
|
||||
<span className="text-small" style={{ color: '#6F6F6F' }}>
|
||||
{post.author}
|
||||
</span>
|
||||
<span className="text-small" style={{ color: '#6F6F6F' }}>
|
||||
{formatDate(post.publishedDate)}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
<p
|
||||
className="mb-4 line-clamp-3"
|
||||
style={{
|
||||
fontSize: 'var(--font-body)',
|
||||
lineHeight: '1.5',
|
||||
color: '#6F6F6F',
|
||||
fontFamily: 'var(--font-family-base)'
|
||||
}}
|
||||
>
|
||||
{post.excerpt}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between pt-4 border-t" style={{ borderColor: 'rgba(0, 0, 0, 0.05)' }}>
|
||||
<span className="text-small" style={{ color: '#6F6F6F' }}>
|
||||
{post.author}
|
||||
</span>
|
||||
<span className="text-small" style={{ color: '#6F6F6F' }}>
|
||||
{formatDate(post.publishedDate)}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* CTA Section */}
|
||||
<CTABannerSection />
|
||||
|
||||
@@ -4,14 +4,45 @@ import { BrandedTag } from "./about/BrandedTag";
|
||||
import { PrimaryCTAButton } from "./PrimaryCTAButton";
|
||||
import { navigateTo } from "./Router";
|
||||
|
||||
export function CTABannerSection() {
|
||||
interface CTABannerSectionProps {
|
||||
ctaBands?: Array<{
|
||||
id: string;
|
||||
background_image_url: string;
|
||||
background_image_alt_text: string;
|
||||
text: string;
|
||||
cta_text: string;
|
||||
cta_destination: string;
|
||||
}>;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function CTABannerSection({ ctaBands = [], isLoading }: CTABannerSectionProps) {
|
||||
// Get the first CTA band or use default values
|
||||
const ctaBand = ctaBands && ctaBands.length > 0 ? ctaBands[0] : null;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section className="relative h-[700px] overflow-hidden bg-gray-100 animate-pulse">
|
||||
<div className="absolute inset-0 bg-gray-200" />
|
||||
<div className="relative h-full flex items-center justify-end section-margin-x">
|
||||
<div className="bg-gray-300 rounded-lg p-16 max-w-2xl w-full h-96" />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// If no CTA band is available, don't render anything
|
||||
if (!ctaBand) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="relative h-[700px] overflow-hidden">
|
||||
{/* Background Image */}
|
||||
<div className="absolute inset-0">
|
||||
<ImageWithFallback
|
||||
src="https://images.unsplash.com/photo-1552664730-d307ca884978?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=2940&q=80"
|
||||
alt="Professional team collaborating in modern office"
|
||||
src={ctaBand.background_image_url || "https://images.unsplash.com/photo-1552664730-d307ca884978?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=2940&q=80"}
|
||||
alt={ctaBand.background_image_alt_text || "Professional team collaborating in modern office"}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
|
||||
@@ -34,11 +65,11 @@ export function CTABannerSection() {
|
||||
{/* Branded Tag */}
|
||||
<BrandedTag text="Next Steps" variant="white" />
|
||||
|
||||
{/* Main Headline */}
|
||||
{/* Main Headline - Use API text or fallback */}
|
||||
<h2
|
||||
className="text-h2-white mb-8"
|
||||
>
|
||||
Ready to transform your leadership?
|
||||
{ctaBand.text || "Ready to transform your leadership?"}
|
||||
<span
|
||||
className="italic"
|
||||
style={{ color: 'var(--color-brand-accent)' }}
|
||||
@@ -48,10 +79,10 @@ export function CTABannerSection() {
|
||||
to start your development journey now.
|
||||
</h2>
|
||||
|
||||
{/* CTA Button - Updated to redirect to contact page */}
|
||||
{/* CTA Button */}
|
||||
<PrimaryCTAButton
|
||||
text="Schedule a Consultation"
|
||||
onClick={() => navigateTo('/contact?topic=consulting')}
|
||||
text={ctaBand.cta_text || "Schedule a Consultation"}
|
||||
onClick={() => navigateTo(ctaBand.cta_destination || '/contact?topic=consulting')}
|
||||
ariaLabel="Schedule a consultation with our leadership experts"
|
||||
className="cta-banner-yellow"
|
||||
/>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { PrimaryCTAButton } from './PrimaryCTAButton';
|
||||
import { ImageWithFallback } from './figma/ImageWithFallback';
|
||||
import { navigateTo } from './Router';
|
||||
import kautilyabg from '../assets/Kautilya.png';
|
||||
import { useCreateLeadMutation, useGetLeadCategoriesQuery } from '../redux/services/contactUsApi';
|
||||
|
||||
interface ContactProps {
|
||||
topic?: string;
|
||||
@@ -19,13 +20,23 @@ export function Contact({ topic }: ContactProps) {
|
||||
name: '',
|
||||
mobileNumber: '',
|
||||
emailId: '',
|
||||
interestedIn: topic || '',
|
||||
interestedIn: '',
|
||||
message: ''
|
||||
});
|
||||
|
||||
|
||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const { data: categoryData } = useGetLeadCategoriesQuery({
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
status: "active"
|
||||
});
|
||||
|
||||
const categories = categoryData?.data?.items || [];
|
||||
|
||||
const [createLead] = useCreateLeadMutation();
|
||||
|
||||
useEffect(() => {
|
||||
console.log('Contact component mounted with topic:', topic);
|
||||
// Set default interested in based on topic parameter
|
||||
@@ -65,10 +76,22 @@ export function Contact({ topic }: ContactProps) {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
// Simulate form submission
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
setIsSubmitted(true);
|
||||
try {
|
||||
await createLead({
|
||||
full_name: formData.name,
|
||||
phone_number: formData.mobileNumber,
|
||||
email_address: formData.emailId,
|
||||
lead_category_xid: formData.interestedIn,
|
||||
message: formData.message,
|
||||
lead_status: "NEW"
|
||||
}).unwrap();
|
||||
|
||||
setIsSubmitted(true);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Lead creation failed", error);
|
||||
}
|
||||
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
@@ -80,16 +103,21 @@ export function Contact({ topic }: ContactProps) {
|
||||
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<CheckCircle2 className="w-10 h-10 text-green-600" />
|
||||
</div>
|
||||
|
||||
<h1 className="text-h2 mb-4">Thank You!</h1>
|
||||
|
||||
<p className="text-body-lg text-muted mb-8">
|
||||
We've received your message and will get back to you within 24 hours.
|
||||
We've received your message and will get back to you within 24 hours.
|
||||
Our leadership development experts are excited to help transform your organization.
|
||||
</p>
|
||||
<PrimaryCTAButton
|
||||
text="Return to Home"
|
||||
onClick={() => navigateTo('/')}
|
||||
ariaLabel="Return to homepage"
|
||||
/>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<PrimaryCTAButton
|
||||
text="Return to Home"
|
||||
onClick={() => navigateTo('/')}
|
||||
ariaLabel="Return to homepage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -107,7 +135,7 @@ export function Contact({ topic }: ContactProps) {
|
||||
alt="Google Maps showing office location and navigation"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
|
||||
|
||||
{/* Dark overlay for better text readability */}
|
||||
<div className="absolute inset-0 bg-black/60" />
|
||||
</div>
|
||||
@@ -140,7 +168,7 @@ export function Contact({ topic }: ContactProps) {
|
||||
<div className="lg:col-span-1">
|
||||
<div className="bg-white rounded-xl shadow-lg border border-gray-100 h-fit overflow-hidden">
|
||||
{/* Contact Info Header */}
|
||||
<div
|
||||
<div
|
||||
className="p-8 text-white"
|
||||
style={{ backgroundColor: 'var(--color-brand-primary)' }}
|
||||
>
|
||||
@@ -154,7 +182,7 @@ export function Contact({ topic }: ContactProps) {
|
||||
<div className="p-8 space-y-8">
|
||||
{/* Corporate Office */}
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
<div
|
||||
className="w-12 h-12 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ backgroundColor: 'rgba(248, 195, 1, 0.1)' }}
|
||||
>
|
||||
@@ -173,7 +201,7 @@ export function Contact({ topic }: ContactProps) {
|
||||
|
||||
{/* Registered Office */}
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
<div
|
||||
className="w-12 h-12 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ backgroundColor: 'rgba(248, 195, 1, 0.1)' }}
|
||||
>
|
||||
@@ -192,7 +220,7 @@ export function Contact({ topic }: ContactProps) {
|
||||
|
||||
{/* Email */}
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
<div
|
||||
className="w-12 h-12 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ backgroundColor: 'rgba(248, 195, 1, 0.1)' }}
|
||||
>
|
||||
@@ -209,7 +237,7 @@ export function Contact({ topic }: ContactProps) {
|
||||
|
||||
{/* Phone */}
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
<div
|
||||
className="w-12 h-12 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ backgroundColor: 'rgba(248, 195, 1, 0.1)' }}
|
||||
>
|
||||
@@ -226,7 +254,7 @@ export function Contact({ topic }: ContactProps) {
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div
|
||||
<div
|
||||
className="p-8 border-t"
|
||||
style={{ borderColor: 'var(--color-border)' }}
|
||||
>
|
||||
@@ -236,7 +264,7 @@ export function Contact({ topic }: ContactProps) {
|
||||
variant="outline"
|
||||
className="w-full justify-start hover:bg-blue-50 hover:border-blue-200 transition-all duration-300"
|
||||
onClick={() => navigateTo('/services/learning-facility')}
|
||||
style={{
|
||||
style={{
|
||||
borderColor: 'var(--color-brand-primary)',
|
||||
color: 'var(--color-brand-primary)'
|
||||
}}
|
||||
@@ -248,7 +276,7 @@ export function Contact({ topic }: ContactProps) {
|
||||
variant="outline"
|
||||
className="w-full justify-start hover:bg-blue-50 hover:border-blue-200 transition-all duration-300"
|
||||
onClick={() => navigateTo('/leadership-journey')}
|
||||
style={{
|
||||
style={{
|
||||
borderColor: 'var(--color-brand-primary)',
|
||||
color: 'var(--color-brand-primary)'
|
||||
}}
|
||||
@@ -267,7 +295,7 @@ export function Contact({ topic }: ContactProps) {
|
||||
{/* Form Header */}
|
||||
<div className="p-8 border-b" style={{ borderColor: 'var(--color-border)' }}>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center"
|
||||
style={{ backgroundColor: 'rgba(248, 195, 1, 0.1)' }}
|
||||
>
|
||||
@@ -279,7 +307,7 @@ export function Contact({ topic }: ContactProps) {
|
||||
Fill out the form below and we'll get back to you within 24 hours.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Form Content */}
|
||||
<div className="p-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
@@ -294,7 +322,7 @@ export function Contact({ topic }: ContactProps) {
|
||||
onChange={(e) => handleInputChange('name', e.target.value)}
|
||||
placeholder="Enter your full name"
|
||||
className="w-full h-12 border-gray-200 focus:border-blue-500 focus:ring-blue-500"
|
||||
style={{
|
||||
style={{
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
fontSize: 'var(--font-body)'
|
||||
}}
|
||||
@@ -312,7 +340,7 @@ export function Contact({ topic }: ContactProps) {
|
||||
onChange={(e) => handleInputChange('mobileNumber', e.target.value)}
|
||||
placeholder="+91 98765 43210"
|
||||
className="w-full h-12 border-gray-200 focus:border-blue-500 focus:ring-blue-500"
|
||||
style={{
|
||||
style={{
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
fontSize: 'var(--font-body)'
|
||||
}}
|
||||
@@ -330,7 +358,7 @@ export function Contact({ topic }: ContactProps) {
|
||||
onChange={(e) => handleInputChange('emailId', e.target.value)}
|
||||
placeholder="example@company.com"
|
||||
className="w-full h-12 border-gray-200 focus:border-blue-500 focus:ring-blue-500"
|
||||
style={{
|
||||
style={{
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
fontSize: 'var(--font-body)'
|
||||
}}
|
||||
@@ -345,9 +373,9 @@ export function Contact({ topic }: ContactProps) {
|
||||
value={formData.interestedIn}
|
||||
onValueChange={(value: string) => handleInputChange('interestedIn', value)}
|
||||
>
|
||||
<SelectTrigger
|
||||
<SelectTrigger
|
||||
className="w-full h-12 border-gray-200 focus:border-blue-500 focus:ring-blue-500"
|
||||
style={{
|
||||
style={{
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
fontSize: 'var(--font-body)'
|
||||
}}
|
||||
@@ -355,14 +383,13 @@ export function Contact({ topic }: ContactProps) {
|
||||
<SelectValue placeholder="Select your area of interest" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Leadership Development">Leadership Development</SelectItem>
|
||||
<SelectItem value="Executive Coaching">Executive Coaching</SelectItem>
|
||||
<SelectItem value="Management Development">Management Development</SelectItem>
|
||||
<SelectItem value="Culture Competence">Culture Competence</SelectItem>
|
||||
<SelectItem value="Consulting Services">Consulting Services</SelectItem>
|
||||
<SelectItem value="Learning Facility">Learning Facility</SelectItem>
|
||||
<SelectItem value="Online Courses">Online Courses</SelectItem>
|
||||
<SelectItem value="General Inquiry">General Inquiry</SelectItem>
|
||||
<SelectContent>
|
||||
{categories.map((cat: any) => (
|
||||
<SelectItem key={cat.id} value={cat.id}>
|
||||
{cat.category_type}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -378,7 +405,7 @@ export function Contact({ topic }: ContactProps) {
|
||||
placeholder="Please enter your message or inquiry..."
|
||||
rows={6}
|
||||
className="w-full border-gray-200 focus:border-blue-500 focus:ring-blue-500 resize-none"
|
||||
style={{
|
||||
style={{
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
fontSize: 'var(--font-body)'
|
||||
}}
|
||||
@@ -392,7 +419,7 @@ export function Contact({ topic }: ContactProps) {
|
||||
</div>
|
||||
<PrimaryCTAButton
|
||||
text={isSubmitting ? "Sending Message..." : "Send Message"}
|
||||
onClick={() => {}}
|
||||
onClick={() => { handleSubmit }}
|
||||
ariaLabel="Send contact message"
|
||||
className="w-full sm:w-auto"
|
||||
/>
|
||||
@@ -415,19 +442,19 @@ export function Contact({ topic }: ContactProps) {
|
||||
alt="Professional team collaborating in modern office"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
|
||||
|
||||
{/* Subtle dark overlay for overall image */}
|
||||
<div className="absolute inset-0 bg-black/30" />
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{/* Content Container */}
|
||||
<div className="relative h-full flex items-center justify-end section-margin-x">
|
||||
{/* CTA Content Block */}
|
||||
<div
|
||||
<div
|
||||
className="bg-opacity-95 backdrop-blur-sm rounded-lg p-16 max-w-2xl"
|
||||
style={{
|
||||
style={{
|
||||
backgroundColor: 'var(--color-brand-primary)'
|
||||
}}
|
||||
>
|
||||
@@ -436,8 +463,8 @@ export function Contact({ topic }: ContactProps) {
|
||||
|
||||
{/* Main Headline */}
|
||||
<h2 className="text-h2-white mb-8">
|
||||
Ready to transform your leadership?
|
||||
<span
|
||||
Ready to transform your leadership?
|
||||
<span
|
||||
className="italic"
|
||||
style={{ color: 'var(--color-brand-accent)' }}
|
||||
>
|
||||
@@ -446,7 +473,7 @@ export function Contact({ topic }: ContactProps) {
|
||||
to start your development journey now.
|
||||
</h2>
|
||||
|
||||
<PrimaryCTAButton
|
||||
<PrimaryCTAButton
|
||||
text="Schedule a Consultation"
|
||||
onClick={() => navigateTo('/contact?topic=consulting')}
|
||||
ariaLabel="Schedule a consultation with our leadership experts"
|
||||
|
||||
@@ -72,7 +72,7 @@ export function FooterNew() {
|
||||
About Us
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigateTo('/learning/blogs')}
|
||||
onClick={() => navigateTo('/learning/articles')}
|
||||
className="block text-small-white transition-all duration-300 text-left"
|
||||
style={{
|
||||
color: 'white',
|
||||
|
||||
22
src/components/FullScreenLoader.tsx
Normal file
22
src/components/FullScreenLoader.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from "react";
|
||||
import { Loader } from "./Loader";
|
||||
|
||||
interface FullScreenLoaderProps {
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export const FullScreenLoader: React.FC<FullScreenLoaderProps> = ({
|
||||
text = "Loading...",
|
||||
}) => {
|
||||
return (
|
||||
<div className="fixed inset-0 flex flex-col items-center justify-center bg-white/100 z-[9999]">
|
||||
<Loader />
|
||||
|
||||
{text && (
|
||||
<p className="mt-6 text-lg text-gray-600">
|
||||
{text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,19 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { navigateTo } from './Router';
|
||||
import svgPaths from "../imports/svg-i1joeov37f";
|
||||
import PrimaryCTAButton from './PrimaryCTAButton';
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { navigateTo } from "./Router";
|
||||
import PrimaryCTAButton from "./PrimaryCTAButton";
|
||||
|
||||
interface HeroSectionItem {
|
||||
id: string;
|
||||
headline: string;
|
||||
subtext: string;
|
||||
background_image_url: string;
|
||||
cta_text: string;
|
||||
cta_destination: string;
|
||||
}
|
||||
|
||||
interface SlideData {
|
||||
id: number;
|
||||
id: string;
|
||||
title: string;
|
||||
backgroundImage: string;
|
||||
shortTitle: string;
|
||||
@@ -13,68 +21,42 @@ interface SlideData {
|
||||
route: string;
|
||||
}
|
||||
|
||||
export default function HeroSection() {
|
||||
const [currentSlide, setCurrentSlide] = useState(0);
|
||||
const [isAutoPlaying, setIsAutoPlaying] = useState(true);
|
||||
const [progressValues, setProgressValues] = useState([0, 0, 0, 0, 0, 0]);
|
||||
interface HeroSectionProps {
|
||||
heroSections: HeroSectionItem[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const slides: SlideData[] = [
|
||||
{
|
||||
id: 1,
|
||||
title: "Know Your Leaders. Strengthen Your Pipeline.",
|
||||
backgroundImage: "https://images.unsplash.com/photo-1697059361419-349e924ed363?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxidXNpbmVzcyUyMGxlYWRlcnMlMjBzdHJhdGVneSUyMG1lZXRpbmd8ZW58MXx8fHwxNzU2ODA3Mjc0fDA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral",
|
||||
shortTitle: "Leadership Pipeline Development",
|
||||
ctaText: "Discover Our Assessment Solutions",
|
||||
route: '/services/leadership-pipeline-development'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Build Leaders Who Drive Business Growth",
|
||||
backgroundImage: "https://images.unsplash.com/photo-1705234384669-c6d31c61b789?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxleGVjdXRpdmUlMjBsZWFkZXJzaGlwJTIwZGV2ZWxvcG1lbnQlMjB0cmFpbmluZ3xlbnwxfHx8fDE3NTY4MDcyNjJ8MA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral",
|
||||
shortTitle: "Leadership Development",
|
||||
ctaText: "Explore Leadership Journeys",
|
||||
route: '/services/leadership-development'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Strengthen the Backbone: Your Managers",
|
||||
backgroundImage: "https://images.unsplash.com/photo-1565688527174-775059ac429c?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxtYW5hZ2VtZW50JTIwdGVhbSUyMGRpc2N1c3Npb24lMjBvZmZpY2V8ZW58MXx8fHwxNzU2ODA2ODg1fDA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral",
|
||||
shortTitle: "Management Development",
|
||||
ctaText: "Strengthen your Managerial Calibre",
|
||||
route: '/services/management-development'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "Shape Cultures That Accelerate Performance",
|
||||
backgroundImage: "https://images.unsplash.com/photo-1531535807748-218331acbcb4?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxjb3Jwb3JhdGUlMjBjdWx0dXJlJTIwdGVhbSUyMGNvbGxhYm9yYXRpb258ZW58MXx8fHwxNzU2ODA2ODg5fDA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral",
|
||||
shortTitle: "Culture & Competence Consulting",
|
||||
ctaText: "Learn how we facilitate Change",
|
||||
route: '/services/culture-competence'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: "Unlock Leadership Potential",
|
||||
backgroundImage: "https://images.unsplash.com/photo-1714974528833-a10e19a8f951?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxleGVjdXRpdmUlMjBjb2FjaGluZyUyMG1lbnRvciUyMGNvbnZlcnNhdGlvbnxlbnwxfHx8fDE3NTY4MDY4OTR8MA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral",
|
||||
shortTitle: "Coaching & Mentoring",
|
||||
ctaText: "Start a Coaching Conversation",
|
||||
route: '/services/executive-coaching'
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: "Experience Learning in a World-Class Environment",
|
||||
backgroundImage: "https://images.unsplash.com/photo-1582213782179-e0d53f98f2ca?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxjb25mZXJlbmNlJTIwY2VudGVyJTIwbGVhcm5pbmclMjBmYWNpbGl0eXxlbnwxfHx8fDE3NTY4MDc0MDB8MA&ixlib=rb-4.1.0&q=80&w=1080&utm_source=figma&utm_medium=referral",
|
||||
shortTitle: "Learning Facility",
|
||||
ctaText: "Explore Our Learning Facility",
|
||||
route: '/services/learning-facility'
|
||||
}
|
||||
];
|
||||
export default function HeroSection({
|
||||
heroSections,
|
||||
isLoading,
|
||||
}: HeroSectionProps) {
|
||||
const slides: SlideData[] = heroSections.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.headline,
|
||||
backgroundImage: item.background_image_url,
|
||||
shortTitle: item.subtext,
|
||||
ctaText: item.cta_text,
|
||||
route: item.cta_destination,
|
||||
}));
|
||||
|
||||
const totalSlides = slides.length;
|
||||
const slideDuration = 5000; // 5 seconds per slide
|
||||
|
||||
// Auto-advance slides
|
||||
const [currentSlide, setCurrentSlide] = useState(0);
|
||||
const [isAutoPlaying, setIsAutoPlaying] = useState(true);
|
||||
const [progressValues, setProgressValues] = useState<number[]>([]);
|
||||
|
||||
const slideDuration = 5000;
|
||||
|
||||
/* Initialize progress array when slides load */
|
||||
useEffect(() => {
|
||||
if (!isAutoPlaying) return;
|
||||
if (totalSlides > 0) {
|
||||
setProgressValues(new Array(totalSlides).fill(0));
|
||||
}
|
||||
}, [totalSlides]);
|
||||
|
||||
/* Auto slide */
|
||||
useEffect(() => {
|
||||
if (!isAutoPlaying || totalSlides === 0) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setCurrentSlide((prev) => (prev + 1) % totalSlides);
|
||||
@@ -83,48 +65,50 @@ export default function HeroSection() {
|
||||
return () => clearInterval(interval);
|
||||
}, [isAutoPlaying, totalSlides]);
|
||||
|
||||
// Progress bar animation
|
||||
/* Progress animation */
|
||||
useEffect(() => {
|
||||
if (!isAutoPlaying) return;
|
||||
if (!isAutoPlaying || totalSlides === 0) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setProgressValues(prev => {
|
||||
setProgressValues((prev) => {
|
||||
const newProgress = [...prev];
|
||||
newProgress[currentSlide] = Math.min(newProgress[currentSlide] + (100 / (slideDuration / 100)), 100);
|
||||
// Reset progress when slide changes
|
||||
|
||||
newProgress[currentSlide] = Math.min(
|
||||
newProgress[currentSlide] + 100 / (slideDuration / 100),
|
||||
100
|
||||
);
|
||||
|
||||
if (newProgress[currentSlide] >= 100) {
|
||||
newProgress[currentSlide] = 0;
|
||||
// Reset other slides
|
||||
|
||||
newProgress.forEach((_, index) => {
|
||||
if (index !== currentSlide) {
|
||||
newProgress[index] = 0;
|
||||
}
|
||||
if (index !== currentSlide) newProgress[index] = 0;
|
||||
});
|
||||
}
|
||||
|
||||
return newProgress;
|
||||
});
|
||||
}, 100);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [currentSlide, isAutoPlaying]);
|
||||
}, [currentSlide, isAutoPlaying, totalSlides]);
|
||||
|
||||
// Reset progress when manually changing slides
|
||||
/* Reset progress when slide changes */
|
||||
useEffect(() => {
|
||||
setProgressValues(prev => {
|
||||
const newProgress = [0, 0, 0, 0, 0, 0];
|
||||
newProgress[currentSlide] = 0;
|
||||
return newProgress;
|
||||
});
|
||||
}, [currentSlide]);
|
||||
setProgressValues(new Array(totalSlides).fill(0));
|
||||
}, [currentSlide, totalSlides]);
|
||||
|
||||
const goToSlide = useCallback((slideIndex: number) => {
|
||||
if (slideIndex !== currentSlide) {
|
||||
setCurrentSlide(slideIndex);
|
||||
setIsAutoPlaying(false);
|
||||
// Resume auto-play after manual interaction
|
||||
setTimeout(() => setIsAutoPlaying(true), 3000);
|
||||
}
|
||||
}, [currentSlide]);
|
||||
const goToSlide = useCallback(
|
||||
(slideIndex: number) => {
|
||||
if (slideIndex !== currentSlide) {
|
||||
setCurrentSlide(slideIndex);
|
||||
setIsAutoPlaying(false);
|
||||
|
||||
setTimeout(() => setIsAutoPlaying(true), 3000);
|
||||
}
|
||||
},
|
||||
[currentSlide]
|
||||
);
|
||||
|
||||
const nextSlide = useCallback(() => {
|
||||
const next = (currentSlide + 1) % totalSlides;
|
||||
@@ -136,10 +120,11 @@ export default function HeroSection() {
|
||||
goToSlide(prev);
|
||||
}, [currentSlide, totalSlides, goToSlide]);
|
||||
|
||||
// Pause auto-play on hover
|
||||
const handleMouseEnter = () => setIsAutoPlaying(false);
|
||||
const handleMouseLeave = () => setIsAutoPlaying(true);
|
||||
|
||||
if (isLoading || slides.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section
|
||||
className="hero-section"
|
||||
@@ -150,9 +135,9 @@ export default function HeroSection() {
|
||||
{slides.map((slide, index) => (
|
||||
<div
|
||||
key={slide.id}
|
||||
className={`hero-slide ${index === currentSlide ? 'active' : ''}`}
|
||||
className={`hero-slide ${index === currentSlide ? "active" : ""}`}
|
||||
style={{
|
||||
backgroundImage: `url('${slide.backgroundImage}')`
|
||||
backgroundImage: `url('${slide.backgroundImage}')`,
|
||||
}}
|
||||
>
|
||||
<div className="hero-overlay" />
|
||||
@@ -162,13 +147,10 @@ export default function HeroSection() {
|
||||
{/* Hero Content */}
|
||||
<div className="hero-content">
|
||||
<div className="hero-text-section">
|
||||
{/* Title */}
|
||||
<h1 className="hero-title" style={{ whiteSpace: 'pre-line' }}>
|
||||
<h1 className="hero-title" style={{ whiteSpace: "pre-line" }}>
|
||||
{slides[currentSlide].title}
|
||||
</h1>
|
||||
|
||||
{/* Dynamic CTA Button - Enhanced with Proper Navigation */}
|
||||
|
||||
<PrimaryCTAButton
|
||||
text={slides[currentSlide].ctaText}
|
||||
onClick={() => navigateTo(slides[currentSlide].route)}
|
||||
@@ -177,10 +159,8 @@ export default function HeroSection() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Bottom Navigation */}
|
||||
<div className="hero-navigation">
|
||||
{/* Progress Section */}
|
||||
<div className="hero-progress-container">
|
||||
{slides.map((slide, index) => (
|
||||
<div
|
||||
@@ -188,35 +168,41 @@ export default function HeroSection() {
|
||||
className="hero-progress-item"
|
||||
onClick={() => goToSlide(index)}
|
||||
>
|
||||
{/* Progress Bar */}
|
||||
<div
|
||||
key={slide.id}
|
||||
className="hero-progress-item"
|
||||
onClick={() => navigateTo(slide.route)}
|
||||
className={`hero-progress-segment ${
|
||||
index === currentSlide ? "active" : ""
|
||||
}`}
|
||||
>
|
||||
{/* Progress Bar */}
|
||||
<div className={`hero-progress-segment ${index === currentSlide ? 'active' : ''}`}>
|
||||
<div
|
||||
className="hero-progress-fill"
|
||||
style={{ width: index === currentSlide ? `${progressValues[index]}%` : '0%' }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="hero-progress-fill"
|
||||
style={{
|
||||
width:
|
||||
index === currentSlide
|
||||
? `${progressValues[index] ?? 0}%`
|
||||
: "0%",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Progress Number */}
|
||||
<div className={`hero-progress-number ${index === currentSlide ? 'active' : ''}`}>
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</div>
|
||||
<div
|
||||
className={`hero-progress-number ${
|
||||
index === currentSlide ? "active" : ""
|
||||
}`}
|
||||
>
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</div>
|
||||
|
||||
{/* Progress Text */}
|
||||
<div className={`hero-progress-text ${index === currentSlide ? 'active' : ''}`}>
|
||||
{slide.shortTitle}
|
||||
</div>
|
||||
<div
|
||||
className={`hero-progress-text ${
|
||||
index === currentSlide ? "active" : ""
|
||||
}`}
|
||||
>
|
||||
{slide.shortTitle}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Navigation Arrows */}
|
||||
<div className="hero-controls">
|
||||
<button
|
||||
className="hero-nav-button"
|
||||
@@ -225,6 +211,7 @@ export default function HeroSection() {
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="hero-nav-button"
|
||||
onClick={nextSlide}
|
||||
|
||||
@@ -66,6 +66,7 @@ import privateSpaceImage from '../assets/private-space.jpg';
|
||||
import executiveBoardroomImage from '../assets/exe-boardroom.jpg';
|
||||
import morningReflectionImage from '../assets/morning.jpg';
|
||||
import campusArialViewImage from '../assets/campus-arial-view.jpg';
|
||||
import { VirtualTour360 } from './VirtualTour360';
|
||||
|
||||
const facilityFeatures = [
|
||||
{
|
||||
@@ -345,6 +346,7 @@ export function LearningFacilityNew() {
|
||||
const maxSlide = Math.max(0, facilityFeatures.length - cardsPerView);
|
||||
const maxTourSlide = Math.max(0, virtualTourStops.length - tourCardsPerView);
|
||||
|
||||
|
||||
// Handle window resize
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
@@ -419,11 +421,6 @@ export function LearningFacilityNew() {
|
||||
return () => clearInterval(interval);
|
||||
}, [heroBackgroundImages.length]);
|
||||
|
||||
const handleStartTour = () => {
|
||||
setIsVirtualTourActive(true);
|
||||
setCurrentTourStop(0);
|
||||
};
|
||||
|
||||
const handleNextStop = () => {
|
||||
if (currentTourStop < virtualTourStops.length - 1) {
|
||||
setCurrentTourStop(currentTourStop + 1);
|
||||
@@ -451,6 +448,11 @@ export function LearningFacilityNew() {
|
||||
setSelectedFacility(null);
|
||||
};
|
||||
|
||||
const [isVirtualTourOpen, setIsVirtualTourOpen] = useState(false);
|
||||
const handleStartTour = () => {
|
||||
setIsVirtualTourOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen" style={{ backgroundColor: '#F7F7FD' }}>
|
||||
|
||||
@@ -1766,6 +1768,16 @@ export function LearningFacilityNew() {
|
||||
onClose={handleCloseModal}
|
||||
/>
|
||||
|
||||
{/* Virtual Tour Modal */}
|
||||
<VirtualTour360
|
||||
isOpen={isVirtualTourOpen}
|
||||
onClose={() => setIsVirtualTourOpen(false)}
|
||||
onBookNow={() => {
|
||||
// Optional: auto-open booking modal after tour
|
||||
handleBookNow();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* CTA Section - Using standardized home page CTA */}
|
||||
<CTABannerSection />
|
||||
|
||||
|
||||
175
src/components/Loader.tsx
Normal file
175
src/components/Loader.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import React from "react";
|
||||
|
||||
interface LoaderProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Loader: React.FC<LoaderProps> = ({ className = "" }) => {
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
.pl {
|
||||
width: 8em;
|
||||
height: 8em;
|
||||
}
|
||||
|
||||
.pl circle {
|
||||
transform-box: fill-box;
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
|
||||
.pl__ring2 {
|
||||
animation: ring2_ 4s 0.04s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.pl__ring4 {
|
||||
animation: ring4_ 4s 0.12s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.pl__ring6 {
|
||||
animation: ring6_ 4s 0.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes ring2_ {
|
||||
from {
|
||||
stroke-dashoffset: -329.207488554;
|
||||
transform: rotate(-0.25turn);
|
||||
animation-timing-function: ease-in;
|
||||
}
|
||||
23% {
|
||||
stroke-dashoffset: -82.46680575;
|
||||
transform: rotate(1turn);
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
46%, 50% {
|
||||
stroke-dashoffset: -329.207488554;
|
||||
transform: rotate(2.25turn);
|
||||
animation-timing-function: ease-in;
|
||||
}
|
||||
73% {
|
||||
stroke-dashoffset: -82.46680575;
|
||||
transform: rotate(3.5turn);
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
96%, to {
|
||||
stroke-dashoffset: -329.207488554;
|
||||
transform: rotate(4.75turn);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ring4_ {
|
||||
from {
|
||||
stroke-dashoffset: -253.9600625988;
|
||||
transform: rotate(-0.25turn);
|
||||
animation-timing-function: ease-in;
|
||||
}
|
||||
23% {
|
||||
stroke-dashoffset: -63.61725015;
|
||||
transform: rotate(1turn);
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
46%, 50% {
|
||||
stroke-dashoffset: -253.9600625988;
|
||||
transform: rotate(2.25turn);
|
||||
animation-timing-function: ease-in;
|
||||
}
|
||||
73% {
|
||||
stroke-dashoffset: -63.61725015;
|
||||
transform: rotate(3.5turn);
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
96%, to {
|
||||
stroke-dashoffset: -253.9600625988;
|
||||
transform: rotate(4.75turn);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ring6_ {
|
||||
from {
|
||||
stroke-dashoffset: -203.795111962;
|
||||
transform: rotate(-0.25turn);
|
||||
animation-timing-function: ease-in;
|
||||
}
|
||||
23% {
|
||||
stroke-dashoffset: -51.05087975;
|
||||
transform: rotate(1turn);
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
46%, 50% {
|
||||
stroke-dashoffset: -203.795111962;
|
||||
transform: rotate(2.25turn);
|
||||
animation-timing-function: ease-in;
|
||||
}
|
||||
73% {
|
||||
stroke-dashoffset: -51.05087975;
|
||||
transform: rotate(3.5turn);
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
96%, to {
|
||||
stroke-dashoffset: -203.795111962;
|
||||
transform: rotate(4.75turn);
|
||||
}
|
||||
}
|
||||
|
||||
.loader-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div className={`loader-center ${className}`}>
|
||||
<svg
|
||||
className="pl"
|
||||
viewBox="0 0 128 128"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
{/* Ring 2 */}
|
||||
<circle
|
||||
className="pl__ring2"
|
||||
cx="64"
|
||||
cy="64"
|
||||
r="52.5"
|
||||
fill="none"
|
||||
stroke="hsl(240,92%,19%)"
|
||||
strokeWidth="12"
|
||||
transform="rotate(-90,64,64)"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="329.9 329.9"
|
||||
strokeDashoffset="-329.3"
|
||||
/>
|
||||
|
||||
{/* Ring 4 */}
|
||||
<circle
|
||||
className="pl__ring4"
|
||||
cx="64"
|
||||
cy="64"
|
||||
r="37.5"
|
||||
fill="none"
|
||||
stroke="hsl(13,90%,55%)"
|
||||
strokeWidth="9"
|
||||
transform="rotate(-90,64,64)"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="254.5 254.5"
|
||||
strokeDashoffset="-254"
|
||||
/>
|
||||
|
||||
{/* Ring 6 */}
|
||||
<circle
|
||||
className="pl__ring6"
|
||||
cx="64"
|
||||
cy="64"
|
||||
r="22.5"
|
||||
fill="none"
|
||||
stroke="hsl(47,99%,49%)"
|
||||
strokeWidth="9"
|
||||
transform="rotate(-90,64,64)"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="204.2 204.2"
|
||||
strokeDashoffset="-203.9"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import {
|
||||
Users,
|
||||
Settings,
|
||||
User,
|
||||
Globe,
|
||||
MessageSquare,
|
||||
import {
|
||||
Users,
|
||||
Settings,
|
||||
User,
|
||||
Globe,
|
||||
MessageSquare,
|
||||
GraduationCap,
|
||||
TrendingUp,
|
||||
Building,
|
||||
@@ -14,58 +14,36 @@ import {
|
||||
import { BrandedTag } from "./about/BrandedTag";
|
||||
import { StandardCTAButton } from "./StandardCTAButton";
|
||||
import { navigateTo } from "./Router";
|
||||
import { ImageWithFallback } from "./figma/ImageWithFallback";
|
||||
|
||||
// Services data
|
||||
const recognitionItems = [
|
||||
{
|
||||
id: 1,
|
||||
title: "Leadership Pipeline Development",
|
||||
description: "Build a strong foundation for sustainable leadership succession. Identify, assess, and develop high-potential talent through structured pathways that ensure organizational continuity and growth.",
|
||||
icon: <TrendingUp size={28} />,
|
||||
route: '/services/leadership-pipeline-development'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Leadership Development",
|
||||
description: "Comprehensive programs designed to cultivate strategic thinking and emotional intelligence. Develop capabilities that drive organizational success through authentic leadership practices.",
|
||||
icon: <Users size={28} />,
|
||||
route: '/services/leadership-development'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Management Development",
|
||||
description: "Essential skills training for first-time and experienced managers seeking growth. Focus on communication, delegation, and performance management excellence.",
|
||||
icon: <Settings size={28} />,
|
||||
route: '/services/management-development'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "Culture & Competence Consulting",
|
||||
description: "Transform organizational culture and build inclusive practices that enhance team collaboration. Navigate cultural differences with confidence and create environments where everyone thrives.",
|
||||
icon: <Globe size={28} />,
|
||||
route: '/services/culture-competence'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: "Coaching & Mentoring",
|
||||
description: "One-on-one personalized development for senior leaders and high-potential talent. Strategic guidance for complex leadership challenges, career advancement, and personal growth.",
|
||||
icon: <MessageSquare size={28} />,
|
||||
route: '/services/executive-coaching'
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: "Leadership Campus (Facility)",
|
||||
description: "Experience learning in a world-class environment designed for transformation. Our state-of-the-art facility provides the perfect setting for immersive leadership development programs.",
|
||||
icon: <Building size={28} />,
|
||||
route: '/services/learning-facility'
|
||||
}
|
||||
];
|
||||
interface HighlightCard {
|
||||
card_title: string;
|
||||
icon_url: string;
|
||||
accessible_label: string;
|
||||
body_text: string;
|
||||
display_order: number;
|
||||
}
|
||||
|
||||
export function ServicesSection() {
|
||||
interface ServicesSectionProps {
|
||||
highlightCards?: HighlightCard[];
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function ServicesSection({ highlightCards = [], isLoading = false }: ServicesSectionProps) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const sectionRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Create service items from highlightCards data
|
||||
const serviceItems = highlightCards.map((card, index) => ({
|
||||
id: index + 1,
|
||||
title: card.card_title,
|
||||
description: card.body_text,
|
||||
iconUrl: card.icon_url,
|
||||
accessibleLabel: card.accessible_label,
|
||||
route: '/services' // You might want to map to specific routes based on title
|
||||
}));
|
||||
|
||||
// Add card refs helper
|
||||
const addCardRef = (el: HTMLDivElement | null, index: number) => {
|
||||
cardRefs.current[index] = el;
|
||||
@@ -102,11 +80,33 @@ export function ServicesSection() {
|
||||
}
|
||||
};
|
||||
|
||||
// Show loading skeleton if isLoading is true
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section ref={sectionRef} className="py-16 lg:py-20" style={{ backgroundColor: '#fff' }}>
|
||||
<div className="section-margin-x">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/4 mb-6"></div>
|
||||
<div className="h-12 bg-gray-200 rounded w-2/3 mb-4"></div>
|
||||
<div className="h-24 bg-gray-200 rounded w-full mb-8"></div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="h-48 bg-gray-200 rounded"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
<section
|
||||
ref={sectionRef}
|
||||
className="py-16 lg:py-20"
|
||||
style={{
|
||||
style={{
|
||||
backgroundColor: '#fff',
|
||||
fontFamily: 'var(--font-family-brand)'
|
||||
}}
|
||||
@@ -119,11 +119,11 @@ export function ServicesSection() {
|
||||
{/* Left Side - Sticky Content */}
|
||||
<div className="col-span-5 sticky top-24 self-start">
|
||||
<div className="recognition-header pr-8">
|
||||
<BrandedTag
|
||||
text="Our Services"
|
||||
<BrandedTag
|
||||
text="Our Services"
|
||||
/>
|
||||
<h2
|
||||
id="recognition-section-heading"
|
||||
<h2
|
||||
id="recognition-section-heading"
|
||||
className="text-h2 mb-6"
|
||||
>
|
||||
Shaping Leaders, Cultures, and Institutions
|
||||
@@ -133,7 +133,7 @@ export function ServicesSection() {
|
||||
</p>
|
||||
{/* CTA Button - Left aligned */}
|
||||
<div className="primary-cta-container-left cta-left-locked">
|
||||
<StandardCTAButton
|
||||
<StandardCTAButton
|
||||
text="Services Page"
|
||||
onClick={() => navigateTo('/services')}
|
||||
ariaLabel="Explore our services"
|
||||
@@ -144,12 +144,12 @@ export function ServicesSection() {
|
||||
|
||||
{/* Right Side - Scrolling Cards */}
|
||||
<div className="col-span-7">
|
||||
<div
|
||||
<div
|
||||
className="recognition-cards space-y-6"
|
||||
role="list"
|
||||
aria-label="Leadership development services"
|
||||
>
|
||||
{recognitionItems.map((item, index) => (
|
||||
{serviceItems.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
ref={(el) => addCardRef(el, index)}
|
||||
@@ -159,41 +159,50 @@ export function ServicesSection() {
|
||||
aria-describedby={`recognition-desc-${item.id}`}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => handleKeyDown(e, index)}
|
||||
style={{
|
||||
style={{
|
||||
transitionDelay: `${(index + 1) * 150}ms`,
|
||||
opacity: isVisible ? 1 : 0
|
||||
}}
|
||||
onClick={() => navigateTo(item.route)}
|
||||
>
|
||||
<div
|
||||
<div
|
||||
className="p-8 transition-all duration-300 hover:shadow-xl hover:-translate-y-1 border bg-white"
|
||||
style={{
|
||||
style={{
|
||||
borderColor: 'var(--color-border)',
|
||||
borderRadius: '12px',
|
||||
fontFamily: 'var(--font-family-brand)'
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start mb-6">
|
||||
<div
|
||||
<div
|
||||
className="w-14 h-14 flex items-center justify-center transition-transform duration-300 group-hover:scale-110"
|
||||
style={{
|
||||
style={{
|
||||
backgroundColor: 'var(--color-brand-primary)',
|
||||
borderRadius: '12px',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
{/* Image icon from icon_url */}
|
||||
<img
|
||||
src={item.iconUrl}
|
||||
alt={item.accessibleLabel || item.title}
|
||||
className="w-8 h-8 object-contain filter brightness-0 invert" // Makes white icon on colored background
|
||||
onError={(e) => {
|
||||
// Fallback if image fails to load
|
||||
e.currentTarget.style.display = 'none';
|
||||
// You could add a fallback icon here if needed
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="recognition-card-content">
|
||||
<h3
|
||||
<h3
|
||||
id={`recognition-title-${item.id}`}
|
||||
className="text-h4 mb-4"
|
||||
>
|
||||
{item.title}
|
||||
</h3>
|
||||
<p
|
||||
<p
|
||||
id={`recognition-desc-${item.id}`}
|
||||
className="text-small text-muted leading-relaxed"
|
||||
>
|
||||
@@ -211,11 +220,11 @@ export function ServicesSection() {
|
||||
<div className="lg:hidden">
|
||||
{/* Mobile Header */}
|
||||
<div className="text-center mb-8">
|
||||
<BrandedTag
|
||||
text="Our Services"
|
||||
<BrandedTag
|
||||
text="Our Services"
|
||||
/>
|
||||
<h2
|
||||
id="recognition-section-heading-mobile"
|
||||
<h2
|
||||
id="recognition-section-heading-mobile"
|
||||
className="text-h2 mb-6"
|
||||
>
|
||||
Shaping Leaders, Cultures, and Institutions
|
||||
@@ -225,7 +234,7 @@ export function ServicesSection() {
|
||||
</p>
|
||||
{/* CTA Button - Left aligned for mobile */}
|
||||
<div className="primary-cta-container-left cta-left-locked">
|
||||
<StandardCTAButton
|
||||
<StandardCTAButton
|
||||
text="Services Page"
|
||||
onClick={() => navigateTo('/services')}
|
||||
ariaLabel="Explore our services"
|
||||
@@ -235,16 +244,16 @@ export function ServicesSection() {
|
||||
|
||||
{/* Mobile Horizontal Scrollable Cards */}
|
||||
<div className="relative">
|
||||
<div
|
||||
<div
|
||||
className="flex gap-6 overflow-x-auto scrollbar-hide pb-4"
|
||||
style={{
|
||||
style={{
|
||||
scrollSnapType: 'x mandatory',
|
||||
WebkitOverflowScrolling: 'touch'
|
||||
}}
|
||||
role="list"
|
||||
aria-label="Leadership development services"
|
||||
>
|
||||
{recognitionItems.map((item, index) => (
|
||||
{serviceItems.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`recognition-card-mobile group focus-ring flex-shrink-0 ${isVisible ? 'animate-in' : ''}`}
|
||||
@@ -253,42 +262,45 @@ export function ServicesSection() {
|
||||
aria-describedby={`recognition-desc-mobile-${item.id}`}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => handleKeyDown(e, index)}
|
||||
style={{
|
||||
style={{
|
||||
scrollSnapAlign: 'start',
|
||||
width: '320px',
|
||||
width: '320px',
|
||||
transitionDelay: `${(index + 1) * 150}ms`,
|
||||
opacity: isVisible ? 1 : 0
|
||||
}}
|
||||
>
|
||||
<div
|
||||
<div
|
||||
className="p-6 transition-all duration-300 hover:shadow-xl hover:-translate-y-1 border bg-white h-full"
|
||||
style={{
|
||||
style={{
|
||||
borderColor: 'var(--color-border)',
|
||||
borderRadius: '12px',
|
||||
fontFamily: 'var(--font-family-brand)'
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start mb-6">
|
||||
<div
|
||||
<div
|
||||
className="w-12 h-12 flex items-center justify-center transition-transform duration-300 group-hover:scale-110"
|
||||
style={{
|
||||
style={{
|
||||
backgroundColor: 'var(--color-brand-primary)',
|
||||
borderRadius: '12px',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
<ImageWithFallback
|
||||
src={item.iconUrl}
|
||||
alt={item.accessibleLabel || item.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="recognition-card-content">
|
||||
<h3
|
||||
<h3
|
||||
id={`recognition-title-mobile-${item.id}`}
|
||||
className="text-h4 mb-4"
|
||||
>
|
||||
{item.title}
|
||||
</h3>
|
||||
<p
|
||||
<p
|
||||
id={`recognition-desc-mobile-${item.id}`}
|
||||
className="text-small text-muted leading-relaxed"
|
||||
>
|
||||
@@ -301,8 +313,6 @@ export function ServicesSection() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { Button } from "./ui/button";
|
||||
import { useAnimatedCounter } from "./hooks/useAnimatedCounter";
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { BrandedTag } from "./about/BrandedTag";
|
||||
import { PrimaryCTAButton } from "./PrimaryCTAButton";
|
||||
import { useAnimatedCounter } from "../redux/hooks/useAnimatedCounter";
|
||||
|
||||
interface Stat {
|
||||
id: string;
|
||||
number: number;
|
||||
suffix: string;
|
||||
label: string;
|
||||
display_order: number;
|
||||
}
|
||||
|
||||
interface StatsSectionProps {
|
||||
stats: Stat[];
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
interface StatItemProps {
|
||||
end: number;
|
||||
@@ -17,123 +27,104 @@ function StatItem({ end, suffix, label, duration = 2000 }: StatItemProps) {
|
||||
|
||||
return (
|
||||
<div className="mb-0">
|
||||
{/* Top line */}
|
||||
<div
|
||||
<div
|
||||
className="w-full h-[1px] mb-4"
|
||||
style={{ backgroundColor: 'var(--color-brand-gray-muted)' }}
|
||||
style={{ backgroundColor: "var(--color-brand-gray-muted)" }}
|
||||
/>
|
||||
|
||||
{/* Large number */}
|
||||
|
||||
<span
|
||||
ref={ref}
|
||||
className="stats-number mb-3 leading-none"
|
||||
style={{
|
||||
color: 'var(--color-brand-primary)',
|
||||
fontFamily: 'var(--font-family-base)'
|
||||
style={{
|
||||
color: "var(--color-brand-primary)",
|
||||
fontFamily: "var(--font-family-base)",
|
||||
}}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
|
||||
{/* Yellow square and label */}
|
||||
|
||||
<div className="flex items-center mb-4">
|
||||
<div
|
||||
<div
|
||||
className="w-2 h-2 mr-3 flex-shrink-0"
|
||||
style={{ backgroundColor: 'var(--color-brand-accent)' }}
|
||||
style={{ backgroundColor: "var(--color-brand-accent)" }}
|
||||
/>
|
||||
<span
|
||||
<span
|
||||
className="text-eyebrow"
|
||||
style={{ color: 'var(--color-brand-black)' }}
|
||||
style={{ color: "var(--color-brand-black)" }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Bottom line */}
|
||||
<div
|
||||
|
||||
<div
|
||||
className="w-full h-[1px]"
|
||||
style={{ backgroundColor: 'var(--color-brand-gray-muted)' }}
|
||||
style={{ backgroundColor: "var(--color-brand-gray-muted)" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatsSection() {
|
||||
export function StatsSection({ stats = [], isLoading }: StatsSectionProps) {
|
||||
|
||||
const sortedStats = [...stats].sort(
|
||||
(a, b) => a.display_order - b.display_order
|
||||
);
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
return (
|
||||
<section
|
||||
<section
|
||||
className="py-20"
|
||||
style={{ backgroundColor: 'var(--color-brand-bg-light)' }}
|
||||
style={{ backgroundColor: "var(--color-brand-bg-light)" }}
|
||||
>
|
||||
<div className="section-margin-x">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="mb-12 lg:mb-16 md:mb-12 sm:mb-8">
|
||||
{/* Branded Tag */}
|
||||
<BrandedTag
|
||||
text="Serving Leaders Across Industries"
|
||||
/>
|
||||
|
||||
{/* Main Heading */}
|
||||
<BrandedTag text="Serving Leaders Across Industries" />
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 lg:gap-12 items-start">
|
||||
<div className="lg:col-span-6 md:col-span-8 sm:col-span-12">
|
||||
<h2 className="text-h2 mb-8">
|
||||
Your Partner in Leadership, Culture, and Capability Building
|
||||
Your Partner in Leadership, Culture, and Capability Building
|
||||
</h2>
|
||||
{/* CTA Button */}
|
||||
<PrimaryCTAButton
|
||||
|
||||
<PrimaryCTAButton
|
||||
text="About Us"
|
||||
onClick={() => console.log('About us clicked')}
|
||||
onClick={() => console.log("About us clicked")}
|
||||
ariaLabel="Learn more about KLC"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Desktop Statistics */}
|
||||
{/* Desktop */}
|
||||
<div className="hidden lg:block lg:col-start-9 lg:col-end-13">
|
||||
<div className="space-y-6">
|
||||
<StatItem
|
||||
end={27000}
|
||||
suffix="+"
|
||||
label="LEADERS DEVELOPED"
|
||||
duration={2500}
|
||||
/>
|
||||
<StatItem
|
||||
end={150}
|
||||
suffix="+"
|
||||
label="CORPORATES"
|
||||
duration={2000}
|
||||
/>
|
||||
<StatItem
|
||||
end={5000}
|
||||
suffix="+"
|
||||
label="ROOM NIGHTS UTILISED"
|
||||
duration={1800}
|
||||
/>
|
||||
{sortedStats.map((stat) => (
|
||||
<StatItem
|
||||
key={stat.id}
|
||||
end={stat.number}
|
||||
suffix={stat.suffix}
|
||||
label={stat.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Statistics - Show below content on mobile/tablet */}
|
||||
{/* Mobile */}
|
||||
<div className="block lg:hidden mt-12">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 sm:gap-8">
|
||||
<StatItem
|
||||
end={27000}
|
||||
suffix="+"
|
||||
label="LEADERS DEVELOPED"
|
||||
duration={2500}
|
||||
/>
|
||||
<StatItem
|
||||
end={150}
|
||||
suffix="+"
|
||||
label="CORPORATE CLIENTS"
|
||||
duration={2000}
|
||||
/>
|
||||
<StatItem
|
||||
end={20}
|
||||
suffix="+"
|
||||
label="COUNTRIES SERVED"
|
||||
duration={1800}
|
||||
/>
|
||||
{sortedStats.map((stat) => (
|
||||
<StatItem
|
||||
key={stat.id}
|
||||
end={stat.number}
|
||||
suffix={stat.suffix}
|
||||
label={stat.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
355
src/components/VirtualTour360.tsx
Normal file
355
src/components/VirtualTour360.tsx
Normal file
@@ -0,0 +1,355 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
||||
import panormaImage from '../assets/panoramas/cedar_bridge_sunset.jpg';
|
||||
import panormaImage2 from '../assets/panoramas/cayley_interior.jpg';
|
||||
|
||||
interface VirtualTour360Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onBookNow?: () => void;
|
||||
}
|
||||
|
||||
// Your 360° panorama images
|
||||
const tourScenes = [
|
||||
{
|
||||
id: 0,
|
||||
title: "Cedar Bridge Sunset",
|
||||
capacity: "Outdoor Learning Space",
|
||||
description: "A serene outdoor setting with stunning sunset views, perfect for meditation and reflection sessions at Kautilya Leadership Centre.",
|
||||
features: ["Natural environment", "Sunset views", "Peaceful atmosphere", "Meditation area", "Reflection space"],
|
||||
panoramaUrl: panormaImage
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
title: "Training Amphitheater",
|
||||
capacity: "80-120 attendees",
|
||||
description: "State-of-the-art amphitheater with tiered seating, advanced acoustics, and immersive presentation technology.",
|
||||
features: ["Tiered seating", "4K projection", "Live streaming", "Acoustic panels"],
|
||||
panoramaUrl: panormaImage2
|
||||
},
|
||||
];
|
||||
|
||||
export function VirtualTour360({ isOpen, onClose, onBookNow }: VirtualTour360Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [currentSceneIndex, setCurrentSceneIndex] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Three.js refs
|
||||
const sceneRef = useRef<THREE.Scene | null>(null);
|
||||
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
|
||||
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
|
||||
const controlsRef = useRef<OrbitControls | null>(null);
|
||||
const currentMeshRef = useRef<THREE.Mesh | null>(null);
|
||||
const currentTextureRef = useRef<THREE.Texture | null>(null);
|
||||
const animationRef = useRef<number | null>(null);
|
||||
|
||||
// Initialize Three.js scene
|
||||
useEffect(() => {
|
||||
if (!isOpen || !containerRef.current) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
|
||||
// Setup scene
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x050510);
|
||||
sceneRef.current = scene;
|
||||
|
||||
// Setup camera
|
||||
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
|
||||
camera.position.set(0, 0, 0.1);
|
||||
cameraRef.current = camera;
|
||||
|
||||
// Setup renderer
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
renderer.setPixelRatio(window.devicePixelRatio);
|
||||
renderer.domElement.style.touchAction = "none"; // Important for touch events
|
||||
container.appendChild(renderer.domElement);
|
||||
rendererRef.current = renderer;
|
||||
|
||||
// Setup controls - ONLY ROTATION (no built-in zoom)
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableZoom = false; // Disable built-in zoom, we'll handle custom zoom
|
||||
controls.enablePan = false;
|
||||
controls.enableRotate = true;
|
||||
controls.rotateSpeed = 1.2;
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.05;
|
||||
controls.target.set(0, 0, 0);
|
||||
controlsRef.current = controls;
|
||||
|
||||
// 🔥 CUSTOM ZOOM - Mouse wheel (desktop)
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
if (!cameraRef.current) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const zoomSpeed = 0.05;
|
||||
cameraRef.current.fov += event.deltaY * zoomSpeed;
|
||||
|
||||
// Limit zoom range
|
||||
cameraRef.current.fov = Math.max(40, Math.min(90, cameraRef.current.fov));
|
||||
cameraRef.current.updateProjectionMatrix();
|
||||
};
|
||||
|
||||
renderer.domElement.addEventListener("wheel", handleWheel, { passive: false });
|
||||
|
||||
// 🔥 CUSTOM ZOOM - Mobile pinch gesture
|
||||
let lastDistance = 0;
|
||||
|
||||
const getDistance = (touches: TouchList) => {
|
||||
if (touches.length < 2) return 0;
|
||||
const dx = touches[0].clientX - touches[1].clientX;
|
||||
const dy = touches[0].clientY - touches[1].clientY;
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
if (!cameraRef.current) return;
|
||||
|
||||
if (e.touches.length === 2) {
|
||||
e.preventDefault();
|
||||
|
||||
const distance = getDistance(e.touches);
|
||||
|
||||
if (lastDistance > 0) {
|
||||
const delta = distance - lastDistance;
|
||||
cameraRef.current.fov -= delta * 0.05;
|
||||
cameraRef.current.fov = Math.max(40, Math.min(90, cameraRef.current.fov));
|
||||
cameraRef.current.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
lastDistance = distance;
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
lastDistance = 0;
|
||||
};
|
||||
|
||||
renderer.domElement.addEventListener("touchmove", handleTouchMove, { passive: false });
|
||||
renderer.domElement.addEventListener("touchend", handleTouchEnd);
|
||||
|
||||
// Animation loop
|
||||
const animate = () => {
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
|
||||
if (controlsRef.current) controlsRef.current.update();
|
||||
if (rendererRef.current && sceneRef.current && cameraRef.current) {
|
||||
rendererRef.current.render(sceneRef.current, cameraRef.current);
|
||||
}
|
||||
};
|
||||
animate();
|
||||
|
||||
// Load first panorama
|
||||
loadPanorama(tourScenes[0].panoramaUrl);
|
||||
|
||||
// Handle window resize
|
||||
const handleResize = () => {
|
||||
if (cameraRef.current && rendererRef.current) {
|
||||
cameraRef.current.aspect = window.innerWidth / window.innerHeight;
|
||||
cameraRef.current.updateProjectionMatrix();
|
||||
rendererRef.current.setSize(window.innerWidth, window.innerHeight);
|
||||
}
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
if (animationRef.current) cancelAnimationFrame(animationRef.current);
|
||||
|
||||
window.removeEventListener('resize', handleResize);
|
||||
renderer.domElement.removeEventListener("wheel", handleWheel);
|
||||
renderer.domElement.removeEventListener("touchmove", handleTouchMove);
|
||||
renderer.domElement.removeEventListener("touchend", handleTouchEnd);
|
||||
|
||||
if (controlsRef.current) controlsRef.current.dispose();
|
||||
if (rendererRef.current) rendererRef.current.dispose();
|
||||
if (currentTextureRef.current) currentTextureRef.current.dispose();
|
||||
|
||||
if (container && renderer.domElement && container.contains(renderer.domElement)) {
|
||||
container.removeChild(renderer.domElement);
|
||||
}
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Load panorama image
|
||||
const loadPanorama = (imageUrl: string) => {
|
||||
if (!sceneRef.current) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
const textureLoader = new THREE.TextureLoader();
|
||||
textureLoader.load(
|
||||
imageUrl,
|
||||
(texture) => {
|
||||
texture.wrapS = THREE.RepeatWrapping;
|
||||
texture.wrapT = THREE.ClampToEdgeWrapping;
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
|
||||
// Remove old mesh
|
||||
if (currentMeshRef.current) sceneRef.current?.remove(currentMeshRef.current);
|
||||
if (currentTextureRef.current) currentTextureRef.current.dispose();
|
||||
|
||||
// Create new sphere with texture
|
||||
const geometry = new THREE.SphereGeometry(500, 64, 64);
|
||||
const material = new THREE.MeshBasicMaterial({ map: texture, side: THREE.BackSide });
|
||||
const sphere = new THREE.Mesh(geometry, material);
|
||||
sceneRef.current?.add(sphere);
|
||||
|
||||
currentMeshRef.current = sphere;
|
||||
currentTextureRef.current = texture;
|
||||
|
||||
// Reset camera FOV when loading new scene
|
||||
if (cameraRef.current) {
|
||||
cameraRef.current.fov = 75;
|
||||
cameraRef.current.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
},
|
||||
undefined,
|
||||
(error) => {
|
||||
console.error('Error loading panorama:', error);
|
||||
setIsLoading(false);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Navigate to next scene
|
||||
const nextScene = () => {
|
||||
const nextIndex = (currentSceneIndex + 1) % tourScenes.length;
|
||||
setCurrentSceneIndex(nextIndex);
|
||||
loadPanorama(tourScenes[nextIndex].panoramaUrl);
|
||||
};
|
||||
|
||||
// Navigate to previous scene
|
||||
const prevScene = () => {
|
||||
const prevIndex = (currentSceneIndex - 1 + tourScenes.length) % tourScenes.length;
|
||||
setCurrentSceneIndex(prevIndex);
|
||||
loadPanorama(tourScenes[prevIndex].panoramaUrl);
|
||||
};
|
||||
|
||||
// Jump to specific scene
|
||||
const goToScene = (index: number) => {
|
||||
setCurrentSceneIndex(index);
|
||||
loadPanorama(tourScenes[index].panoramaUrl);
|
||||
};
|
||||
|
||||
const currentScene = tourScenes[currentSceneIndex];
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] bg-black">
|
||||
{/* Canvas container for Three.js */}
|
||||
<div ref={containerRef} className="absolute inset-0" />
|
||||
|
||||
{/* UI Overlay */}
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
{/* Top Bar */}
|
||||
<div className="absolute top-0 left-0 right-0 bg-gradient-to-r from-[#04045B]/95 to-[#04045B]/80 backdrop-blur-md border-b border-yellow-400/30 pointer-events-auto z-30">
|
||||
<div className="flex justify-between items-center px-4 md:px-6 py-3 md:py-4">
|
||||
<div className="flex items-center gap-2 md:gap-3">
|
||||
<div className="w-8 h-8 md:w-10 md:h-10 bg-yellow-400 rounded-xl flex items-center justify-center font-bold text-[#04045B] text-base md:text-xl">
|
||||
KL
|
||||
</div>
|
||||
<div className="hidden sm:block">
|
||||
<h1 className="text-white font-semibold text-sm md:text-lg">Kautilya Leadership Centre</h1>
|
||||
<p className="text-white/70 text-xs">360° Immersive Experience</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 md:gap-3">
|
||||
<button
|
||||
onClick={prevScene}
|
||||
className="w-8 h-8 md:w-9 md:h-9 rounded-full bg-white/20 hover:bg-yellow-400 hover:text-[#04045B] transition-all flex items-center justify-center text-white text-sm md:text-base touch-manipulation"
|
||||
aria-label="Previous scene"
|
||||
>
|
||||
◀
|
||||
</button>
|
||||
<div className="bg-black/50 backdrop-blur-sm px-3 py-1.5 md:px-4 rounded-full text-xs md:text-sm font-medium">
|
||||
{currentScene.title}
|
||||
</div>
|
||||
<button
|
||||
onClick={nextScene}
|
||||
className="w-8 h-8 md:w-9 md:h-9 rounded-full bg-white/20 hover:bg-yellow-400 hover:text-[#04045B] transition-all flex items-center justify-center text-white text-sm md:text-base touch-manipulation"
|
||||
aria-label="Next scene"
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 md:w-9 md:h-9 rounded-full bg-white/20 hover:bg-red-500 transition-all flex items-center justify-center text-white text-sm md:text-base touch-manipulation"
|
||||
aria-label="Close"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Panel - Bottom Left */}
|
||||
<div className="absolute bottom-4 md:bottom-6 left-4 md:left-6 max-w-[280px] md:max-w-[320px] bg-[#04045B]/90 backdrop-blur-md rounded-xl md:rounded-2xl p-4 md:p-5 border border-yellow-400/40 pointer-events-auto z-25">
|
||||
<h2 className="text-yellow-400 text-lg md:text-2xl font-bold mb-1">{currentScene.title}</h2>
|
||||
<div className="text-white/80 text-xs md:text-sm mb-2 md:mb-3 flex items-center gap-2">
|
||||
🎯 Capacity: {currentScene.capacity}
|
||||
</div>
|
||||
<p className="text-white/90 text-xs md:text-sm mb-3 md:mb-4 leading-relaxed">
|
||||
{currentScene.description}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5 md:gap-2 mb-3 md:mb-4">
|
||||
{currentScene.features.map((feature, idx) => (
|
||||
<span key={idx} className="bg-yellow-400/20 text-yellow-400 text-[10px] md:text-xs px-2 py-1 md:px-3 rounded-full">
|
||||
✓ {feature}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
onBookNow?.();
|
||||
onClose();
|
||||
}}
|
||||
className="w-full bg-yellow-400 hover:bg-yellow-500 text-[#04045B] font-bold py-2 md:py-2.5 rounded-full transition-all flex items-center justify-center gap-2 text-sm md:text-base touch-manipulation"
|
||||
>
|
||||
📅 Book This Space →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scene Thumbnails - Bottom Right */}
|
||||
<div className="absolute bottom-4 md:bottom-6 right-4 md:right-6 flex flex-col gap-2 pointer-events-auto z-25">
|
||||
{tourScenes.map((scene, idx) => (
|
||||
<button
|
||||
key={scene.id}
|
||||
onClick={() => goToScene(idx)}
|
||||
className={`bg-black/70 backdrop-blur-sm rounded-full px-3 py-1.5 md:px-4 md:py-2 flex items-center gap-2 md:gap-3 hover:bg-[#04045B]/90 transition-all touch-manipulation ${
|
||||
idx === currentSceneIndex ? 'border-l-4 border-yellow-400' : 'border-l-4 border-yellow-400/40'
|
||||
}`}
|
||||
>
|
||||
<div className="w-1.5 h-1.5 md:w-2 md:h-2 bg-yellow-400 rounded-full" />
|
||||
<span className="text-white text-[10px] md:text-xs font-medium truncate max-w-[120px] md:max-w-[150px]">
|
||||
{scene.title}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Loading Overlay */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 bg-[#04045B] flex items-center justify-center z-50">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 border-4 border-yellow-400/30 border-t-yellow-400 rounded-full animate-spin mx-auto mb-4"></div>
|
||||
<p className="text-white text-sm">Loading 360° Experience...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Instruction Hint */}
|
||||
<div className="absolute bottom-4 md:bottom-6 left-1/2 transform -translate-x-1/2 bg-black/60 backdrop-blur-sm rounded-full px-3 py-1.5 md:px-4 md:py-2 text-white text-[10px] md:text-xs pointer-events-auto whitespace-nowrap">
|
||||
🖱️ Drag to look around • 👆 Pinch/Scroll to zoom in/out
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
interface UseAnimatedCounterOptions {
|
||||
start?: number;
|
||||
end: number;
|
||||
duration?: number;
|
||||
decimals?: number;
|
||||
suffix?: string;
|
||||
}
|
||||
|
||||
export function useAnimatedCounter({
|
||||
start = 0,
|
||||
end,
|
||||
duration = 2000,
|
||||
decimals = 0,
|
||||
suffix = ''
|
||||
}: UseAnimatedCounterOptions) {
|
||||
const [count, setCount] = useState(start);
|
||||
const [isInView, setIsInView] = useState(false);
|
||||
const countRef = useRef<HTMLSpanElement>(null);
|
||||
const frameRef = useRef<number>();
|
||||
const startTimeRef = useRef<number>();
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting && !isInView) {
|
||||
setIsInView(true);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
if (countRef.current) {
|
||||
observer.observe(countRef.current);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [isInView]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInView) return;
|
||||
|
||||
const animate = (currentTime: number) => {
|
||||
if (!startTimeRef.current) startTimeRef.current = currentTime;
|
||||
|
||||
const elapsed = currentTime - startTimeRef.current;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
// Easing function for smooth animation
|
||||
const easeOutQuart = 1 - Math.pow(1 - progress, 4);
|
||||
const currentCount = start + (end - start) * easeOutQuart;
|
||||
|
||||
setCount(currentCount);
|
||||
|
||||
if (progress < 1) {
|
||||
frameRef.current = requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
frameRef.current = requestAnimationFrame(animate);
|
||||
|
||||
return () => {
|
||||
if (frameRef.current) {
|
||||
cancelAnimationFrame(frameRef.current);
|
||||
}
|
||||
};
|
||||
}, [isInView, start, end, duration]);
|
||||
|
||||
const formattedCount = decimals > 0
|
||||
? count.toFixed(decimals)
|
||||
: Math.floor(count).toLocaleString();
|
||||
|
||||
return {
|
||||
count: formattedCount + suffix,
|
||||
ref: countRef
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user