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-google-recaptcha": "^3.1.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"sitemap": "^9.0.0", "sitemap": "^9.0.0",
"swiper": "^12.0.3",
"xml2js": "^0.6.2" "xml2js": "^0.6.2"
}, },
"devDependencies": { "devDependencies": {
@ -9369,6 +9370,25 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/tar-stream": {
"version": "1.6.2", "version": "1.6.2",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-1.6.2.tgz", "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-google-recaptcha": "^3.1.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"sitemap": "^9.0.0", "sitemap": "^9.0.0",
"swiper": "^12.0.3",
"xml2js": "^0.6.2" "xml2js": "^0.6.2"
}, },
"devDependencies": { "devDependencies": {

View File

@ -5,12 +5,61 @@ import FAQ from "@/components/FAQ/FAQ";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import styles from "./about.module.css"; 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 { 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() { 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 // Animation variants
const fadeInUp = { const fadeInUp = {
@ -65,22 +114,58 @@ export default function AboutContent() {
} }
}; };
// Auto-slide testimonials
useEffect(() => { useEffect(() => {
const interval = setInterval(() => { async function loadReviews() {
setCurrentTestimonial((prev) => (prev + 1) % testimonialData.length); try {
}, 5000); // Change every 5 seconds const res = await fetch("/api/reviews");
if (!res.ok) throw new Error("Failed to fetch");
return () => clearInterval(interval); 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 = () => { const displayedReviews = reviews.length > 0 && reviews.length < 3
setCurrentTestimonial((prev) => (prev + 1) % testimonialData.length); ? [...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 ( return (
<main className={styles.main}> <main className={styles.main}>
@ -212,67 +297,92 @@ export default function AboutContent() {
<span>ANTALYA</span> <span>ANTALYA</span>
<Image src="/images/eat.png" alt="Testimonials Section Cutlery Icon" width={24} height={24} /> <Image src="/images/eat.png" alt="Testimonials Section Cutlery Icon" width={24} height={24} />
</div> </div>
<motion.h2 <h2 className={styles.sectionTitleCenter}>What Our Guests Say</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>
<AnimatePresence mode="wait"> <div className={styles.testimonialSlider}>
<motion.div <button className={`${styles.sliderBtn} prev-btn`}>
key={currentTestimonial} <FaChevronLeft />
className={styles.testimonialCard} </button>
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }} <div style={{ flex: 1, width: '100%' }}>
exit={{ opacity: 0, scale: 0.95 }} {loading ? (
transition={{ duration: 0.5 }} <div className="text-center" style={{ color: '#F5E6D3', padding: '40px' , textAlign: 'center'}}>
> <p>Loading reviews...</p>
<p className={styles.testimonialText}>"{testimonialData[currentTestimonial].text}"</p>
<div style={{ fontSize: '1.5rem', color: '#ffdf00', textAlign: 'center', letterSpacing: '3px' }}>
{'★★★★★'}
</div> </div>
{/* <div className={styles.testimonialAuthor}> ) : (
<div className={styles.authorImageWrapper}> <Swiper
<Image spaceBetween={30}
src={testimonialData[currentTestimonial].image} slidesPerView={1}
alt={testimonialData[currentTestimonial].name} loop={true}
fill navigation={{
className={styles.authorImage} prevEl: '.prev-btn',
/> nextEl: '.next-btn',
</div> }}
<div className={styles.authorInfo}> autoplay={{
<p className={styles.authorName}>{testimonialData[currentTestimonial].name}</p> delay: 4000,
<p className={styles.authorRole}>{testimonialData[currentTestimonial].role}</p> disableOnInteraction: false,
</div> }}
</div> */} onSwiper={setSwiperInstance}
</motion.div> modules={[Autoplay, Navigation]}
</AnimatePresence> 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 */} {fullText.length > 300 && (
<div className={styles.sliderDots}> <button
{testimonialData.map((_, index) => ( style={{ background: 'none', border: 'none', color: '#a67c52', cursor: 'pointer', fontWeight: 'bold', fontSize: '1rem', marginTop: '10px' }}
<button onClick={() => setExpandedReview(isExpanded ? null : index)}
key={index} >
className={`${styles.dot} ${index === currentTestimonial ? styles.activeDot : ''}`} {isExpanded ? "Read Less" : "Read More"}
onClick={() => setCurrentTestimonial(index)} </button>
/> )}
))} </div>
</SwiperSlide>
);
})}
</Swiper>
)}
</div>
<button className={`${styles.sliderBtn} next-btn`}>
<FaChevronRight />
</button>
</div> </div>
<div className={styles.buttonContainer}> <div className={styles.buttonContainer}>

View File

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

@ -291,3 +291,116 @@ h2 {
transform: rotate(360deg); 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 { .sliderContainer {
padding: 0 15px; padding: 0 15px;
} }

View File

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

View File

@ -39,6 +39,7 @@
.sliderContainer { .sliderContainer {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center;
gap: 30px; gap: 30px;
max-width: 1400px; max-width: 1400px;
width: 100%; width: 100%;
@ -57,22 +58,16 @@
background: #242323; background: #242323;
border: 14px solid rgb(196, 156, 92); border: 14px solid rgb(196, 156, 92);
border-radius: 30px; border-radius: 30px;
padding: 40px 30px; padding: 30px 20px;
text-align: center; text-align: center;
position: relative; position: relative;
/* Flex basis for 3 items: 100% / 3 = 33.333% */
flex: 0 0 33.333%;
max-width: 33.333%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
box-sizing: border-box; box-sizing: border-box;
margin: 0 15px; height: 100%;
/* Visual gap */ width: 100%;
/* Adjust flex basis to account for margin: calc(33.333% - 30px) */
flex: 0 0 calc(33.333% - 30px);
max-width: calc(33.333% - 30px);
} }
.avatarContainer { .avatarContainer {
@ -83,7 +78,9 @@
border-radius: 50%; border-radius: 50%;
padding: 7px; padding: 7px;
background: rgb(196, 156, 92); background: rgb(196, 156, 92);
/* Gold gradient border effect */ display: flex;
align-items: center;
justify-content: center;
} }
.avatar { .avatar {
@ -103,58 +100,93 @@
overflow: hidden; overflow: hidden;
} }
.avatar,
.authorImage { .authorImage {
width: 100%;
height: 100%;
border-radius: 50%; border-radius: 50%;
object-fit: cover; object-fit: cover;
background-color: #333;
} }
.name { .name {
font-family: var(--font-inter), sans-serif; font-family: var(--font-inter), sans-serif;
/* Assuming Inter or similar */
font-size: 1.1rem; font-size: 1.1rem;
color: #f5e6d3; color: #f5e6d3;
margin-bottom: 30px; /* White as seen in image */
font-weight: 500; margin-bottom: 5px;
font-weight: 600;
} }
.text { .text {
font-family: var(--font-playfair); font-family: var(--font-paragraph);
font-size: 1.2rem; color: var(--color-paragraph);
line-height: 1.6; font-size: 1.15rem;
color: #e0e0e0; line-height: 1.7;
margin-bottom: 30px; /* White as seen in image */
margin-bottom: 20px;
font-style: italic; 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 { .stars {
color: var(--color-paragraph); color: var(--color-paragraph);
font-size: 1.5rem; font-size: 16px;
letter-spacing: 5px; display: flex;
justify-content: center;
gap: 3px;
margin-bottom: 15px;
} }
.arrow { .navBtn {
position: absolute; background: transparent;
top: 50%;
transform: translateY(-50%);
color: var(--color-paragraph);
font-size: 3rem;
cursor: pointer;
background: none;
border: none; border: none;
transition: transform 0.2s; color: rgb(196, 156, 92);
font-size: 2.5rem;
cursor: pointer;
transition: all 0.3s;
z-index: 10; z-index: 10;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
width: 50px;
height: 50px;
} }
.arrow:hover { .navBtn:hover {
transform: translateY(-50%) scale(1.1); transform: scale(1.2);
} color: #fff;
.prevArrow {
left: 0;
}
.nextArrow {
right: 0;
} }
/* Lanterns decoration placeholder */ /* Lanterns decoration placeholder */
@ -191,40 +223,44 @@
.sliderContainer { .sliderContainer {
padding: 0 50px; padding: 0 50px;
} }
.arrow {
font-size: 2.8rem;
}
} }
@media (max-width: 1200px) { @media (max-width: 1200px) {
.sliderContainer { .sliderContainer {
padding: 0 40px; padding: 0 40px;
} }
}
.arrow { @media (min-width: 1025px) {
font-size: 2.5rem; .navBtn {
position: absolute;
top: 50%;
transform: translateY(-50%);
}
.prevArrow {
left: 0;
}
.nextArrow {
right: 0;
} }
} }
@media (max-width: 1024px) { @media (max-width: 1024px) {
.sliderContainer { .sliderContainer {
padding: 0 30px; padding: 0 45px;
} }
.card { .title {
flex: 0 0 100%; font-size: 2.5rem;
max-width: 50%; margin-bottom: 40px;
margin: 0;
} }
.card:nth-child(3) { .navBtn {
display: none; position: absolute;
} top: 50%;
transform: translateY(-50%);
.arrow {
font-size: 2.2rem; font-size: 2.2rem;
} }
@ -235,20 +271,30 @@
.nextArrow { .nextArrow {
right: 5px; right: 5px;
} }
.title {
font-size: 2.5rem;
margin-bottom: 40px;
}
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.sliderContainer { .sliderContainer {
padding: 0 20px; padding: 0 35px;
} }
.arrow { .card {
font-size: 2rem; 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 { .prevArrow {
@ -262,28 +308,23 @@
@media (max-width: 480px) { @media (max-width: 480px) {
.sliderContainer { .sliderContainer {
padding: 0 15px; padding: 0 30px;
}
.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;
} }
.title { .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 { useState, useEffect } from 'react'
import Image from 'next/image' import Image from 'next/image'
import styles from './Testimonials.module.css' 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() { 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 = () => { const testimonial_list_slider: any = {
setCurrentIndex((prev) => (prev + 1) % (testimonials.length - 2)) spaceBetween: 30,
} slidesPerView: 3,
navigation: {
const prevSlide = () => { prevEl: `.${styles.prevArrow}`,
setCurrentIndex((prev) => (prev === 0 ? testimonials.length - 3 : prev - 1)) nextEl: `.${styles.nextArrow}`,
} },
pagination: false,
// Auto-slide effect loop: true,
useEffect(() => { autoplay: {
const interval = setInterval(() => { delay: 3000,
nextSlide() disableOnInteraction: false,
}, 5000) // Change slide every 5 seconds },
modules: [Autoplay, Navigation],
return () => clearInterval(interval) breakpoints: {
}, []) 0: {
slidesPerView: 1,
// Get the 3 visible testimonials },
const getVisibleTestimonials = () => { 768: {
const items = [] slidesPerView: 2,
for (let i = 0; i < 3; i++) { },
const index = (currentIndex + i) % testimonials.length 1024: {
items.push(testimonials[index]) 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 ( return (
<section className={styles.section}> <section className={styles.section}>
@ -49,31 +162,76 @@ export default function Testimonials() {
<h2 className={styles.title}>Testimonials</h2> <h2 className={styles.title}>Testimonials</h2>
<div className={styles.sliderContainer}> <div className={styles.sliderContainer}>
<button className={`${styles.arrow} ${styles.prevArrow}`} onClick={prevSlide} suppressHydrationWarning></button> {loading ? (
<div className="text-center" style={{ color: 'var(--color-paragraph)', padding: '40px' , textAlign: 'center'}}>
{visibleItems.map((item) => ( <p>Loading reviews...</p>
<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>
</div> </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>
<div className={styles.buttonContainer}> <div className={styles.buttonContainer}>