Files
Octopus-React-Wp/frontend/src/components/ServicePageTemplate.jsx
T
2025-10-26 15:38:17 +01:00

495 lines
15 KiB
React

import { useState, useMemo, isValidElement, useEffect } from "react";
import {
Box,
Typography,
Button,
Grid,
Card,
CardContent,
Modal,
Fade,
Backdrop,
CircularProgress,
} from "@mui/material";
import { Swiper, SwiperSlide } from "swiper/react";
import { Navigation, Pagination, Autoplay } from "swiper/modules";
import "swiper/css";
import "swiper/css/navigation";
import "swiper/css/pagination";
import PropTypes from "prop-types";
import { Helmet } from "react-helmet-async";
import {
extractFirstImageSrc,
removeImageTags,
stripHtml,
} from "../utils/htmlHelpers";
const DEFAULT_CARD_IMAGE = "https://picsum.photos/id/43/800/400.webp";
const ServicePageTemplate = ({
title,
subtitle,
interestTitle,
description,
desirTitle,
features,
image,
heroBackground,
ctaText,
ctaLink,
carouselItems,
seo, // ✅ Ajout de l'objet SEO
}) => {
const [openModal, setOpenModal] = useState(false);
const [modalContent, setModalContent] = useState({});
const [loadingImage, setLoadingImage] = useState(true);
const [heroLoaded, setHeroLoaded] = useState(false);
const renderRichText = (value) => {
if (value === null || value === undefined) return null;
if (isValidElement(value)) return value;
if (typeof value === "string") {
return <span dangerouslySetInnerHTML={{ __html: value }} />;
}
return null;
};
const descriptionIsElement = isValidElement(description);
const rawDescriptionHtml =
!descriptionIsElement && typeof description === "string" ? description : "";
const descriptionMedia = useMemo(() => {
if (!rawDescriptionHtml) {
return { html: rawDescriptionHtml, media: null };
}
if (typeof window === "undefined" || !window.DOMParser) {
return { html: rawDescriptionHtml, media: null };
}
const parser = new window.DOMParser();
const doc = parser.parseFromString(rawDescriptionHtml, "text/html");
const firstImage = doc.body.querySelector("img");
if (firstImage) {
const media = {
src: firstImage.getAttribute("src"),
alt: firstImage.getAttribute("alt") || "",
};
firstImage.remove();
return { html: doc.body.innerHTML, media };
}
return { html: rawDescriptionHtml, media: null };
}, [rawDescriptionHtml]);
useEffect(() => {
if (!image) {
setHeroLoaded(true);
return;
}
setHeroLoaded(false);
const img = new Image();
img.src = image;
img.onload = () => setHeroLoaded(true);
img.onerror = () => setHeroLoaded(true);
return () => {
img.onload = null;
img.onerror = null;
};
}, [image]);
const defaultGradient = "linear-gradient(135deg, #0a1930 0%, #020710 85%)";
const activeGradient = heroBackground || defaultGradient;
const heroBackgroundStyle =
heroLoaded && image
? `url(${image}), ${activeGradient}`
: activeGradient;
const buildModalPayload = (feature) => {
const rawHtml = feature.modalText || feature.description || "";
const sanitizedHtml = removeImageTags(rawHtml);
const extractedImage = extractFirstImageSrc(rawHtml);
const dynamicImage =
extractedImage || feature.modalImage || DEFAULT_CARD_IMAGE;
return {
title: feature.title,
text: sanitizedHtml || "Texte non disponible.",
image: dynamicImage,
};
};
const getPreviewText = (feature) => {
const rawPreview = feature.description || feature.modalText || "";
const textOnly = stripHtml(removeImageTags(rawPreview));
if (!textOnly) return "Contenu indisponible.";
return textOnly.length > 140 ? `${textOnly.substring(0, 140)}…` : textOnly;
};
const handleCardClick = (feature) => {
setModalContent(buildModalPayload(feature));
setLoadingImage(true);
setOpenModal(true);
};
const handleCloseModal = () => {
setOpenModal(false);
};
return (
<Box
sx={{
backgroundColor: "#f5f5f5",
color: "#333",
fontFamily: "Arial, sans-serif",
}}
>
{/* ✅ SEO META TAGS */}
<Helmet>
<title>{seo?.metaTitle || title}</title>
<meta name="description" content={seo?.metaDescription || ""} />
<meta name="keywords" content={seo?.keywords || ""} />
<meta property="og:title" content={seo?.metaTitle || title} />
<meta property="og:description" content={seo?.metaDescription || ""} />
<meta property="og:image" content={seo?.ogImage || image} />
<meta property="og:type" content="website" />
<meta name="twitter:title" content={seo?.metaTitle || title} />
<meta name="twitter:description" content={seo?.metaDescription || ""} />
<meta name="twitter:image" content={seo?.ogImage || image} />
<meta name="twitter:card" content="summary_large_image" />
</Helmet>
{/* Section Hero */}
<Box
sx={{
mt: 5,
backgroundImage: heroBackgroundStyle,
backgroundSize: heroLoaded && image ? "cover, cover" : "cover",
backgroundPosition: heroLoaded && image ? "center, center" : "center",
minHeight: "70vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
color: "black",
padding: "20px",
backgroundColor: heroLoaded ? undefined : "#020712",
transition: "background-image 0.8s ease, opacity 0.4s ease",
}}
>
<Box
sx={{
maxWidth: "800px",
backgroundColor: "rgba(255, 255, 255, 0.39)",
p: 5,
borderRadius: 5,
}}
>
<Typography
variant="h1"
sx={{
fontWeight: "bold",
mb: 2,
fontSize: {
xs: "2rem",
md: "3rem",
lg: "3rem",
whiteSpace: "pre-line",
},
}}
>
{title}
</Typography>
<Typography variant="body1" component="div" sx={{ mb: 4 }}>
{renderRichText(subtitle) ?? subtitle ?? ""}
</Typography>
<Button
href={ctaLink}
variant="contained"
color="secondary"
size="large"
sx={{
textTransform: "none",
fontWeight: "bold",
backgroundColor: "#0e467f",
"&:hover": { backgroundColor: "#00bcd4" },
}}
>
{ctaText}
</Button>
</Box>
</Box>
{/* Section numero 1 */}
<Box sx={{ fontWeight: "5", textAlign: "center" }}>
<Typography
variant="h2"
sx={{
fontWeight: "bold",
mb: 7,
fontSize: { xs: "2rem", md: "2rem", lg: "2rem" },
whiteSpace: "pre-line",
}}
>
{interestTitle}
</Typography>
{descriptionIsElement ? (
<Typography variant="body1" component="div">
{renderRichText(description)}
</Typography>
) : (
<Box
sx={{
display: "flex",
flexDirection: { xs: "column", md: "row" },
gap: 4,
alignItems: "center",
justifyContent: "center",
textAlign: { xs: "center", md: "left" },
}}
>
{descriptionMedia.media?.src && (
<Box
component="img"
src={descriptionMedia.media.src}
alt={descriptionMedia.media.alt || "Illustration"}
sx={{
width: { xs: "100%", md: "42%" },
borderRadius: 4,
objectFit: "cover",
boxShadow: "0 25px 45px rgba(0,0,0,0.25)",
mb: { xs: 2, md: 5 },
mx: { xs: "auto", md: 0 },
marginLeft: { xs: 2, md: 6 } ,
}}
/>
)}
<Typography
variant="body1"
component="div"
sx={{
flex: 1,
textAlign: { xs: "center", md: "left" },
"& p": {
margin: "0 auto",
textAlign: "left",
lineHeight: 1.65,
maxWidth: 760,
},
"& p + p": {
marginTop: 1.2,
},
}}
dangerouslySetInnerHTML={{ __html: descriptionMedia.html || "" }}
/>
</Box>
)}
</Box>
{/* Section numero 2 */}
<Box sx={{ backgroundColor: "#ffffff", padding: "40px 20px" }}>
<Typography
variant="h2"
sx={{
fontWeight: "bold",
textAlign: "center",
mb: 4,
fontSize: { xs: "2rem", md: "3rem", lg: "2rem" },
}}
>
{desirTitle}
</Typography>
<Grid container spacing={4} justifyContent="center">
{features.map((feature, index) => (
<Grid item xs={12} md={4} key={index}>
<Card
onClick={() => handleCardClick(feature)}
sx={{
cursor: "pointer",
textAlign: "center",
padding: "20px",
backgroundColor: "#f9f9f9",
transition: "0.3s",
"&:hover": { transform: "scale(1.05)", boxShadow: 5 },
}}
>
<CardContent>
<Typography
variant="h3"
sx={{
fontWeight: "bold",
mb: 2,
fontSize: 26,
whiteSpace: "pre-line",
}}
>
{feature.title}
</Typography>
<Typography variant="body2" sx={{ color: "#555" }}>
{getPreviewText(feature)}
</Typography>
</CardContent>
</Card>
</Grid>
))}
</Grid>
</Box>
{/* Modal */}
<Modal
open={openModal}
onClose={handleCloseModal}
closeAfterTransition
BackdropComponent={Backdrop}
BackdropProps={{ timeout: 500 }}
>
<Fade in={openModal}>
<Box
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: "90%",
maxWidth: "600px",
p: 4,
textAlign: "center",
background: "white",
borderRadius: 2,
boxShadow: 3,
}}
>
{loadingImage && <CircularProgress sx={{ mb: 2 }} />}
<img
src={modalContent.image}
alt={modalContent.title}
style={{
width: "100%",
borderRadius: "8px",
display: loadingImage ? "none" : "block",
}}
onLoad={() => setLoadingImage(false)}
/>
<Typography variant="h3" sx={{ fontWeight: "bold", mb: 2 }}>
{modalContent.title}
</Typography>
<Typography variant="body1" sx={{ mb: 4 }} dangerouslySetInnerHTML={{ __html: modalContent.text }} >
</Typography>
<Button
onClick={handleCloseModal}
variant="contained"
color="secondary"
>
Retour
</Button>
</Box>
</Fade>
</Modal>
{/* Carousel Section */}
{carouselItems && carouselItems.length > 0 && (
<Box
sx={{
padding: "40px 20px",
textAlign: "center",
backgroundColor: "#f9f9f9",
}}
>
<Typography
variant="h2"
sx={{
marginBottom: 4,
fontSize: { xs: "2rem", md: "3rem", lg: "3rem" }, // Responsive
}}
>
Découvrez nos réalisations
</Typography>
<Swiper
modules={[Navigation, Pagination, Autoplay]}
spaceBetween={20}
slidesPerView={1}
navigation
pagination={{ clickable: true }}
autoplay={{ delay: 3000 }}
loop
>
{carouselItems.map((item, index) => (
<SwiperSlide key={index}>
<Card
sx={{
display: "flex",
alignItems: "center",
padding: "20px",
boxShadow: 3,
}}
>
<img
src={item.image}
alt={item.title}
style={{
width: "40%",
height: "auto",
objectFit: "cover",
borderRadius: "8px",
marginRight: "20px",
}}
/>
<CardContent>
<Typography
variant="h3"
sx={{
fontWeight: "bold",
fontSize: { xs: "1.2rem", md: "2rem", lg: "2.5rem" }, // Responsive
}}
>
{item.title}
</Typography>
<Typography variant="body2">{item.description}</Typography>
</CardContent>
</Card>
</SwiperSlide>
))}
</Swiper>
</Box>
)}
</Box>
);
};
// ✅ Définition des types des props avec PropTypes
ServicePageTemplate.propTypes = {
title: PropTypes.string.isRequired,
subtitle: PropTypes.string.isRequired,
interestTitle: PropTypes.string.isRequired,
description: PropTypes.string.isRequired,
desirTitle: PropTypes.string.isRequired,
features: PropTypes.arrayOf(
PropTypes.shape({
title: PropTypes.string.isRequired,
description: PropTypes.string.isRequired,
modalText: PropTypes.string,
modalImage: PropTypes.string,
})
).isRequired,
image: PropTypes.string,
ctaText: PropTypes.string.isRequired,
ctaLink: PropTypes.string.isRequired,
carouselItems: PropTypes.arrayOf(
PropTypes.shape({
title: PropTypes.string.isRequired,
description: PropTypes.string.isRequired,
image: PropTypes.string.isRequired,
})
),
heroBackground: PropTypes.string,
seo: PropTypes.object, // ✅ Ajout du SEO en option
};
// ✅ Valeurs par défaut
ServicePageTemplate.defaultProps = {
carouselItems: [],
heroBackground: "linear-gradient(135deg, #0a1930 0%, #020710 85%)",
seo: {}, // ✅ SEO par défaut vide
};
export default ServicePageTemplate;