Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 37 additions & 31 deletions frontend/__tests__/unit/components/LogoCarousel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,27 @@ jest.mock('next/image', () => {
return function MockImage({
src,
alt,
style,
fill,
className,
width,
height,
}: {
src: string
alt: string
style?: React.CSSProperties
fill?: boolean
className?: string
width?: number
height?: number
}) {
// eslint-disable-next-line @next/next/no-img-element
return <img src={src} alt={alt} style={style} data-testid="sponsor-image" data-fill={fill} />
return (
// eslint-disable-next-line @next/next/no-img-element -- mock for unit tests
<img
src={src}
alt={alt}
className={className}
width={width}
height={height}
data-testid="sponsor-image"
/>
)
}
})

Expand Down Expand Up @@ -156,20 +167,20 @@ describe('MovingLogos (LogoCarousel)', () => {
render(<MovingLogos sponsors={mockSponsors} />)

const scroller = document.querySelector('.animate-scroll')
expect(scroller).toHaveStyle('animation-duration: 6s')
expect(scroller).toHaveStyle('animation-duration: 9s')
})

it('updates animation duration when sponsors change', () => {
const { rerender } = render(<MovingLogos sponsors={mockSponsors} />)

let scroller = document.querySelector('.animate-scroll')
expect(scroller).toHaveStyle('animation-duration: 6s')
expect(scroller).toHaveStyle('animation-duration: 9s')

const newSponsors = [...mockSponsors, ...mockSponsors]
rerender(<MovingLogos sponsors={newSponsors} />)

scroller = document.querySelector('.animate-scroll')
expect(scroller).toHaveStyle('animation-duration: 12s')
expect(scroller).toHaveStyle('animation-duration: 18s')
})
})

Expand Down Expand Up @@ -260,7 +271,7 @@ describe('MovingLogos (LogoCarousel)', () => {

const scroller = document.querySelector('.animate-scroll')
expect(scroller).toBeInTheDocument()
expect(scroller).toHaveClass('animate-scroll', 'flex', 'w-full', 'gap-6')
expect(scroller).toHaveClass('animate-scroll', 'flex', 'w-max', 'gap-6')
})
})

