Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 | 1x 18x 18x 18x 18x 18x 18x 9x 9x 9x 8x 18x 8x 18x 18x 18x 9x 7x 28x | import React, {useEffect, useState} from "react";
import {useTranslation} from "react-i18next";
import {Card} from "@/components/ui/card/Card";
import {PageSection} from "@/components/ui/pageSection/PageSection";
import {SeoHead} from "@/components/seoHead/SeoHead";
import {linkedinIcon} from "@/consts/Consts";
import {Pagination} from "@/components/ui/pagination/Pagination";
import {PageGrid} from "@/components/ui/pageGrid/PageGrid";
import {ExpandableText} from "@/components/ui/expandableText/ExpandableText";
import {CardContent} from "@/components/ui/cardContent/CardContent";
import {getTestimonials} from "@/services/portfolioService";
import {Loading} from "@/components/loading/Loading";
import {ErrorState} from "@/components/errorState/ErrorState";
import {BrandIcon} from "@/components/ui/brandIcon/BrandIcon";
const ITEMS_PER_PAGE = 4;
/**
* Testimonials component renders a paginated list of testimonial cards.
* Each card displays the name, role, and a collapsible quote of a person.
* Pagination allows browsing through all testimonials, showing a fixed number per page.
*
* Uses translations for all textual content via react-i18next.
*
* @component
* @module pages/testimonials/Testimonials
* @returns {JSX.Element} The Testimonials section with pagination and collapsible testimonial cards.
*/
export default function Testimonials() {
const {t} = useTranslation();
const [page, setPage] = useState(1);
const [testimonials, setTestimonials] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const loadTestimonials = () => {
setLoading(true);
setError(null);
getTestimonials()
.then(setTestimonials)
.catch(setError)
.finally(() => setLoading(false));
};
useEffect(() => {
loadTestimonials();
}, []);
const totalPages = Math.ceil(testimonials.length / ITEMS_PER_PAGE);
const displayedTestimonials = testimonials.slice(
(page - 1) * ITEMS_PER_PAGE,
page * ITEMS_PER_PAGE
);
if (loading) return <Loading/>;
if (error) return <ErrorState message={t("error_generic")} onRetry={loadTestimonials}/>;
return (
<>
<SeoHead pageKey="testimonials" path="/testimonials"/>
<PageSection title={t("testimonials_page.title")}>
{/* Pagination mobile sticky */}
<div
className="md:hidden sticky top-0 z-20 bg-white/80 dark:bg-gray-900/80 backdrop-blur-md py-2 mb-4 border-b border-gray-200 dark:border-gray-700"
>
<Pagination
page={page}
totalPages={totalPages}
onPageChange={setPage}
/>
</div>
<PageGrid page={page}>
{displayedTestimonials.map((texts, idx) => (
<Card
key={idx}
data-testid="testimonial-card"
className="relative w-full p-5 sm:p-6 border border-gray-200/60
dark:border-gray-700/60 bg-white/60 dark:bg-gray-800/40 backdrop-blur-md
rounded-xl hover:shadow-lg transition-all duration-300
flex flex-col md:flex-row items-start gap-4"
>
<CardContent className="p-2">
{/* Header */}
<div
className="flex-shrink-0 mb-3 pb-1 border-b border-gray-200/50 dark:border-gray-700/50 px-3 pt-3">
<div className="flex items-center gap-2.5">
<img
src={`https://api.dicebear.com/7.x/bottts/svg?seed=${encodeURIComponent(t(texts.nameKey))}`}
alt={`${t(texts.nameKey)} avatar`}
className="w-10 h-10 rounded-full ring-2 ring-gray-200 dark:ring-gray-600 bg-white shadow-md"
/>
<div className="flex flex-col">
<p className="font-semibold text-gray-900 dark:text-white flex items-center gap-1.5 text-sm sm:text-base">
{t(texts.nameKey)}
{texts.linkedinUrl && (
<a href={texts.linkedinUrl} target="_blank"
rel="noopener noreferrer">
<BrandIcon icon={linkedinIcon} color="#0A66C2" size={18}/>
</a>
)}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 font-medium">
{t(texts.roleKey)}
</p>
</div>
</div>
</div>
{/* Quote */}
<div className="flex flex-1 justify-center items-center px-3 py-4">
<ExpandableText
value={t(texts.quoteKey)}
maxLines={4}
className="text-center italic text-sm md:text-base text-gray-800 dark:text-gray-200
leading-snug font-light tracking-tight px-3 py-3 bg-gradient-to-b
from-transparent via-white/70 to-transparent dark:via-gray-800/50
rounded-xl backdrop-blur-sm shadow-inner border border-gray-100/40 dark:border-gray-700/40"
/>
</div>
</CardContent>
</Card>
))}
</PageGrid>
{/* Pagination desktop normal */}
<div className="hidden md:block mt-4">
<Pagination
page={page}
totalPages={totalPages}
onPageChange={setPage}
/>
</div>
</PageSection>
</>
);
}
|