Skip to content

Commit e26dcd8

Browse files
Feature/image carousel (#59)
* Add testimonial carousel images * Fix testimonial carousel layout and arrows * Remove package-lock.json to fix Nx backend build * yarn fixes * Refine testimonial carousel animation and side card sizing * card sizing changes * Fix carousel animation to smoothly rotate between slides * Fix carousel animation to smoothly rotate between slides
1 parent 800b282 commit e26dcd8

15 files changed

Lines changed: 481 additions & 274 deletions

apps/frontend/src/app.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { useEffect } from 'react';
22
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
3-
3+
import './styles.css';
44
import apiClient from '@api/apiClient';
55
import Root from '@containers/root';
66
import NotFound from '@containers/404';
7-
import Test from '@containers/test';
7+
import TestimonialTester from '@containers/TestimonialTester';
88
import { DonationForm } from '@containers/donations/DonationForm';
99
import { ShadcnExample } from '@components/ShadcnExample';
1010
import { AuthProvider } from '@components/AuthProvider';
@@ -34,7 +34,7 @@ const router = createBrowserRouter([
3434
},
3535
{
3636
path: '/test',
37-
element: <Test />,
37+
element: <TestimonialTester />,
3838
},
3939
{
4040
path: '/shadcn-example',
Lines changed: 195 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,203 @@
1-
import React, { useEffect, useState } from 'react';
2-
import {
3-
TestimonialCarousel,
4-
TestimonialCarouselProps,
5-
} from './TestimonialCarousel';
6-
7-
const SLIDES: TestimonialCarouselProps[] = [
8-
{
9-
title: 'Make a Difference',
10-
body: 'Read below for more about FCC and how your gift supports our small organization serving the entire Fenway community!',
11-
linkText: 'Contact Us for any questions!',
12-
},
13-
{
14-
title: 'Support the Arts',
15-
body: 'Your generosity helps us bring live performances, workshops, and community events to the Fenway neighborhood.',
16-
linkText: 'Learn how to get involved',
17-
},
18-
{
19-
title: 'Invest in Community',
20-
body: 'Every contribution—large or small—directly funds programs that connect neighbors, families, and local artists.',
21-
linkText: 'See where your gift goes',
22-
},
23-
{
24-
title: 'Join Our Mission',
25-
body: 'Partner with FCC to build a more vibrant, inclusive Fenway community through culture, creativity, and care.',
26-
linkText: 'Become a supporter today',
27-
},
28-
];
29-
30-
export const AutoRotatingTestimonialCarousel: React.FC = () => {
31-
const [index, setIndex] = useState(0);
32-
33-
// auto-rotate every 7 seconds
1+
import React, { useCallback, useEffect, useMemo, useState } from 'react';
2+
3+
export interface CarouselSlide {
4+
id: number | string;
5+
image: string;
6+
alt?: string;
7+
objectPosition?: string;
8+
}
9+
10+
type Props = {
11+
slides: CarouselSlide[];
12+
animMs?: number;
13+
autoMs?: number;
14+
};
15+
16+
const ArrowSvgRight: React.FC<{ className?: string }> = ({ className }) => (
17+
<svg
18+
xmlns="http://www.w3.org/2000/svg"
19+
width="16"
20+
height="26"
21+
viewBox="0 0 16 26"
22+
fill="none"
23+
className={className}
24+
aria-hidden="true"
25+
>
26+
<path
27+
d="M9.58759 12.5054L0.0000983635 2.91793L2.91803 0L15.4235 12.5054L2.91803 25.0109L0.0000983635 22.0929L9.58759 12.5054Z"
28+
fill="#1D1B20"
29+
/>
30+
</svg>
31+
);
32+
33+
const CardSlot: React.FC<{
34+
slide: CarouselSlide;
35+
style: React.CSSProperties;
36+
animMs: number;
37+
}> = ({ slide, style, animMs }) => {
38+
return (
39+
<div
40+
className="absolute top-1/2 left-1/2 overflow-hidden"
41+
style={{
42+
borderRadius: 10,
43+
backgroundColor: '#d3d3d3',
44+
backgroundImage: `url(${slide.image})`,
45+
backgroundRepeat: 'no-repeat',
46+
backgroundSize: '102.5% 102%',
47+
backgroundPosition: '-2px -2px',
48+
transitionProperty: 'transform, opacity, box-shadow, width, height',
49+
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
50+
transitionDuration: `${animMs}ms`,
51+
willChange: 'transform, opacity',
52+
...style,
53+
}}
54+
aria-label={slide.alt}
55+
/>
56+
);
57+
};
58+
59+
export const AutoRotatingTestimonialCarousel: React.FC<Props> = ({
60+
slides,
61+
animMs = 500,
62+
autoMs = 7000,
63+
}) => {
64+
const len = slides.length;
65+
const [activeIndex, setActiveIndex] = useState(0);
66+
const [isAnimating, setIsAnimating] = useState(false);
67+
68+
const goTo = useCallback(
69+
(next: number) => {
70+
if (len <= 1 || isAnimating) return;
71+
setIsAnimating(true);
72+
setActiveIndex(((next % len) + len) % len);
73+
window.setTimeout(() => setIsAnimating(false), animMs);
74+
},
75+
[animMs, isAnimating, len],
76+
);
77+
3478
useEffect(() => {
35-
const id = window.setInterval(() => {
36-
setIndex((prev) => (prev + 1) % SLIDES.length);
37-
}, 7000);
79+
if (len <= 1) return;
3880

39-
return () => window.clearInterval(id);
81+
const t = window.setInterval(() => {
82+
if (isAnimating) return;
83+
setIsAnimating(true);
84+
setActiveIndex((prev) => (prev - 1 + len) % len);
85+
window.setTimeout(() => setIsAnimating(false), animMs);
86+
}, autoMs);
87+
88+
return () => window.clearInterval(t);
89+
}, [autoMs, animMs, isAnimating, len]);
90+
91+
const slotStyles = useMemo(() => {
92+
const base = { width: 193.834, height: 193.833 };
93+
94+
return {
95+
left: {
96+
...base,
97+
opacity: 0.85,
98+
zIndex: 10,
99+
boxShadow: '0 4px 8px 0 rgba(0,0,0,0.25)',
100+
transform: `translate(-50%, -50%) translateX(-120px) scale(0.8)`,
101+
} as React.CSSProperties,
102+
center: {
103+
...base,
104+
opacity: 1,
105+
zIndex: 30,
106+
boxShadow: '0 4px 10px 0 rgba(0,0,0,0.50)',
107+
transform: `translate(-50%, -50%) translateX(0px) scale(1)`,
108+
} as React.CSSProperties,
109+
right: {
110+
...base,
111+
opacity: 0.85,
112+
zIndex: 10,
113+
boxShadow: '0 4px 8px 0 rgba(0,0,0,0.25)',
114+
transform: `translate(-50%, -50%) translateX(120px) scale(0.8)`,
115+
} as React.CSSProperties,
116+
};
40117
}, []);
41118

42-
const currentSlide = SLIDES[index];
119+
if (!len) return null;
120+
121+
const ArrowButton = ({
122+
direction,
123+
onClick,
124+
}: {
125+
direction: 'left' | 'right';
126+
onClick: () => void;
127+
}) => (
128+
<button
129+
onClick={onClick}
130+
disabled={isAnimating}
131+
className="w-[40px] h-[40px] flex items-center justify-center rounded-full disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none"
132+
style={{
133+
borderRadius: '80px',
134+
background: '#EFEFEF',
135+
border: 'none',
136+
}}
137+
aria-label={direction === 'left' ? 'Previous slide' : 'Next slide'}
138+
>
139+
<ArrowSvgRight className={direction === 'left' ? 'rotate-180' : ''} />
140+
</button>
141+
);
142+
143+
return (
144+
<div className="w-full">
145+
<div className="mx-auto w-full max-w-[650px]">
146+
<div className="flex items-center justify-between gap-8 sm:gap-16">
147+
{/* Left arrow (previous) */}
148+
<ArrowButton direction="left" onClick={() => goTo(activeIndex + 1)} />
149+
150+
{/* Overlapping stage */}
151+
<div
152+
className="relative"
153+
style={{
154+
width: 420,
155+
height: 220,
156+
}}
157+
>
158+
{slides.map((slide, index) => {
159+
const position = (index - activeIndex + len) % len;
160+
let style: React.CSSProperties;
161+
162+
if (position === len - 1) {
163+
// Left position
164+
style = slotStyles.left;
165+
} else if (position === 0) {
166+
// Center position
167+
style = slotStyles.center;
168+
} else if (position === 1) {
169+
// Right position
170+
style = slotStyles.right;
171+
} else {
172+
// Hidden - off screen
173+
style = {
174+
...slotStyles.right,
175+
opacity: 0,
176+
transform: `translate(-50%, -50%) translateX(240px) scale(0.6)`,
177+
pointerEvents: 'none',
178+
};
179+
}
180+
181+
return (
182+
<CardSlot
183+
key={slide.id}
184+
slide={slide}
185+
style={style}
186+
animMs={animMs}
187+
/>
188+
);
189+
})}
190+
</div>
43191

44-
return <TestimonialCarousel {...currentSlide} />;
192+
{/* Right arrow (next) */}
193+
<ArrowButton
194+
direction="right"
195+
onClick={() => goTo(activeIndex - 1)}
196+
/>
197+
</div>
198+
</div>
199+
</div>
200+
);
45201
};
46202

47203
export default AutoRotatingTestimonialCarousel;

apps/frontend/src/components/testimonials/TestimonialCarousel.spec.tsx

Lines changed: 0 additions & 98 deletions
This file was deleted.

apps/frontend/src/components/testimonials/TestimonialCarousel.tsx

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,18 @@
11
import React from 'react';
2-
import './testimonials.css';
2+
import AutoRotatingTestimonialCarousel, {
3+
CarouselSlide,
4+
} from './AutoRotatingTestimonialCarousel';
35

46
export interface TestimonialCarouselProps {
5-
title?: string;
6-
body?: string;
7-
linkText?: string;
7+
slides: CarouselSlide[];
88
}
99

1010
export const TestimonialCarousel: React.FC<TestimonialCarouselProps> = ({
11-
title = 'Make a Difference',
12-
body = 'Read below for more about FCC and how your gift supports our small organization serving the entire Fenway community!',
13-
linkText = 'Contact Us for any questions!',
11+
slides,
1412
}) => {
1513
return (
16-
<div className="testimonial-carousel">
17-
<div className="testimonial-carousel__text">
18-
<h2 className="testimonial-carousel__title">{title}</h2>
19-
20-
<p className="testimonial-carousel__body">{body}</p>
21-
22-
<a href="#contact" className="testimonial-carousel__link">
23-
{linkText}
24-
</a>
25-
</div>
14+
<div className="w-full flex justify-center">
15+
<AutoRotatingTestimonialCarousel slides={slides} />
2616
</div>
2717
);
2818
};
2.39 MB
Loading
862 KB
Loading
1.04 MB
Loading

0 commit comments

Comments
 (0)