110 lines
3.7 KiB
TypeScript
110 lines
3.7 KiB
TypeScript
'use client';
|
|
|
|
import React from 'react';
|
|
import { Star, CheckCircle, ChevronRight } from 'lucide-react';
|
|
import Link from 'next/link';
|
|
import type { Testimonial } from '@/lib/types';
|
|
|
|
interface Props {
|
|
testimonials: Testimonial[];
|
|
title?: string;
|
|
subtitle?: string;
|
|
}
|
|
|
|
function StarRating({ rating }: { rating: number }) {
|
|
return (
|
|
<div className="flex gap-0.5" aria-label={`${rating} out of 5 stars`}>
|
|
{[...Array(5)].map((_, i) => (
|
|
<Star
|
|
key={i}
|
|
className={`w-4 h-4 ${i < rating ? 'fill-yellow-400 text-yellow-400' : 'fill-gray-200 text-gray-200'}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TestimonialCard({ testimonial }: { testimonial: Testimonial }) {
|
|
return (
|
|
<div className="w-[340px] sm:w-[380px] bg-white rounded-2xl shadow-sm border border-gray-100 p-6 flex flex-col h-full">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<StarRating rating={testimonial.rating} />
|
|
{testimonial.verified && (
|
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-green-50 text-green-700 text-xs font-medium rounded-full border border-green-200">
|
|
<CheckCircle className="w-3 h-3" />
|
|
Verified
|
|
</span>
|
|
)}
|
|
</div>
|
|
<h3 className="text-sm font-semibold text-gray-900 mb-2">"{testimonial.title}"</h3>
|
|
<p className="text-gray-600 text-sm leading-relaxed flex-grow">{testimonial.content}</p>
|
|
<div className="border-t border-gray-100 pt-4 mt-4">
|
|
<span className="font-semibold text-gray-900 text-sm block">{testimonial.author.name}</span>
|
|
{(testimonial.author.role || testimonial.author.company) && (
|
|
<span className="text-xs text-gray-600 block">
|
|
{[testimonial.author.role, testimonial.author.company].filter(Boolean).join(', ')}
|
|
</span>
|
|
)}
|
|
{testimonial.author.location && (
|
|
<span className="text-xs text-gray-400 block">{testimonial.author.location}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export const TestimonialsCarousel: React.FC<Props> = ({
|
|
testimonials,
|
|
title = 'What Our Customers Say',
|
|
subtitle = 'Real experiences from businesses using QR Master',
|
|
}) => {
|
|
// Duplicate for seamless loop: when first set exits left, second set is identical → no visible reset
|
|
const doubled = [...testimonials, ...testimonials];
|
|
|
|
return (
|
|
<section className="py-16 bg-gray-50 overflow-hidden">
|
|
<style>{`
|
|
@keyframes marquee {
|
|
from { transform: translateX(0); }
|
|
to { transform: translateX(-50%); }
|
|
}
|
|
.marquee-track {
|
|
animation: marquee 90s linear infinite;
|
|
}
|
|
.marquee-track:hover {
|
|
animation-play-state: paused;
|
|
}
|
|
`}</style>
|
|
|
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl mb-10">
|
|
<div className="text-center">
|
|
<h2 className="text-3xl md:text-4xl font-bold text-gray-900 mb-4">{title}</h2>
|
|
<p className="text-lg text-gray-600 max-w-2xl mx-auto">{subtitle}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Full-width overflow mask */}
|
|
<div className="w-full overflow-hidden">
|
|
<div
|
|
className="marquee-track flex gap-6"
|
|
style={{ width: 'max-content' }}
|
|
>
|
|
{doubled.map((t, idx) => (
|
|
<TestimonialCard key={`${t.id}-${idx}`} testimonial={t} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-8 text-center">
|
|
<Link
|
|
href="/testimonials"
|
|
className="inline-flex items-center text-blue-600 font-semibold hover:text-blue-700 transition-colors text-sm"
|
|
>
|
|
See all {testimonials.length} reviews
|
|
<ChevronRight className="w-4 h-4 ml-1" />
|
|
</Link>
|
|
</div>
|
|
</section>
|
|
);
|
|
};
|