Expand All @@ -284,9 +295,11 @@ describe('MovingLogos (LogoCarousel)', () => {
it('provides fallback for empty imageUrl', () => {
render(<MovingLogos sponsors={mockSponsorsWithoutImages} />)

const imageContainer = document.querySelector('.relative.mb-4')
expect(imageContainer).toBeInTheDocument()
expect(imageContainer?.querySelector('img')).not.toBeInTheDocument()
const images = screen.queryAllByTestId('sponsor-image')
expect(images).toHaveLength(0)

const fallbackText = screen.getAllByText('No Image Sponsor')
expect(fallbackText.length).toBeGreaterThan(0)
})

it('uses generic fallback alt text when sponsor name is missing', () => {
Expand Down Expand Up @@ -426,7 +439,7 @@ describe('MovingLogos (LogoCarousel)', () => {
expect(screen.getAllByTestId('sponsor-image')).toHaveLength(200)

const scroller = document.querySelector('.animate-scroll')
expect(scroller).toHaveStyle('animation-duration: 200s')
expect(scroller).toHaveStyle('animation-duration: 300s')
})
})

Expand Down Expand Up @@ -482,20 +495,16 @@ describe('MovingLogos (LogoCarousel)', () => {
const overflowContainer = document.querySelector('.relative.overflow-hidden.py-2')
expect(overflowContainer).toBeInTheDocument()

const scroller = document.querySelector('.animate-scroll.flex.w-full.gap-6')
const scroller = document.querySelector('.animate-scroll.flex.w-max.gap-6')
expect(scroller).toBeInTheDocument()

const sponsorContainers = document.querySelectorAll('[class*="min-w-[220px]"]')
expect(sponsorContainers).toHaveLength(6)
})

it('applies correct styles to images', () => {
render(<MovingLogos sponsors={mockSponsors} />)

const images = screen.getAllByTestId('sponsor-image')
for (const image of images) {
expect(image).toHaveAttribute('style', 'object-fit: contain;')
expect(image).toHaveAttribute('data-fill', 'true')
expect(image).toHaveClass('h-full', 'w-full', 'object-contain')
}
})

Expand All @@ -508,14 +517,11 @@ describe('MovingLogos (LogoCarousel)', () => {
const scroller = overflowContainer?.querySelector('.animate-scroll')
expect(scroller).toBeInTheDocument()

const sponsorContainer = scroller?.querySelector('[class*="min-w-[220px]"]')
expect(sponsorContainer).toBeInTheDocument()

const link = sponsorContainer?.querySelector('a')
const link = scroller?.querySelector('a')
expect(link).toBeInTheDocument()

const imageContainer = link?.querySelector('.relative.mb-4')
expect(imageContainer).toBeInTheDocument()
const logoWrapper = link?.querySelector('.bg-white.rounded-lg.shadow-md')
expect(logoWrapper).toBeInTheDocument()
})

it('applies correct footer styling', () => {
Expand Down Expand Up @@ -547,14 +553,14 @@ describe('MovingLogos (LogoCarousel)', () => {
expect(donateLink).toHaveClass('text-primary', 'font-medium', 'hover:underline')
})

it('sets correct minimum width for sponsor containers', () => {
it('wraps logos in white containers for dark mode visibility', () => {
render(<MovingLogos sponsors={mockSponsors} />)

const sponsorContainers = document.querySelectorAll('[class*="min-w-[220px]"]')
expect(sponsorContainers).toHaveLength(6)
const logoWrappers = document.querySelectorAll('.bg-white.rounded-lg.shadow-md')
expect(logoWrappers).toHaveLength(6)

for (const container of sponsorContainers) {
expect(container).toHaveClass('min-w-[220px]')
for (const wrapper of logoWrappers) {
expect(wrapper).toHaveClass('bg-white', 'rounded-lg', 'shadow-md')
}
})
})
Expand Down
47 changes: 26 additions & 21 deletions frontend/src/components/LogoCarousel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,38 +5,43 @@ import type { Sponsor } from 'types/home'
interface MovingLogosProps {
readonly sponsors: Sponsor[]
}

/**
* Corporate Supporters Carousel Component
*
* Renders an infinite scrolling marquee of corporate supporter logos.
*
* Key Features:
* - **Infinite Loop**: Uses CSS keyframes to scroll continuously.
* - **Dark Mode Support**: Wraps logos in white cards to ensure visibility.
* - **Normalization**: Enforces consistent sizing for all logos.
*
* @returns {JSX.Element} The rendered logo carousel component.
*/
export default function MovingLogos({ sponsors }: Readonly<MovingLogosProps>) {
// Keep duration behavior stable even when sponsors is empty.
const animationDurationSeconds = Math.max(sponsors.length, 1) * 2
const animationDuration = `${Math.max(sponsors.length, 1) * 3}s`

const renderSponsorCard = (sponsor: Sponsor, index: number, keySuffix: string) => {
// Include `index` to guarantee uniqueness even if `sponsor.id` repeats (e.g. in tests).
const keyBase = sponsor.id
? `${sponsor.id}-${index}`
: `${sponsor.url || sponsor.name || 'sponsor'}-${index}`

return (
<div
key={['logo-carousel', keyBase, keySuffix ?? ''].filter(Boolean).join('-')}
className="flex min-w-[220px] shrink-0 flex-col items-center rounded-lg p-5"
key={`logo-carousel-${keyBase}-${keySuffix}`}
className="flex shrink-0 items-center justify-center"
>
<Link
href={sponsor.url}
target="_blank"
rel="noopener noreferrer"
className="flex h-full w-full flex-col items-center justify-center"
>
<div className="relative mb-4 flex h-16 w-full items-center justify-center">
<Link href={sponsor.url} target="_blank" rel="noopener noreferrer">
<div className="flex h-20 w-44 items-center justify-center rounded-lg bg-white px-3 py-1 shadow-md">
{sponsor.imageUrl ? (
<Image
fill
alt={sponsor.name ? `${sponsor.name}'s logo` : 'Sponsor logo'}
className="h-full w-full object-contain"
height={72}
src={sponsor.imageUrl}
style={{ objectFit: 'contain' }}
width={168}
/>
) : (
<span className="sr-only">{sponsor.name}</span>
<span className="text-sm text-gray-400">{sponsor.name || 'Sponsor'}</span>
)}
</div>
</Link>
Expand All @@ -46,13 +51,13 @@ export default function MovingLogos({ sponsors }: Readonly<MovingLogosProps>) {

return (
<div>
<div className="relative overflow-hidden py-2">
<div className="group relative overflow-hidden py-2">
<div
className="animate-scroll flex w-full gap-6"
style={{ animationDuration: `${animationDurationSeconds}s` }}
className="animate-scroll flex w-max gap-6 group-hover:[animation-play-state:paused]"
style={{ animationDuration }}
>
{sponsors.map((sponsor, index) => renderSponsorCard(sponsor, index, ''))}
{sponsors.map((sponsor, index) => renderSponsorCard(sponsor, index, 'loop'))}
{sponsors.map((sponsor, index) => renderSponsorCard(sponsor, index, 'a'))}
{sponsors.map((sponsor, index) => renderSponsorCard(sponsor, index, 'b'))}
</div>
</div>
<div className="text-muted-foreground mt-4 flex w-full flex-col items-center justify-center text-center text-sm">
Expand Down
4 changes: 2 additions & 2 deletions frontend/tailwind.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,11 @@ export default {
keyframes: {
scroll: {
'0%': { transform: 'translateX(0)' },
'100%': { transform: 'translateX(-500%)' },
'100%': { transform: 'translateX(-50%)' },
},
},
animation: {
scroll: 'scroll 0.5s linear infinite',
scroll: 'scroll 30s linear infinite',
},
},
},
Expand Down