Octopus-React-Wp/frontend/src/components/Pages/EditPageACF.jsx

1655 lines
56 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useCallback, useMemo } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { getToken } from "../../auth";
import { getPageById, updatePageACF, updateRankMathMeta } from "../../wordpress";
import api from "../../api";
import {
Box,
Container,
Typography,
TextField,
Button,
Paper,
CircularProgress,
Grid,
Stack,
Dialog,
DialogTitle,
DialogContent,
IconButton,
Slider,
} from "@mui/material";
import { Add, Delete, ArrowBack, Save } from "@mui/icons-material";
import CloseIcon from "@mui/icons-material/Close";
import RichTextEditor from "../common/RichTextEditor";
import ImageUploaderCloudinary from "../ImageUploaderCloudinary";
import CloudinaryGallerySelector from "../CloudinaryGallerySelector";
import {
extractFirstImageSrc,
stripHtml,
} from "../../utils/htmlHelpers";
const RICH_TEXT_HINTS = ["content", "description", "texte", "text", "body", "html"];
const CARD_KEYWORDS = ["carte", "card", "feature", "bloc", "block"];
const HERO_KEYWORDS = [
"hero",
"principal",
"second",
"interet",
"interest",
"intro",
"desir",
"subtitle",
];
const CARD_PLACEHOLDER = "https://picsum.photos/seed/acf-card/420/240";
const HERO_BACKGROUND_KEYS = [
"hero_background",
"background_hero",
"hero_bg",
"bg_hero",
"background",
];
const HERO_IMAGE_KEYS = [
"hero_background_image",
"hero_background_image_url",
"hero_image_url",
"hero_image",
"image_hero",
"img_hero_url",
"img_hero",
"hero_photo",
];
const HERO_BACKGROUND_PRESETS = [
"linear-gradient(135deg,#0a1930 0%,#020710 85%)",
"linear-gradient(120deg,#081b33,#133b5c,#0b1f3a)",
"linear-gradient(160deg,#14052d,#3b0c59,#0d0419)",
"linear-gradient(135deg,#0b1226,#0f3b50,#05111f)",
"linear-gradient(145deg,#1e0d2b,#381a4e,#16082c)",
];
const HERO_IMAGE_PLACEHOLDER =
"https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=1200&q=80";
function EditPageACF() {
const { id } = useParams();
const navigate = useNavigate();
const [acfFields, setAcfFields] = useState({});
const [pageTitle, setPageTitle] = useState("");
const [pageUrl, setPageUrl] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [successMessage, setSuccessMessage] = useState("");
const [seoTitle, setSeoTitle] = useState("");
const [seoDescription, setSeoDescription] = useState("");
const [seoCanonical, setSeoCanonical] = useState("");
const [seoOgTitle, setSeoOgTitle] = useState("");
const [seoOgDescription, setSeoOgDescription] = useState("");
const [seoOgImage, setSeoOgImage] = useState("");
const [seoRobots, setSeoRobots] = useState("");
const [seoFocusKeyword, setSeoFocusKeyword] = useState("");
const [seoLoading, setSeoLoading] = useState(true);
const [seoError, setSeoError] = useState(null);
const [imageDialog, setImageDialog] = useState({
open: false,
title: "",
setter: null,
getter: null,
mode: "insert",
targetIndex: null,
width: 100,
alt: "",
});
const [ogDialog, setOgDialog] = useState({ open: false, source: "" });
const [heroImagePickerOpen, setHeroImagePickerOpen] = useState(false);
const glassCardSx = {
background: "rgba(7, 13, 28, 0.82)",
border: "1px solid rgba(132, 169, 255, 0.18)",
borderRadius: 4,
p: { xs: 2.5, md: 4 },
boxShadow: "0 35px 55px rgba(4, 6, 14, 0.65)",
backdropFilter: "blur(18px)",
color: "#f4f7ff",
};
const sectionTitleSx = {
fontWeight: "bold",
fontSize: { xs: "1.25rem", md: "1.5rem" },
mb: 2,
color: "#f6f8ff",
};
const primaryButtonSx = {
py: 1.2,
borderRadius: 999,
fontWeight: "bold",
textTransform: "none",
letterSpacing: 0.3,
background: "linear-gradient(135deg, #1ec6ff, #7f61ff)",
boxShadow: "0 15px 30px rgba(20, 36, 70, 0.35)",
"&:hover": {
background: "linear-gradient(135deg, #27b2ff, #5c4bff)",
boxShadow: "0 18px 35px rgba(17, 23, 46, 0.45)",
},
};
const ghostButtonSx = {
py: 1.1,
borderRadius: 999,
fontWeight: "bold",
textTransform: "none",
borderColor: "rgba(255,255,255,0.4)",
color: "#e3e8ff",
"&:hover": {
borderColor: "rgba(255,255,255,0.7)",
background: "rgba(255,255,255,0.05)",
},
};
const labelSx = {
color: "rgba(224, 232, 255, 0.85)",
"&.Mui-focused": { color: "#8fd1ff" },
};
const textFieldSx = {
"& .MuiOutlinedInput-root": {
backgroundColor: "rgba(2, 5, 16, 0.75)",
color: "#f7f9ff",
borderRadius: 3,
"& fieldset": { borderColor: "rgba(255,255,255,0.15)" },
"&:hover fieldset": { borderColor: "#6bc9ff" },
"&.Mui-focused fieldset": { borderColor: "#2ee1ff" },
},
"& .MuiOutlinedInput-input": {
color: "#f7f9ff",
},
"& .MuiInputLabel-root": { color: "rgba(224,232,255,0.85)" },
};
const quillStyle = {
marginBottom: "12px",
backgroundColor: "rgba(255,255,255,0.98)",
borderRadius: "14px",
color: "#07122a",
};
const infoTextColor = "rgba(255,255,255,0.9)";
const secondaryInfoTextColor = "rgba(255,255,255,0.82)";
const isRichTextField = (fieldName = "", value = "") => {
if (!fieldName) return false;
const lowered = fieldName.toLowerCase();
if (RICH_TEXT_HINTS.some((hint) => lowered.includes(hint))) {
return true;
}
if (typeof value === "string" && value.trim().startsWith("<")) {
return true;
}
return false;
};
const openImageDialog = (
title,
getter,
setter,
mode = "insert",
targetIndex = null,
initialAlt = ""
) => {
setImageDialog({
open: true,
title,
setter,
getter,
mode,
targetIndex,
width: 100,
alt: initialAlt || "",
});
};
const closeImageDialog = () =>
setImageDialog({
open: false,
title: "",
setter: null,
getter: null,
mode: "insert",
targetIndex: null,
width: 100,
alt: "",
});
const buildImageMarkup = (url, width, alt = "") =>
`<p><img src="${url}" alt="${alt?.replace(/"/g, "&quot;") || ""}" data-custom-width="${width}" width="${width}%" style="width:${width}%;max-width:100%;height:auto;" /></p>`;
const handleImageSelected = (url) => {
if (!url || !imageDialog.setter) return;
const targetWidth = imageDialog.width || 100;
const insertMarkup = () => {
const imageMarkup = buildImageMarkup(url, targetWidth, imageDialog.alt);
imageDialog.setter((prev) => `${prev || ""}${imageMarkup}`);
};
const replaceImage = () => {
if (typeof window === "undefined" || !imageDialog.getter) {
insertMarkup();
return;
}
const currentHtml = imageDialog.getter() || "";
const parser = new window.DOMParser();
const doc = parser.parseFromString(currentHtml, "text/html");
const images = doc.body.querySelectorAll("img");
const target = images[imageDialog.targetIndex ?? -1];
if (target) {
target.setAttribute("src", url);
target.setAttribute("style", `width:${targetWidth}%;max-width:100%;height:auto;`);
target.setAttribute("data-custom-width", String(targetWidth));
target.setAttribute("width", `${targetWidth}%`);
target.setAttribute("alt", imageDialog.alt || target.getAttribute("alt") || "");
imageDialog.setter(doc.body.innerHTML);
} else {
insertMarkup();
}
};
if (imageDialog.mode === "replace") {
replaceImage();
} else {
insertMarkup();
}
closeImageDialog();
};
const getImagesFromHtml = (html) => {
if (typeof window === "undefined" || !html) return [];
const parser = new window.DOMParser();
const doc = parser.parseFromString(html, "text/html");
return Array.from(doc.body.querySelectorAll("img")).map((img) => {
const dataWidth = img.getAttribute("data-custom-width");
if (dataWidth) {
return {
src: img.getAttribute("src"),
width: Number(dataWidth) || 100,
alt: img.getAttribute("alt") || "",
};
}
const attrWidth = img.getAttribute("width");
if (attrWidth && attrWidth.includes("%")) {
return {
src: img.getAttribute("src"),
width: Number(attrWidth.replace("%", "")) || 100,
alt: img.getAttribute("alt") || "",
};
}
const styleAttr = img.getAttribute("style") || "";
const widthMatch = styleAttr.match(/width:\s*(\d+(?:\.\d+)?)%/i);
return {
src: img.getAttribute("src"),
width: widthMatch ? Number(widthMatch[1]) : 100,
alt: img.getAttribute("alt") || "",
};
});
};
const removeImageAt = (getter, setter, index) => {
if (typeof window === "undefined") return;
const currentHtml = getter() || "";
const parser = new window.DOMParser();
const doc = parser.parseFromString(currentHtml, "text/html");
const images = doc.body.querySelectorAll("img");
const target = images[index];
if (target) {
target.remove();
setter(doc.body.innerHTML);
}
};
const updateImageWidth = (getter, setter, index, width) => {
if (typeof window === "undefined") return;
const currentHtml = getter() || "";
const parser = new window.DOMParser();
const doc = parser.parseFromString(currentHtml, "text/html");
const images = doc.body.querySelectorAll("img");
const target = images[index];
if (target) {
target.setAttribute("style", `width:${width}%;max-width:100%;height:auto;`);
target.setAttribute("data-custom-width", String(width));
target.setAttribute("width", `${width}%`);
setter(doc.body.innerHTML);
}
};
const updateImageAlt = (getter, setter, index, alt) => {
if (typeof window === "undefined") return;
const currentHtml = getter() || "";
const parser = new window.DOMParser();
const doc = parser.parseFromString(currentHtml, "text/html");
const images = doc.body.querySelectorAll("img");
const target = images[index];
if (target) {
target.setAttribute("alt", alt);
setter(doc.body.innerHTML);
}
};
const renderImageManager = (label, getter, setter, key) => {
const images = getImagesFromHtml(getter());
if (!images.length) return null;
return (
<Box sx={{ mt: 2 }}>
<Typography sx={{ fontWeight: "bold", mb: 1 }}>Images insérées {label}</Typography>
<Stack spacing={2}>
{images.map((img, index) => (
<Paper
key={`${key}-${index}`}
variant="outlined"
sx={{
p: 2,
borderRadius: 3,
borderColor: "rgba(255,255,255,0.15)",
backgroundColor: "rgba(2,6,18,0.6)",
}}
>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
alignItems="center"
>
<Box
component="img"
src={img.src}
alt={img.alt || `image-${index}`}
sx={{
width: 120,
height: 80,
objectFit: "cover",
borderRadius: 2,
border: "1px solid rgba(255,255,255,0.15)",
}}
/>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
alignItems="center"
flex={1}
>
<Box sx={{ minWidth: 220 }}>
<Typography
variant="caption"
sx={{ color: secondaryInfoTextColor, mb: 0.5, display: "block" }}
>
Largeur : {img.width}%
</Typography>
<Slider
value={img.width}
min={10}
max={100}
step={1}
onChange={(_, value) =>
updateImageWidth(
getter,
setter,
index,
Array.isArray(value) ? value[0] : value
)
}
sx={{ color: "#45d6ff" }}
/>
</Box>
<Button
variant="contained"
sx={primaryButtonSx}
onClick={() =>
openImageDialog(
`Remplacer ${label}`,
getter,
setter,
"replace",
index,
img.alt
)
}
>
Remplacer
</Button>
<TextField
label="Texte alternatif"
fullWidth
variant="outlined"
value={img.alt}
onChange={(e) => updateImageAlt(getter, setter, index, e.target.value)}
InputLabelProps={{ sx: labelSx }}
sx={textFieldSx}
/>
<Button
color="error"
variant="outlined"
sx={ghostButtonSx}
onClick={() => removeImageAt(getter, setter, index)}
>
Supprimer
</Button>
</Stack>
</Stack>
</Paper>
))}
</Stack>
</Box>
);
};
const loadSeoMeta = useCallback(async () => {
if (!pageUrl) return;
try {
setSeoLoading(true);
setSeoError(null);
const { data } = await api.get("rankmath/v1/getHead", {
params: { url: pageUrl },
});
if (
data?.success &&
data.head &&
typeof window !== "undefined" &&
window.DOMParser
) {
const parser = new window.DOMParser();
const parsedDocument = parser.parseFromString(
`<!doctype html><html><head>${data.head}</head><body></body></html>`,
"text/html"
);
const head = parsedDocument.head;
const pick = (selector, attribute = "content") =>
head.querySelector(selector)?.getAttribute(attribute) || "";
setSeoTitle(head.querySelector("title")?.textContent || "");
setSeoDescription(pick('meta[name="description"]'));
setSeoCanonical(
head.querySelector('link[rel="canonical"]')?.getAttribute("href") ||
pick('meta[property="og:url"]') ||
pageUrl
);
setSeoOgTitle(pick('meta[property="og:title"]'));
setSeoOgDescription(pick('meta[property="og:description"]'));
setSeoOgImage(pick('meta[property="og:image"]'));
setSeoRobots(pick('meta[name="robots"]') || "index,follow");
} else {
throw new Error("Réponse Rank Math invalide");
}
} catch (loadError) {
console.error("❌ Erreur chargement SEO Rank Math :", loadError);
setSeoError("Impossible de charger les métadonnées SEO.");
} finally {
setSeoLoading(false);
}
}, [pageUrl]);
useEffect(() => {
if (!getToken()) {
navigate("/admin/login");
}
}, [navigate]);
useEffect(() => {
const fetchPage = async () => {
if (!id) {
setError("❌ L'ID de la page est invalide !");
setLoading(false);
return;
}
try {
const pageData = await getPageById(id);
if (!pageData || !pageData.acf) {
setError("❌ Impossible de charger les champs ACF.");
setLoading(false);
return;
}
setAcfFields(pageData.acf);
const rawTitle = pageData.title?.rendered || pageData.title || "";
const cleanTitle = rawTitle.replace(/<[^>]*>/g, "");
setPageTitle(cleanTitle || `Page ${id}`);
const canonicalLink =
pageData.link ||
`https://preprod.octopusdesign.fr/?page_id=${id}`;
setPageUrl(canonicalLink);
setSeoCanonical(canonicalLink);
const seoMeta = pageData._rank_math_seo_meta || {};
setSeoFocusKeyword(
seoMeta.focus_keyword || seoMeta.primary_keyword || ""
);
if (seoMeta.rank_math_title) {
setSeoTitle(seoMeta.rank_math_title);
}
if (seoMeta.rank_math_description) {
setSeoDescription(seoMeta.rank_math_description);
}
setLoading(false);
} catch (fetchError) {
console.error("❌ Erreur récupération page :", fetchError);
setError("⚠ Impossible de récupérer les champs ACF.");
setLoading(false);
}
};
fetchPage();
}, [id]);
useEffect(() => {
if (pageUrl) {
loadSeoMeta();
} else {
setSeoLoading(false);
}
}, [pageUrl, loadSeoMeta]);
const handleUpdateACF = async (e) => {
e.preventDefault();
try {
setError(null);
await updatePageACF(id, acfFields);
await updateRankMathMeta(Number(id), {
rank_math_title: seoTitle,
rank_math_description: seoDescription,
rank_math_canonical_url: seoCanonical || pageUrl,
rank_math_facebook_title: seoOgTitle || seoTitle,
rank_math_facebook_description: seoOgDescription || seoDescription,
rank_math_facebook_image: seoOgImage,
rank_math_twitter_title: seoOgTitle || seoTitle,
rank_math_twitter_description: seoOgDescription || seoDescription,
rank_math_twitter_image: seoOgImage,
rank_math_robots: seoRobots,
rank_math_focus_keyword: seoFocusKeyword,
});
await loadSeoMeta();
setSuccessMessage("✅ Modifications enregistrées !");
setTimeout(() => navigate("/admin/pages-acf"), 2000);
} catch (saveError) {
console.error("❌ Erreur mise à jour ACF/SEO :", saveError);
setError("⚠ Impossible de mettre à jour les champs ACF ou SEO.");
}
};
const handleFieldChange = (field, value) => {
setAcfFields((prevFields) => ({
...prevFields,
[field]: value,
}));
};
const createFieldSetter = (field) => (valueOrUpdater) => {
if (typeof valueOrUpdater === "function") {
setAcfFields((prevFields) => {
const previousValue = prevFields[field] || "";
const nextValue = valueOrUpdater(previousValue);
return {
...prevFields,
[field]: nextValue,
};
});
} else {
handleFieldChange(field, valueOrUpdater);
}
};
const handleFAQChange = (index, field, value) => {
const currentFaq = [...(acfFields.faq?.faq_list || [])];
currentFaq[index][field] = value;
setAcfFields((prev) => ({
...prev,
faq: { ...prev.faq, faq_list: currentFaq },
}));
};
const addFAQ = () => {
setAcfFields((prev) => ({
...prev,
faq: {
...prev.faq,
faq_list: [...(prev.faq?.faq_list || []), { question: "", answer: "" }],
},
}));
};
const deleteFAQ = (index) => {
const currentFaq = acfFields.faq?.faq_list || [];
const updated = currentFaq.filter((_, i) => i !== index);
const updatedACF = {
...acfFields,
faq: { ...acfFields.faq, faq_list: updated },
};
if (!updated.length) {
delete updatedACF.faq.faq_list;
}
setAcfFields(updatedACF);
};
const faqItems = acfFields.faq?.faq_list || [];
const fieldsToRender = Object.keys(acfFields).filter(
(field) => field !== "faq" && field !== "faq_list"
);
const findFieldFromKeys = (keys, allFields = Object.keys(acfFields)) => {
const lowerKeys = keys.map((key) => key.toLowerCase());
return (
allFields.find((field) => lowerKeys.includes(field.toLowerCase())) || null
);
};
const backgroundFieldFromAll =
findFieldFromKeys(HERO_BACKGROUND_KEYS, Object.keys(acfFields));
const heroBackgroundField = backgroundFieldFromAll || HERO_BACKGROUND_KEYS[0];
const heroBackgroundValue = acfFields[heroBackgroundField] || "";
const imageFieldFromAll =
findFieldFromKeys(HERO_IMAGE_KEYS, Object.keys(acfFields));
const heroImageField = imageFieldFromAll || HERO_IMAGE_KEYS[0];
const heroImageValue = acfFields[heroImageField] || "";
const formatLabel = (field) =>
field
.replace(/_/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase());
const isCardField = (field) =>
CARD_KEYWORDS.some((keyword) => field.toLowerCase().includes(keyword));
const getCardGroupKey = (field) => {
const parts = field.split("_");
const lowerParts = parts.map((part) => part.toLowerCase());
const index = lowerParts.findIndex((part) => CARD_KEYWORDS.includes(part));
if (index === -1) return field;
return parts
.slice(index)
.join("_")
.toLowerCase();
};
const getCardFieldPriority = (field) => {
const lower = field.toLowerCase();
if (lower.includes("titre") || lower.includes("title")) return 0;
if (lower.includes("description") || lower.includes("content")) return 1;
if (lower.includes("modal")) return 2;
if (lower.includes("image")) return 3;
return 4;
};
const renderFieldControl = (field, options = {}) => {
const value = acfFields[field];
const richText = isRichTextField(field, value);
const getter = () => acfFields[field] || "";
const setter = createFieldSetter(field);
const displayLabel = options.label || formatLabel(field);
const elementKey = options.key ?? field;
return (
<Box
key={elementKey}
sx={{
borderRadius: 3,
border: "1px solid rgba(255,255,255,0.22)",
p: 2,
background: "rgba(6,12,26,0.78)",
...options.sx,
}}
>
<Typography sx={{ fontWeight: "bold", mb: 1 }}>
{displayLabel}
</Typography>
{options.helperText && (
<Typography
variant="body2"
sx={{ color: infoTextColor, mb: 1 }}
>
{options.helperText}
</Typography>
)}
{richText ? (
<>
<RichTextEditor
value={value || ""}
onChange={(content) => handleFieldChange(field, content)}
minHeight={220}
sx={quillStyle}
/>
<Button
variant="outlined"
sx={ghostButtonSx}
onClick={() =>
openImageDialog(
displayLabel,
getter,
setter,
"insert",
null,
`Illustration ${displayLabel}`
)
}
>
Insérer une image Cloudinary
</Button>
{renderImageManager(displayLabel, getter, setter, field)}
</>
) : (
<TextField
fullWidth
variant="outlined"
value={
typeof value === "object" && value !== null
? JSON.stringify(value)
: value || ""
}
onChange={(e) => handleFieldChange(field, e.target.value)}
InputLabelProps={{ sx: labelSx }}
sx={textFieldSx}
/>
)}
</Box>
);
};
const cardGroups = useMemo(() => {
return Object.entries(acfFields).reduce((acc, [field, value]) => {
if (!isCardField(field)) return acc;
const key = getCardGroupKey(field);
if (!acc[key]) {
acc[key] = {
fields: {},
};
}
acc[key].fields[field] = value;
return acc;
}, {});
}, [acfFields]);
const nonCardFields = fieldsToRender.filter((field) => {
const lower = field.toLowerCase();
return (
!isCardField(field) &&
!HERO_BACKGROUND_KEYS.includes(lower) &&
!HERO_IMAGE_KEYS.includes(lower)
);
});
const heroFields = [];
const supportingFields = [];
nonCardFields.forEach((field) => {
const lower = field.toLowerCase();
if (HERO_KEYWORDS.some((keyword) => lower.includes(keyword))) {
heroFields.push(field);
} else {
supportingFields.push(field);
}
});
const storageKeys = useMemo(
() => ({
background: `hero_bg_${id}`,
image: `hero_img_${id}`,
}),
[id]
);
const heroBackgroundPreview =
heroBackgroundValue || HERO_BACKGROUND_PRESETS[0];
const storeValue = (key, value) => {
if (typeof window === "undefined") return;
if (!value) {
window.localStorage.removeItem(key);
} else {
window.localStorage.setItem(key, value);
}
};
const loadStoredValue = (key) => {
if (typeof window === "undefined") return null;
return window.localStorage.getItem(key);
};
const applyHeroBackground = (value) => {
handleFieldChange(heroBackgroundField, value);
storeValue(storageKeys.background, value);
};
const heroImagePreview = heroImageValue || HERO_IMAGE_PLACEHOLDER;
const applyHeroImage = (value) => {
handleFieldChange(heroImageField, value);
storeValue(storageKeys.image, value);
};
const handleHeroImageUpload = (url) => {
if (!url) return;
applyHeroImage(url);
setHeroImagePickerOpen(false);
};
useEffect(() => {
if (!heroBackgroundValue) {
const stored = loadStoredValue(storageKeys.background);
if (stored) {
setAcfFields((prev) => ({
...prev,
[heroBackgroundField]: stored,
}));
}
}
if (!heroImageValue) {
const storedImg = loadStoredValue(storageKeys.image);
if (storedImg) {
setAcfFields((prev) => ({
...prev,
[heroImageField]: storedImg,
}));
}
}
}, [
heroBackgroundValue,
heroImageValue,
heroBackgroundField,
heroImageField,
storageKeys.background,
storageKeys.image,
]);
return (
<Box
sx={{
minHeight: "100vh",
py: { xs: 4, md: 8 },
px: { xs: 2, md: 4 },
background:
"radial-gradient(circle at 10% 20%, #081736, #020611 55%, #01040b)",
position: "relative",
overflow: "hidden",
}}
>
<Box
sx={{
position: "absolute",
inset: 0,
background:
"radial-gradient(circle at 70% 20%, rgba(49,209,255,0.25), transparent 45%), radial-gradient(circle at 20% 80%, rgba(125,87,255,0.25), transparent 40%)",
filter: "blur(40px)",
}}
/>
<Container
maxWidth="lg"
sx={{
position: "relative",
zIndex: 1,
"& .MuiTypography-body1": { color: infoTextColor },
}}
>
<Paper elevation={0} sx={{ ...glassCardSx, textAlign: "center", mb: 5 }}>
<Typography
variant="h3"
sx={{
fontWeight: 700,
mb: 1,
background: "linear-gradient(120deg,#ffffff,#59c2ff,#8f7bff)",
WebkitBackgroundClip: "text",
color: "transparent",
}}
>
{pageTitle || "Édition ACF"}
</Typography>
<Typography sx={{ color: infoTextColor }}>
Retouchez vos champs ACF, importez des visuels Cloudinary et
synchronisez Rank Math depuis une interface premium.
</Typography>
</Paper>
{loading ? (
<Box display="flex" justifyContent="center" alignItems="center" py={6}>
<CircularProgress />
</Box>
) : error ? (
<Typography textAlign="center" color="error">
{error}
</Typography>
) : (
<Box component="form" onSubmit={handleUpdateACF}>
<Stack spacing={4}>
{successMessage && (
<Paper
elevation={0}
sx={{
...glassCardSx,
borderColor: "rgba(72, 255, 184, 0.6)",
background:
"linear-gradient(135deg, rgba(48,199,135,0.25), rgba(43,170,241,0.25))",
textAlign: "center",
}}
>
<Typography fontWeight="bold" color="#e5fff5">
{successMessage}
</Typography>
</Paper>
)}
<Box sx={glassCardSx}>
<Typography sx={sectionTitleSx} mb={1.5}>
🎨 Fond du hero
</Typography>
<Stack
direction={{ xs: "column", lg: "row" }}
spacing={3}
alignItems="stretch"
>
<Box sx={{ flex: 1 }}>
<Typography sx={{ color: infoTextColor, mb: 2 }}>
Définissez un dégradé ou une couleur unie pour le fond
du hero. Vous pouvez coller n'importe quel gradient CSS
(par exemple <code>linear-gradient(...)</code>).
</Typography>
<Box
sx={{
borderRadius: 4,
border: "1px solid rgba(255,255,255,0.18)",
height: 180,
background: heroBackgroundPreview,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontWeight: 600,
letterSpacing: 0.5,
mb: 3,
boxShadow: "0 25px 45px rgba(5,7,15,0.5)",
}}
>
Aperçu du fond
</Box>
<Stack spacing={2}>
<TextField
label={formatLabel(heroBackgroundField)}
fullWidth
variant="outlined"
value={heroBackgroundValue}
onChange={(e) => applyHeroBackground(e.target.value)}
placeholder="linear-gradient(135deg, #0a1930 0%, #020710 85%)"
InputLabelProps={{ sx: labelSx }}
sx={textFieldSx}
helperText="Collez votre gradient CSS ou une couleur (ex: #0a1930)."
/>
<Stack direction="row" flexWrap="wrap" gap={1.5}>
{HERO_BACKGROUND_PRESETS.map((preset) => (
<Box
key={preset}
onClick={() => applyHeroBackground(preset)}
sx={{
width: 80,
height: 48,
borderRadius: 2,
background: preset,
cursor: "pointer",
border:
heroBackgroundValue === preset
? "2px solid #6bc9ff"
: "1px solid rgba(255,255,255,0.25)",
boxShadow:
heroBackgroundValue === preset
? "0 6px 18px rgba(107,201,255,0.45)"
: "0 4px 12px rgba(0,0,0,0.35)",
}}
/>
))}
</Stack>
</Stack>
</Box>
<Box sx={{ flex: 1 }}>
<Typography sx={{ fontWeight: "bold", mb: 1.5 }}>
🖼 Visuel principal
</Typography>
<Typography sx={{ color: secondaryInfoTextColor, mb: 2 }}>
Ajoutez une photo pour le hero (affichée sur les pages
services). Laissez vide pour utiliser uniquement le
dégradé.
</Typography>
<Box
sx={{
borderRadius: 4,
border: "1px solid rgba(255,255,255,0.18)",
overflow: "hidden",
mb: 2,
position: "relative",
minHeight: 180,
background: "#02050c",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{heroImageValue ? (
<Box
component="img"
src={heroImagePreview}
alt="Hero visuel"
sx={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
) : (
<Typography
sx={{
color: secondaryInfoTextColor,
fontWeight: 600,
letterSpacing: 0.5,
}}
>
Aucune image sélectionnée
</Typography>
)}
</Box>
<Stack spacing={2}>
<TextField
label={formatLabel(heroImageField)}
fullWidth
variant="outlined"
value={heroImageValue}
onChange={(e) => applyHeroImage(e.target.value)}
placeholder="https://res.cloudinary.com/..."
InputLabelProps={{ sx: labelSx }}
sx={textFieldSx}
helperText="Collez un lien direct (Cloudinary, WordPress, etc.)."
/>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
alignItems="stretch"
>
<Button
variant="contained"
sx={{ ...primaryButtonSx, flex: 1 }}
onClick={() => setHeroImagePickerOpen(true)}
>
📤 Importer / choisir une image
</Button>
<Button
variant="outlined"
color="error"
sx={{ ...ghostButtonSx, flex: 1 }}
onClick={() => applyHeroImage("")}
disabled={!heroImageValue}
>
Supprimer la photo
</Button>
</Stack>
</Stack>
</Box>
</Stack>
</Box>
<Box sx={glassCardSx}>
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
justifyContent="space-between"
alignItems={{ xs: "stretch", md: "center" }}
sx={{ mb: 2 }}
>
<Box>
<Typography sx={sectionTitleSx} mb={0}>
🧱 Contenus principaux
</Typography>
<Typography sx={{ color: infoTextColor }}>
Retrouvez les champs clés du héros et des sections
éditoriales avant de gérer les cartes.
</Typography>
</Box>
<Button
variant="outlined"
startIcon={<ArrowBack />}
sx={ghostButtonSx}
onClick={() => navigate("/admin/pages-acf")}
>
Retour
</Button>
</Stack>
{heroFields.length === 0 && supportingFields.length === 0 ? (
<Typography sx={{ color: secondaryInfoTextColor }}>
Aucun champ principal à afficher pour cette page.
</Typography>
) : (
<>
{heroFields.length > 0 && (
<>
<Typography
sx={{ fontWeight: "bold", mb: 2, color: "#8fd1ff" }}
>
🎯 Titre principal & descriptions
</Typography>
<Grid container spacing={3}>
{heroFields.map((field) => (
<Grid
item
xs={12}
md={isRichTextField(field, acfFields[field]) ? 12 : 6}
key={field}
>
{renderFieldControl(field)}
</Grid>
))}
</Grid>
</>
)}
{supportingFields.length > 0 && (
<>
<Typography
sx={{
fontWeight: "bold",
mb: 2,
mt: heroFields.length ? 4 : 0,
color: "#fdd8ff",
}}
>
🧩 Sections complémentaires
</Typography>
<Grid container spacing={3}>
{supportingFields.map((field) => (
<Grid
item
xs={12}
md={isRichTextField(field, acfFields[field]) ? 12 : 6}
key={field}
>
{renderFieldControl(field)}
</Grid>
))}
</Grid>
</>
)}
</>
)}
</Box>
{Object.keys(cardGroups).length > 0 && (
<Box sx={glassCardSx}>
<Typography sx={sectionTitleSx}>
🃏 Cartes & blocs interactifs
</Typography>
<Typography sx={{ color: infoTextColor, mb: 2 }}>
Gérez chaque carte via un panneau dédié avec aperçu visuel,
titres et descriptions WYSIWYG.
</Typography>
<Stack spacing={3}>
{Object.entries(cardGroups).map(([groupKey, group]) => {
const fieldNames = Object.keys(group.fields);
const sortedFields = [...fieldNames].sort(
(a, b) => getCardFieldPriority(a) - getCardFieldPriority(b)
);
const titleField = sortedFields.find((field) =>
field.toLowerCase().includes("titre") ||
field.toLowerCase().includes("title")
);
const imageField = sortedFields.find((field) =>
field.toLowerCase().includes("image")
);
const concatenatedHtml = sortedFields
.map((field) =>
typeof group.fields[field] === "string"
? group.fields[field]
: ""
)
.join(" ");
const embeddedImage = extractFirstImageSrc(concatenatedHtml);
const fallbackImage =
(imageField &&
typeof group.fields[imageField] === "string" &&
group.fields[imageField]) ||
"";
const previewImage =
embeddedImage || fallbackImage || CARD_PLACEHOLDER;
const cardTitlePreview = titleField
? stripHtml(group.fields[titleField]) ||
formatLabel(groupKey)
: formatLabel(groupKey);
return (
<Paper
key={groupKey}
elevation={0}
sx={{
borderRadius: 4,
border: "1px solid rgba(255,255,255,0.25)",
p: { xs: 2, md: 3 },
background: "rgba(8,16,38,0.9)",
}}
>
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
alignItems="stretch"
sx={{ mb: 2 }}
>
<Box flex={1}>
<Typography
variant="h6"
sx={{
fontWeight: 800,
color: "#ffffff",
textShadow: "0 2px 10px rgba(0,0,0,0.45)",
}}
>
{cardTitlePreview || formatLabel(groupKey)}
</Typography>
<Typography
variant="body2"
sx={{ color: infoTextColor }}
>
{formatLabel(groupKey)}
</Typography>
</Box>
<Box
component="img"
src={previewImage}
alt={`${groupKey}-preview`}
sx={{
width: { xs: "100%", md: 220 },
borderRadius: 3,
objectFit: "cover",
border: "1px solid rgba(255,255,255,0.3)",
}}
/>
</Stack>
<Stack spacing={2}>
{sortedFields.map((field) =>
renderFieldControl(field, {
label: `${formatLabel(groupKey)} ${formatLabel(
field
)}`,
key: `${groupKey}-${field}`,
})
)}
</Stack>
</Paper>
);
})}
</Stack>
</Box>
)}
{faqItems.length > 0 && (
<Box sx={glassCardSx}>
<Typography sx={sectionTitleSx}>📌 FAQ</Typography>
<Stack spacing={2}>
{faqItems.map((item, index) => (
<Paper
key={`faq-${index}`}
elevation={0}
sx={{
borderRadius: 3,
border: "1px solid rgba(255,255,255,0.12)",
background: "rgba(255,255,255,0.02)",
p: 2,
}}
>
<Stack spacing={2}>
<TextField
label="Question"
fullWidth
variant="outlined"
value={item.question || ""}
onChange={(e) =>
handleFAQChange(index, "question", e.target.value)
}
InputLabelProps={{ sx: labelSx }}
sx={textFieldSx}
/>
<TextField
label="Réponse"
fullWidth
variant="outlined"
multiline
minRows={3}
value={item.answer || ""}
onChange={(e) =>
handleFAQChange(index, "answer", e.target.value)
}
InputLabelProps={{ sx: labelSx }}
sx={textFieldSx}
/>
<Button
startIcon={<Delete />}
variant="outlined"
color="error"
sx={ghostButtonSx}
onClick={() => deleteFAQ(index)}
>
Supprimer
</Button>
</Stack>
</Paper>
))}
<Button
startIcon={<Add />}
variant="contained"
sx={primaryButtonSx}
onClick={addFAQ}
>
Ajouter une question
</Button>
</Stack>
</Box>
)}
<Box sx={glassCardSx}>
<Typography sx={sectionTitleSx}>
🔍 Métadonnées SEO (Rank Math)
</Typography>
{seoError && (
<Typography color="error" sx={{ mb: 2 }}>
{seoError}
</Typography>
)}
{seoLoading ? (
<Box display="flex" justifyContent="center" py={2}>
<CircularProgress size={32} />
</Box>
) : (
<Stack spacing={2}>
<TextField
label="Mot-clé principal"
fullWidth
variant="outlined"
value={seoFocusKeyword}
onChange={(e) => setSeoFocusKeyword(e.target.value)}
helperText="Mot-clé Rank Math principal (focus keyword)."
InputLabelProps={{ sx: labelSx }}
sx={textFieldSx}
/>
<TextField
label="Meta Title"
fullWidth
variant="outlined"
value={seoTitle}
onChange={(e) => setSeoTitle(e.target.value)}
InputLabelProps={{ sx: labelSx }}
sx={textFieldSx}
/>
<TextField
label="Meta Description"
fullWidth
multiline
minRows={3}
variant="outlined"
value={seoDescription}
onChange={(e) => setSeoDescription(e.target.value)}
InputLabelProps={{ sx: labelSx }}
sx={textFieldSx}
/>
<TextField
label="URL canonique"
fullWidth
variant="outlined"
value={seoCanonical}
onChange={(e) => setSeoCanonical(e.target.value)}
placeholder={pageUrl}
InputLabelProps={{ sx: labelSx }}
sx={textFieldSx}
/>
<TextField
label="Titre OpenGraph"
fullWidth
variant="outlined"
value={seoOgTitle}
onChange={(e) => setSeoOgTitle(e.target.value)}
InputLabelProps={{ sx: labelSx }}
sx={textFieldSx}
/>
<TextField
label="Description OpenGraph"
fullWidth
multiline
minRows={3}
variant="outlined"
value={seoOgDescription}
onChange={(e) => setSeoOgDescription(e.target.value)}
InputLabelProps={{ sx: labelSx }}
sx={textFieldSx}
/>
<Stack spacing={1}>
<TextField
label="Image OpenGraph (URL)"
fullWidth
variant="outlined"
value={seoOgImage}
onChange={(e) => setSeoOgImage(e.target.value)}
placeholder="https://..."
InputLabelProps={{ sx: labelSx }}
sx={textFieldSx}
/>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<Button
variant="outlined"
sx={{ ...ghostButtonSx, flex: 1 }}
onClick={() => setOgDialog({ open: true, source: "upload" })}
>
Importer depuis l'ordinateur
</Button>
<Button
variant="outlined"
sx={{ ...ghostButtonSx, flex: 1 }}
onClick={() => setOgDialog({ open: true, source: "library" })}
>
Choisir dans Cloudinary
</Button>
</Stack>
{seoOgImage && (
<Box
component="img"
src={seoOgImage}
alt="Aperçu OG"
sx={{
width: "100%",
maxHeight: 180,
objectFit: "cover",
borderRadius: 2,
border: "1px solid rgba(255,255,255,0.15)",
}}
/>
)}
</Stack>
<TextField
label="Robots"
fullWidth
variant="outlined"
value={seoRobots}
onChange={(e) => setSeoRobots(e.target.value)}
placeholder="index,follow"
InputLabelProps={{ sx: labelSx }}
sx={textFieldSx}
/>
</Stack>
)}
</Box>
<Box sx={{ ...glassCardSx, textAlign: "center" }}>
<Stack spacing={2}>
<Button
type="submit"
variant="contained"
startIcon={<Save />}
sx={primaryButtonSx}
>
Enregistrer les modifications
</Button>
<Button
variant="outlined"
startIcon={<ArrowBack />}
sx={ghostButtonSx}
onClick={() => navigate("/admin/pages-acf")}
>
Retour à la liste des pages
</Button>
</Stack>
</Box>
</Stack>
</Box>
)}
</Container>
<Dialog open={imageDialog.open} onClose={closeImageDialog} fullWidth maxWidth="md">
<DialogTitle
sx={{
pr: 6,
fontWeight: "bold",
color: "#0b1b3a",
}}
>
{imageDialog.title || "Ajouter une image"}
<IconButton
aria-label="Fermer"
onClick={closeImageDialog}
sx={{ position: "absolute", right: 16, top: 16 }}
>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent dividers>
<Box sx={{ mb: 3 }}>
<TextField
label="Largeur (%)"
type="number"
value={imageDialog.width}
onChange={(e) =>
setImageDialog((prev) => ({
...prev,
width: Math.min(100, Math.max(10, Number(e.target.value) || 100)),
}))
}
InputLabelProps={{ sx: { color: "#0b1b3a" } }}
/>
</Box>
<Box sx={{ mb: 3 }}>
<TextField
label="Texte alternatif (description)"
fullWidth
value={imageDialog.alt}
onChange={(e) =>
setImageDialog((prev) => ({
...prev,
alt: e.target.value,
}))
}
helperText="Décrivez brièvement l'image pour l'accessibilité."
InputLabelProps={{ sx: { color: "#0b1b3a" } }}
/>
</Box>
<Typography variant="subtitle1" sx={{ fontWeight: "bold", mb: 1 }}>
Importer depuis votre ordinateur :
</Typography>
<ImageUploaderCloudinary onUploadSuccess={handleImageSelected} />
<Box sx={{ mt: 4 }}>
<Typography variant="subtitle1" sx={{ fontWeight: "bold", mb: 1 }}>
Ou sélectionner une image existante :
</Typography>
<CloudinaryGallerySelector onSelect={handleImageSelected} />
</Box>
</DialogContent>
</Dialog>
<Dialog
open={ogDialog.open}
onClose={() => setOgDialog({ open: false, source: "" })}
fullWidth
maxWidth="md"
>
<DialogTitle
sx={{
pr: 6,
fontWeight: "bold",
color: "#0b1b3a",
}}
>
Image OpenGraph
<IconButton
aria-label="Fermer"
onClick={() => setOgDialog({ open: false, source: "" })}
sx={{ position: "absolute", right: 16, top: 16 }}
>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent dividers>
{ogDialog.source === "upload" ? (
<ImageUploaderCloudinary
onUploadSuccess={(url) => {
setSeoOgImage(url);
setOgDialog({ open: false, source: "" });
}}
/>
) : (
<CloudinaryGallerySelector
onSelect={(url) => {
setSeoOgImage(url);
setOgDialog({ open: false, source: "" });
}}
/>
)}
</DialogContent>
</Dialog>
<Dialog
open={heroImagePickerOpen}
onClose={() => setHeroImagePickerOpen(false)}
fullWidth
maxWidth="md"
>
<DialogTitle
sx={{
pr: 6,
fontWeight: "bold",
color: "#0b1b3a",
}}
>
Image du hero
<IconButton
aria-label="Fermer"
onClick={() => setHeroImagePickerOpen(false)}
sx={{ position: "absolute", right: 16, top: 16 }}
>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent dividers>
<Typography sx={{ fontWeight: "bold", mb: 1 }}>
Importer depuis votre ordinateur
</Typography>
<ImageUploaderCloudinary onUploadSuccess={handleHeroImageUpload} />
<Box sx={{ mt: 4 }}>
<Typography sx={{ fontWeight: "bold", mb: 1 }}>
Choisir dans Cloudinary
</Typography>
<CloudinaryGallerySelector onSelect={handleHeroImageUpload} />
</Box>
</DialogContent>
</Dialog>
</Box>
);
}
export default EditPageACF;