testimonial integration updated
This commit is contained in:
parent
75e3ec6764
commit
645c68fb5c
20
package-lock.json
generated
20
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}>
|
||||
"{isExpanded ? fullText : truncateText(fullText)}"
|
||||
</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}>
|
||||
|
||||
@ -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;
|
||||
|
||||
69
src/app/api/reviews/route.ts
Normal file
69
src/app/api/reviews/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -226,7 +226,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
@media (max-width: 500px) {
|
||||
.sliderContainer {
|
||||
padding: 0 15px;
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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}>
|
||||
"{isExpanded ? fullText : truncateText(fullText)}"
|
||||
</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}>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user