287 lines
11 KiB
TypeScript
287 lines
11 KiB
TypeScript
import React, { useState, useEffect, useMemo } from 'react';
|
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
|
|
|
interface Partner {
|
|
name?: string;
|
|
logo?: string;
|
|
link?: string;
|
|
}
|
|
|
|
interface PartnersCarouselProps {
|
|
partners: Partner[];
|
|
autoSlideInterval?: number;
|
|
showNavigation?: boolean;
|
|
itemsPerView?: number;
|
|
}
|
|
|
|
const PartnersCarousel: React.FC<PartnersCarouselProps> = ({
|
|
partners,
|
|
autoSlideInterval = 5000,
|
|
showNavigation = true,
|
|
itemsPerView = 6,
|
|
}) => {
|
|
const [currentIndex, setCurrentIndex] = useState(0);
|
|
const [windowWidth, setWindowWidth] = useState(typeof window !== 'undefined' ? window.innerWidth : 1920);
|
|
|
|
// Handle window resize
|
|
useEffect(() => {
|
|
if (typeof window === 'undefined') return;
|
|
|
|
const handleResize = () => {
|
|
setWindowWidth(window.innerWidth);
|
|
};
|
|
|
|
window.addEventListener('resize', handleResize);
|
|
return () => window.removeEventListener('resize', handleResize);
|
|
}, []);
|
|
|
|
// Calculate responsive items per view
|
|
const responsiveItemsPerView = useMemo(() => {
|
|
if (windowWidth < 640) return 2; // sm: 2 items
|
|
if (windowWidth < 768) return 3; // md: 3 items
|
|
if (windowWidth < 1024) return 4; // lg: 4 items
|
|
return itemsPerView; // xl: 6 items
|
|
}, [windowWidth, itemsPerView]);
|
|
|
|
// Calculate max index (how many slides we can have)
|
|
const maxIndex = Math.max(0, partners.length - responsiveItemsPerView);
|
|
const canNavigate = partners.length > responsiveItemsPerView;
|
|
const allItemsFit = partners.length <= responsiveItemsPerView;
|
|
|
|
// Auto-slide functionality
|
|
useEffect(() => {
|
|
if (!canNavigate) return;
|
|
|
|
const interval = setInterval(() => {
|
|
setCurrentIndex((prev) => {
|
|
if (prev >= maxIndex) {
|
|
return 0; // Loop back to start
|
|
}
|
|
return prev + 1;
|
|
});
|
|
}, autoSlideInterval);
|
|
|
|
return () => clearInterval(interval);
|
|
}, [canNavigate, maxIndex, autoSlideInterval]);
|
|
|
|
// Reset index when it exceeds max
|
|
useEffect(() => {
|
|
if (currentIndex > maxIndex) {
|
|
setCurrentIndex(0);
|
|
}
|
|
}, [maxIndex, currentIndex]);
|
|
|
|
const goToPrevious = () => {
|
|
if (!canNavigate) return;
|
|
setCurrentIndex((prev) => (prev === 0 ? maxIndex : prev - 1));
|
|
};
|
|
|
|
const goToNext = () => {
|
|
if (!canNavigate) return;
|
|
setCurrentIndex((prev) => (prev >= maxIndex ? 0 : prev + 1));
|
|
};
|
|
|
|
const goToSlide = (index: number) => {
|
|
if (index < 0 || index > maxIndex) return;
|
|
setCurrentIndex(index);
|
|
};
|
|
|
|
if (partners.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const itemWidth = 100 / responsiveItemsPerView;
|
|
const translateX = allItemsFit ? 0 : -(currentIndex * itemWidth);
|
|
|
|
return (
|
|
<div className="relative w-full max-w-7xl mx-auto">
|
|
{/* Carousel Container */}
|
|
<div className={`relative ${allItemsFit ? '' : 'overflow-hidden'} px-4 sm:px-6 lg:px-8`}>
|
|
{allItemsFit ? (
|
|
// Centered layout when all items fit - show only logos without containers
|
|
<div className="flex justify-center items-center flex-wrap gap-4 md:gap-6 lg:gap-8">
|
|
{partners.map((partner, index) => {
|
|
return (
|
|
<div
|
|
key={`partner-${index}`}
|
|
className="flex-shrink-0"
|
|
>
|
|
{partner.link ? (
|
|
<a
|
|
href={partner.link}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center justify-center group"
|
|
>
|
|
{partner.logo ? (
|
|
<img
|
|
src={partner.logo}
|
|
alt={partner.name || `Partner ${index + 1}`}
|
|
className="max-w-[80px] md:max-w-[100px] lg:max-w-[120px] max-h-12 md:max-h-16 lg:max-h-20 object-contain opacity-60 group-hover:opacity-100 transition-opacity duration-300"
|
|
onError={(e) => {
|
|
const target = e.target as HTMLImageElement;
|
|
target.style.display = 'none';
|
|
}}
|
|
/>
|
|
) : (
|
|
<span className="text-gray-600 font-semibold text-xs md:text-sm text-center">
|
|
{partner.name || `Partner ${index + 1}`}
|
|
</span>
|
|
)}
|
|
</a>
|
|
) : (
|
|
<>
|
|
{partner.logo ? (
|
|
<img
|
|
src={partner.logo}
|
|
alt={partner.name || `Partner ${index + 1}`}
|
|
className="max-w-[80px] md:max-w-[100px] lg:max-w-[120px] max-h-12 md:max-h-16 lg:max-h-20 object-contain opacity-60 hover:opacity-100 transition-opacity duration-300"
|
|
onError={(e) => {
|
|
const target = e.target as HTMLImageElement;
|
|
target.style.display = 'none';
|
|
}}
|
|
/>
|
|
) : (
|
|
<span className="text-gray-600 font-semibold text-xs md:text-sm text-center">
|
|
{partner.name || `Partner ${index + 1}`}
|
|
</span>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
// Carousel layout when items don't all fit
|
|
<div
|
|
className="flex transition-transform duration-500 ease-in-out"
|
|
style={{
|
|
transform: `translateX(${translateX}%)`,
|
|
willChange: 'transform',
|
|
}}
|
|
>
|
|
{partners.map((partner, index) => (
|
|
<div
|
|
key={`partner-${index}`}
|
|
className="flex-shrink-0 px-2 sm:px-3 md:px-4 flex items-center justify-center"
|
|
style={{ width: `${itemWidth}%` }}
|
|
>
|
|
{partner.link ? (
|
|
<a
|
|
href={partner.link}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center justify-center group"
|
|
>
|
|
{partner.logo ? (
|
|
<img
|
|
src={partner.logo}
|
|
alt={partner.name || `Partner ${index + 1}`}
|
|
className="max-w-[80px] md:max-w-[100px] lg:max-w-[120px] max-h-12 md:max-h-16 lg:max-h-20 object-contain opacity-60 group-hover:opacity-100 transition-opacity duration-300"
|
|
onError={(e) => {
|
|
const target = e.target as HTMLImageElement;
|
|
target.style.display = 'none';
|
|
}}
|
|
/>
|
|
) : (
|
|
<span className="text-gray-600 font-semibold text-xs md:text-sm text-center">
|
|
{partner.name || `Partner ${index + 1}`}
|
|
</span>
|
|
)}
|
|
</a>
|
|
) : (
|
|
<>
|
|
{partner.logo ? (
|
|
<img
|
|
src={partner.logo}
|
|
alt={partner.name || `Partner ${index + 1}`}
|
|
className="max-w-[80px] md:max-w-[100px] lg:max-w-[120px] max-h-12 md:max-h-16 lg:max-h-20 object-contain opacity-60 hover:opacity-100 transition-opacity duration-300"
|
|
onError={(e) => {
|
|
const target = e.target as HTMLImageElement;
|
|
target.style.display = 'none';
|
|
}}
|
|
/>
|
|
) : (
|
|
<span className="text-gray-600 font-semibold text-xs md:text-sm text-center">
|
|
{partner.name || `Partner ${index + 1}`}
|
|
</span>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Navigation Buttons */}
|
|
{showNavigation && canNavigate && (
|
|
<>
|
|
<button
|
|
onClick={goToPrevious}
|
|
disabled={currentIndex === 0}
|
|
className="absolute left-0 top-1/2 -translate-y-1/2 z-10
|
|
w-10 h-10 sm:w-12 sm:h-12
|
|
bg-gradient-to-r from-[#d4af37] to-[#c9a227]
|
|
text-white rounded-full
|
|
flex items-center justify-center
|
|
shadow-lg shadow-[#d4af37]/40
|
|
hover:shadow-xl hover:shadow-[#d4af37]/50
|
|
hover:scale-110
|
|
active:scale-95
|
|
transition-all duration-300
|
|
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100
|
|
group backdrop-blur-sm"
|
|
aria-label="Previous partners"
|
|
>
|
|
<ChevronLeft className="w-5 h-5 sm:w-6 sm:h-6 group-hover:-translate-x-0.5 transition-transform" />
|
|
</button>
|
|
|
|
<button
|
|
onClick={goToNext}
|
|
disabled={currentIndex >= maxIndex}
|
|
className="absolute right-0 top-1/2 -translate-y-1/2 z-10
|
|
w-10 h-10 sm:w-12 sm:h-12
|
|
bg-gradient-to-r from-[#d4af37] to-[#c9a227]
|
|
text-white rounded-full
|
|
flex items-center justify-center
|
|
shadow-lg shadow-[#d4af37]/40
|
|
hover:shadow-xl hover:shadow-[#d4af37]/50
|
|
hover:scale-110
|
|
active:scale-95
|
|
transition-all duration-300
|
|
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100
|
|
group backdrop-blur-sm"
|
|
aria-label="Next partners"
|
|
>
|
|
<ChevronRight className="w-5 h-5 sm:w-6 sm:h-6 group-hover:translate-x-0.5 transition-transform" />
|
|
</button>
|
|
</>
|
|
)}
|
|
|
|
{/* Dots Indicator */}
|
|
{canNavigate && maxIndex > 0 && (
|
|
<div className="flex justify-center items-center gap-1.5 sm:gap-2 mt-6 md:mt-8">
|
|
{Array.from({ length: maxIndex + 1 }).map((_, index) => (
|
|
<button
|
|
key={index}
|
|
onClick={() => goToSlide(index)}
|
|
className={`transition-all duration-300 rounded-full
|
|
${
|
|
index === currentIndex
|
|
? 'w-8 h-2 sm:w-10 sm:h-2.5 bg-gradient-to-r from-[#d4af37] to-[#c9a227] shadow-lg shadow-[#d4af37]/40'
|
|
: 'w-2 h-2 sm:w-2.5 sm:h-2.5 bg-gray-300 hover:bg-[#d4af37]/50'
|
|
}
|
|
`}
|
|
aria-label={`Go to partners slide ${index + 1}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default PartnersCarousel;
|