127 lines
4.0 KiB
TypeScript
127 lines
4.0 KiB
TypeScript
'use client'
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
import ContentCard from './ui/content-cards'
|
|
import './content-card-carousel.css'
|
|
|
|
interface ContentCardItem {
|
|
src?: string
|
|
button_link?: string
|
|
heading?: string
|
|
button_text?: string
|
|
card_text?: string
|
|
}
|
|
|
|
interface ContentCardCarouselProps {
|
|
cards: ContentCardItem[]
|
|
}
|
|
|
|
function getVisibleCardCount() {
|
|
if (typeof window === 'undefined') return 1
|
|
if (window.matchMedia('(min-width: 1024px)').matches) return 3
|
|
if (window.matchMedia('(min-width: 640px)').matches) return 2
|
|
return 1
|
|
}
|
|
|
|
export default function ContentCardCarousel({ cards }: ContentCardCarouselProps) {
|
|
const [visibleCards, setVisibleCards] = useState(1)
|
|
const [currentIndex, setCurrentIndex] = useState(0)
|
|
|
|
const syncVisibleCards = useCallback(() => {
|
|
setVisibleCards(getVisibleCardCount())
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
syncVisibleCards()
|
|
|
|
const mediaQueries = [
|
|
window.matchMedia('(min-width: 640px)'),
|
|
window.matchMedia('(min-width: 1024px)'),
|
|
]
|
|
|
|
const onBreakpointChange = () => syncVisibleCards()
|
|
|
|
mediaQueries.forEach((mq) => mq.addEventListener('change', onBreakpointChange))
|
|
window.addEventListener('resize', onBreakpointChange)
|
|
|
|
return () => {
|
|
mediaQueries.forEach((mq) => mq.removeEventListener('change', onBreakpointChange))
|
|
window.removeEventListener('resize', onBreakpointChange)
|
|
}
|
|
}, [syncVisibleCards])
|
|
|
|
const maxIndex = useMemo(() => Math.max(cards.length - visibleCards, 0), [cards.length, visibleCards])
|
|
|
|
useEffect(() => {
|
|
setCurrentIndex((previous) => Math.min(previous, maxIndex))
|
|
}, [maxIndex])
|
|
|
|
// % of track width — matches flex slide sizes (100% / 50% / 33.333% per breakpoint)
|
|
const trackOffsetPercent = cards.length > 0 ? (currentIndex * 100) / cards.length : 0
|
|
|
|
const next = () => {
|
|
if (maxIndex === 0) return
|
|
setCurrentIndex((previous) => (previous >= maxIndex ? 0 : previous + 1))
|
|
}
|
|
|
|
const previous = () => {
|
|
if (maxIndex === 0) return
|
|
setCurrentIndex((index) => (index <= 0 ? maxIndex : index - 1))
|
|
}
|
|
|
|
return (
|
|
<div className="w-full max-w-full pb-30">
|
|
<h1 className="mb-4 flex justify-center text-5xl font-bold">Our Case Studies</h1>
|
|
|
|
<div className="carousel-viewport relative overflow-hidden">
|
|
<div
|
|
className="carousel-track flex items-stretch transition-transform duration-500 ease-in-out"
|
|
style={{ transform: `translate3d(-${trackOffsetPercent}%, 0, 0)` }}
|
|
>
|
|
{cards.map((card, index) => {
|
|
const isEdgeCard =
|
|
visibleCards > 1 && (index === currentIndex || index === currentIndex + visibleCards - 1)
|
|
|
|
return (
|
|
<div
|
|
key={`${card.heading ?? 'card'}-${index}`}
|
|
className={`carousel-slide flex shrink-0 justify-center px-2 py-3 transition-opacity duration-500 ${
|
|
isEdgeCard ? 'opacity-75' : 'opacity-100'
|
|
}`}
|
|
>
|
|
<div className="flex h-full w-full max-w-sm">
|
|
<ContentCard {...card} />
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
<div className="pointer-events-none absolute inset-y-0 left-0 w-12 bg-gradient-to-r from-white to-transparent dark:from-black" />
|
|
<div className="pointer-events-none absolute inset-y-0 right-0 w-12 bg-gradient-to-l from-white to-transparent dark:from-black" />
|
|
</div>
|
|
|
|
<div className="carousel-controls relative z-20 mt-6 flex justify-center gap-4">
|
|
<button
|
|
type="button"
|
|
onClick={previous}
|
|
disabled={maxIndex === 0}
|
|
className="carousel-control-btn"
|
|
aria-label="Show previous content cards"
|
|
>
|
|
←
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={next}
|
|
disabled={maxIndex === 0}
|
|
className="carousel-control-btn"
|
|
aria-label="Show next content cards"
|
|
>
|
|
→
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|