testimonial integration updated

This commit is contained in:
akash 2026-01-10 18:47:08 +05:30
parent 75e3ec6764
commit 645c68fb5c
10 changed files with 746 additions and 223 deletions

20
package-lock.json generated
View File

@ -16,6 +16,7 @@
"react-google-recaptcha": "^3.1.0",
"react-icons": "^5.5.0",
"sitemap": "^9.0.0",
"swiper": "^12.0.3",
"xml2js": "^0.6.2"
},
"devDependencies": {
@ -9369,6 +9370,25 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/swiper": {
"version": "12.0.3",
"resolved": "https://registry.npmjs.org/swiper/-/swiper-12.0.3.tgz",
"integrity": "sha512-BHd6U1VPEIksrXlyXjMmRWO0onmdNPaTAFduzqR3pgjvi7KfmUCAm/0cj49u2D7B0zNjMw02TSeXfinC1hDCXg==",
"funding": [
{
"type": "patreon",
"url": "https://www.patreon.com/swiperjs"
},
{
"type": "open_collective",
"url": "http://opencollective.com/swiper"
}
],
"license": "MIT",
"engines": {
"node": ">= 4.7.0"
}
},
"node_modules/tar-stream": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-1.6.2.tgz",

View File

@ -19,6 +19,7 @@
"react-google-recaptcha": "^3.1.0",
"react-icons": "^5.5.0",
"sitemap": "^9.0.0",
"swiper": "^12.0.3",
"xml2js": "^0.6.2"
},
"devDependencies": {
@ -36,4 +37,4 @@
"selenium-webdriver": "^4.38.0",
"typescript": "^5"
}
}
}

View File

