Spaces:
Sleeping
Sleeping
| "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"> | |
| “{testimonial.content}” | |
| </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't just take our word for it — hear from the businesses we'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> | |
| ) | |
| } | |