237 lines
9.1 KiB
TypeScript
237 lines
9.1 KiB
TypeScript
"use client"
|
|
|
|
import Image from "next/image"
|
|
import { useState, useEffect } from "react"
|
|
import { ChevronLeft, ChevronRight, Star } from "lucide-react"
|
|
import { motion, AnimatePresence } from "framer-motion"
|
|
import { ensureHttpsImageUrl } from "@/lib/utils"
|
|
|
|
interface Testimonial {
|
|
id: number | string
|
|
name: string
|
|
title: string
|
|
organization: string
|
|
image: string
|
|
text: string
|
|
rating: number
|
|
}
|
|
|
|
export default function Testimonials() {
|
|
const [testimonials, setTestimonials] = useState<Testimonial[]>([])
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [startIndex, setStartIndex] = useState(0)
|
|
const itemsPerPage = 3
|
|
|
|
// Fetch testimonials from API
|
|
useEffect(() => {
|
|
const fetchTestimonials = async () => {
|
|
try {
|
|
// Use Next.js API route instead of direct Strapi call
|
|
const res = await fetch("/api/testimonials", {
|
|
headers: { Accept: "application/json" },
|
|
})
|
|
|
|
if (!res.ok) throw new Error(`Testimonials API status ${res.status}`)
|
|
const json = await res.json()
|
|
|
|
const items = Array.isArray(json?.data) ? json.data : []
|
|
const mapped = items
|
|
.filter((item: any) => {
|
|
// Only include items that have an avatar image
|
|
const attrs = item?.attributes ?? item ?? {}
|
|
const avatarUrl =
|
|
attrs?.avatar?.data?.attributes?.url ||
|
|
attrs?.avatar?.data?.url ||
|
|
attrs?.avatar?.url ||
|
|
attrs?.avatar ||
|
|
""
|
|
return !!avatarUrl
|
|
})
|
|
.map((item: any, index: number): Testimonial => {
|
|
const attrs = item?.attributes ?? item ?? {}
|
|
|
|
// Normalize rating 0-5
|
|
const rawRating = Number(attrs?.rating ?? 0)
|
|
const rating = Number.isFinite(rawRating)
|
|
? Math.min(5, Math.max(0, Math.round(rawRating)))
|
|
: 0
|
|
|
|
// Resolve media URL - handle both nested and direct formats
|
|
const avatarData = attrs?.avatar?.data || attrs?.avatar
|
|
const avatarAttrs = avatarData?.attributes || avatarData || {}
|
|
const avatarUrl = avatarAttrs?.url || attrs?.avatar?.url || ""
|
|
|
|
// Ensure HTTPS to prevent Mixed Content errors
|
|
const image = ensureHttpsImageUrl(avatarUrl)
|
|
|
|
return {
|
|
id: item?.id ?? attrs?.id ?? index,
|
|
name: attrs?.name ?? "",
|
|
title: attrs?.role ?? "",
|
|
organization: attrs?.company ?? "",
|
|
text: attrs?.quote ?? "",
|
|
rating,
|
|
image,
|
|
}
|
|
})
|
|
|
|
setTestimonials(mapped)
|
|
setError(null)
|
|
} catch (err: any) {
|
|
console.error("Failed to load testimonials:", err)
|
|
setTestimonials([])
|
|
setError("Failed to load testimonials")
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
fetchTestimonials()
|
|
}, [])
|
|
|
|
const nextSlide = () => {
|
|
if (testimonials.length === 0) return
|
|
setStartIndex((prev) => (prev + 1) % Math.max(1, testimonials.length))
|
|
}
|
|
|
|
const prevSlide = () => {
|
|
if (testimonials.length === 0) return
|
|
setStartIndex((prev) => (prev === 0 ? testimonials.length - 1 : prev - 1))
|
|
}
|
|
|
|
// Sliding window over testimonials
|
|
const visibleTestimonials: Testimonial[] = []
|
|
const visibleCount = Math.min(itemsPerPage, testimonials.length)
|
|
for (let i = 0; i < visibleCount; i++) {
|
|
const index = (startIndex + i) % Math.max(1, testimonials.length || 1)
|
|
const item = testimonials[index]
|
|
if (item) visibleTestimonials.push(item)
|
|
}
|
|
|
|
const handleNext = () => {
|
|
setStartIndex((prev) => (prev + 1) % testimonials.length)
|
|
}
|
|
|
|
const handlePrev = () => {
|
|
setStartIndex((prev) => (prev - 1 + testimonials.length) % testimonials.length)
|
|
}
|
|
|
|
return (
|
|
<section className="w-full bg-[#EAEAEA] py-16 px-4 md:py-24">
|
|
<div className="max-w-[1100px] mx-auto">
|
|
<h2 className="text-4xl md:text-5xl font-bold text-[#353535] text-center mb-16">
|
|
Testimonials
|
|
</h2>
|
|
|
|
<div className="relative flex items-center justify-center">
|
|
{/* Navigation Buttons */}
|
|
<button
|
|
onClick={handlePrev}
|
|
className="absolute left-1 xs:left-2 sm:left-3 md:left-4 lg:left-2 xl:-left-16 top-1/2 -translate-y-1/2 z-10 p-1.5 sm:p-2 md:p-2.5 lg:p-2 xl:p-2.5 rounded-full bg-[#353535] text-white hover:bg-[#F48120] transition-colors shadow-md"
|
|
aria-label="Previous testimonial"
|
|
>
|
|
<ChevronLeft className="w-4 h-4 sm:w-5 sm:h-5 md:w-5 md:h-5 lg:w-5 lg:h-5 xl:w-5 xl:h-5" />
|
|
</button>
|
|
|
|
<button
|
|
onClick={handleNext}
|
|
className="absolute right-1 xs:right-2 sm:right-3 md:right-4 lg:right-2 xl:-right-16 top-1/2 -translate-y-1/2 z-10 p-1.5 sm:p-2 md:p-2.5 lg:p-2 xl:p-2.5 rounded-full bg-[#353535] text-white hover:bg-[#F48120] transition-colors shadow-md"
|
|
aria-label="Next testimonial"
|
|
>
|
|
<ChevronRight className="w-4 h-4 sm:w-5 sm:h-5 md:w-5 md:h-5 lg:w-5 lg:h-5 xl:w-5 xl:h-5" />
|
|
</button>
|
|
|
|
{/* Carousel Viewport */}
|
|
<div className="w-full overflow-visible">
|
|
{isLoading && (
|
|
<div className="text-center py-10 text-[#353535]">Loading testimonials...</div>
|
|
)}
|
|
{!isLoading && testimonials.length === 0 && (
|
|
<div className="text-center py-10 text-[#353535]">
|
|
No testimonials available at the moment.
|
|
</div>
|
|
)}
|
|
<div className="flex justify-center lg:grid lg:grid-cols-2 xl:grid-cols-3 gap-6">
|
|
<AnimatePresence mode="popLayout">
|
|
{visibleTestimonials.map((testimonial, index) => (
|
|
<motion.div
|
|
key={`${testimonial.id}-${startIndex}`}
|
|
initial={{ opacity: 0, x: 20 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, x: -20 }}
|
|
transition={{ duration: 0.3 }}
|
|
className={`h-full w-full max-w-[400px] lg:max-w-none ${index > 0 ? 'hidden lg:block' : ''} ${index > 1 ? 'xl:block hidden lg:hidden' : ''}`}
|
|
>
|
|
{/*
|
|
Card Design:
|
|
- White background
|
|
- Padding around
|
|
- Inner border that changes color on hover
|
|
*/}
|
|
<div className="group bg-white p-4 h-full cursor-pointer transition-shadow hover:shadow-lg">
|
|
<div className="w-full h-full min-h-[480px] border border-gray-300 group-hover:border-[#F48120] transition-colors duration-300 p-6 md:p-8 flex flex-col items-center text-center">
|
|
|
|
{/* Profile Image */}
|
|
{testimonial.image && (
|
|
<div className="w-24 h-24 mb-6 rounded-full overflow-hidden bg-gray-200">
|
|
<Image
|
|
src={testimonial.image}
|
|
alt={testimonial.name}
|
|
width={96}
|
|
height={96}
|
|
className="object-cover"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Star Rating */}
|
|
<div className="flex gap-1 mb-6">
|
|
{Array.from({ length: 5 }).map((_, i) => {
|
|
const isFilled = i < testimonial.rating
|
|
return (
|
|
<Star
|
|
key={i}
|
|
size={18}
|
|
className={
|
|
isFilled
|
|
? "fill-[#F48120] text-[#F48120]"
|
|
: "fill-transparent text-[#D1D5DB]"
|
|
}
|
|
strokeWidth={1.5}
|
|
/>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* Text - Serif font as per design image */}
|
|
<p className="font-serif text-[#353535] text-sm leading-relaxed mb-6 text-justify">
|
|
{testimonial.text}
|
|
</p>
|
|
|
|
{/* Divider */}
|
|
<div className="w-full h-px bg-gray-300 mb-6 mt-auto"></div>
|
|
|
|
{/* Author Info */}
|
|
<div className="w-full text-left">
|
|
<h4 className="font-bold text-[#353535] text-lg mb-1">
|
|
{testimonial.name}
|
|
</h4>
|
|
<div className="text-[#555] text-sm">
|
|
<p>{testimonial.title}</p>
|
|
<p>{testimonial.organization}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
))}
|
|
</AnimatePresence>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|