@ -5,12 +5,61 @@ import FAQ from "@/components/FAQ/FAQ";
import Image from "next/image";
import Link from "next/link";
import styles from "./about.module.css";
import { featuresData, testimonialData, ctaData, aboutFaqData } from "@/utils/constant";
import { featuresData, ctaData, aboutFaqData } from "@/utils/constant";
import Testimonials from "@/components/Testimonials/Testimonials";
import { useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { motion } from "framer-motion";
import { Swiper, SwiperSlide } from 'swiper/react';
import { Autoplay, Navigation } from 'swiper/modules';
import 'swiper/css';
import 'swiper/css/navigation';
import { FaStar, FaChevronLeft, FaChevronRight } from 'react-icons/fa';
interface Review {
text?: string;
description?: string;
snippet?: string;
review_text?: string;
body?: string;
content?: string;
rating: number;
profile_photo_url?: string;
author_profile_photo_url?: string;
user?: {
thumbnail?: string;
name?: string;
};
author_name?: string;
}
export default function AboutContent() {
const [currentTestimonial, setCurrentTestimonial] = useState(0);
const [reviews, setReviews] = useState<Review[]>([]);
const [loading, setLoading] = useState(true);
const [expandedReview, setExpandedReview] = useState<number | null>(null);
const [swiperInstance, setSwiperInstance] = useState<any>(null);
// Auto-collapse expanded review after 10 seconds and handle autoplay
useEffect(() => {
if (expandedReview !== null) {
// Stop autoplay when a review is expanded
if (swiperInstance && swiperInstance.autoplay) {
swiperInstance.autoplay.stop();
}
const timer = setTimeout(() => {
setExpandedReview(null);
}, 5000); // 10 seconds
return () => {
clearTimeout(timer);
};
} else {
// Resume autoplay when reviews are collapsed
if (swiperInstance && swiperInstance.autoplay) {
swiperInstance.autoplay.start();
}
}
}, [expandedReview, swiperInstance]);
// Animation variants
const fadeInUp = {
@ -65,22 +114,58 @@ export default function AboutContent() {
}
};
// Auto-slide testimonials
useEffect(() => {
const interval = setInterval(() => {
setCurrentTestimonial((prev) => (prev + 1) % testimonialData.length);
}, 5000); // Change every 5 seconds
return () => clearInterval(interval);
async function loadReviews() {
try {
const res = await fetch("/api/reviews");
if (!res.ok) throw new Error("Failed to fetch");
const data = await res.json();
const cleaned = (data.reviews || []).filter((r: Review) =>
(r.text || r.description || r.snippet || r.review_text || r.body || r.content) &&
r.rating >= 4
);
setReviews(cleaned);
} catch (error) {
console.error("About: Failed to fetch reviews", error);
} finally {
setLoading(false);
}
}
loadReviews();
}, []);
const nextTestimonial = () => {
setCurrentTestimonial((prev) => (prev + 1) % testimonialData.length);
};
const displayedReviews = reviews.length > 0 && reviews.length < 3
? [...reviews, ...reviews, ...reviews]
: reviews;
function renderStars(rating: number) {
return [...Array(5)].map((_, i) => (
<FaStar
key={i}
style={{ color: i < rating ? '#ffc107' : '#e4e5e9', fontSize: '1.2rem', marginRight: '2px' }}
/>
));
}
function getReviewText(r: Review) {
return r.text || r.description || r.snippet || r.review_text || r.body || r.content || "";
}
function truncateText(text: string) {
return text.length > 200 ? text.substring(0, 200) + "..." : text;
}
function getProfileImage(r: Review) {
const url = r.profile_photo_url || r.author_profile_photo_url || r.user?.thumbnail;
if (!url) return null;
return url.startsWith("http") ? url : `https://lh3.googleusercontent.com/${url}`;
}
function getInitials(name: string) {
if (!name) return "U";
return name.split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase();
}
const prevTestimonial = () => {
setCurrentTestimonial((prev) => (prev - 1 + testimonialData.length) % testimonialData.length);
};
return (
<main className={styles.main}>
@ -212,67 +297,92 @@ export default function AboutContent() {
<span>ANTALYA</span>
<Image src="/images/eat.png" alt="Testimonials Section Cutlery Icon" width={24} height={24} />
</div>
<motion.h2
className={styles.sectionTitleCenter}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
variants={fadeInUp}
>
What Our Guests Say
</motion.h2>
<motion.div
className={styles.testimonialSlider}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
variants={fadeIn}
>
<button className={styles.sliderBtn} onClick={prevTestimonial}></button>
<h2 className={styles.sectionTitleCenter}>What Our Guests Say</h2>
<AnimatePresence mode="wait">
<motion.div
key={currentTestimonial}
className={styles.testimonialCard}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.5 }}
>
<p className={styles.testimonialText}>"{testimonialData[currentTestimonial].text}"</p>
<div style={{ fontSize: '1.5rem', color: '#ffdf00', textAlign: 'center', letterSpacing: '3px' }}>
{'★★★★★'}
<div className={styles.testimonialSlider}>
<button className={`${styles.sliderBtn} prev-btn`}>
<FaChevronLeft />
</button>
<div style={{ flex: 1, width: '100%' }}>
{loading ? (
<div className="text-center" style={{ color: '#F5E6D3', padding: '40px' , textAlign: 'center'}}>
<p>Loading reviews...</p>
</div>
{/* <div className={styles.testimonialAuthor}>
<div className={styles.authorImageWrapper}>
<Image
src={testimonialData[currentTestimonial].image}
alt={testimonialData[currentTestimonial].name}
fill
className={styles.authorImage}
/>
</div>
<div className={styles.authorInfo}>
<p className={styles.authorName}>{testimonialData[currentTestimonial].name}</p>
<p className={styles.authorRole}>{testimonialData[currentTestimonial].role}</p>
</div>
</div> */}
</motion.div>
</AnimatePresence>
) : (
<Swiper
spaceBetween={30}
slidesPerView={1}
loop={true}
navigation={{
prevEl: '.prev-btn',
nextEl: '.next-btn',
}}
autoplay={{
delay: 4000,
disableOnInteraction: false,
}}
onSwiper={setSwiperInstance}
modules={[Autoplay, Navigation]}
className="testimonial_list"
style={{ paddingBottom: '30px' }}
>
{displayedReviews.map((r, index) => {
const fullText = getReviewText(r);
const isExpanded = expandedReview === index;
const profileImg = getProfileImage(r);
const name = r.user?.name || r.author_name || "Customer";
<button className={styles.sliderBtn} onClick={nextTestimonial}></button>
return (
<SwiperSlide key={index} style={{ height: 'auto' }}>
<div className={styles.testimonialCard}>
<div className={styles.testimonialAuthor}>
<div className={styles.authorImageWrapper}>
{profileImg ? (
<img
src={profileImg}
alt={name}
className={styles.authorImage}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={(e) => ((e.target as HTMLImageElement).src = '/images/placeholder.png')}
/>
) : (
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#333', fontSize: '24px', fontWeight: 'bold', color: '#fff' }}>
{getInitials(name)}
</div>
)}
</div>
<div className={styles.authorInfo}>
<p className={styles.authorName}>{name}</p>
<div className={styles.stars} style={{ justifyContent: 'start', marginBottom: 0 }}>
{renderStars(r.rating)}
</div>
</div>
</div>
</motion.div>
<p className={styles.testimonialText}>
&quot;{isExpanded ? fullText : truncateText(fullText)}&quot;
</p>
{/* Slider dots */}
<div className={styles.sliderDots}>
{testimonialData.map((_, index) => (
<button
key={index}
className={`${styles.dot} ${index === currentTestimonial ? styles.activeDot : ''}`}
onClick={() => setCurrentTestimonial(index)}
/>
))}
{fullText.length > 300 && (
<button
style={{ background: 'none', border: 'none', color: '#a67c52', cursor: 'pointer', fontWeight: 'bold', fontSize: '1rem', marginTop: '10px' }}
onClick={() => setExpandedReview(isExpanded ? null : index)}
>
{isExpanded ? "Read Less" : "Read More"}
</button>
)}
</div>
</SwiperSlide>
);
})}
</Swiper>
)}
</div>
<button className={`${styles.sliderBtn} next-btn`}>
<FaChevronRight />
</button>
</div>
<div className={styles.buttonContainer}>

View File

@ -56,11 +56,11 @@
}
.testimonialsSection {
padding: 80px 20px;
background-color: #3a0c08;
background-image: url('/images/about-us/guest-bg.webp');
background-size: cover;
background-position: center;
background-repeat: repeat;
}
.container {
@ -149,6 +149,13 @@
margin-bottom: 1rem;
}
.stars {
display: flex;
gap: 5px;
color: var(--color-paragraph);
font-size: 1.2rem;
}
/* Features Section */
.featuresGrid {
display: grid;
@ -214,9 +221,9 @@
background: transparent;
border: 2px solid #b07c4b;
color: var(--color-alterparagraph) !important;
font-size: 2.5rem;
width: 50px;
height: 50px;
font-size: 1.2rem;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
@ -230,11 +237,11 @@
}
.sliderBtn:first-child {
left: 10px;
left: 20px;
}
.sliderBtn:last-child {
right: 10px;
right: 20px;
}
.sliderBtn:hover {
@ -245,21 +252,24 @@
.testimonialCard {
background-color: #F5E6D3;
padding: 3rem 2.5rem;
padding: 2rem 2.5rem;
border: 2px solid #b07c4b;
display: flex;
flex-direction: column;
gap: 2rem;
gap: 1.5rem;
flex: 1;
min-height: 300px;
min-height: 400px;
justify-content: center;
align-items: center;
}
.testimonialText {
font-family: var(--font-lato);
font-size: 1.3rem;
font-size: 1.15rem;
line-height: 1.8;
font-style: italic;
text-align: center;
color: #a67c52;
}
.testimonialAuthor {
@ -292,9 +302,10 @@
.authorName {
font-family: var(--font-playfair);
font-size: 1.2rem;
font-size: 1.1rem;
color: #3e2723;
font-weight: 600;
margin-bottom: 10px;
}
.authorRole {
@ -333,7 +344,7 @@
.button {
display: inline-block;
margin-top: 3rem;
/* margin-top: 3rem; */
padding: 0.8rem 2rem;
border: 1px solid var(--color-gold);
color: #d3cab3;

View File

@ -0,0 +1,69 @@
import { NextResponse } from 'next/server';
export const dynamic = 'force-static';
export const revalidate = 3600; // Cache for 1 hour during build/dev
interface SerpReview {
text?: string;
rating: number;
[key: string]: any;
}
export async function GET() {
const apiKey = "37eb7f83988cfd76ffb5c5af9adc25652efe5607e39997fc7d0e054d690ef25e";
const placeId = "ChIJJU4PkTL1K4gRPSWJdlAQ7ko";
let allReviews: SerpReview[] = [];
let nextPageToken: string | null = null;
let pageCount = 0;
try {
while (pageCount < 3) {
pageCount++;
const url: string = `https://serpapi.com/search.json?engine=google_maps_reviews&hl=en&api_key=${apiKey}&place_id=${placeId}${nextPageToken ? `&next_page_token=${nextPageToken}` : ""}`;
console.log(`API: Fetching Page ${pageCount} from URL: ${url.replace(apiKey, "HIDDEN")}`);
const response: Response = await fetch(url);
if (!response.ok) {
const errorText = await response.text();
console.error(`API: SerpAPI fetch failed with status ${response.status}:`, errorText.slice(0, 200));
// If the first page fails, we should report it in the response
if (pageCount === 1) {
return NextResponse.json({ error: "SerpAPI Fetch Failed", status: response.status, details: errorText.slice(0, 200) }, { status: response.status });
}
break;
}
const data: { reviews?: SerpReview[]; error?: string; serpapi_pagination?: { next_page_token?: string } } = await response.json();
if (data.error) {
console.error("API: SerpAPI JSON Error:", data.error);
if (pageCount === 1) {
return NextResponse.json({ error: "SerpAPI Error", details: data.error }, { status: 400 });
}
break;
}
if (data.reviews && data.reviews.length > 0) {
allReviews = [...allReviews, ...data.reviews];
} else {
break;
}
if (!data.serpapi_pagination || !data.serpapi_pagination.next_page_token) {
break;
}
nextPageToken = data.serpapi_pagination.next_page_token;
await new Promise((resolve) => setTimeout(resolve, 200));
}
return NextResponse.json({ reviews: allReviews, total: allReviews.length });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
const stack = error instanceof Error ? error.stack : "";
console.error("API: Unexpected Server Error:", message, stack);
return NextResponse.json({ error: "Failed to fetch reviews", details: message, stack: stack?.slice(0, 200) }, { status: 500 });
}
}

View File

@ -290,4 +290,117 @@ h2 {
100% {
transform: rotate(360deg);
}
}
.google-review-card {
background: #fff;
border-radius: 12px;
padding: 25px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.05);
border: 1px solid #f0f0f0;
height: 100%;
transition: all 0.3s ease;
display: flex;
flex-direction: column;
}
.google-review-header {
display: flex;
align-items: center;
gap: 15px;
margin-bottom: 20px;
}
.google-avatar {
width: 60px;
height: 60px;
border-radius: 50%;
background: #546e7a;
color: #fff;
font-weight: 600;
font-size: 24px;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
flex-shrink: 0;
}
.google-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.google-user-info {
display: flex;
flex-direction: column;
}
.google-name {
font-size: 18px;
font-weight: 700;
color: #333;
margin: 0;
line-height: 1.2;
}
.google-stars {
margin-top: 4px;
}
.google-stars span {
font-size: 16px;
color: #ffc107;
margin-right: 2px;
}
.google-text {
font-size: 15px;
color: #555;
line-height: 1.6;
margin: 0;
flex-grow: 1;
}
.google-review-images {
display: flex;
gap: 8px;
margin-top: 15px;
flex-wrap: wrap;
}
.google-review-photo {
width: 70px;
height: 70px;
border-radius: 6px;
object-fit: cover;
border: 1px solid #eee;
}
.read-more-btn {
background: transparent;
border: none;
color: #d32f2f;
padding: 15px 0 0 0;
font-size: 16px;
cursor: pointer;
font-weight: 700;
text-align: center;
width: 100%;
display: block;
margin-top: auto;
}
.read-more-btn:hover {
text-decoration: underline;
}
.equal-height {
min-height: 380px;
}
/* Home specific adjustments to maintain original layout density */
.google-review-card-home {
padding: 0;
}

View File

@ -226,7 +226,7 @@
}
}
@media (max-width: 480px) {
@media (max-width: 500px) {
.sliderContainer {
padding: 0 15px;
}

View File

@ -407,8 +407,8 @@
margin-left: 10%;
right: auto;
bottom: auto;
border-top: 10px solid #000000;
border-left: 10px solid #000000;
border-top: 10px solid #c49c5c;
border-left: 10px solid #c49c5c;
}
.callWidgetLeft {

View File

@ -39,6 +39,7 @@
.sliderContainer {
display: flex;
justify-content: center;
align-items: center;
gap: 30px;
max-width: 1400px;
width: 100%;
@ -57,22 +58,16 @@
background: #242323;
border: 14px solid rgb(196, 156, 92);
border-radius: 30px;
padding: 40px 30px;
padding: 30px 20px;
text-align: center;
position: relative;
/* Flex basis for 3 items: 100% / 3 = 33.333% */
flex: 0 0 33.333%;
max-width: 33.333%;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
box-sizing: border-box;
margin: 0 15px;
/* Visual gap */
/* Adjust flex basis to account for margin: calc(33.333% - 30px) */
flex: 0 0 calc(33.333% - 30px);
max-width: calc(33.333% - 30px);
height: 100%;
width: 100%;
}
.avatarContainer {
@ -83,7 +78,9 @@
border-radius: 50%;
padding: 7px;
background: rgb(196, 156, 92);
/* Gold gradient border effect */
display: flex;
align-items: center;
justify-content: center;
}
.avatar {
@ -103,58 +100,93 @@
overflow: hidden;
}
.avatar,
.authorImage {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
background-color: #333;
}
.name {
font-family: var(--font-inter), sans-serif;
/* Assuming Inter or similar */
font-size: 1.1rem;
color: #f5e6d3;
margin-bottom: 30px;
font-weight: 500;
/* White as seen in image */
margin-bottom: 5px;
font-weight: 600;
}
.text {
font-family: var(--font-playfair);
font-size: 1.2rem;
line-height: 1.6;
color: #e0e0e0;
margin-bottom: 30px;
font-family: var(--font-paragraph);
color: var(--color-paragraph);
font-size: 1.15rem;
line-height: 1.7;
/* White as seen in image */
margin-bottom: 20px;
font-style: italic;
opacity: 0.95;
flex-grow: 1;
}
.initials {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #333;
font-size: 24px;
font-weight: bold;
color: #fff;
border-radius: 50%;
}
.readMoreBtn {
background: none;
border: none;
color: #f5e6d3;
cursor: pointer;
font-weight: 600;
font-size: 1rem;
margin-top: 10px;
text-transform: capitalize;
font-family: var(--font-inter);
}
.readMoreBtn:hover {
text-decoration: underline;
}
.stars {
color: var(--color-paragraph);
font-size: 1.5rem;
letter-spacing: 5px;
font-size: 16px;
display: flex;
justify-content: center;
gap: 3px;
margin-bottom: 15px;
}
.arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
color: var(--color-paragraph);
font-size: 3rem;
cursor: pointer;
background: none;
.navBtn {
background: transparent;
border: none;
transition: transform 0.2s;
color: rgb(196, 156, 92);
font-size: 2.5rem;
cursor: pointer;
transition: all 0.3s;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
width: 50px;
height: 50px;
}
.arrow:hover {
transform: translateY(-50%) scale(1.1);
}
.prevArrow {
left: 0;
}
.nextArrow {
right: 0;
.navBtn:hover {
transform: scale(1.2);
color: #fff;
}
/* Lanterns decoration placeholder */
@ -191,40 +223,44 @@
.sliderContainer {
padding: 0 50px;
}
.arrow {
font-size: 2.8rem;
}
}
@media (max-width: 1200px) {
.sliderContainer {
padding: 0 40px;
}
}
.arrow {
font-size: 2.5rem;
@media (min-width: 1025px) {
.navBtn {
position: absolute;
top: 50%;
transform: translateY(-50%);
}
.prevArrow {
left: 0;
}
.nextArrow {
right: 0;
}
}
@media (max-width: 1024px) {
.sliderContainer {
padding: 0 30px;
padding: 0 45px;
}
.card {
flex: 0 0 100%;
max-width: 50%;
margin: 0;
.title {
font-size: 2.5rem;
margin-bottom: 40px;
}
.card:nth-child(3) {
display: none;
}
.arrow {
.navBtn {
position: absolute;
top: 50%;
transform: translateY(-50%);
font-size: 2.2rem;
}
@ -235,20 +271,30 @@
.nextArrow {
right: 5px;
}
.title {
font-size: 2.5rem;
margin-bottom: 40px;
}
}
@media (max-width: 768px) {
.sliderContainer {
padding: 0 20px;
padding: 0 35px;
}
.arrow {
font-size: 2rem;
.card {
border-width: 10px;
/* Strong but not too thick for small screens */
padding: 25px 15px;
}
.avatarContainer {
width: 100px;
height: 100px;
margin-bottom: 15px;
}
.navBtn {
font-size: 1.8rem;
width: 35px;
height: 35px;
opacity: 0.7;
}
.prevArrow {
@ -262,28 +308,23 @@
@media (max-width: 480px) {
.sliderContainer {
padding: 0 15px;
}
.card {
flex: 0 0 100%;
max-width: 100%;
margin: 0;
}
.card:nth-child(2) {
display: none;
}
.card:nth-child(3) {
display: none;
}
.arrow {
font-size: 1.8rem;
padding: 0 30px;
}
.title {
font-size: 2rem;
font-size: 2.2rem;
}
.section {
padding: 60px 10px;
}
.card {
border-width: 8px;
border-radius: 20px;
}
.navBtn {
font-size: 1.5rem;
}
}

View File

@ -3,41 +3,154 @@
import { useState, useEffect } from 'react'
import Image from 'next/image'
import styles from './Testimonials.module.css'
import { testimonialData } from '@/utils/constant'
import { Swiper, SwiperSlide } from 'swiper/react'
import { Autoplay, Navigation } from 'swiper/modules'
import 'swiper/css'
import 'swiper/css/navigation'
import { FaStar, FaChevronLeft, FaChevronRight } from 'react-icons/fa'
const testimonials = testimonialData
interface Review {
text?: string;
description?: string;
snippet?: string;
review_text?: string;
body?: string;
content?: string;
rating: number;
profile_photo_url?: string;
author_profile_photo_url?: string;
user?: {
thumbnail?: string;
name?: string;
};
author_name?: string;
}
export default function Testimonials() {
const [currentIndex, setCurrentIndex] = useState(0)
const [reviews, setReviews] = useState<Review[]>([]);
const [loading, setLoading] = useState(true);
const [expandedReview, setExpandedReview] = useState<number | null>(null);
const [swiperInstance, setSwiperInstance] = useState<any>(null);
const nextSlide = () => {
setCurrentIndex((prev) => (prev + 1) % (testimonials.length - 2))
}
const prevSlide = () => {
setCurrentIndex((prev) => (prev === 0 ? testimonials.length - 3 : prev - 1))
}
// Auto-slide effect
useEffect(() => {
const interval = setInterval(() => {
nextSlide()
}, 5000) // Change slide every 5 seconds
return () => clearInterval(interval)
}, [])
// Get the 3 visible testimonials
const getVisibleTestimonials = () => {
const items = []
for (let i = 0; i < 3; i++) {
const index = (currentIndex + i) % testimonials.length
items.push(testimonials[index])
const testimonial_list_slider: any = {
spaceBetween: 30,
slidesPerView: 3,
navigation: {
prevEl: `.${styles.prevArrow}`,
nextEl: `.${styles.nextArrow}`,
},
pagination: false,
loop: true,
autoplay: {
delay: 3000,
disableOnInteraction: false,
},
modules: [Autoplay, Navigation],
breakpoints: {
0: {
slidesPerView: 1,
},
768: {
slidesPerView: 2,
},
1024: {
slidesPerView: 3,
},
}
return items
};
// Auto-collapse expanded review after 10 seconds and handle autoplay
useEffect(() => {
if (expandedReview !== null) {
// Stop autoplay when a review is expanded
if (swiperInstance && swiperInstance.autoplay) {
swiperInstance.autoplay.stop();
}
const timer = setTimeout(() => {
setExpandedReview(null);
}, 10000); // 10 seconds
return () => {
clearTimeout(timer);
};
} else {
// Resume autoplay when reviews are collapsed
if (swiperInstance && swiperInstance.autoplay) {
swiperInstance.autoplay.start();
}
}
}, [expandedReview, swiperInstance]);
useEffect(() => {
async function loadReviews() {
try {
const res = await fetch("/api/reviews");
if (!res.ok) {
let details = "Unknown error";
try {
const errorData = await res.json();
details = errorData.details || errorData.error || "No details";
} catch (e) { }
throw new Error(`HTTP error! status: ${res.status} - ${details}`);
}
const text = await res.text();
let dataAt;
try {
dataAt = JSON.parse(text);
} catch (e) {
console.error("Home: Invalid JSON response", text.slice(0, 100));
return;
}
const cleaned = (dataAt.reviews || []).filter((r: Review) =>
(r.text || r.description || r.snippet || r.review_text || r.body || r.content) &&
r.rating >= 4
);
setReviews(cleaned);
} catch (error) {
console.error("Home: Failed to fetch reviews", error);
} finally {
setLoading(false);
}
}
loadReviews();
}, []);
const displayedReviews = reviews.length > 0 && reviews.length < 3
? [...reviews, ...reviews, ...reviews]
: reviews;
function renderStars(rating: number) {
return [...Array(5)].map((_, i) => (
<FaStar
key={i}
style={{ color: i < rating ? '#ffc107' : '#e4e5e9', fontSize: '16px', marginRight: '5px' }}
/>
));
}
const visibleItems = getVisibleTestimonials()
function getReviewText(r: Review) {
return r.text || r.description || r.snippet || r.review_text || r.body || r.content || "";
}
function truncateText(text: string) {
return text.length > 150 ? text.substring(0, 150) + "..." : text;
}
function getProfileImage(r: Review) {
const url = r.profile_photo_url || r.author_profile_photo_url || r.user?.thumbnail;
if (!url) return null;
return url.startsWith("http") ? url : `https://lh3.googleusercontent.com/${url}`;
}
function getInitials(name: string) {
if (!name) return "U";
return name.split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase();
}
return (
<section className={styles.section}>
@ -49,31 +162,76 @@ export default function Testimonials() {
<h2 className={styles.title}>Testimonials</h2>
<div className={styles.sliderContainer}>
<button className={`${styles.arrow} ${styles.prevArrow}`} onClick={prevSlide} suppressHydrationWarning></button>
{visibleItems.map((item) => (
<div key={item.id} className={styles.card}>
<div className={styles.avatarContainer}>
<div className={styles.avatar} style={{ overflow: 'hidden' }}>
<div className={styles.authorImageWrapper}>
<Image
src={item.image}
alt={`${item.name} - Review ${item.id}`}
fill
className={styles.authorImage}
/>
</div>
</div>
</div>
{/* <h3 className={styles.name}>— {item.name}</h3> */}
<p className={styles.testimonialText}>"{item.text}"</p>
<div style={{ fontSize: '1.5rem', color: '#ffdf00', textAlign: 'center', letterSpacing: '3px', marginTop: '20px' }}>
{'★★★★★'}
</div>
{loading ? (
<div className="text-center" style={{ color: 'var(--color-paragraph)', padding: '40px' , textAlign: 'center'}}>
<p>Loading reviews...</p>
</div>
))}
) : (
<>
<button className={`${styles.navBtn} ${styles.prevArrow}`}>
<FaChevronLeft />
</button>
<Swiper
{...testimonial_list_slider}
onSwiper={setSwiperInstance}
className="testimonial_list"
style={{ paddingBottom: '30px', flex: 1 }}
>
{displayedReviews.map((r, index) => {
const fullText = getReviewText(r);
const isExpanded = expandedReview === index;
const profileImg = getProfileImage(r);
const name = r.user?.name || r.author_name || "Customer";
<button className={`${styles.arrow} ${styles.nextArrow}`} onClick={nextSlide} suppressHydrationWarning></button>
return (
<SwiperSlide key={index} style={{ height: 'auto' }}>
<div className={styles.card}>
<div className={styles.avatarContainer}>
<div className={styles.authorImageWrapper}>
{profileImg ? (
<img
src={profileImg}
alt={name}
className={styles.authorImage}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={(e) => ((e.target as HTMLImageElement).src = '/images/placeholder.png')}
/>
) : (
<div className={styles.initials}>
{getInitials(name)}
</div>
)}
</div>
</div>
<p className={styles.name}>{name}</p>
<div className={styles.stars}>
{renderStars(r.rating)}
</div>
<p className={styles.text}>
&quot;{isExpanded ? fullText : truncateText(fullText)}&quot;
</p>
<button
className={styles.readMoreBtn}
onClick={() => setExpandedReview(isExpanded ? null : index)}
>
{isExpanded ? "Read Less" : "Read More"}
</button>
</div>
</SwiperSlide>
);
})}
</Swiper>
<button className={`${styles.navBtn} ${styles.nextArrow}`}>
<FaChevronRight />
</button>
</>
)}
</div>
<div className={styles.buttonContainer}>