webdev-service / components /home /TestimonialsCarousel.tsx
underrate's picture
Initial commit
34ed5b1 verified
Raw
History Blame Contribute Delete
14.1 kB
"use client"
import { Card, CardContent, CardHeader, CardFooter } from "@/components/ui/card"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Star, Quote, ChevronLeft, ChevronRight } from "lucide-react"
import { useRef, useState, useEffect, useCallback } from "react"
type Testimonial = {
id: string
clientName: string
company: string | null
content: string
rating: number
imageUrl: string | null
}
/* Static fallback used when the API is unreachable (build-time, etc.) */
const fallbackTestimonials: Testimonial[] = [
{
id: "fallback-1",
clientName: "Sarah Johnson",
company: "TechFlow",
content:
"NexaFlow transformed our online presence. Their attention to detail and technical expertise is unmatched. They delivered exactly what we needed.",
rating: 5,
imageUrl: null,
},
{
id: "fallback-2",
clientName: "Michael Chen",
company: "StartupX",
content:
"The team delivered our MVP ahead of schedule and it looks fantastic. They truly understood our vision and brought it to life.",
rating: 5,
imageUrl: null,
},
{
id: "fallback-3",
clientName: "Emily Davis",
company: "Creative Studio",
content:
"Professional, responsive, and incredibly talented. They truly understand modern web design and the latest technologies.",
rating: 5,
imageUrl: null,
},
{
id: "fallback-4",
clientName: "David Wilson",
company: "InnovateCo",
content:
"Working with NexaFlow was a game-changer for our business. The website they built exceeded our expectations in every way.",
rating: 5,
imageUrl: null,
},
{
id: "fallback-5",
clientName: "Lisa Anderson",
company: "GrowthHub",
content:
"From concept to launch, the process was smooth and professional. Our new app has received amazing feedback from users.",
rating: 5,
imageUrl: null,
},
]
function getInitials(name: string): string {
return name
.split(" ")
.map((w) => w[0])
.join("")
.toUpperCase()
.slice(0, 2)
}
function TestimonialCard({ testimonial, isActive }: { testimonial: Testimonial; isActive: boolean }) {
return (
<Card
className={`relative h-full glass-card border-0 transition-all duration-500 rounded-3xl overflow-hidden ${
isActive ? "scale-100 opacity-100" : "scale-95 opacity-70"
}`}
>
{/* Animated gradient background */}
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-accent/5 opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
{/* Quote decoration */}
<div className="absolute top-6 right-6 group-hover:scale-110 group-hover:rotate-12 transition-all duration-300" aria-hidden="true">
<Quote className="h-10 w-10 text-primary/20" suppressHydrationWarning />
</div>
<CardHeader className="pb-2 relative z-10">
<div className="flex gap-0.5 mb-4" role="img" aria-label={`${testimonial.rating} out of 5 stars`}>
{[...Array(testimonial.rating)].map((_, j) => (
<Star
key={j}
className="w-5 h-5 fill-amber-400 text-amber-400 group-hover:scale-110 transition-all duration-300"
style={{ transitionDelay: `${j * 50}ms` }}
suppressHydrationWarning
/>
))}
</div>
</CardHeader>
<CardContent className="relative z-10">
<blockquote className="text-base text-foreground leading-relaxed">
&ldquo;{testimonial.content}&rdquo;
</blockquote>
</CardContent>
<CardFooter className="flex items-center gap-4 mt-auto pt-4 relative z-10">
<Avatar className="h-12 w-12 glass-card group-hover:scale-110 transition-all duration-300 ring-2 ring-primary/20">
<AvatarFallback className="bg-gradient-to-br from-primary/20 to-accent/20 text-primary text-sm font-semibold">
{getInitials(testimonial.clientName)}
</AvatarFallback>
</Avatar>
<div>
<p className="font-semibold text-sm group-hover:text-primary transition-colors duration-300">
{testimonial.clientName}
</p>
{testimonial.company && (
<p className="text-xs text-muted-foreground">{testimonial.company}</p>
)}
</div>
</CardFooter>
{/* Bottom accent line */}
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-primary via-accent to-primary transform scale-x-0 group-hover:scale-x-100 transition-transform duration-500 origin-left" />
{/* Shine effect */}
<div className="absolute inset-0 shine-effect pointer-events-none rounded-3xl" />
</Card>
)
}
export function TestimonialsCarousel() {
const [testimonials, setTestimonials] = useState<Testimonial[]>(fallbackTestimonials)
const [currentIndex, setCurrentIndex] = useState(0)
const [isVisible, setIsVisible] = useState(false)
const [isPaused, setIsPaused] = useState(false)
const sectionRef = useRef<HTMLElement>(null)
// Fetch testimonials
useEffect(() => {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:5000/api"
fetch(`${apiUrl}/testimonials`)
.then((res) => res.json())
.then((data: Testimonial[]) => {
if (data.length > 0) setTestimonials(data)
})
.catch(() => {
// Use fallback testimonials
})
}, [])
// Intersection observer for reveal animation
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true)
}
},
{ threshold: 0.1 }
)
if (sectionRef.current) {
observer.observe(sectionRef.current)
}
return () => observer.disconnect()
}, [])
// Auto-scroll carousel
useEffect(() => {
if (isPaused || testimonials.length <= 3) return
const interval = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % testimonials.length)
}, 5000)
return () => clearInterval(interval)
}, [isPaused, testimonials.length])
const goToPrevious = useCallback(() => {
setCurrentIndex((prev) => (prev - 1 + testimonials.length) % testimonials.length)
}, [testimonials.length])
const goToNext = useCallback(() => {
setCurrentIndex((prev) => (prev + 1) % testimonials.length)
}, [testimonials.length])
const goToSlide = useCallback((index: number) => {
setCurrentIndex(index)
}, [])
// Get visible testimonials for carousel
const getVisibleTestimonials = () => {
const visible = []
for (let i = 0; i < 3; i++) {
const index = (currentIndex + i) % testimonials.length
visible.push({ testimonial: testimonials[index], position: i })
}
return visible
}
return (
<section
ref={sectionRef}
className="py-24 bg-background relative overflow-hidden"
id="testimonials"
aria-label="Client testimonials"
onMouseEnter={() => setIsPaused(true)}
onMouseLeave={() => setIsPaused(false)}
>
{/* Background decorations */}
<div className="absolute inset-0 gradient-mesh opacity-20" />
<div className="absolute top-1/4 right-0 w-96 h-96 bg-primary/10 rounded-full blur-3xl" />
<div className="absolute bottom-1/4 left-0 w-96 h-96 bg-accent/10 rounded-full blur-3xl" />
{/* Floating quote icons */}
<div className="absolute inset-0 overflow-hidden pointer-events-none opacity-5">
{[...Array(6)].map((_, i) => (
<Quote
key={i}
className="absolute w-16 h-16 text-primary animate-float"
style={{
top: `${20 + i * 15}%`,
left: `${10 + i * 15}%`,
animationDelay: `${i * 0.5}s`,
}}
suppressHydrationWarning
/>
))}
</div>
<div className="container relative z-10">
{/* Section header */}
<div
className={`text-center mb-14 transition-all duration-1000 ${
isVisible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-10"
}`}
>
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full glass-card mb-6">
<Star className="w-4 h-4 text-amber-400 fill-amber-400" suppressHydrationWarning />
<span className="text-sm font-medium text-primary">Testimonials</span>
</div>
<h2 className="text-4xl md:text-5xl font-bold tracking-tight">
What Our <span className="gradient-text">Clients</span> Say
</h2>
<p className="text-lg text-muted-foreground max-w-2xl mx-auto mt-4">
Don&apos;t just take our word for it — hear from the businesses we&apos;ve helped succeed.
</p>
</div>
{/* Carousel */}
<div
className={`relative transition-all duration-1000 delay-200 ${
isVisible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-10"
}`}
>
{/* Navigation buttons */}
<button
onClick={goToPrevious}
className="absolute left-0 top-1/2 -translate-y-1/2 z-20 w-12 h-12 rounded-full glass-card flex items-center justify-center hover:bg-primary/20 transition-all duration-300 hover:scale-110 group"
aria-label="Previous testimonial"
>
<ChevronLeft className="w-6 h-6 text-primary group-hover:-translate-x-0.5 transition-transform" suppressHydrationWarning />
</button>
<button
onClick={goToNext}
className="absolute right-0 top-1/2 -translate-y-1/2 z-20 w-12 h-12 rounded-full glass-card flex items-center justify-center hover:bg-primary/20 transition-all duration-300 hover:scale-110 group"
aria-label="Next testimonial"
>
<ChevronRight className="w-6 h-6 text-primary group-hover:translate-x-0.5 transition-transform" suppressHydrationWarning />
</button>
{/* Cards container */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 px-8 md:px-16">
{getVisibleTestimonials().map(({ testimonial, position }) => (
<div
key={`${testimonial.id}-${currentIndex}-${position}`}
className="group"
style={{
animationDelay: `${position * 100}ms`,
}}
>
<TestimonialCard testimonial={testimonial} isActive={position === 1} />
</div>
))}
</div>
</div>
{/* Dots navigation */}
<div
className={`flex justify-center gap-2 mt-8 transition-all duration-1000 delay-300 ${
isVisible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-10"
}`}
>
{testimonials.slice(0, 6).map((_, index) => (
<button
key={index}
onClick={() => goToSlide(index)}
className={`w-2 h-2 rounded-full transition-all duration-300 ${
index === currentIndex
? "w-8 bg-primary"
: "bg-muted-foreground/30 hover:bg-muted-foreground/50"
}`}
aria-label={`Go to testimonial ${index + 1}`}
/>
))}
</div>
{/* Trust indicator */}
<div
className={`text-center mt-8 transition-all duration-1000 delay-400 ${
isVisible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-10"
}`}
>
<p className="text-sm text-muted-foreground">
Trusted by <span className="text-primary font-semibold">50+</span> satisfied clients
</p>
</div>
</div>
</section>
)
}