1655 lines
56 KiB
JavaScript
1655 lines
56 KiB
JavaScript
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, """) || ""}" 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;
|