495 lines
15 KiB
React
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;
|