346 lines
12 KiB
TypeScript
346 lines
12 KiB
TypeScript
'use client'
|
|
import { useState, useEffect } from "react"
|
|
import { Autoplay, Navigation, Pagination } from "swiper/modules"
|
|
import { Swiper, SwiperSlide } from "swiper/react"
|
|
import 'swiper/css';
|
|
import 'swiper/css/navigation';
|
|
import 'swiper/css/pagination';
|
|
|
|
interface Review {
|
|
name: string;
|
|
rating: number;
|
|
text: string;
|
|
date: string;
|
|
profilePhoto: string | null;
|
|
reviewLink: string | null;
|
|
}
|
|
|
|
const swiperOptions = {
|
|
modules: [Autoplay, Pagination, Navigation],
|
|
slidesPerView: 3,
|
|
spaceBetween: 24,
|
|
autoplay: {
|
|
delay: 3500,
|
|
disableOnInteraction: false,
|
|
},
|
|
loop: true,
|
|
navigation: {
|
|
nextEl: '.h1n',
|
|
prevEl: '.h1p',
|
|
},
|
|
pagination: {
|
|
el: '.swiper-pagination',
|
|
clickable: true,
|
|
},
|
|
breakpoints: {
|
|
0: { slidesPerView: 1, spaceBetween: 16 },
|
|
640: { slidesPerView: 2, spaceBetween: 20 },
|
|
1024: { slidesPerView: 3, spaceBetween: 24 },
|
|
},
|
|
}
|
|
|
|
function StarRating({ rating }: { rating: number }) {
|
|
return (
|
|
<div style={{ display: 'flex', gap: '2px' }}>
|
|
{[1, 2, 3, 4, 5].map((i) => (
|
|
<svg
|
|
key={i}
|
|
width="16" height="16" viewBox="0 0 24 24"
|
|
fill={i <= rating ? '#FBBC04' : 'none'}
|
|
stroke={i <= rating ? '#FBBC04' : 'rgba(255,255,255,0.25)'}
|
|
strokeWidth="1.5"
|
|
>
|
|
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
|
</svg>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Avatar({ photo, name }: { photo: string | null; name: string }) {
|
|
const initials = name
|
|
.split(' ')
|
|
.map((n) => n[0])
|
|
.join('')
|
|
.substring(0, 2)
|
|
.toUpperCase();
|
|
|
|
const [imgError, setImgError] = useState(false);
|
|
|
|
if (photo && !imgError) {
|
|
return (
|
|
<img
|
|
src={photo}
|
|
alt={name}
|
|
onError={() => setImgError(true)}
|
|
style={{
|
|
width: '48px',
|
|
height: '48px',
|
|
borderRadius: '50%',
|
|
objectFit: 'cover',
|
|
border: '2px solid rgba(255,255,255,0.15)',
|
|
flexShrink: 0,
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// fallback: coloured circle with initials
|
|
const colors = ['#4285F4', '#34A853', '#EA4335', '#FBBC05', '#8E44AD', '#E67E22'];
|
|
const colorIndex = name.charCodeAt(0) % colors.length;
|
|
return (
|
|
<div
|
|
style={{
|
|
width: '48px',
|
|
height: '48px',
|
|
borderRadius: '50%',
|
|
background: colors[colorIndex],
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
fontSize: '17px',
|
|
fontWeight: '700',
|
|
color: '#fff',
|
|
flexShrink: 0,
|
|
border: '2px solid rgba(255,255,255,0.15)',
|
|
}}
|
|
>
|
|
{initials}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Inline Google G SVG
|
|
function GoogleIcon() {
|
|
return (
|
|
<svg width="18" height="18" viewBox="0 0 48 48" style={{ flexShrink: 0 }}>
|
|
<path fill="#4285F4" d="M44.5 20H24v8.5h11.8C34.7 33.9 30.1 37 24 37c-7.2 0-13-5.8-13-13s5.8-13 13-13c3.1 0 5.9 1.1 8.1 2.9l6.4-6.4C34.6 4.1 29.6 2 24 2 11.8 2 2 11.8 2 24s9.8 22 22 22c11 0 21-8 21-22 0-1.3-.2-2.7-.5-4z" />
|
|
<path fill="#34A853" d="M6.3 14.7l7 5.1C15 16.1 19.1 13 24 13c3.1 0 5.9 1.1 8.1 2.9l6.4-6.4C34.6 4.1 29.6 2 24 2 16.3 2 9.7 7.4 6.3 14.7z" />
|
|
<path fill="#FBBC05" d="M24 46c5.9 0 11-2 14.7-5.4l-6.8-5.6C29.9 36.7 27.1 37 24 37c-6.1 0-11.3-4-13.2-9.6l-7 5.4C7.5 40.9 15.2 46 24 46z" />
|
|
<path fill="#EA4335" d="M44.5 20H24v8.5h11.8c-1 2.8-2.8 5.1-5.2 6.6l6.8 5.6C41.6 37.3 45 31.2 45 24c0-1.3-.2-2.7-.5-4z" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
export default function TestimonialSlider1() {
|
|
const [reviews, setReviews] = useState<Review[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
|
|
const [isClient, setIsClient] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setIsClient(true);
|
|
async function loadReviews() {
|
|
try {
|
|
const res = await fetch("/api/google-reviews/");
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}));
|
|
throw new Error(data.message || `HTTP ${res.status}`);
|
|
}
|
|
const data = await res.json();
|
|
// Only show 5-star reviews
|
|
const fiveStars = (data.reviews || []).filter((r: Review) => r.rating === 5);
|
|
setReviews(fiveStars);
|
|
} catch (err: any) {
|
|
console.error("Failed to fetch reviews:", err);
|
|
setError(err.message || "Could not load reviews.");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
loadReviews();
|
|
}, []);
|
|
|
|
// Swiper needs at least slidesPerView*2 slides for loop to work correctly
|
|
const loopedReviews =
|
|
reviews.length > 0 && reviews.length < 6
|
|
? [...reviews, ...reviews]
|
|
: reviews;
|
|
|
|
function truncate(text: string, max = 140) {
|
|
return text.length > max ? text.substring(0, max).trimEnd() + '…' : text;
|
|
}
|
|
|
|
if (!isClient) return null;
|
|
|
|
if (loading) {
|
|
return (
|
|
<div style={{ display: 'flex', gap: '24px', justifyContent: 'center', padding: '20px 0' }}>
|
|
{[0, 1, 2].map((i) => (
|
|
<div
|
|
key={i}
|
|
style={{
|
|
flex: '1',
|
|
maxWidth: '360px',
|
|
height: '200px',
|
|
background: 'rgba(255,255,255,0.06)',
|
|
borderRadius: '16px',
|
|
animation: 'pulse 1.5s ease-in-out infinite',
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error || reviews.length === 0) {
|
|
return (
|
|
<div style={{ textAlign: 'center', padding: '40px', color: 'rgba(255,255,255,0.6)', fontSize: '15px' }}>
|
|
{error || "No reviews available at this time."}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="inner-container" style={{ position: 'relative' }}>
|
|
<Swiper {...swiperOptions} className="single-item-carousel">
|
|
{loopedReviews.map((r, index) => {
|
|
const isExpanded = expandedIndex === index;
|
|
const hasMore = r.text.length > 140;
|
|
|
|
return (
|
|
<SwiperSlide key={index} className="slide-item">
|
|
<div
|
|
style={{
|
|
background: 'rgba(255,255,255,0.06)',
|
|
backdropFilter: 'blur(12px)',
|
|
border: '1px solid rgba(255,255,255,0.1)',
|
|
borderRadius: '16px',
|
|
padding: '28px',
|
|
height: '100%',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: '16px',
|
|
boxSizing: 'border-box',
|
|
minHeight: '240px',
|
|
}}
|
|
>
|
|
{/* TOP: Avatar + Name + Google badge */}
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '14px' }}>
|
|
<Avatar photo={r.profilePhoto} name={r.name} />
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '4px' }}>
|
|
<span
|
|
style={{
|
|
color: '#fff',
|
|
fontWeight: '700',
|
|
fontSize: '15px',
|
|
whiteSpace: 'nowrap',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
maxWidth: '150px',
|
|
display: 'block',
|
|
}}
|
|
title={r.name}
|
|
>
|
|
{r.name}
|
|
</span>
|
|
<GoogleIcon />
|
|
</div>
|
|
<StarRating rating={r.rating} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Review text */}
|
|
<div style={{ flex: 1 }}>
|
|
{r.text ? (
|
|
<>
|
|
<p
|
|
style={{
|
|
color: 'rgba(255,255,255,0.82)',
|
|
fontSize: '14px',
|
|
lineHeight: '1.65',
|
|
margin: 0,
|
|
}}
|
|
>
|
|
{isExpanded ? r.text : truncate(r.text)}
|
|
</p>
|
|
{hasMore && (
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); setExpandedIndex(isExpanded ? null : index); }}
|
|
style={{
|
|
marginTop: '8px',
|
|
background: 'none',
|
|
border: 'none',
|
|
color: '#FBBC04',
|
|
cursor: 'pointer',
|
|
fontWeight: '600',
|
|
fontSize: '12px',
|
|
padding: 0,
|
|
}}
|
|
>
|
|
{isExpanded ? 'Read less ↑' : 'Read more ↓'}
|
|
</button>
|
|
)}
|
|
</>
|
|
) : (
|
|
<p style={{ color: 'rgba(255,255,255,0.35)', fontSize: '13px', fontStyle: 'italic', margin: 0 }}>
|
|
No written review.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* BOTTOM: date + view link */}
|
|
{/* <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 'auto' }}>
|
|
{r.date && (
|
|
<span style={{ color: 'rgba(255,255,255,0.35)', fontSize: '11px' }}>{r.date}</span>
|
|
)}
|
|
{r.reviewLink && (
|
|
<a
|
|
href={r.reviewLink}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
style={{ color: 'rgba(255,255,255,0.35)', fontSize: '11px', textDecoration: 'none' }}
|
|
onMouseEnter={(e) => ((e.target as HTMLElement).style.color = '#FBBC04')}
|
|
onMouseLeave={(e) => ((e.target as HTMLElement).style.color = 'rgba(255,255,255,0.35)')}
|
|
>
|
|
View on Google →
|
|
</a>
|
|
)}
|
|
</div> */}
|
|
</div>
|
|
</SwiperSlide>
|
|
);
|
|
})}
|
|
</Swiper>
|
|
|
|
{/* Nav Buttons */}
|
|
<div style={{ display: 'flex', gap: '12px', justifyContent: 'center', marginTop: '32px' }}>
|
|
<button
|
|
className="slider-btn h1p"
|
|
style={{
|
|
width: '46px', height: '46px',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
background: 'rgba(255,255,255,0.07)',
|
|
color: '#fff',
|
|
border: '1px solid rgba(255,255,255,0.15)',
|
|
borderRadius: '50%',
|
|
cursor: 'pointer',
|
|
fontSize: '18px',
|
|
transition: 'background 0.2s',
|
|
}}
|
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.background = 'rgba(255,255,255,0.15)')}
|
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.background = 'rgba(255,255,255,0.07)')}
|
|
>←</button>
|
|
<button
|
|
className="slider-btn h1n"
|
|
style={{
|
|
width: '46px', height: '46px',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
background: 'rgba(255,255,255,0.07)',
|
|
color: '#fff',
|
|
border: '1px solid rgba(255,255,255,0.15)',
|
|
borderRadius: '50%',
|
|
cursor: 'pointer',
|
|
fontSize: '18px',
|
|
transition: 'background 0.2s',
|
|
}}
|
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.background = 'rgba(255,255,255,0.15)')}
|
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.background = 'rgba(255,255,255,0.07)')}
|
|
>→</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|