All files / src/pages/testimonials Testimonials.jsx

100% Statements 21/21
100% Branches 6/6
100% Functions 5/5
100% Lines 19/19

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>
        </>
    );
}