mise a jour structure visuel BO

This commit is contained in:
sebvtl728 2025-11-06 11:28:07 +01:00
parent 1890d830e1
commit a264f9b430
19 changed files with 4776 additions and 1031 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -1,2 +1,30 @@
# Octopus-React-Wp
## Formulaire de contact mise en route
Le formulaire (`frontend/src/components/Pages/Contact.jsx`) envoie désormais une requête POST vers lAPI de contact.
Suivez ces étapes pour activer lenvoi de-mails:
1. **Configuration backend**
- Copiez `server/cloudinary-backend/.env.example` vers `server/cloudinary-backend/.env`.
- Renseignez vos identifiants SMTP (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, etc.).
- Facultatif : ajustez `CONTACT_RECIPIENT`, `CONTACT_FROM`, ou ajoutez des domaines supplémentaires dans `CORS_ALLOWED_ORIGINS`.
2. **Installer et lancer le serveur contact**
```bash
cd server/cloudinary-backend
npm install
npm start
```
Par défaut, lAPI est disponible sur `http://localhost:3001/api/contact`.
3. **Configuration du front-end**
- Copiez `frontend/.env.local.example` vers `frontend/.env.local`.
- Ajustez `VITE_CONTACT_API_URL` si nécessaire (par défaut `http://localhost:3001/api/contact` en dev).
- Relancez lapplication React :
```bash
cd frontend
npm run dev
```
En production, assurez-vous que lURL définie dans `VITE_CONTACT_API_URL` pointe vers votre API accessible publiquement (ex. `https://octopusdesign.fr/api/contact`) et que le serveur backend est déployé avec les mêmes variables denvironnement SMTP.

View File

@ -0,0 +1,3 @@
# URL of the contact API (backend server).
# In development, point to the local Express server.
VITE_CONTACT_API_URL=http://localhost:3001/api/contact

View File

@ -120,3 +120,255 @@ ul li {
padding: 30px 40px 32px 40px;
font-family: sans-serif;
}
/* Liquid glass dashboard */
.glass-dashboard {
position: relative;
overflow: hidden;
background:
radial-gradient(180px at 4% 2%, rgba(123, 192, 255, 0.35), transparent 65%),
radial-gradient(220px at 94% 8%, rgba(255, 180, 250, 0.32), transparent 70%),
radial-gradient(280px at 50% 100%, rgba(102, 204, 255, 0.22), transparent 72%),
linear-gradient(135deg, #0b1a3d 0%, #1e2c58 45%, #5a3fb6 100%);
}
.glass-orb {
position: absolute;
opacity: 0.58;
border-radius: 50%;
background: rgba(255, 255, 255, 0.25);
pointer-events: none;
filter: blur(0);
mix-blend-mode: screen;
}
.glass-orb.orb-1 {
width: 420px;
height: 420px;
top: -180px;
left: -120px;
background: rgba(118, 161, 255, 0.45);
}
.glass-orb.orb-2 {
width: 320px;
height: 320px;
top: 22%;
right: -140px;
background: rgba(255, 172, 236, 0.36);
}
.glass-orb.orb-3 {
width: 460px;
height: 460px;
bottom: -220px;
left: 32%;
background: rgba(125, 215, 255, 0.28);
}
.glass-panel {
background: rgba(255, 255, 255, 0.22);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
border: 1px solid rgba(255, 255, 255, 0.35);
box-shadow: 0 32px 48px -28px rgba(14, 21, 52, 0.68);
}
.hero-panel {
background: rgba(255, 255, 255, 0.14);
}
.glass-card {
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.16),
rgba(255, 255, 255, 0.08)
) !important;
backdrop-filter: blur(22px) !important;
-webkit-backdrop-filter: blur(22px) !important;
border: 1px solid rgba(255, 255, 255, 0.28) !important;
box-shadow: 0 28px 40px -32px rgba(12, 29, 74, 0.48) !important;
position: relative;
overflow: hidden;
transition: box-shadow 0.3s ease, border-color 0.3s ease,
background 0.3s ease;
}
.glass-card::before {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(
circle at 20% 20%,
rgba(255, 255, 255, 0.28),
transparent 55%
);
opacity: 0.6;
pointer-events: none;
}
.glass-card:hover {
box-shadow: 0 36px 50px -28px rgba(12, 29, 74, 0.56) !important;
border-color: rgba(49, 83, 151, 0.35) !important;
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.22),
rgba(255, 255, 255, 0.12)
) !important;
}
.glass-subcard {
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.6),
rgba(255, 255, 255, 0.42)
) !important;
backdrop-filter: blur(20px) !important;
-webkit-backdrop-filter: blur(20px) !important;
border: 1px solid rgba(255, 255, 255, 0.35) !important;
box-shadow: 0 32px 46px -30px rgba(12, 29, 74, 0.5) !important;
position: relative;
overflow: hidden;
}
.glass-subcard::before {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(
320px at 18% 30%,
rgba(123, 192, 255, 0.12),
transparent 70%
),
radial-gradient(
320px at 82% 12%,
rgba(255, 180, 250, 0.1),
transparent 68%
);
pointer-events: none;
opacity: 0.6;
}
.course-modal {
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
}
.course-modal__hero {
position: relative;
overflow: hidden;
}
.course-modal__hero::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: radial-gradient(
260px at 8% 18%,
rgba(255, 255, 255, 0.35),
transparent 65%
),
radial-gradient(
320px at 92% 12%,
rgba(250, 233, 255, 0.3),
transparent 70%
);
opacity: 0.35;
pointer-events: none;
}
.course-modal__hero-glow {
position: absolute;
width: 420px;
height: 420px;
right: -120px;
bottom: -140px;
background: radial-gradient(
circle,
rgba(255, 255, 255, 0.55) 0%,
rgba(255, 255, 255, 0) 70%
);
pointer-events: none;
}
.course-modal__close {
box-shadow: 0 16px 24px -18px rgba(0, 0, 0, 0.45);
}
.course-modal__steps {
position: relative;
}
.course-modal__step-card {
transition: transform 0.25s ease, box-shadow 0.25s ease;
}
.course-modal__step-card:hover {
transform: translateY(-4px);
box-shadow: 0 32px 36px -28px rgba(12, 29, 74, 0.55);
}
.course-modal__filters {
position: relative;
overflow: hidden;
}
.course-modal__filters::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
135deg,
rgba(123, 192, 255, 0.12),
rgba(255, 180, 250, 0.08)
);
pointer-events: none;
}
.course-modal__section-header {
position: relative;
}
.glass-article-card {
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.68),
rgba(255, 255, 255, 0.5)
) !important;
backdrop-filter: blur(24px) !important;
-webkit-backdrop-filter: blur(24px) !important;
border: 1px solid rgba(255, 255, 255, 0.4) !important;
box-shadow: 0 36px 50px -30px rgba(12, 29, 74, 0.54) !important;
position: relative;
overflow: hidden;
transition: transform 0.35s ease, box-shadow 0.35s ease,
border-color 0.35s ease;
will-change: transform, box-shadow;
}
.glass-article-card::before {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(
480px at 20% 10%,
rgba(255, 255, 255, 0.32),
transparent 65%
),
radial-gradient(
360px at 82% 12%,
rgba(255, 214, 150, 0.2),
transparent 70%
);
opacity: 0.6;
pointer-events: none;
}
.glass-article-card:hover {
transform: translate3d(0, -6px, 0);
box-shadow: 0 44px 56px -30px rgba(12, 29, 74, 0.62);
border-color: rgba(49, 83, 151, 0.42);
z-index: 2;
}

File diff suppressed because it is too large Load Diff

View File

@ -1,20 +1,110 @@
import { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { getPostById, updatePost, uploadImageFromUrl } from "../wordpress"; // utilise uploadImageFromUrl
import { getPostById, updatePost, uploadImageFromUrl } from "../wordpress";
import { getToken } from "../auth";
import {
Box,
Container,
Typography,
TextField,
Button,
Paper,
Grid,
Card,
CardContent,
Stack,
Chip,
Divider,
} from "@mui/material";
import { ArrowBack, Height, Publish } from "@mui/icons-material";
import { ArrowBack, Publish, Image as ImageIcon, History } from "@mui/icons-material";
import ImageUploaderCloudinary from "./ImageUploaderCloudinary";
import CloudinaryGallerySelector from "./CloudinaryGallerySelector";
import ReactQuill from "react-quill";
import "react-quill/dist/quill.snow.css";
import "../assets/styleCours.css";
const BUTTON_BASE_SX = Object.freeze({
borderRadius: 999,
textTransform: "none",
fontWeight: 600,
letterSpacing: 0.3,
display: "inline-flex",
alignItems: "center",
gap: 0.75,
transition:
"transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease, border-color 0.2s ease",
"&:hover": {
transform: "translateY(-1px)",
},
"&:active": {
transform: "translateY(0)",
},
});
const BUTTON_SIZE_SX = Object.freeze({
small: {
px: 2.1,
py: 0.55,
fontSize: "0.78rem",
minHeight: 30,
},
medium: {
px: 2.8,
py: 0.8,
fontSize: "0.86rem",
minHeight: 36,
},
large: {
px: 3.4,
py: 1,
fontSize: "0.94rem",
minHeight: 42,
},
});
const BUTTON_VARIANT_SX = Object.freeze({
primary: {
background: "linear-gradient(135deg, #315397, #7bc0ff)",
color: "#ffffff",
border: "1px solid rgba(255,255,255,0.35)",
boxShadow: "0 18px 28px -18px rgba(11, 26, 61, 0.55)",
"&:hover": {
background: "linear-gradient(135deg, #26467d, #6fb6ff)",
boxShadow: "0 22px 32px -18px rgba(11, 26, 61, 0.6)",
},
"&.Mui-disabled": {
color: "rgba(255,255,255,0.72)",
background:
"linear-gradient(135deg, rgba(49,83,151,0.35), rgba(123,192,255,0.35))",
boxShadow: "none",
border: "1px solid rgba(49,83,151,0.18)",
},
},
ghost: {
background: "rgba(255,255,255,0.16)",
color: "rgba(255,255,255,0.92)",
border: "1px solid rgba(255,255,255,0.28)",
backdropFilter: "blur(8px)",
"&:hover": {
background: "rgba(255,255,255,0.24)",
boxShadow: "0 18px 28px -18px rgba(6, 18, 38, 0.45)",
},
},
outline: {
background: "rgba(255,255,255,0.84)",
color: "#315397",
border: "1px solid rgba(49,83,151,0.26)",
"&:hover": {
background: "rgba(255,255,255,0.96)",
borderColor: "rgba(49,83,151,0.46)",
boxShadow: "0 14px 22px -18px rgba(11, 26, 61, 0.4)",
},
},
});
const composeButtonSx = (variant, size = "medium") => ({
...BUTTON_BASE_SX,
...(BUTTON_SIZE_SX[size] || BUTTON_SIZE_SX.medium),
...(BUTTON_VARIANT_SX[variant] || BUTTON_VARIANT_SX.primary),
});
function EditPost() {
const { id } = useParams();
@ -23,6 +113,13 @@ function EditPost() {
const [content, setContent] = useState("");
const [imageUrl, setImageUrl] = useState(null); // preview
const [imageFile, setImageFile] = useState(null); // pour upload
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [postMeta, setPostMeta] = useState({
date: null,
modified: null,
status: "",
});
// Auth
useEffect(() => {
@ -33,13 +130,21 @@ function EditPost() {
useEffect(() => {
const fetchPost = async () => {
try {
setLoading(true);
const post = await getPostById(id);
setTitle(post.title.rendered);
setContent(post.content.rendered);
setImageUrl(post?.jetpack_featured_media_url || null); // preview actuelle
} catch (err) {
console.error("Erreur chargement article :", err);
setImageUrl(post?.jetpack_featured_media_url || null);
setPostMeta({
date: post.date || null,
modified: post.modified || null,
status: post.status || "",
});
} catch (error) {
console.error("Erreur chargement article :", error);
navigate("/admin/gestion-articles");
} finally {
setLoading(false);
}
};
fetchPost();
@ -62,82 +167,233 @@ function EditPost() {
],
};
const handleUpdate = async (e) => {
e.preventDefault();
try {
let imageId = null;
const canSubmit =
title.trim().length > 0 && content.trim().length > 0 && Boolean(imageUrl);
if (imageFile) {
// upload image vers WordPress
const handleUpdate = async (event) => {
event.preventDefault();
if (!canSubmit || submitting) {
return;
}
try {
setSubmitting(true);
let imageId = undefined;
if (imageFile || imageUrl) {
imageId = await uploadImageFromUrl(imageUrl);
}
await updatePost(id, title, content, imageId);
alert("✅ Article mis à jour !");
navigate("/admin/gestion-articles");
} catch (error) {
console.error("❌ Erreur update :", error);
alert("❌ Erreur mise à jour.");
} finally {
setSubmitting(false);
}
};
return (
<Box
className="glass-dashboard"
sx={{
minHeight: "100vh",
display: "flex",
justifyContent: "center",
alignItems: "center",
position: "relative",
px: { xs: 2, md: 6 },
pt: { xs: 6, md: 8 },
pb: { xs: 8, md: 10 },
}}
>
<Container maxWidth="lg">
<Paper elevation={6} sx={{ p: 4, borderRadius: 3 }}>
<Box className="glass-orb orb-1" />
<Box className="glass-orb orb-2" />
<Box className="glass-orb orb-3" />
<Box sx={{ position: "relative", zIndex: 1, maxWidth: 1180, mx: "auto" }}>
<Stack
direction={{ xs: "column", md: "row" }}
justifyContent="space-between"
alignItems={{ xs: "flex-start", md: "center" }}
spacing={2}
sx={{ mb: 3 }}
>
<Button
onClick={() => navigate("/admin/gestion-articles")}
variant="outlined"
variant="contained"
startIcon={<ArrowBack />}
sx={composeButtonSx("ghost")}
>
Retour
Retour aux articles
</Button>
<Typography variant="h4" align="center" sx={{ mb: 3 }}>
Modifier l'article
<Stack spacing={0.5}>
<Typography
variant="overline"
sx={{
letterSpacing: 2,
fontWeight: 700,
color: "rgba(255,255,255,0.8)",
}}
>
Publication Octopus
</Typography>
<Typography
variant="h3"
sx={{
color: "#ffffff",
fontWeight: 800,
letterSpacing: "-0.5px",
}}
>
Modifier larticle
</Typography>
<form onSubmit={handleUpdate}>
<TextField
label="Titre"
fullWidth
value={title}
onChange={(e) => setTitle(e.target.value)}
sx={{ mb: 2 }}
/>
<Typography
variant="body1"
sx={{ mt: 2, mb: 2, fontWeight: "bold" }}
sx={{
color: "rgba(255,255,255,0.85)",
maxWidth: 520,
}}
>
Contenu de l'article :
Ajustez votre contenu, remplacez le visuel mis en avant et publiez
les mises à jour directement sur le site Octopus.
</Typography>
</Stack>
</Stack>
<Box sx={{ height: 250, mb: 7 }}>
<Grid container spacing={3.5}>
<Grid item xs={12} lg={7}>
<Card
className="glass-subcard"
sx={{
borderRadius: 4,
p: { xs: 2.5, md: 3 },
boxShadow: "0 28px 36px -28px rgba(12, 29, 74, 0.5)",
}}
>
<CardContent sx={{ p: 0 }}>
<Stack spacing={2}>
<Stack
direction="row"
spacing={1}
alignItems="center"
sx={{ flexWrap: "wrap" }}
>
<Chip
icon={<History fontSize="small" />}
label={
postMeta.modified
? `Modifié le ${new Intl.DateTimeFormat("fr-FR", {
day: "numeric",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(postMeta.modified))}`
: "Modification en cours"
}
sx={{
bgcolor: "rgba(255,255,255,0.78)",
color: "#0b1a3d",
fontWeight: 600,
}}
/>
<Chip
label={
postMeta.status === "publish"
? "Publiée"
: postMeta.status === "draft"
? "Brouillon"
: postMeta.status || "Statut inconnu"
}
color={
postMeta.status === "publish"
? "success"
: postMeta.status === "draft"
? "warning"
: "default"
}
sx={{ fontWeight: 600 }}
/>
</Stack>
{loading ? (
<Typography variant="body2" sx={{ color: "rgba(11, 26, 61, 0.65)" }}>
Chargement de larticle
</Typography>
) : (
<Box component="form" onSubmit={handleUpdate} noValidate>
<TextField
label="Titre de l'article"
fullWidth
variant="outlined"
value={title}
onChange={(event) => setTitle(event.target.value)}
required
sx={{
mb: 3,
"& .MuiOutlinedInput-root": {
borderRadius: 2,
backgroundColor: "rgba(255,255,255,0.94)",
},
}}
/>
<Box
sx={{
mb: 3,
borderRadius: 2,
overflow: "hidden",
border: "1px solid rgba(49,83,151,0.15)",
backgroundColor: "rgba(255,255,255,0.92)",
}}
>
<ReactQuill
theme="snow"
value={content}
onChange={setContent}
modules={modules}
style={{ height: "100%" }}
style={{ height: 280 }}
/>
</Box>
<Typography fontWeight="bold">Image depuis ton ordi :</Typography>
<Stack spacing={2.5}>
<Box>
<Typography
variant="subtitle1"
sx={{ fontWeight: 700, color: "#0b1a3d" }}
>
Remplacer le visuel
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)" }}
>
Importez une nouvelle image optimisée pour vos
contenus.
</Typography>
<Box sx={{ mt: 2 }}>
<ImageUploaderCloudinary
onUploadSuccess={(url) => {
setImageUrl(url);
convertUrlToFile(url).then(setImageFile);
}}
/>
</Box>
</Box>
<Typography fontWeight="bold" sx={{ mt: 3 }}>
Ou choisir une image déjà envoyée :
<Divider
sx={{ borderColor: "rgba(11, 26, 61, 0.08)" }}
/>
<Box>
<Typography
variant="subtitle1"
sx={{ fontWeight: 700, color: "#0b1a3d" }}
>
Sélectionner depuis la médiathèque
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)", mb: 2 }}
>
Choisissez un visuel existant dans Cloudinary.
</Typography>
<CloudinaryGallerySelector
onSelect={async (url) => {
@ -146,35 +402,157 @@ function EditPost() {
setImageFile(file);
}}
/>
{imageUrl && (
<Box sx={{ mt: 2 }}>
<Typography variant="body2">Aperçu :</Typography>
<img
src={imageUrl}
style={{
width: "100%",
borderRadius: 6,
maxHeight: 200,
objectFit: "cover",
}}
alt="preview"
/>
</Box>
)}
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={1.5}
sx={{ pt: 1 }}
>
<Button
type="submit"
variant="contained"
fullWidth
sx={{ mt: 3 }}
startIcon={<Publish />}
disabled={!canSubmit || submitting}
sx={{
...composeButtonSx("primary"),
minWidth: 220,
}}
>
Mettre à jour
{submitting ? "Enregistrement…" : "Mettre à jour"}
</Button>
</form>
</Paper>
</Container>
<Button
variant="contained"
sx={composeButtonSx("outline")}
onClick={() => {
setTitle("");
setContent("");
setImageUrl(null);
setImageFile(null);
}}
>
Réinitialiser
</Button>
</Stack>
</Stack>
</Box>
)}
</Stack>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} lg={5}>
<Stack spacing={3}>
<Card
className="glass-subcard"
sx={{
borderRadius: 4,
p: { xs: 2.5, md: 3 },
boxShadow: "0 28px 36px -28px rgba(12, 29, 74, 0.45)",
}}
>
<Stack direction="row" spacing={2} alignItems="flex-start">
<Box
sx={{
width: 48,
height: 48,
borderRadius: 2,
bgcolor: "rgba(123,192,255,0.22)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#315397",
}}
>
<ImageIcon />
</Box>
<Box>
<Typography
variant="subtitle1"
sx={{ fontWeight: 700, color: "#0b1a3d", mb: 1 }}
>
Rappels éditoriaux
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.68)" }}
>
Vérifiez la cohérence des titres et sous-titres.<br />
Ajoutez des liens internes vers vos formations Octopus.<br />
Noubliez pas de relire la mise en forme dans laperçu
WordPress.
</Typography>
</Box>
</Stack>
</Card>
<Card
className="glass-article-card"
sx={{
borderRadius: 4,
overflow: "hidden",
minHeight: 220,
}}
>
<Box
sx={{
backgroundColor: "rgba(255,255,255,0.9)",
p: { xs: 2.5, md: 3 },
display: "flex",
flexDirection: "column",
height: "100%",
gap: 1.5,
}}
>
<Typography
variant="subtitle1"
sx={{ fontWeight: 700, color: "#0b1a3d" }}
>
Aperçu visuel
</Typography>
{imageUrl ? (
<Box
component="img"
src={imageUrl}
alt="Aperçu de limage sélectionnée"
sx={{
width: "100%",
borderRadius: 3,
height: 200,
objectFit: "cover",
boxShadow: "0 20px 28px -24px rgba(12, 29, 74, 0.48)",
}}
/>
) : (
<Box
sx={{
flexGrow: 1,
borderRadius: 3,
border: "1px dashed rgba(49,83,151,0.35)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "rgba(49,83,151,0.6)",
fontStyle: "italic",
}}
>
Aucun visuel sélectionné pour le moment
</Box>
)}
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)" }}
>
Ce visuel illustrera larticle dans la liste des publications
et sur les réseaux sociaux.
</Typography>
</Box>
</Card>
</Stack>
</Grid>
</Grid>
</Box>
</Box>
);
}

View File

@ -1,28 +1,128 @@
import React, { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { getToken } from "../auth";
import { getPageById, updatePageACF } from "../wordpress";
import {
Box,
Container,
Typography,
TextField,
Button,
Paper,
Grid,
Card,
CardContent,
Stack,
Divider,
CircularProgress,
} from "@mui/material";
import { Add, Delete, ArrowBack, Save } from "@mui/icons-material";
import ReactQuill from "react-quill";
import "react-quill/dist/quill.snow.css";
import "../assets/styleCours.css";
const ETUDE_PAGE_ID = 272;
const BUTTON_BASE_SX = Object.freeze({
borderRadius: 999,
textTransform: "none",
fontWeight: 600,
letterSpacing: 0.3,
display: "inline-flex",
alignItems: "center",
gap: 0.75,
transition:
"transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease, border-color 0.2s ease",
"&:hover": {
transform: "translateY(-1px)",
},
"&:active": {
transform: "translateY(0)",
},
});
const BUTTON_SIZE_SX = Object.freeze({
small: {
px: 2.1,
py: 0.55,
fontSize: "0.78rem",
minHeight: 30,
},
medium: {
px: 2.8,
py: 0.8,
fontSize: "0.86rem",
minHeight: 36,
},
large: {
px: 3.4,
py: 1,
fontSize: "0.94rem",
minHeight: 42,
},
});
const BUTTON_VARIANT_SX = Object.freeze({
primary: {
background: "linear-gradient(135deg, #315397, #7bc0ff)",
color: "#ffffff",
border: "1px solid rgba(255,255,255,0.28)",
boxShadow: "0 18px 28px -18px rgba(11, 26, 61, 0.55)",
"&:hover": {
background: "linear-gradient(135deg, #26467d, #6fb6ff)",
boxShadow: "0 22px 32px -18px rgba(11, 26, 61, 0.62)",
},
"&.Mui-disabled": {
color: "rgba(255,255,255,0.72)",
background:
"linear-gradient(135deg, rgba(49,83,151,0.35), rgba(123,192,255,0.35))",
boxShadow: "none",
border: "1px solid rgba(49,83,151,0.18)",
},
},
ghost: {
background: "rgba(255,255,255,0.16)",
color: "rgba(255,255,255,0.92)",
border: "1px solid rgba(255,255,255,0.24)",
backdropFilter: "blur(8px)",
"&:hover": {
background: "rgba(255,255,255,0.24)",
boxShadow: "0 18px 28px -18px rgba(6, 18, 38, 0.45)",
},
},
outline: {
background: "rgba(255,255,255,0.84)",
color: "#315397",
border: "1px solid rgba(49,83,151,0.26)",
"&:hover": {
background: "rgba(255,255,255,0.95)",
borderColor: "rgba(49,83,151,0.46)",
boxShadow: "0 14px 22px -18px rgba(11, 26, 61, 0.4)",
},
},
danger: {
background: "linear-gradient(135deg, #f05b6b, #ff8a80)",
color: "#3a0a0f",
border: "1px solid rgba(240,91,107,0.35)",
boxShadow: "0 18px 26px -18px rgba(105, 21, 33, 0.45)",
"&:hover": {
background: "linear-gradient(135deg, #e34659, #ff7575)",
boxShadow: "0 22px 30px -18px rgba(105, 21, 33, 0.52)",
},
},
});
const composeButtonSx = (variant, size = "medium") => ({
...BUTTON_BASE_SX,
...(BUTTON_SIZE_SX[size] || BUTTON_SIZE_SX.medium),
...(BUTTON_VARIANT_SX[variant] || BUTTON_VARIANT_SX.primary),
});
function EditPageEtude() {
const navigate = useNavigate();
const [acfFields, setAcfFields] = useState({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [successMessage, setSuccessMessage] = useState("");
const [submitting, setSubmitting] = useState(false);
// Vérifie l'authentification
useEffect(() => {
@ -50,15 +150,22 @@ function EditPageEtude() {
}, []);
// Mise à jour ACF
const handleUpdateACF = async (e) => {
e.preventDefault();
const handleUpdateACF = async (event) => {
event.preventDefault();
if (submitting) {
return;
}
try {
setSubmitting(true);
await updatePageACF(ETUDE_PAGE_ID, acfFields);
setSuccessMessage("✅ Modifications enregistrées !");
setError(null);
setTimeout(() => navigate("/admin/dashboard"), 2000);
} catch {
setTimeout(() => navigate("/admin/dashboard"), 1800);
} catch (err) {
console.error("⚠ Impossible de mettre à jour les champs ACF :", err);
setError("⚠ Impossible de mettre à jour les champs ACF.");
} finally {
setSubmitting(false);
}
};
@ -98,124 +205,464 @@ function EditPageEtude() {
setAcfFields(prev => ({ ...prev, [field]: updated }));
};
if (loading) return <Typography>Chargement...</Typography>;
const stats = useMemo(() => {
const toArray = (value) =>
Array.isArray(value)
? value
: value
? Object.values(value)
: [];
const competencies = toArray(acfFields.liste_des_competences);
const expertises = toArray(acfFields.expertises_specifiques);
return {
competencies: competencies.length,
expertises: expertises.length,
};
}, [acfFields]);
if (loading) {
return (
<Box
className="glass-dashboard"
sx={{
minHeight: "100vh",
position: "relative",
display: "flex",
alignItems: "center",
justifyContent: "center",
px: { xs: 2, md: 6 },
pt: { xs: 6, md: 8 },
pb: { xs: 8, md: 10 },
}}
>
<Box className="glass-orb orb-1" />
<Box className="glass-orb orb-2" />
<Box className="glass-orb orb-3" />
<Stack spacing={2} alignItems="center" sx={{ position: "relative", zIndex: 1 }}>
<CircularProgress color="inherit" />
<Typography variant="body1" sx={{ color: "#ffffff" }}>
Chargement de la page Bureau détude
</Typography>
</Stack>
</Box>
);
}
return (
<Box sx={{ py: 4, backgroundColor: "#f5f5f5" }}>
<Container maxWidth="md">
<Paper sx={{ p: 4, borderRadius: 3 }} elevation={4}>
<Box
className="glass-dashboard"
sx={{
minHeight: "100vh",
position: "relative",
px: { xs: 2, md: 6 },
pt: { xs: 6, md: 8 },
pb: { xs: 8, md: 10 },
}}
>
<Box className="glass-orb orb-1" />
<Box className="glass-orb orb-2" />
<Box className="glass-orb orb-3" />
<Box sx={{ position: "relative", zIndex: 1, maxWidth: 1180, mx: "auto" }}>
<Stack
direction={{ xs: "column", md: "row" }}
justifyContent="space-between"
alignItems={{ xs: "flex-start", md: "center" }}
spacing={2}
sx={{ mb: 3 }}
>
<Button
startIcon={<ArrowBack />}
variant="outlined"
onClick={() => navigate("/admin/dashboard")}
sx={{ mb: 2 }}
variant="contained"
sx={composeButtonSx("ghost")}
>
Retour
Retour au dashboard
</Button>
<Typography variant="h4" sx={{ fontWeight: "bold", mb: 3, textAlign: "center" }}>
Modifier la Page Formations
<Stack spacing={0.5}>
<Typography
variant="overline"
sx={{
letterSpacing: 2,
fontWeight: 700,
color: "rgba(255,255,255,0.8)",
}}
>
Bureau détude Octopus
</Typography>
<Typography
variant="h3"
sx={{
color: "#ffffff",
fontWeight: 800,
letterSpacing: "-0.5px",
}}
>
Gestion des contenus ACF
</Typography>
<Typography
variant="body1"
sx={{
color: "rgba(255,255,255,0.85)",
maxWidth: 520,
}}
>
Mettez à jour lintroduction, la méthodologie et les expertises du
bureau détude pour garder des informations pertinentes et à jour.
</Typography>
</Stack>
</Stack>
{error && <Typography color="error" sx={{ mb: 2 }}>{error}</Typography>}
{successMessage && <Typography color="success.main" sx={{ mb: 2, textAlign: "center" }}>{successMessage}</Typography>}
<form onSubmit={handleUpdateACF}>
<Grid container spacing={3}>
{/* Introduction */}
<Grid item xs={12}>
<Typography variant="h6">Introduction</Typography>
<ReactQuill
value={acfFields.introduction || ""}
onChange={(val) => handleFieldChange("introduction", val)}
/>
<Grid container spacing={2.5} sx={{ mb: 4 }}>
<Grid item xs={12} md={6}>
<Card className="glass-subcard" sx={{ borderRadius: 4, p: 2.5 }}>
<Typography
variant="caption"
sx={{ color: "rgba(11, 26, 61, 0.65)", fontWeight: 600 }}
>
Compétences listées
</Typography>
<Typography
variant="h4"
sx={{ color: "#0b1a3d", fontWeight: 800, letterSpacing: "-0.5px" }}
>
{stats.competencies}
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)", mt: 0.5 }}
>
Ajoutez des compétences pour valoriser lexpertise Octopus.
</Typography>
</Card>
</Grid>
<Grid item xs={12} md={6}>
<Card className="glass-subcard" sx={{ borderRadius: 4, p: 2.5 }}>
<Typography
variant="caption"
sx={{ color: "rgba(11, 26, 61, 0.65)", fontWeight: 600 }}
>
Expertises spécifiques
</Typography>
<Typography
variant="h4"
sx={{ color: "#0b1a3d", fontWeight: 800, letterSpacing: "-0.5px" }}
>
{stats.expertises}
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)", mt: 0.5 }}
>
Développez vos expertises pour renforcer la crédibilité de léquipe.
</Typography>
</Card>
</Grid>
</Grid>
{/* Méthodologie */}
<Grid item xs={12}>
<Typography variant="h6">Méthodologie</Typography>
{error ? (
<Card
className="glass-subcard"
sx={{ borderRadius: 4, p: 3, mb: 3, borderColor: "rgba(240,91,107,0.35) !important" }}
>
<Typography color="error" sx={{ fontWeight: 600 }}>
{error}
</Typography>
</Card>
) : null}
{successMessage ? (
<Card
className="glass-subcard"
sx={{ borderRadius: 4, p: 3, mb: 3, borderColor: "rgba(123,192,255,0.45) !important" }}
>
<Typography sx={{ color: "#0b8043", fontWeight: 600 }}>
{successMessage}
</Typography>
</Card>
) : null}
<Box component="form" onSubmit={handleUpdateACF}>
<Stack spacing={3.5}>
<Card className="glass-subcard" sx={{ borderRadius: 4 }}>
<CardContent sx={{ p: { xs: 2.5, md: 3 } }}>
<Stack spacing={2.5}>
<Box>
<Typography
variant="h6"
sx={{ color: "#0b1a3d", fontWeight: 700, mb: 1 }}
>
Introduction
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)", mb: 1.5 }}
>
Présentez la vision globale du bureau détude et son rôle
au sein des formations Octopus.
</Typography>
<Box
sx={{
borderRadius: 2,
border: "1px solid rgba(49,83,151,0.15)",
backgroundColor: "rgba(255,255,255,0.92)",
}}
>
<ReactQuill
value={acfFields.introduction || ""}
onChange={(value) => handleFieldChange("introduction", value)}
style={{ height: 220 }}
/>
</Box>
</Box>
<Divider sx={{ borderColor: "rgba(11, 26, 61, 0.08)" }} />
<Box>
<Typography
variant="h6"
sx={{ color: "#0b1a3d", fontWeight: 700, mb: 1 }}
>
Méthodologie
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)", mb: 1.5 }}
>
Décrivez en quelques lignes la manière dont le bureau
détude accompagne les clients et partenaires.
</Typography>
<TextField
fullWidth
value={acfFields.methodologie || ""}
onChange={(e) => handleFieldChange("methodologie", e.target.value)}
onChange={(event) =>
handleFieldChange("methodologie", event.target.value)
}
multiline
minRows={3}
sx={{
"& .MuiOutlinedInput-root": {
borderRadius: 2,
backgroundColor: "rgba(255,255,255,0.94)",
},
}}
/>
</Grid>
</Box>
</Stack>
</CardContent>
</Card>
{/* Liste des compétences */}
<Grid item xs={12}>
<Typography variant="h6">Liste des compétences</Typography>
{getRepeater("liste_des_competences").map((item, i) => (
<Paper key={i} sx={{ p:2, mb:2 }}>
<Card className="glass-subcard" sx={{ borderRadius: 4 }}>
<CardContent sx={{ p: { xs: 2.5, md: 3 } }}>
<Stack spacing={2.5}>
<Box>
<Typography
variant="h6"
sx={{ color: "#0b1a3d", fontWeight: 700, mb: 1 }}
>
Liste des compétences
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)", mb: 1.5 }}
>
Ajoutez les compétences clés qui démontrent lexpertise du
bureau détude. Chaque compétence peut contenir un titre et
une description détaillée.
</Typography>
</Box>
<Stack spacing={2}>
{getRepeater("liste_des_competences").map((item, index) => (
<Card
key={index}
className="glass-card"
sx={{ borderRadius: 3, p: { xs: 2, md: 2.5 } }}
>
<Stack spacing={1.5}>
<TextField
label="Titre"
fullWidth
value={item.comp_titre}
onChange={(e) => handleRepeaterChange("liste_des_competences", i, "comp_titre", e.target.value)}
sx={{ mb:1 }}
onChange={(event) =>
handleRepeaterChange(
"liste_des_competences",
index,
"comp_titre",
event.target.value
)
}
sx={{
"& .MuiOutlinedInput-root": {
borderRadius: 2,
backgroundColor: "rgba(255,255,255,0.9)",
},
}}
/>
<TextField
label="Description"
fullWidth
multiline rows={3}
multiline
minRows={3}
value={item.comp_description}
onChange={(e) => handleRepeaterChange("liste_des_competences", i, "comp_description", e.target.value)}
sx={{ mb:1 }}
onChange={(event) =>
handleRepeaterChange(
"liste_des_competences",
index,
"comp_description",
event.target.value
)
}
sx={{
"& .MuiOutlinedInput-root": {
borderRadius: 2,
backgroundColor: "rgba(255,255,255,0.9)",
},
}}
/>
<Button startIcon={<Delete />} variant="outlined" color="error" onClick={() => deleteRepeaterItem("liste_des_competences", i)}>
<Button
startIcon={<Delete />}
onClick={() =>
deleteRepeaterItem("liste_des_competences", index)
}
variant="contained"
sx={composeButtonSx("danger", "small")}
>
Supprimer
</Button>
</Paper>
</Stack>
</Card>
))}
<Button startIcon={<Add />} variant="contained" onClick={() => addRepeaterItem("liste_des_competences", { comp_titre: "", comp_description: "" })}>
</Stack>
<Button
startIcon={<Add />}
variant="contained"
onClick={() =>
addRepeaterItem("liste_des_competences", {
comp_titre: "",
comp_description: "",
})
}
sx={composeButtonSx("primary", "small")}
>
Ajouter une compétence
</Button>
</Grid>
</Stack>
</CardContent>
</Card>
{/* Expertises spécifiques */}
<Grid item xs={12}>
<Typography variant="h6">Expertises spécifiques</Typography>
{getRepeater("expertises_specifiques").map((item, i) => (
<Paper key={i} sx={{ p:2, mb:2 }}>
<Card className="glass-subcard" sx={{ borderRadius: 4 }}>
<CardContent sx={{ p: { xs: 2.5, md: 3 } }}>
<Stack spacing={2.5}>
<Box>
<Typography
variant="h6"
sx={{ color: "#0b1a3d", fontWeight: 700, mb: 1 }}
>
Expertises spécifiques
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)", mb: 1.5 }}
>
Décrivez les expertises métiers ou sectorielles qui
distinguent votre bureau détude.
</Typography>
</Box>
<Stack spacing={2}>
{getRepeater("expertises_specifiques").map((item, index) => (
<Card
key={index}
className="glass-card"
sx={{ borderRadius: 3, p: { xs: 2, md: 2.5 } }}
>
<Stack spacing={1.5}>
<TextField
label="Nom"
label="Nom de lexpertise"
fullWidth
value={item.expertise_nom}
onChange={(e) => handleRepeaterChange("expertises_specifiques", i, "expertise_nom", e.target.value)}
sx={{ mb:1 }}
onChange={(event) =>
handleRepeaterChange(
"expertises_specifiques",
index,
"expertise_nom",
event.target.value
)
}
sx={{
"& .MuiOutlinedInput-root": {
borderRadius: 2,
backgroundColor: "rgba(255,255,255,0.9)",
},
}}
/>
<TextField
label="Détails"
fullWidth multiline rows={2}
fullWidth
multiline
minRows={2}
value={item.expertise_details}
onChange={(e) => handleRepeaterChange("expertises_specifiques", i, "expertise_details", e.target.value)}
sx={{ mb:1 }}
onChange={(event) =>
handleRepeaterChange(
"expertises_specifiques",
index,
"expertise_details",
event.target.value
)
}
sx={{
"& .MuiOutlinedInput-root": {
borderRadius: 2,
backgroundColor: "rgba(255,255,255,0.9)",
},
}}
/>
<Button startIcon={<Delete />} variant="outlined" color="error" onClick={() => deleteRepeaterItem("expertises_specifiques", i)}>
<Button
startIcon={<Delete />}
onClick={() =>
deleteRepeaterItem("expertises_specifiques", index)
}
variant="contained"
sx={composeButtonSx("danger", "small")}
>
Supprimer
</Button>
</Paper>
</Stack>
</Card>
))}
<Button startIcon={<Add />} variant="contained" onClick={() => addRepeaterItem("expertises_specifiques", { expertise_nom: "", expertise_details: "" })}>
</Stack>
<Button
startIcon={<Add />}
variant="contained"
onClick={() =>
addRepeaterItem("expertises_specifiques", {
expertise_nom: "",
expertise_details: "",
})
}
sx={composeButtonSx("primary", "small")}
>
Ajouter une expertise
</Button>
</Grid>
</Stack>
</CardContent>
</Card>
{/* Save */}
<Grid item xs={12}>
<Button
type="submit"
fullWidth
variant="contained"
startIcon={<Save />}
sx={{ py:1.5 }}
disabled={submitting}
sx={{ ...composeButtonSx("primary"), alignSelf: "flex-end", minWidth: 220 }}
>
Enregistrer
{submitting ? "Enregistrement…" : "Enregistrer les modifications"}
</Button>
</Grid>
</Grid>
</form>
</Paper>
</Container>
</Stack>
</Box>
</Box>
</Box>
);
}

View File

@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { getToken } from "../auth";
import { fetchPages } from "../wordpress";
@ -6,24 +6,109 @@ import {
Box,
Typography,
Button,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Grid,
Card,
TextField,
Stack,
CircularProgress,
} from "@mui/material";
import { Edit, ArrowBack, Add } from "@mui/icons-material";
import "../assets/styleCours.css";
const HOME_PAGE_ID = 13; // Remplace 13 par l'ID réel de la page d'accueil
const ETUDE_PAGE_ID = 272; // Remplace 13 par l'ID réel de la page d'accueil
const BUTTON_BASE_SX = Object.freeze({
borderRadius: 999,
textTransform: "none",
fontWeight: 600,
letterSpacing: 0.3,
display: "inline-flex",
alignItems: "center",
gap: 0.75,
transition:
"transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease, border-color 0.2s ease",
"&:hover": {
transform: "translateY(-1px)",
},
"&:active": {
transform: "translateY(0)",
},
});
const BUTTON_SIZE_SX = Object.freeze({
small: {
px: 2.1,
py: 0.55,
fontSize: "0.78rem",
minHeight: 30,
},
medium: {
px: 2.8,
py: 0.8,
fontSize: "0.86rem",
minHeight: 36,
},
large: {
px: 3.4,
py: 1,
fontSize: "0.94rem",
minHeight: 42,
},
});
const BUTTON_VARIANT_SX = Object.freeze({
primary: {
background: "linear-gradient(135deg, #315397, #7bc0ff)",
color: "#ffffff",
border: "1px solid rgba(255,255,255,0.28)",
boxShadow: "0 18px 28px -18px rgba(11, 26, 61, 0.55)",
"&:hover": {
background: "linear-gradient(135deg, #26467d, #6fb6ff)",
boxShadow: "0 22px 32px -18px rgba(11, 26, 61, 0.62)",
},
"&.Mui-disabled": {
color: "rgba(255,255,255,0.72)",
background:
"linear-gradient(135deg, rgba(49,83,151,0.35), rgba(123,192,255,0.35))",
boxShadow: "none",
border: "1px solid rgba(49,83,151,0.18)",
},
},
ghost: {
background: "rgba(255,255,255,0.16)",
color: "rgba(255,255,255,0.92)",
border: "1px solid rgba(255,255,255,0.24)",
backdropFilter: "blur(8px)",
"&:hover": {
background: "rgba(255,255,255,0.24)",
boxShadow: "0 18px 28px -18px rgba(6, 18, 38, 0.45)",
},
},
outline: {
background: "rgba(255,255,255,0.84)",
color: "#315397",
border: "1px solid rgba(49,83,151,0.26)",
"&:hover": {
background: "rgba(255,255,255,0.95)",
borderColor: "rgba(49,83,151,0.46)",
boxShadow: "0 14px 22px -18px rgba(11, 26, 61, 0.4)",
},
},
});
const composeButtonSx = (variant, size = "medium") => ({
...BUTTON_BASE_SX,
...(BUTTON_SIZE_SX[size] || BUTTON_SIZE_SX.medium),
...(BUTTON_VARIANT_SX[variant] || BUTTON_VARIANT_SX.primary),
});
function GestionPagesACF() {
const navigate = useNavigate();
const [pages, setPages] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [searchTerm, setSearchTerm] = useState("");
// Vérifier l'authentification
useEffect(() => {
@ -49,7 +134,6 @@ function GestionPagesACF() {
return;
}
// Exclure la page d'accueil
const filteredPages = response.filter(
(page) => page.id !== HOME_PAGE_ID && page.id !== ETUDE_PAGE_ID,
);
@ -65,88 +149,333 @@ function GestionPagesACF() {
loadPages();
}, []);
if (loading) return <Typography>Chargement...</Typography>;
if (error) return <Typography color="error">{error}</Typography>;
const filteredPages = useMemo(() => {
const term = searchTerm.trim().toLowerCase();
if (!term) {
return pages;
}
return pages.filter((page) =>
page.title?.rendered?.toLowerCase?.().includes(term),
);
}, [pages, searchTerm]);
if (loading) {
return (
<Box sx={{ padding: "40px 20px", mt: 6 }}>
<Box
className="glass-dashboard"
sx={{
minHeight: "100vh",
position: "relative",
display: "flex",
alignItems: "center",
justifyContent: "center",
px: { xs: 2, md: 6 },
pt: { xs: 6, md: 8 },
pb: { xs: 8, md: 10 },
}}
>
<Box className="glass-orb orb-1" />
<Box className="glass-orb orb-2" />
<Box className="glass-orb orb-3" />
<Stack spacing={2} alignItems="center" sx={{ position: "relative", zIndex: 1 }}>
<CircularProgress color="inherit" />
<Typography variant="body1" sx={{ color: "#ffffff" }}>
Chargement des pages services
</Typography>
</Stack>
</Box>
);
}
if (error) {
return (
<Box
className="glass-dashboard"
sx={{
minHeight: "100vh",
position: "relative",
px: { xs: 2, md: 6 },
pt: { xs: 6, md: 8 },
pb: { xs: 8, md: 10 },
}}
>
<Box className="glass-orb orb-1" />
<Box className="glass-orb orb-2" />
<Box className="glass-orb orb-3" />
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 4,
mt: 5,
position: "relative",
zIndex: 1,
maxWidth: 720,
mx: "auto",
mt: 6,
}}
>
<Card
className="glass-subcard"
sx={{
borderRadius: 4,
p: 3,
borderColor: "rgba(240,91,107,0.32) !important",
}}
>
<Typography color="error" sx={{ fontWeight: 600 }}>
{error}
</Typography>
</Card>
</Box>
</Box>
);
}
return (
<Box
className="glass-dashboard"
sx={{
minHeight: "100vh",
position: "relative",
px: { xs: 2, md: 6 },
pt: { xs: 6, md: 8 },
pb: { xs: 8, md: 10 },
}}
>
<Box className="glass-orb orb-1" />
<Box className="glass-orb orb-2" />
<Box className="glass-orb orb-3" />
<Box sx={{ position: "relative", zIndex: 1, maxWidth: 1180, mx: "auto" }}>
<Stack
direction={{ xs: "column", md: "row" }}
justifyContent="space-between"
alignItems={{ xs: "flex-start", md: "center" }}
spacing={2}
sx={{ mb: 3 }}
>
<Button
startIcon={<ArrowBack />}
variant="outlined"
color="secondary"
variant="contained"
onClick={() => navigate("/admin/dashboard")}
sx={composeButtonSx("ghost")}
>
Retour au Dashboard
Retour au dashboard
</Button>
<Stack spacing={0.5}>
<Typography
variant="h4"
sx={{ fontWeight: "bold", textAlign: "center", mb: 3 }}
variant="overline"
sx={{
letterSpacing: 2,
fontWeight: 700,
color: "rgba(255,255,255,0.8)",
}}
>
📄 Gestion des Pages Services
Gestion des pages services
</Typography>
<Typography
variant="h3"
sx={{
color: "#ffffff",
fontWeight: 800,
letterSpacing: "-0.5px",
}}
>
Contenus ACF disponibles
</Typography>
<Typography
variant="body1"
sx={{
color: "rgba(255,255,255,0.85)",
maxWidth: 520,
}}
>
Accédez rapidement aux pages service Octopus équipées de champs
ACF pour les mettre à jour en quelques clics.
</Typography>
</Stack>
<Button
startIcon={<Add />}
variant="contained"
color="primary"
onClick={() =>
window.open("https://it.sveitl.synology.me/", "_blank")
}
sx={composeButtonSx("primary")}
>
Ticket
Ouvrir un ticket
</Button>
</Box>
<TableContainer component={Paper}>
<Table>
<TableHead sx={{ backgroundColor: "#0e467f" }}>
<TableRow>
<TableCell sx={{ color: "white", fontWeight: "bold" }}>
Titre
</TableCell>
<TableCell
sx={{ color: "white", fontWeight: "bold", textAlign: "center" }}
</Stack>
<Grid container spacing={2.5} sx={{ mb: 4 }}>
<Grid item xs={12} md={6}>
<Card className="glass-subcard" sx={{ borderRadius: 4, p: 2.5 }}>
<Typography
variant="caption"
sx={{ color: "rgba(11, 26, 61, 0.65)", fontWeight: 600 }}
>
Pages référencées
</Typography>
<Typography
variant="h4"
sx={{ color: "#0b1a3d", fontWeight: 800, letterSpacing: "-0.5px" }}
>
{pages.length}
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)", mt: 0.5 }}
>
Ensemble des pages service disposant de champs ACF personnalisés.
</Typography>
</Card>
</Grid>
<Grid item xs={12} md={6}>
<Card className="glass-subcard" sx={{ borderRadius: 4, p: 2.5 }}>
<Typography
variant="caption"
sx={{ color: "rgba(11, 26, 61, 0.65)", fontWeight: 600 }}
>
Pages visibles
</Typography>
<Typography
variant="h4"
sx={{ color: "#0b1a3d", fontWeight: 800, letterSpacing: "-0.5px" }}
>
{filteredPages.length}
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)", mt: 0.5 }}
>
Résultats actuels après filtrage par titre.
</Typography>
</Card>
</Grid>
</Grid>
<Card
className="glass-subcard"
sx={{
borderRadius: 4,
p: { xs: 2.5, md: 3 },
mb: 4,
boxShadow: "0 28px 36px -28px rgba(12, 29, 74, 0.48)",
}}
>
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
alignItems={{ xs: "stretch", md: "center" }}
justifyContent="space-between"
>
<Box>
<Typography
variant="h6"
sx={{ color: "#0b1a3d", fontWeight: 700 }}
>
Rechercher une page
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)" }}
>
Filtrez par titre pour trouver une page service en un instant.
</Typography>
</Box>
<TextField
label="Rechercher..."
variant="outlined"
fullWidth
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
sx={{
maxWidth: 360,
"& .MuiOutlinedInput-root": {
borderRadius: 999,
backgroundColor: "rgba(255,255,255,0.94)",
},
}}
/>
</Stack>
</Card>
{filteredPages.length === 0 ? (
<Card className="glass-subcard" sx={{ borderRadius: 4, p: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 700, color: "#0b1a3d" }}>
Aucune page trouvée
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)", mt: 1 }}
>
Ajustez votre recherche ou vérifiez que la page dispose bien de
champs ACF activés.
</Typography>
</Card>
) : (
<Grid container spacing={2.5}>
{filteredPages.map((page, index) => (
<Grid
item
xs={12}
md={6}
lg={4}
key={page.id}
sx={{
mb:2,
mt: {
xs: index >= 1 ? 2 : 0,
md: index >= 3 ? 4 : 0,
},
}}
>
<Card
className="glass-card"
sx={{
borderRadius: 4,
p: { xs: 2.5, md: 3 },
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
boxShadow: "0 32px 44px -30px rgba(12, 29, 74, 0.5)",
}}
>
<Stack spacing={1.8}>
<Typography
variant="overline"
sx={{ color: "rgba(11, 26, 61, 0.65)", letterSpacing: 1, }}
>
</Typography>
<Typography
variant="h6"
sx={{ color: "#0b1a3d", fontWeight: 700, lineHeight: 1.4 }}
>
Actions
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{pages.length > 0 ? (
pages.map((page) => (
<TableRow key={page.id}>
<TableCell sx={{ fontWeight: "bold" }}>
{page.title.rendered}
</TableCell>
<TableCell sx={{ textAlign: "center" }}>
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.68)", lineHeight: 1.5 }}
>
Cliquez sur &quot;Modifier&quot; pour ajuster les contenus
de cette page service depuis léditeur ACF dédié.
</Typography>
</Stack>
<Button
startIcon={<Edit />}
variant="outlined"
color="warning"
variant="contained"
sx={composeButtonSx("outline", "small")}
onClick={() => navigate(`/admin/edit-page/${page.id}`)}
>
Modifier
</Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={2} sx={{ textAlign: "center", py: 2 }}>
Aucune page avec des champs ACF trouvée.
</TableCell>
</TableRow>
</Card>
</Grid>
))}
</Grid>
)}
</TableBody>
</Table>
</TableContainer>
</Box>
</Box>
);
}

View File

@ -22,6 +22,12 @@ import Faq from "../Faq";
const Contact = () => {
const CONTACT_API_URL =
import.meta.env.VITE_CONTACT_API_URL ||
(import.meta.env.DEV
? "http://localhost:3001/api/contact"
: "https://octopusdesign.fr/api/contact");
const [metaTitle, setMetaTitle] = useState("Contactez-nous");
const [metaDescription, setMetaDescription] = useState(
"Contactez notre équipe pour plus d'informations."
@ -35,7 +41,11 @@ const Contact = () => {
});
const [errors, setErrors] = useState({});
const [loading, setLoading] = useState(false);
const [successMessage, setSuccessMessage] = useState(false);
const [alertState, setAlertState] = useState({
open: false,
severity: "success",
message: "",
});
const [openLightbox, setOpenLightbox] = useState(false);
const [showFAQ, setShowFAQ] = useState(false); // État pour afficher/masquer la FAQ
@ -96,7 +106,7 @@ const Contact = () => {
try {
const response = await fetch(
"https://votre-site.com/wp-json/custom/v1/contact",
CONTACT_API_URL,
{
method: "POST",
headers: {
@ -107,7 +117,11 @@ const Contact = () => {
);
if (response.ok) {
setSuccessMessage(true);
setAlertState({
open: true,
severity: "success",
message: "Merci ! Votre message a bien été envoyé.",
});
setFormData({
name: "",
email: "",
@ -120,7 +134,13 @@ const Contact = () => {
throw new Error(errorData.message || "Une erreur est survenue.");
}
} catch (error) {
alert(error.message);
setAlertState({
open: true,
severity: "error",
message:
error.message ||
"Impossible d'envoyer votre message. Veuillez réessayer.",
});
} finally {
setLoading(false);
}
@ -135,6 +155,13 @@ const Contact = () => {
setOpenLightbox(false);
};
const handleCloseAlert = (_, reason) => {
if (reason === "clickaway") {
return;
}
setAlertState((prev) => ({ ...prev, open: false }));
};
return (
<Box>
{/* SEO */}
@ -490,12 +517,17 @@ const Contact = () => {
</form>
<Snackbar
open={successMessage}
open={alertState.open}
autoHideDuration={6000}
onClose={() => setSuccessMessage(false)}
onClose={handleCloseAlert}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
>
<Alert onClose={() => setSuccessMessage(false)} severity="success">
Message envoyé avec succès!
<Alert
onClose={handleCloseAlert}
severity={alertState.severity}
sx={{ width: "100%" }}
>
{alertState.message}
</Alert>
</Snackbar>
</Box>

View File

@ -4,22 +4,113 @@ import { uploadImageFromUrl, createPost } from "../../wordpress";
import { getToken } from "../../auth";
import {
Box,
Container,
Typography,
TextField,
Button,
Paper,
Grid,
Card,
CardContent,
Stack,
Chip,
Divider,
} from "@mui/material";
import { ArrowBack, Publish } from "@mui/icons-material";
import { ArrowBack, Publish, Image as ImageIcon } from "@mui/icons-material";
import ImageUploaderCloudinary from "../ImageUploaderCloudinary";
import CloudinaryGallerySelector from "../CloudinaryGallerySelector";
import ReactQuill from "react-quill";
import "react-quill/dist/quill.snow.css";
import "../../assets/styleCours.css";
const BUTTON_BASE_SX = Object.freeze({
borderRadius: 999,
textTransform: "none",
fontWeight: 600,
letterSpacing: 0.3,
display: "inline-flex",
alignItems: "center",
gap: 0.75,
transition:
"transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease, border-color 0.2s ease",
"&:hover": {
transform: "translateY(-1px)",
},
"&:active": {
transform: "translateY(0)",
},
});
const BUTTON_SIZE_SX = Object.freeze({
small: {
px: 2.1,
py: 0.55,
fontSize: "0.78rem",
minHeight: 30,
},
medium: {
px: 2.8,
py: 0.8,
fontSize: "0.86rem",
minHeight: 36,
},
large: {
px: 3.4,
py: 1,
fontSize: "0.94rem",
minHeight: 42,
},
});
const BUTTON_VARIANT_SX = Object.freeze({
primary: {
background: "linear-gradient(135deg, #315397, #7bc0ff)",
color: "#ffffff",
border: "1px solid rgba(255,255,255,0.35)",
boxShadow: "0 18px 28px -18px rgba(11, 26, 61, 0.55)",
"&:hover": {
background: "linear-gradient(135deg, #26467d, #6fb6ff)",
boxShadow: "0 22px 32px -18px rgba(11, 26, 61, 0.6)",
},
"&.Mui-disabled": {
color: "rgba(255,255,255,0.75)",
background:
"linear-gradient(135deg, rgba(49,83,151,0.35), rgba(123,192,255,0.35))",
boxShadow: "none",
border: "1px solid rgba(49,83,151,0.18)",
},
},
ghost: {
background: "rgba(255,255,255,0.16)",
color: "rgba(255,255,255,0.92)",
border: "1px solid rgba(255,255,255,0.28)",
backdropFilter: "blur(8px)",
"&:hover": {
background: "rgba(255,255,255,0.24)",
boxShadow: "0 18px 28px -18px rgba(6, 18, 38, 0.45)",
},
},
outline: {
background: "rgba(255,255,255,0.84)",
color: "#315397",
border: "1px solid rgba(49,83,151,0.26)",
"&:hover": {
background: "rgba(255,255,255,0.96)",
borderColor: "rgba(49,83,151,0.46)",
boxShadow: "0 14px 22px -18px rgba(11, 26, 61, 0.4)",
},
},
});
const composeButtonSx = (variant, size = "medium") => ({
...BUTTON_BASE_SX,
...(BUTTON_SIZE_SX[size] || BUTTON_SIZE_SX.medium),
...(BUTTON_VARIANT_SX[variant] || BUTTON_VARIANT_SX.primary),
});
function CreatePost() {
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [imageUrl, setImageUrl] = useState(null);
const [submitting, setSubmitting] = useState(false);
const navigate = useNavigate();
// Vérification de l'authentification
@ -29,126 +120,352 @@ function CreatePost() {
}
}, [navigate]);
const handleSubmit = async (e) => {
e.preventDefault();
const canPublish =
title.trim().length > 0 && content.trim().length > 0 && Boolean(imageUrl);
const handleSubmit = async (event) => {
event.preventDefault();
if (!canPublish || submitting) {
return;
}
try {
setSubmitting(true);
const imageId = await uploadImageFromUrl(imageUrl);
await createPost(title, content, imageId);
alert("✅ Article publié !");
navigate("/admin/gestion-articles");
} catch (error) {
console.error("❌ Erreur création post :", error.response?.data || error.message);
} catch (error) {
console.error(
"❌ Erreur création post :",
error?.response?.data || error?.message || error
);
alert("❌ Erreur lors de la création du post.");
}
} finally {
setSubmitting(false);
}
};
return (
<Box
className="glass-dashboard"
sx={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundImage:
"url('https://source.unsplash.com/1600x900/?office,writing')",
backgroundSize: "cover",
backgroundPosition: "center",
position: "relative",
px: { xs: 2, md: 6 },
pt: { xs: 6, md: 8 },
pb: { xs: 8, md: 10 },
}}
>
<Container maxWidth="sm">
<Paper elevation={6} sx={{ padding: 4, borderRadius: 3 }}>
<Box className="glass-orb orb-1" />
<Box className="glass-orb orb-2" />
<Box className="glass-orb orb-3" />
<Box sx={{ position: "relative", zIndex: 1, maxWidth: 1180, mx: "auto" }}>
<Stack
direction={{ xs: "column", md: "row" }}
justifyContent="space-between"
alignItems={{ xs: "flex-start", md: "center" }}
spacing={2}
sx={{ mb: 3 }}
>
<Button
startIcon={<ArrowBack />}
variant="outlined"
color="secondary"
variant="contained"
onClick={() => navigate("/admin/gestion-articles")}
sx={{ mb: 2 }}
sx={composeButtonSx("ghost")}
>
Retour
Retour aux articles
</Button>
<Stack spacing={0.5}>
<Typography
variant="h4"
sx={{ fontWeight: "bold", textAlign: "center", mb: 3 }}
variant="overline"
sx={{
letterSpacing: 2,
fontWeight: 700,
color: "rgba(255,255,255,0.8)",
}}
>
Créer un article
Publication Octopus
</Typography>
<Typography
variant="h3"
sx={{
color: "#ffffff",
fontWeight: 800,
letterSpacing: "-0.5px",
}}
>
Rédiger un nouvel article
</Typography>
<Typography
variant="body1"
sx={{
color: "rgba(255,255,255,0.85)",
maxWidth: 520,
}}
>
Structurez votre contenu, ajoutez une image mise en avant et
publiez directement sur le blog WordPress Octopus.
</Typography>
</Stack>
</Stack>
<form onSubmit={handleSubmit}>
<Grid container spacing={3.5}>
<Grid item xs={12} lg={7}>
<Card
className="glass-subcard"
sx={{
borderRadius: 4,
p: { xs: 2.5, md: 3 },
boxShadow: "0 28px 36px -28px rgba(12, 29, 74, 0.5)",
}}
>
<CardContent sx={{ p: 0 }}>
<Stack spacing={2}>
<Stack
direction="row"
spacing={1}
alignItems="center"
sx={{ flexWrap: "wrap" }}
>
<Chip
label="Brouillon"
sx={{
bgcolor: "rgba(49,83,151,0.12)",
color: "#315397",
fontWeight: 600,
}}
/>
<Chip
label={
canPublish
? "Prêt pour publication"
: "Complétez les champs requis"
}
color={canPublish ? "success" : "warning"}
sx={{ fontWeight: 600 }}
/>
</Stack>
<Box component="form" onSubmit={handleSubmit} noValidate>
<TextField
label="Titre de l'article"
fullWidth
margin="normal"
variant="outlined"
value={title}
onChange={(e) => setTitle(e.target.value)}
onChange={(event) => setTitle(event.target.value)}
required
/>
<Box sx={{ height: 250, mb: 7 }}>
<ReactQuill
label="Contenu"
fullWidth
margin="normal"
variant="outlined"
multiline
rows={4}
value={content}
onChange={(e) => setContent(e.target.value)}
required
style={{height:"100%"}}
/>
</Box>
{/* Upload via Cloudinary */}
<Box sx={{ mt: 3 }}>
<Typography variant="body1" sx={{ fontWeight: "bold", mb: 1 }}>
Importer une image depuis ton ordi :
</Typography>
<ImageUploaderCloudinary onUploadSuccess={(url) => setImageUrl(url)} />
</Box>
{/* Sélection galerie Cloudinary */}
<Box sx={{ mt: 4 }}>
<Typography variant="body1" sx={{ fontWeight: "bold", mb: 1 }}>
Ou choisir une image déjà envoyée :
</Typography>
<CloudinaryGallerySelector onSelect={(url) => setImageUrl(url)} />
</Box>
{/* Aperçu */}
{imageUrl && (
<Box sx={{ mt: 3 }}>
<Typography variant="body2">Aperçu de limage :</Typography>
<img
src={imageUrl}
alt="Image sélectionnée"
style={{
width: "100%",
maxHeight: 200,
objectFit: "cover",
borderRadius: 8,
marginTop: 8,
sx={{
mb: 3,
"& .MuiOutlinedInput-root": {
borderRadius: 2,
backgroundColor: "rgba(255,255,255,0.94)",
},
}}
/>
</Box>
)}
<Box
sx={{
mb: 3,
borderRadius: 2,
overflow: "hidden",
border: "1px solid rgba(49,83,151,0.15)",
backgroundColor: "rgba(255,255,255,0.92)",
}}
>
<ReactQuill
value={content}
onChange={setContent}
style={{ height: 280 }}
/>
</Box>
<Stack spacing={2.5}>
<Box>
<Typography
variant="subtitle1"
sx={{ fontWeight: 700, color: "#0b1a3d" }}
>
Importer une image
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)" }}
>
Ajoutez un visuel optimisé pour les réseaux sociaux
(format 1600x900 recommandé).
</Typography>
<Box sx={{ mt: 2 }}>
<ImageUploaderCloudinary
onUploadSuccess={(url) => setImageUrl(url)}
/>
</Box>
</Box>
<Divider sx={{ borderColor: "rgba(11, 26, 61, 0.08)" }} />
<Box>
<Typography
variant="subtitle1"
sx={{ fontWeight: 700, color: "#0b1a3d" }}
>
Sélectionner depuis la médiathèque
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)", mb: 2 }}
>
Retrouver un visuel déjà téléversé dans Cloudinary.
</Typography>
<CloudinaryGallerySelector
onSelect={(url) => setImageUrl(url)}
/>
</Box>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={1.5}
sx={{ pt: 1 }}
>
<Button
type="submit"
variant="contained"
color="primary"
fullWidth
startIcon={<Publish />}
sx={{ mt: 4 }}
disabled={!canPublish || submitting}
sx={{ ...composeButtonSx("primary"), minWidth: 200 }}
>
Publier l'article
{submitting ? "Publication..." : "Publier l'article"}
</Button>
</form>
</Paper>
</Container>
<Button
variant="contained"
onClick={() => {
setTitle("");
setContent("");
setImageUrl(null);
}}
sx={composeButtonSx("outline")}
>
Réinitialiser
</Button>
</Stack>
</Stack>
</Box>
</Stack>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} lg={5}>
<Stack spacing={3}>
<Card
className="glass-subcard"
sx={{
borderRadius: 4,
p: { xs: 2.5, md: 3 },
boxShadow: "0 28px 36px -28px rgba(12, 29, 74, 0.45)",
}}
>
<Stack direction="row" spacing={2} alignItems="flex-start">
<Box
sx={{
width: 48,
height: 48,
borderRadius: 2,
bgcolor: "rgba(123,192,255,0.22)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#315397",
}}
>
<ImageIcon />
</Box>
<Box>
<Typography
variant="subtitle1"
sx={{ fontWeight: 700, color: "#0b1a3d", mb: 1 }}
>
Conseils de rédaction
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.68)" }}
>
Introduisez le sujet dans les 250 premiers caractères pour
optimiser le SEO.<br />
Utilisez des sous-titres (<code>&lt;h2&gt;</code>) pour
clarifier la structure.<br />
Ajoutez des appels à laction vers vos formations Octopus.
</Typography>
</Box>
</Stack>
</Card>
<Card
className="glass-article-card"
sx={{
borderRadius: 4,
overflow: "hidden",
minHeight: 220,
}}
>
<Box
sx={{
backgroundColor: "rgba(255,255,255,0.9)",
p: { xs: 2.5, md: 3 },
display: "flex",
flexDirection: "column",
height: "100%",
gap: 1.5,
}}
>
<Typography
variant="subtitle1"
sx={{ fontWeight: 700, color: "#0b1a3d" }}
>
Aperçu visuel
</Typography>
{imageUrl ? (
<Box
component="img"
src={imageUrl}
alt="Aperçu de limage sélectionnée"
sx={{
width: "100%",
borderRadius: 3,
height: 200,
objectFit: "cover",
boxShadow: "0 20px 28px -24px rgba(12, 29, 74, 0.48)",
}}
/>
) : (
<Box
sx={{
flexGrow: 1,
borderRadius: 3,
border: "1px dashed rgba(49,83,151,0.35)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "rgba(49,83,151,0.6)",
fontStyle: "italic",
}}
>
Aucun visuel sélectionné pour le moment
</Box>
)}
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)" }}
>
Ce visuel sera utilisé comme image de couverture sur la liste
des articles et les réseaux sociaux.
</Typography>
</Box>
</Card>
</Stack>
</Grid>
</Grid>
</Box>
</Box>
);
}

View File

@ -7,18 +7,119 @@ import {
Grid,
Card,
CardContent,
IconButton,
Button,
Stack,
Chip,
Divider,
} from "@mui/material";
import LogoutIcon from "@mui/icons-material/Logout"; // Icône de déconnexion
import LogoutIcon from "@mui/icons-material/Logout";
import ArticleIcon from "@mui/icons-material/Article";
import DashboardIcon from "@mui/icons-material/Dashboard";
import HomeIcon from "@mui/icons-material/Home"; // Icône pour la gestion page d'accueil
import HomeIcon from "@mui/icons-material/Home";
import BuildIcon from "@mui/icons-material/Build";
import SchoolIcon from "@mui/icons-material/School";
import InsightsIcon from "@mui/icons-material/Insights";
import SettingsSuggestIcon from "@mui/icons-material/SettingsSuggest";
import AutoStoriesIcon from "@mui/icons-material/AutoStories";
import PublicIcon from "@mui/icons-material/Public";
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
const BUTTON_BASE_SX = Object.freeze({
borderRadius: 999,
textTransform: "none",
fontWeight: 600,
letterSpacing: 0.3,
display: "inline-flex",
alignItems: "center",
gap: 0.75,
transition:
"transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease, border-color 0.2s ease",
"&:hover": {
transform: "translateY(-1px)",
},
"&:active": {
transform: "translateY(0)",
},
});
const BUTTON_SIZE_SX = Object.freeze({
small: {
px: 2.1,
py: 0.55,
fontSize: "0.78rem",
minHeight: 30,
},
medium: {
px: 2.8,
py: 0.8,
fontSize: "0.86rem",
minHeight: 36,
},
large: {
px: 3.4,
py: 1,
fontSize: "0.94rem",
minHeight: 42,
},
});
const BUTTON_VARIANT_SX = Object.freeze({
primary: {
background: "linear-gradient(135deg, #315397, #7bc0ff)",
color: "#ffffff",
border: "1px solid rgba(255,255,255,0.35)",
boxShadow: "0 18px 28px -18px rgba(11, 26, 61, 0.55)",
"&:hover": {
background: "linear-gradient(135deg, #26467d, #6fb6ff)",
boxShadow: "0 22px 32px -18px rgba(11, 26, 61, 0.6)",
},
"&.Mui-disabled": {
color: "rgba(255,255,255,0.72)",
background:
"linear-gradient(135deg, rgba(49,83,151,0.35), rgba(123,192,255,0.35))",
boxShadow: "none",
border: "1px solid rgba(49,83,151,0.18)",
},
},
ghost: {
background: "rgba(255,255,255,0.16)",
color: "rgba(255,255,255,0.92)",
border: "1px solid rgba(255,255,255,0.28)",
backdropFilter: "blur(8px)",
"&:hover": {
background: "rgba(255,255,255,0.24)",
boxShadow: "0 18px 28px -18px rgba(6, 18, 38, 0.45)",
},
},
outline: {
background: "rgba(255,255,255,0.84)",
color: "#315397",
border: "1px solid rgba(49,83,151,0.32)",
"&:hover": {
background: "rgba(255,255,255,0.96)",
borderColor: "rgba(49,83,151,0.48)",
boxShadow: "0 14px 22px -18px rgba(11, 26, 61, 0.4)",
},
},
subtle: {
background: "rgba(49,83,151,0.08)",
color: "#315397",
border: "1px solid rgba(49,83,151,0.12)",
"&:hover": {
background: "rgba(49,83,151,0.14)",
borderColor: "rgba(49,83,151,0.24)",
},
},
});
const composeButtonSx = (variant, size = "medium") => ({
...BUTTON_BASE_SX,
...(BUTTON_SIZE_SX[size] || BUTTON_SIZE_SX.medium),
...(BUTTON_VARIANT_SX[variant] || BUTTON_VARIANT_SX.primary),
});
function Dashboard() {
const navigate = useNavigate();
const token = getToken();
const token = getToken();
useEffect(() => {
if (!token) {
@ -31,240 +132,390 @@ function Dashboard() {
navigate("/admin/login"); // Redirige vers la page de connexion
};
const NAV_ITEMS = [
{
title: "Page daccueil",
description: "Mettez à jour les blocs héros, témoignages et sections clés.",
navigateTo: "/admin/Gestion-Page-Accueil",
icon: <HomeIcon fontSize="inherit" />,
accent: "rgba(123,192,255,0.35)",
},
{
title: "Tools studio",
description: "Centralisez vos outils internes et automatisez les tâches.",
navigateTo: "/admin/tools",
icon: <BuildIcon fontSize="inherit" />,
accent: "rgba(255,214,150,0.32)",
},
{
title: "Articles",
description: "Rédigez, programmez et optimisez les publications du blog.",
navigateTo: "/admin/gestion-articles",
icon: <ArticleIcon fontSize="inherit" />,
accent: "rgba(255,172,236,0.28)",
},
{
title: "Pages services",
description: "Actualisez les contenus ACF pour chaque offre Octopus.",
navigateTo: "/admin/pages-acf",
icon: <SettingsSuggestIcon fontSize="inherit" />,
accent: "rgba(158,241,209,0.32)",
},
{
title: "Bureau détude",
description: "Administrez les informations du pôle bureau détude.",
navigateTo: "/admin/bureau-etude-acf",
icon: <InsightsIcon fontSize="inherit" />,
accent: "rgba(255,199,186,0.34)",
},
{
title: "Cours & formations",
description: "Gérez le catalogue Moodle et vos ressources pédagogiques.",
navigateTo: "/admin/liste-cours",
icon: <SchoolIcon fontSize="inherit" />,
accent: "rgba(178,219,255,0.34)",
},
];
const QUICK_ACTIONS = [
{
label: "Nouvel article",
hint: "Créez un contenu en un clic",
onClick: () => navigate("/admin/gestion-articles"),
},
{
label: "Synchroniser Moodle",
hint: "Actualisez vos cours et stats",
onClick: () => navigate("/admin/liste-cours"),
},
{
label: "Voir le site",
hint: "Ouvrir octopusdesign.fr",
onClick: () => window.open("https://www.octopusdesign.fr", "_blank"),
},
];
const KPI_CARDS = [
{
label: "Articles publiés",
value: "128",
delta: "+4 ce mois-ci",
},
{
label: "Cours actifs",
value: "36",
delta: "8 en mise à jour",
},
{
label: "Pages services",
value: "12",
delta: "2 révisions en cours",
},
];
return (
<Box
className="glass-dashboard"
sx={{
minHeight: "100vh",
backgroundImage:
"url('https://source.unsplash.com/1600x900/?technology,office')",
backgroundSize: "cover",
backgroundPosition: "center",
position: "relative",
px: { xs: 2, md: 6 },
pt: { xs: 6, md: 8 },
pb: { xs: 8, md: 10 },
}}
>
<Box className="glass-orb orb-1" />
<Box className="glass-orb orb-2" />
<Box className="glass-orb orb-3" />
<Box sx={{ position: "relative", zIndex: 1, maxWidth: 1240, mx: "auto" }}>
<Stack
direction={{ xs: "column", md: "row" }}
justifyContent="space-between"
alignItems={{ xs: "flex-start", md: "center" }}
spacing={2}
sx={{ mb: 3 }}
>
<Stack spacing={0.5}>
<Typography
variant="overline"
sx={{
letterSpacing: 2,
fontWeight: 700,
color: "rgba(255,255,255,0.8)",
}}
>
Octopus Admin
</Typography>
<Typography
variant="h3"
sx={{
color: "#ffffff",
fontWeight: 800,
letterSpacing: "-0.5px",
}}
>
Tableau de bord centralisé
</Typography>
<Typography
variant="body1"
sx={{
color: "rgba(255,255,255,0.85)",
maxWidth: 540,
}}
>
Accédez à lensemble des outils de gestion : contenus éditoriaux,
pages services, ressources pédagogiques et automatisations.
</Typography>
</Stack>
<Button
variant="contained"
startIcon={<LogoutIcon />}
onClick={handleLogout}
sx={composeButtonSx("ghost")}
>
Déconnexion
</Button>
</Stack>
<Box
className="glass-panel hero-panel"
sx={{
borderRadius: 4,
p: { xs: 3, md: 4 },
mb: 4,
position: "relative",
overflow: "hidden",
}}
>
<Stack
direction={{ xs: "column", md: "row" }}
spacing={{ xs: 3, md: 4 }}
alignItems={{ xs: "flex-start", md: "center" }}
justifyContent="space-between"
>
<Box sx={{ flex: 1 }}>
<Typography
variant="h5"
sx={{
color: "#0b1a3d",
fontWeight: 700,
mb: 1,
}}
>
Priorités du jour
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.7)", lineHeight: 1.6 }}
>
Suivez la progression des mises en ligne, synchronisez Moodle
et gardez un œil sur vos contenus essentiels.
</Typography>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={1}
sx={{ mt: 2, flexWrap: "wrap" }}
>
<Chip
icon={<AutoStoriesIcon fontSize="small" />}
label="Contenus pédagogiques à jour"
sx={{
bgcolor: "rgba(123,192,255,0.16)",
color: "#0b1a3d",
fontWeight: 600,
}}
/>
<Chip
icon={<PublicIcon fontSize="small" />}
label="Octopusdesign.fr en production"
sx={{
bgcolor: "rgba(255,214,150,0.18)",
color: "#0b1a3d",
fontWeight: 600,
}}
/>
</Stack>
</Box>
<Divider
orientation="vertical"
flexItem
sx={{
display: { xs: "none", md: "block" },
borderColor: "rgba(11, 26, 61, 0.08)",
}}
/>
<Box
sx={{
flexBasis: { xs: "100%", md: "40%" },
display: "flex",
flexDirection: "column",
gap: 1.5,
}}
>
<Typography
variant="subtitle2"
sx={{ color: "rgba(11, 26, 61, 0.65)", fontWeight: 700 }}
>
Actions rapides
</Typography>
<Stack spacing={1.2}>
{QUICK_ACTIONS.map((action) => (
<Button
key={action.label}
variant="contained"
onClick={action.onClick}
endIcon={<ArrowForwardIcon fontSize="small" />}
sx={{
...composeButtonSx("subtle", "large"),
justifyContent: "space-between",
width: "100%",
}}
>
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "flex-start" }}>
<Typography variant="body1" sx={{ fontWeight: 700 }}>
{action.label}
</Typography>
<Typography
variant="caption"
sx={{ color: "rgba(49,83,151,0.7)", fontWeight: 500 }}
>
{action.hint}
</Typography>
</Box>
</Button>
))}
</Stack>
</Box>
</Stack>
</Box>
<Grid container spacing={2.5} sx={{ mb: 4 }}>
{KPI_CARDS.map((card) => (
<Grid item xs={12} sm={4} key={card.label}>
<Card
className="glass-subcard"
sx={{
borderRadius: 3,
p: 2.5,
boxShadow: "0 28px 34px -30px rgba(12, 29, 74, 0.52)",
}}
>
<Typography
variant="caption"
sx={{ color: "rgba(11, 26, 61, 0.6)", fontWeight: 600 }}
>
{card.label.toUpperCase()}
</Typography>
<Typography
variant="h4"
sx={{
color: "#0b1a3d",
fontWeight: 800,
letterSpacing: "-0.5px",
}}
>
{card.value}
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)", mt: 0.5 }}
>
{card.delta}
</Typography>
</Card>
</Grid>
))}
</Grid>
<Typography
variant="h6"
sx={{ color: "#ffffff", fontWeight: 700, mb: 2 }}
>
Espaces de gestion
</Typography>
<Grid container spacing={2.5}>
{NAV_ITEMS.map((item) => (
<Grid item xs={12} md={6} key={item.title}>
<Card
onClick={() => navigate(item.navigateTo)}
className="glass-subcard"
sx={{
cursor: "pointer",
borderRadius: 3,
overflow: "hidden",
position: "relative",
transition:
"transform 0.3s ease, box-shadow 0.3s ease, border-color 0.3s ease",
"&::before": {
content: '""',
position: "absolute",
inset: 0,
borderRadius: "inherit",
background: `radial-gradient(360px at 18% 24%, ${item.accent}, transparent 72%)`,
opacity: 0.9,
},
"&:hover": {
transform: "translateY(-8px)",
borderColor: "rgba(49,83,151,0.35)",
boxShadow: "0 38px 40px -30px rgba(12, 29, 74, 0.6)",
},
}}
>
<CardContent
sx={{
position: "relative",
zIndex: 1,
display: "flex",
flexDirection: "column",
gap: 1.5,
p: { xs: 3, md: 3.5 },
}}
>
<Box
sx={{
width: 52,
height: 52,
borderRadius: 2,
backgroundColor: "rgba(255,255,255,0.75)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "20px",
color: "#315397",
boxShadow: "0 16px 26px -20px rgba(11, 26, 61, 0.5)",
fontSize: 28,
}}
>
<Box
sx={{
width: "90%",
maxWidth: "900px",
padding: 4,
backgroundColor: "rgba(255, 255, 255, 0.9)",
borderRadius: 3,
boxShadow: 6,
textAlign: "center",
position: "relative",
}}
{item.icon}
</Box>
<Typography
variant="h6"
sx={{ color: "#0b1a3d", fontWeight: 700 }}
>
{/* Bouton de déconnexion placé en haut à gauche */}
{item.title}
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.68)", lineHeight: 1.6 }}
>
{item.description}
</Typography>
<Button
variant="contained"
color="error"
startIcon={<LogoutIcon />}
onClick={handleLogout}
onClick={() => navigate(item.navigateTo)}
endIcon={<ArrowForwardIcon fontSize="small" />}
sx={{
position: "absolute",
top: 10,
left: 10,
backgroundColor: "#d32f2f",
"&:hover": { backgroundColor: "#b71c1c" },
fontSize: "0.875rem",
padding: "6px 12px",
}}
/>
{/* En-tête */}
<Typography variant="h4" sx={{ fontWeight: "bold", mb: 4, mt: 4 }}>
<DashboardIcon sx={{ fontSize: 40, color: "#0e467f", mr: 1 }} />
Tableau de Bord
</Typography>
{/* Cartes du Dashboard */}
<Grid container spacing={3} justifyContent="center">
{/* ✅ Gestion Page d'Accueil - Première carte */}
<Grid item xs={12} sm={6}>
<Card
onClick={() => navigate("/admin/Gestion-Page-Accueil")}
sx={{
cursor: "pointer",
textAlign: "center",
padding: 2,
transition: "0.3s",
"&:hover": {
backgroundColor: "#0e467f",
color: "#ffffff",
transform: "scale(1.05)",
boxShadow: 8,
},
"&:hover svg": {
fill: "#ffffff",
},
...composeButtonSx("primary", "small"),
alignSelf: "flex-start",
}}
>
<CardContent>
<IconButton sx={{ fontSize: 40, color: "#0e467f" }}>
<HomeIcon fontSize="inherit" />
</IconButton>
<Typography variant="h6" sx={{ fontWeight: "bold" }}>
Gestion Page d'Accueil
</Typography>
Ouvrir
</Button>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={6}>
<Card
onClick={() => navigate("/admin/tools")}
sx={{
cursor: "pointer",
textAlign: "center",
padding: 2,
transition: "0.3s",
"&:hover": {
backgroundColor: "#0e467f",
color: "#ffffff",
transform: "scale(1.05)",
boxShadow: 8,
},
"&:hover svg": {
fill: "#ffffff",
},
}}
>
<CardContent>
<IconButton sx={{ fontSize: 40, color: "#0e467f" }}>
<BuildIcon fontSize="inherit" />
</IconButton>
<Typography variant="h6" sx={{ fontWeight: "bold" }}>
Tools
</Typography>
</CardContent>
</Card>
</Grid>
{/* ✅ Gérer les Articles - Deuxième carte */}
<Grid item xs={12} sm={6}>
<Card
onClick={() => navigate("/admin/gestion-articles")}
sx={{
cursor: "pointer",
textAlign: "center",
padding: 2,
transition: "0.3s",
"&:hover": {
backgroundColor: "#0e467f",
color: "#ffffff",
transform: "scale(1.05)",
boxShadow: 8,
},
"&:hover svg": {
fill: "#ffffff",
},
}}
>
<CardContent>
<IconButton sx={{ fontSize: 40, color: "#0e467f" }}>
<ArticleIcon fontSize="inherit" />
</IconButton>
<Typography variant="h6" sx={{ fontWeight: "bold" }}>
Gérer les Articles
</Typography>
</CardContent>
</Card>
</Grid>
{/* ✅ Gérer les Pages ACF - Nouvelle carte */}
<Grid item xs={12} sm={6}>
<Card
onClick={() => navigate("/admin/pages-acf")}
sx={{
cursor: "pointer",
textAlign: "center",
padding: 2,
transition: "0.3s",
"&:hover": {
backgroundColor: "#0e467f",
color: "#ffffff",
transform: "scale(1.05)",
boxShadow: 8,
},
"&:hover svg": {
fill: "#ffffff",
},
}}
>
<CardContent>
<IconButton sx={{ fontSize: 40, color: "#0e467f" }}>
<ArticleIcon fontSize="inherit" />
</IconButton>
<Typography variant="h6" sx={{ fontWeight: "bold" }}>
Gérer les Pages Services
</Typography>
</CardContent>
</Card>
</Grid>
{/* ✅ Gérer les Pages ACF - Nouvelle carte */}
<Grid item xs={12} sm={6}>
<Card
onClick={() => navigate("/admin/bureau-etude-acf")}
sx={{
cursor: "pointer",
textAlign: "center",
padding: 2,
transition: "0.3s",
"&:hover": {
backgroundColor: "#0e467f",
color: "#ffffff",
transform: "scale(1.05)",
boxShadow: 8,
},
"&:hover svg": {
fill: "#ffffff",
},
}}
>
<CardContent>
<IconButton sx={{ fontSize: 40, color: "#0e467f" }}>
<ArticleIcon fontSize="inherit" />
</IconButton>
<Typography variant="h6" sx={{ fontWeight: "bold" }}>
Gérer la page bureau d'étude
</Typography>
</CardContent>
</Card>
</Grid>
{/* ✅ Gérer les Cours - Nouvelle carte */}
<Grid item xs={12} sm={6}>
<Card
onClick={() => navigate("/admin/liste-cours")}
sx={{
cursor: "pointer",
textAlign: "center",
padding: 2,
transition: "0.3s",
"&:hover": {
backgroundColor: "#0e467f",
color: "#ffffff",
transform: "scale(1.05)",
boxShadow: 8,
},
"&:hover svg": {
fill: "#ffffff",
},
}}
>
<CardContent>
<IconButton sx={{ fontSize: 40, color: "#0e467f" }}>
<DashboardIcon fontSize="inherit" />
</IconButton>
<Typography variant="h6" sx={{ fontWeight: "bold" }}>
Cours
</Typography>
</CardContent>
</Card>
</Grid>
{/* ✅ Gérer les Cours - Nouvelle carte */}
))}
</Grid>
</Box>
</Box>

View File

@ -1,18 +1,118 @@
// Importations nécessaires
import React, { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import {
Box, Typography, Button, Table, TableBody, TableCell,
TableContainer, TableHead, TableRow, Paper, TextField, Avatar
Box,
Typography,
Button,
Grid,
Card,
CardContent,
CardMedia,
TextField,
Stack,
Chip,
CircularProgress,
Tooltip,
} from "@mui/material";
import { Add, ArrowBack, Edit, Delete } from "@mui/icons-material";
import api from "../../api";
import { getToken } from "../../auth";
import { deletePost } from "../../wordpress";
import "../../assets/styleCours.css";
const BUTTON_BASE_SX = Object.freeze({
borderRadius: 999,
textTransform: "none",
fontWeight: 600,
letterSpacing: 0.3,
display: "inline-flex",
alignItems: "center",
gap: 0.75,
transition:
"transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease, border-color 0.2s ease",
"&:hover": {
transform: "translateY(-1px)",
},
"&:active": {
transform: "translateY(0)",
},
});
const BUTTON_SIZE_SX = Object.freeze({
small: {
px: 2.1,
py: 0.55,
fontSize: "0.78rem",
minHeight: 30,
},
medium: {
px: 2.8,
py: 0.8,
fontSize: "0.86rem",
minHeight: 36,
},
large: {
px: 3.4,
py: 1,
fontSize: "0.94rem",
minHeight: 42,
},
});
const BUTTON_VARIANT_SX = Object.freeze({
primary: {
background: "linear-gradient(135deg, #315397, #7bc0ff)",
color: "#ffffff",
border: "1px solid rgba(255,255,255,0.35)",
boxShadow: "0 18px 28px -18px rgba(11, 26, 61, 0.55)",
"&:hover": {
background: "linear-gradient(135deg, #26467d, #6fb6ff)",
boxShadow: "0 22px 32px -18px rgba(11, 26, 61, 0.6)",
},
},
outline: {
background: "rgba(255,255,255,0.84)",
color: "#315397",
border: "1px solid rgba(49,83,151,0.26)",
"&:hover": {
background: "rgba(255,255,255,0.96)",
borderColor: "rgba(49,83,151,0.46)",
boxShadow: "0 14px 22px -18px rgba(11, 26, 61, 0.4)",
},
},
ghost: {
background: "rgba(255,255,255,0.16)",
color: "rgba(255,255,255,0.92)",
border: "1px solid rgba(255,255,255,0.28)",
backdropFilter: "blur(8px)",
"&:hover": {
background: "rgba(255,255,255,0.24)",
boxShadow: "0 18px 28px -18px rgba(6, 18, 38, 0.45)",
},
},
danger: {
background: "linear-gradient(135deg, #f05b6b, #ff8a80)",
color: "#3a0a0f",
border: "1px solid rgba(240,91,107,0.35)",
boxShadow: "0 18px 26px -18px rgba(105, 21, 33, 0.45)",
"&:hover": {
background: "linear-gradient(135deg, #e34659, #ff7575)",
boxShadow: "0 22px 30px -18px rgba(105, 21, 33, 0.52)",
},
},
});
const composeButtonSx = (variant, size = "medium") => ({
...BUTTON_BASE_SX,
...(BUTTON_SIZE_SX[size] || BUTTON_SIZE_SX.medium),
...(BUTTON_VARIANT_SX[variant] || BUTTON_VARIANT_SX.primary),
});
const GestionArticles = () => {
const [posts, setPosts] = useState([]);
const [searchTerm, setSearchTerm] = useState("");
const [loading, setLoading] = useState(true);
const navigate = useNavigate();
// Vérifier si l'utilisateur est connecté
@ -26,7 +126,10 @@ const GestionArticles = () => {
useEffect(() => {
const fetchPosts = async () => {
try {
const response = await api.get("wp/v2/posts?_fields=id,title,excerpt,featured_media");
setLoading(true);
const response = await api.get(
"wp/v2/posts?_fields=id,title,excerpt,featured_media,date,status"
);
const postsData = response.data;
const postsWithImages = await Promise.all(
@ -47,6 +150,8 @@ const GestionArticles = () => {
setPosts(postsWithImages);
} catch (error) {
console.error("❌ Erreur chargement des articles :", error);
} finally {
setLoading(false);
}
};
@ -54,110 +159,406 @@ const GestionArticles = () => {
}, []);
// Supprimer un article et son image associée
const handleDeletePost = async (postId, imageId) => {
if (window.confirm("Es-tu sûr de vouloir supprimer cet article ?")) {
const stripHtml = (value = "") =>
value.replace(/(<([^>]+)>)/gi, "").replace(/&nbsp;/gi, " ").trim();
const previewExcerpt = (value = "", limit = 160) => {
const clean = stripHtml(value);
return clean.length > limit ? `${clean.slice(0, limit)}` : clean;
};
const formatDate = (value) => {
if (!value) {
return "Date inconnue";
}
try {
await deletePost(postId, imageId); // Appel de la fonction deletePost
setPosts(posts.filter((post) => post.id !== postId)); // Met à jour la liste après suppression
return new Intl.DateTimeFormat("fr-FR", {
year: "numeric",
month: "long",
day: "numeric",
}).format(new Date(value));
} catch {
return "Date inconnue";
}
};
const handleDeletePost = async (postId, imageId) => {
const confirmed = window.confirm(
"Supprimer cet article et son visuel associé ?"
);
if (!confirmed) {
return;
}
try {
await deletePost(postId, imageId);
setPosts((prev) => prev.filter((post) => post.id !== postId));
alert("✅ Article supprimé avec succès !");
} catch (error) {
console.error("❌ Erreur suppression article :", error);
alert("⚠ Erreur : impossible de supprimer l'article.");
}
}
};
};
// Filtrage des articles
const filteredPosts = posts.filter((post) =>
post.title.rendered.toLowerCase().includes(searchTerm.toLowerCase()) ||
post.excerpt.rendered.replace(/(<([^>]+)>)/gi, "").replace(/&nbsp;/g, " ").toLowerCase().includes(searchTerm.toLowerCase())
const handleCreate = () => navigate("/admin/create-post");
const handleBack = () => navigate("/admin/dashboard");
const handleEdit = (postId) => navigate(`/admin/edit-post/${postId}`);
const filteredPosts = useMemo(() => {
const term = searchTerm.trim().toLowerCase();
if (!term) {
return posts;
}
return posts.filter((post) => {
const title = post.title?.rendered?.toLowerCase?.() || "";
const excerpt = stripHtml(post.excerpt?.rendered || "").toLowerCase();
return title.includes(term) || excerpt.includes(term);
});
}, [posts, searchTerm]);
const stats = useMemo(() => {
const total = posts.length;
const withImage = posts.reduce(
(acc, post) => (post.image ? acc + 1 : acc),
0
);
const latest = posts.reduce((current, candidate) => {
if (!candidate?.date) {
return current;
}
if (!current) {
return candidate;
}
return new Date(candidate.date) > new Date(current.date)
? candidate
: current;
}, null);
return {
total,
withImage,
withoutImage: total - withImage,
latestLabel: latest?.title?.rendered || "Aucun article",
latestDate: latest?.date ? formatDate(latest.date) : "—",
};
}, [posts]);
return (
<Box sx={{ padding: "40px 20px" }}>
{/* ✅ En-tête avec retour et création */}
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 4, mt:5 }}>
<Button startIcon={<ArrowBack />} variant="outlined" color="secondary" onClick={() => navigate("/admin/dashboard")}>
Retour au Dashboard
</Button>
<Typography variant="h4" sx={{ fontWeight: "bold", textAlign: "center", flexGrow: 1 }}>
Gestion des Articles
</Typography>
<Button startIcon={<Add />} variant="contained" color="primary" onClick={() => navigate("/admin/create-post")}>
Créer un article
</Button>
</Box>
<Box
className="glass-dashboard"
sx={{
minHeight: "100vh",
position: "relative",
px: { xs: 2, md: 6 },
pt: { xs: 6, md: 8 },
pb: { xs: 8, md: 10 },
}}
>
<Box className="glass-orb orb-1" />
<Box className="glass-orb orb-2" />
<Box className="glass-orb orb-3" />
{/* ✅ Recherche */}
<Box sx={{ position: "relative", zIndex: 1, maxWidth: 1240, mx: "auto" }}>
<Stack
direction={{ xs: "column", md: "row" }}
justifyContent="space-between"
alignItems={{ xs: "flex-start", md: "center" }}
spacing={2}
sx={{ mb: 3 }}
>
<Button
startIcon={<ArrowBack />}
variant="contained"
onClick={handleBack}
sx={{ ...composeButtonSx("ghost") }}
>
Retour
</Button>
<Stack spacing={0.5}>
<Typography
variant="overline"
sx={{
letterSpacing: 2,
fontWeight: 700,
color: "rgba(255,255,255,0.8)",
}}
>
Contenu éditorial
</Typography>
<Typography
variant="h3"
sx={{
color: "#ffffff",
fontWeight: 800,
letterSpacing: "-0.5px",
}}
>
Gestion des articles
</Typography>
<Typography
variant="body1"
sx={{
color: "rgba(255,255,255,0.85)",
maxWidth: 520,
}}
>
Consultez les publications WordPress, ajustez vos contenus et
maintenez un flux éditorial cohérent pour Octopus.
</Typography>
</Stack>
<Button
startIcon={<Add />}
variant="contained"
onClick={handleCreate}
sx={{ ...composeButtonSx("primary") }}
>
Nouvel article
</Button>
</Stack>
<Grid container spacing={2.5} sx={{ mb: 4 }}>
<Grid item xs={12} sm={4}>
<Card className="glass-subcard" sx={{ borderRadius: 3, p: 2.5 }}>
<Typography
variant="caption"
sx={{ color: "rgba(11, 26, 61, 0.6)", fontWeight: 600 }}
>
Total articles
</Typography>
<Typography
variant="h4"
sx={{ color: "#0b1a3d", fontWeight: 800, letterSpacing: "-0.5px" }}
>
{stats.total}
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)", mt: 0.5 }}
>
{filteredPosts.length} résultat(s) après filtre
</Typography>
</Card>
</Grid>
<Grid item xs={12} sm={4}>
<Card className="glass-subcard" sx={{ borderRadius: 3, p: 2.5 }}>
<Typography
variant="caption"
sx={{ color: "rgba(11, 26, 61, 0.6)", fontWeight: 600 }}
>
Articles illustrés
</Typography>
<Typography
variant="h4"
sx={{ color: "#0b1a3d", fontWeight: 800, letterSpacing: "-0.5px" }}
>
{stats.withImage}
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)", mt: 0.5 }}
>
{stats.withoutImage} sans visuel associé
</Typography>
</Card>
</Grid>
<Grid item xs={12} sm={4}>
<Card className="glass-subcard" sx={{ borderRadius: 3, p: 2.5 }}>
<Typography
variant="caption"
sx={{ color: "rgba(11, 26, 61, 0.6)", fontWeight: 600 }}
>
Dernière publication
</Typography>
<Typography
variant="subtitle1"
sx={{ color: "#0b1a3d", fontWeight: 700, lineHeight: 1.4 }}
>
{stats.latestLabel}
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)", mt: 0.5 }}
>
{stats.latestDate}
</Typography>
</Card>
</Grid>
</Grid>
<Card
className="glass-subcard"
sx={{
borderRadius: 4,
p: { xs: 2.5, md: 3 },
mb: 4,
boxShadow: "0 24px 36px -28px rgba(12, 29, 74, 0.45)",
}}
>
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
alignItems={{ xs: "stretch", md: "center" }}
justifyContent="space-between"
>
<Box>
<Typography
variant="h6"
sx={{ color: "#0b1a3d", fontWeight: 700 }}
>
Rechercher un article
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)" }}
>
Filtrez par titre ou résumé pour retrouver un contenu en quelques
secondes.
</Typography>
</Box>
<TextField
label="Rechercher un article..."
variant="outlined"
fullWidth
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
sx={{ mb: 3 }}
onChange={(event) => setSearchTerm(event.target.value)}
sx={{
maxWidth: 380,
"& .MuiOutlinedInput-root": {
borderRadius: 999,
backgroundColor: "rgba(255,255,255,0.92)",
},
}}
/>
</Stack>
</Card>
{/* ✅ Tableau des articles */}
<TableContainer component={Paper}>
<Table>
<TableHead sx={{ backgroundColor: "#0e467f" }}>
<TableRow>
<TableCell sx={{ color: "white", fontWeight: "bold" }}>Image</TableCell>
<TableCell sx={{ color: "white", fontWeight: "bold" }}>Titre</TableCell>
<TableCell sx={{ color: "white", fontWeight: "bold" }}>Résumé</TableCell>
<TableCell sx={{ color: "white", fontWeight: "bold", textAlign: "center" }}>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filteredPosts.length > 0 ? (
filteredPosts.map((post) => (
<TableRow key={post.id}>
{/* ✅ Image en vedette */}
<TableCell>
<Avatar
src={post.image || "https://via.placeholder.com/100"}
variant="rounded"
sx={{ width: 80, height: 80 }}
{loading ? (
<Box sx={{ textAlign: "center", py: 6 }}>
<CircularProgress color="inherit" />
</Box>
) : filteredPosts.length === 0 ? (
<Card
className="glass-subcard"
sx={{
borderRadius: 4,
p: 4,
textAlign: "center",
color: "#0b1a3d",
}}
>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1 }}>
Aucun article trouvé
</Typography>
<Typography variant="body2" sx={{ color: "rgba(11, 26, 61, 0.65)" }}>
Ajustez votre recherche ou créez un nouvel article pour alimenter le blog.
</Typography>
<Button
startIcon={<Add />}
variant="contained"
onClick={handleCreate}
sx={{ ...composeButtonSx("primary"), mt: 3 }}
>
Créer un article
</Button>
</Card>
) : (
<Grid container spacing={2.5}>
{filteredPosts.map((post) => (
<Grid item xs={12} md={6} key={post.id}>
<Card
className="glass-article-card"
sx={{
borderRadius: 4,
overflow: "hidden",
position: "relative",
display: "flex",
flexDirection: "column",
height: "100%",
boxShadow: "0 32px 40px -28px rgba(12, 29, 74, 0.5)",
}}
>
<CardMedia
component="div"
image={
post.image ||
"https://images.unsplash.com/photo-1524995997946-a1c2e315a42f?auto=format&fit=crop&w=1200&q=80"
}
sx={{
height: 200,
backgroundSize: "cover",
backgroundPosition: "center",
}}
/>
</TableCell>
{/* ✅ Titre de l'article */}
<TableCell sx={{ fontWeight: "bold" }}>{post.title.rendered}</TableCell>
{/* ✅ Résumé avec 100 caractères max */}
<TableCell>
{post.excerpt.rendered.replace(/(<([^>]+)>)/gi, "").replace(/&nbsp;/g, " ").substring(0, 100)}...
</TableCell>
{/* ✅ Boutons Modifier et Supprimer */}
<TableCell sx={{ textAlign: "center" }}>
<Button startIcon={<Edit />}
variant="outlined"
color="warning"
sx={{ mr: 1 }}
onClick={() => navigate(`/admin/edit-post/${post.id}`)}
<CardContent
sx={{
flexGrow: 1,
display: "flex",
flexDirection: "column",
gap: 1.5,
p: { xs: 3, md: 3.5 },
}}
>
<Stack direction="row" spacing={1} alignItems="center">
<Chip
size="small"
label={`ID ${post.id}`}
sx={{
bgcolor: "rgba(49,83,151,0.12)",
color: "#315397",
fontWeight: 600,
}}
/>
<Tooltip title={stats.latestLabel === post.title.rendered ? "Dernière publication" : ""}>
<Chip
size="small"
label={formatDate(post.date)}
sx={{
bgcolor: "rgba(255,255,255,0.72)",
color: "#0b1a3d",
fontWeight: 600,
}}
/>
</Tooltip>
</Stack>
<Typography
variant="h6"
sx={{ color: "#0b1a3d", fontWeight: 700, lineHeight: 1.4 }}
>
{post.title.rendered}
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.68)", lineHeight: 1.6 }}
>
{previewExcerpt(post.excerpt?.rendered)}
</Typography>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={1}
sx={{ mt: "auto" }}
>
<Button
startIcon={<Edit />}
variant="contained"
onClick={() => handleEdit(post.id)}
sx={{ ...composeButtonSx("outline", "small") }}
>
Modifier
</Button>
<Button
startIcon={<Delete />}
variant="outlined"
color="error"
variant="contained"
onClick={() => handleDeletePost(post.id, post.imageId)}
sx={{ ...composeButtonSx("danger", "small") }}
>
Supprimer
</Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={4} sx={{ textAlign: "center", py: 2 }}>
Aucun article trouvé.
</TableCell>
</TableRow>
</Stack>
</CardContent>
</Card>
</Grid>
))}
</Grid>
)}
</TableBody>
</Table>
</TableContainer>
</Box>
</Box>
);
};

View File

@ -4,29 +4,35 @@ import { useNavigate } from "react-router-dom"; // Import de useNavigate pour la
import {
Box,
Button,
Card,
CardContent,
Grid,
TextField,
Typography,
Container,
Avatar,
Stack,
CircularProgress,
Chip,
} from "@mui/material";
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
import "../../assets/styleCours.css";
function TestLogin() {
const [username, setUsername] = useState("");
const [appPassword, setAppPassword] = useState("");
const [token, setToken] = useState(getToken());
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const navigate = useNavigate(); // Hook pour la navigation
const handleLogin = async (e) => {
e.preventDefault();
setError("");
setLoading(true);
const newToken = await loginWithAppPassword(username, appPassword);
if (newToken) {
setToken(newToken);
} else {
alert("Échec de la connexion !");
setError("Échec de la connexion. Vérifiez vos identifiants.");
}
setLoading(false);
};
const handleLogout = () => {
@ -34,90 +40,230 @@ function TestLogin() {
setToken(null);
};
const composeButtonSx = (variant = "primary") => ({
borderRadius: 999,
textTransform: "none",
fontWeight: 600,
letterSpacing: 0.3,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: 0.75,
transition:
"transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease, border-color 0.2s ease",
px: 3,
py: 0.85,
minHeight: 42,
...(variant === "primary"
? {
background: "linear-gradient(135deg, #315397, #7bc0ff)",
color: "#ffffff",
border: "1px solid rgba(255,255,255,0.28)",
boxShadow: "0 18px 28px -18px rgba(11,26,61,0.55)",
"&:hover": {
background: "linear-gradient(135deg, #26467d, #6fb6ff)",
boxShadow: "0 22px 32px -18px rgba(11,26,61,0.62)",
},
}
: {
background: "rgba(255,255,255,0.84)",
color: "#315397",
border: "1px solid rgba(49,83,151,0.26)",
"&:hover": {
background: "rgba(255,255,255,0.95)",
borderColor: "rgba(49,83,151,0.46)",
boxShadow: "0 14px 22px -18px rgba(11,26,61,0.4)",
},
}),
});
return (
<Box
className="glass-dashboard"
sx={{
backgroundImage: "url('https://source.unsplash.com/random/1600x900?technology')",
backgroundSize: "cover",
backgroundPosition: "center",
minHeight: "100vh",
position: "relative",
px: { xs: 2, md: 6 },
pt: { xs: 8, md: 12 },
pb: { xs: 8, md: 10 },
}}
>
<Box className="glass-orb orb-1" />
<Box className="glass-orb orb-2" />
<Box className="glass-orb orb-3" />
<Grid
container
spacing={4}
alignItems="center"
justifyContent="center"
sx={{ position: "relative", zIndex: 1, maxWidth: 1180, mx: "auto" }}
>
<Grid item xs={12} md={6}>
<Stack spacing={2.5}>
<Typography
variant="overline"
sx={{
letterSpacing: 2,
fontWeight: 700,
color: "rgba(255,255,255,0.8)",
}}
>
Octopus Admin
</Typography>
<Typography
variant="h3"
sx={{
color: "#ffffff",
fontWeight: 800,
letterSpacing: "-0.5px",
lineHeight: 1.1,
}}
>
Accédez à votre espace sécurisé
</Typography>
<Typography
variant="body1"
sx={{
color: "rgba(255,255,255,0.85)",
lineHeight: 1.6,
maxWidth: 420,
}}
>
Connectez-vous avec vos identifiants dapplication pour gérer les
contenus Octopus : articles, pages services, formations et plus
encore.
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap">
<Chip
label="Authentification sécurisée"
sx={{
bgcolor: "rgba(255,255,255,0.18)",
color: "#ffffff",
fontWeight: 600,
}}
/>
<Chip
label="Accès réservé Octopus Team"
sx={{
bgcolor: "rgba(123,192,255,0.18)",
color: "#ffffff",
fontWeight: 600,
}}
/>
</Stack>
</Stack>
</Grid>
<Grid item xs={12} md={6}>
<Box
className="glass-subcard"
sx={{
borderRadius: 4,
p: { xs: 3, md: 4 },
boxShadow: "0 32px 46px -30px rgba(12, 29, 74, 0.5)",
}}
>
<Stack spacing={3} alignItems="center">
<Box
sx={{
width: 72,
height: 72,
borderRadius: "50%",
bgcolor: "rgba(49,83,151,0.14)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#315397",
boxShadow: "0 18px 28px -20px rgba(12, 29, 74, 0.45)",
}}
>
<Container maxWidth="xs">
<Card
sx={{
padding: 4,
boxShadow: 6,
borderRadius: 3,
textAlign: "center",
backdropFilter: "blur(10px)",
backgroundColor: "rgba(255, 255, 255, 0.8)",
}}
<LockOutlinedIcon fontSize="large" />
</Box>
<Typography
variant="h5"
sx={{ fontWeight: 700, color: "#0b1a3d" }}
>
<CardContent>
<Avatar sx={{ margin: "auto", backgroundColor: "#0e467f" }}>
<LockOutlinedIcon />
</Avatar>
<Typography variant="h5" sx={{ fontWeight: "bold", mt: 2 }}>
{token ? "Bienvenue !" : "Connexion"}
{token ? "Bienvenue dans lespace Octopus" : "Connexion"}
</Typography>
{token ? (
<>
<Stack spacing={1.5} sx={{ width: "100%" }}>
<Button
variant="contained"
color="primary"
onClick={() => navigate("/admin/dashboard")}
sx={{ mt: 3, width: "100%" }}
sx={{ ...composeButtonSx("primary"), width: "100%" }}
>
Accéder au tableau de bord
</Button>
<Button
variant="contained"
color="error"
onClick={handleLogout}
sx={{ mt: 2, width: "100%" }}
sx={{ ...composeButtonSx("outline"), width: "100%" }}
>
Déconnexion
</Button>
</>
</Stack>
) : (
<form onSubmit={handleLogin}>
<Box
component="form"
onSubmit={handleLogin}
sx={{ width: "100%" }}
>
<Stack spacing={2}>
<TextField
label="Nom d'utilisateur"
label="Nom dutilisateur"
fullWidth
margin="normal"
variant="outlined"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
sx={{
"& .MuiOutlinedInput-root": {
borderRadius: 999,
backgroundColor: "rgba(255,255,255,0.95)",
},
}}
/>
<TextField
label="Mot de passe d'application"
label="Mot de passe dapplication"
fullWidth
margin="normal"
variant="outlined"
type="password"
value={appPassword}
onChange={(e) => setAppPassword(e.target.value)}
required
sx={{
"& .MuiOutlinedInput-root": {
borderRadius: 999,
backgroundColor: "rgba(255,255,255,0.95)",
},
}}
/>
{error ? (
<Typography color="error" variant="body2">
{error}
</Typography>
) : null}
<Button
type="submit"
variant="contained"
color="primary"
sx={{ mt: 3, width: "100%" }}
disabled={loading}
sx={{ ...composeButtonSx("primary"), width: "100%" }}
>
Se connecter
</Button>
</form>
{loading ? (
<CircularProgress size={20} sx={{ color: "#ffffff" }} />
) : (
"Se connecter"
)}
</CardContent>
</Card>
</Container>
</Button>
</Stack>
</Box>
)}
</Stack>
</Box>
</Grid>
</Grid>
</Box>
);
}

View File

@ -1,8 +1,17 @@
import { useEffect } from "react";
import { Box, Typography, Grid, Card, CardContent, Button } from "@mui/material";
import {
Box,
Typography,
Grid,
Card,
Button,
Stack,
Chip,
} from "@mui/material";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import { useNavigate } from "react-router-dom";
import { getToken } from "../../auth";
import "../../assets/styleCours.css";
const toolsApps = [
{
@ -46,41 +55,173 @@ function Tools() {
navigate(app.url);
};
const composeButtonSx = (variant = "primary") => ({
borderRadius: 999,
textTransform: "none",
fontWeight: 600,
letterSpacing: 0.3,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: 0.75,
transition:
"transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease, border-color 0.2s ease",
px: 3,
py: 0.85,
minHeight: 42,
...(variant === "primary"
? {
background: "linear-gradient(135deg, #315397, #7bc0ff)",
color: "#ffffff",
border: "1px solid rgba(255,255,255,0.28)",
boxShadow: "0 18px 28px -18px rgba(11,26,61,0.55)",
"&:hover": {
background: "linear-gradient(135deg, #26467d, #6fb6ff)",
boxShadow: "0 22px 32px -18px rgba(11,26,61,0.62)",
},
}
: {
background: "rgba(255,255,255,0.84)",
color: "#315397",
border: "1px solid rgba(49,83,151,0.26)",
"&:hover": {
background: "rgba(255,255,255,0.95)",
borderColor: "rgba(49,83,151,0.46)",
boxShadow: "0 14px 22px -18px rgba(11,26,61,0.4)",
},
}),
});
return (
<Box sx={{ minHeight: "100vh", p: 9 }}>
<Box sx={{ display: "flex", alignItems: "center", mb: 3 }}>
<Box
className="glass-dashboard"
sx={{
minHeight: "100vh",
position: "relative",
px: { xs: 2, md: 6 },
pt: { xs: 6, md: 8 },
pb: { xs: 8, md: 10 },
}}
>
<Box className="glass-orb orb-1" />
<Box className="glass-orb orb-2" />
<Box className="glass-orb orb-3" />
<Box sx={{ position: "relative", zIndex: 1, maxWidth: 1180, mx: "auto" }}>
<Stack
direction={{ xs: "column", md: "row" }}
justifyContent="space-between"
alignItems={{ xs: "flex-start", md: "center" }}
spacing={2}
sx={{ mb: 3 }}
>
<Button
startIcon={<ArrowBackIcon />}
onClick={() => navigate("/admin/dashboard")}
sx={{ mr: 2 }}
sx={composeButtonSx("outline")}
>
Retour au dashboard
</Button>
<Typography variant="h4" sx={{ fontWeight: "bold" }}>
Mes applications
<Stack spacing={0.5}>
<Typography
variant="overline"
sx={{
letterSpacing: 2,
fontWeight: 700,
color: "rgba(255,255,255,0.8)",
}}
>
Hub applicatif Octopus
</Typography>
</Box>
<Typography
variant="h3"
sx={{
color: "#ffffff",
fontWeight: 800,
letterSpacing: "-0.5px",
}}
>
Mes outils métiers
</Typography>
<Typography
variant="body1"
sx={{
color: "rgba(255,255,255,0.85)",
maxWidth: 520,
}}
>
Centralisez vos applications internes : réseau, bilans, générateur
de photos et futurs modules dautomatisation Octopus.
</Typography>
</Stack>
<Grid container spacing={4}>
<Stack direction="row" spacing={1} flexWrap="wrap">
<Chip
label="Accès sécurisé"
sx={{
bgcolor: "rgba(255,255,255,0.2)",
color: "#ffffff",
fontWeight: 600,
}}
/>
<Chip
label="Applications internes Octopus"
sx={{
bgcolor: "rgba(123,192,255,0.2)",
color: "#ffffff",
fontWeight: 600,
}}
/>
</Stack>
</Stack>
<Grid container spacing={3}>
{toolsApps.map((app) => (
<Grid item xs={12} sm={6} md={4} key={app.id}>
<Card sx={{ height: "100%", display: "flex", flexDirection: "column" }}>
<CardContent>
<Typography variant="h6">{app.name}</Typography>
<Typography variant="body2" color="text.secondary">
<Grid item xs={12} md={6} lg={4} key={app.id}>
<Card
className="glass-card"
sx={{
borderRadius: 4,
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
p: { xs: 2.5, md: 3 },
}}
>
<Stack spacing={1.8}>
<Typography
variant="overline"
sx={{ color: "rgba(11, 26, 61, 0.65)", letterSpacing: 1 }}
>
{app.external ? "Application externe" : "Module interne"}
</Typography>
<Typography
variant="h6"
sx={{ color: "#0b1a3d", fontWeight: 700 }}
>
{app.name}
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.68)", lineHeight: 1.5 }}
>
{app.description}
</Typography>
<Box sx={{ mt: 2 }}>
<Button variant="contained" color="primary" onClick={() => handleOpenApp(app)}>
</Stack>
<Button
variant="contained"
sx={composeButtonSx("primary")}
onClick={() => handleOpenApp(app)}
>
Ouvrir
</Button>
</Box>
</CardContent>
</Card>
</Grid>
))}
</Grid>
</Box>
</Box>
);
}

BIN
server/cloudinary-backend/.DS_Store vendored Normal file

Binary file not shown.

View File

@ -0,0 +1,28 @@
#
# Backend configuration for contact form emails.
# Copy this file to `.env` and fill in your SMTP details.
#
# SMTP server host (e.g. smtp.gmail.com, smtp.mailgun.org, etc.)
SMTP_HOST=mail.sebvtl.com
# SMTP port (465 for SSL, 587 for TLS)
SMTP_PORT=465
# Use "true" if your provider requires SSL (port 465)
# Otherwise leave to "false" for TLS (port 587)
SMTP_SECURE=true
# SMTP credentials
SMTP_USER=contact@sebvtl.com
SMTP_PASS=Lightwave9.0**
# Optional: override the recipient (defaults to sebastien@octopusdesign.fr)
CONTACT_RECIPIENT=sebastien@octopusdesign.fr
# Optional: override the "from" address visible in the email client
CONTACT_FROM=no-reply@octopusdesign.fr
# Optional: add extra allowed origins for CORS (comma separated)
# Example: CORS_ALLOWED_ORIGINS=https://preprod.octopusdesign.fr,https://admin.octopusdesign.fr
CORS_ALLOWED_ORIGINS=

View File

@ -0,0 +1,123 @@
import express from "express";
import nodemailer from "nodemailer";
const router = express.Router();
const buildTransporterConfig = () => {
const host = process.env.SMTP_HOST;
const port = Number(process.env.SMTP_PORT || 587);
const secure =
typeof process.env.SMTP_SECURE === "string"
? process.env.SMTP_SECURE === "true"
: port === 465;
const user = process.env.SMTP_USER;
const pass = process.env.SMTP_PASS;
const baseConfig = {
host,
port,
secure,
};
if (user && pass) {
baseConfig.auth = { user, pass };
}
return baseConfig;
};
router.post("/contact", async (req, res) => {
const { name, email, subject, message, consent } = req.body || {};
console.info("📨 Requête contact reçue", {
name,
email,
subject,
consent,
});
if (!name || !email || !subject || !message) {
return res.status(400).json({
message:
"Merci de compléter tous les champs requis avant d'envoyer votre message.",
});
}
const recipient =
process.env.CONTACT_RECIPIENT || "sebastien@octopusdesign.fr";
const fromAddress =
process.env.CONTACT_FROM ||
process.env.SMTP_FROM ||
process.env.SMTP_USER ||
"no-reply@octopusdesign.fr";
try {
const transporter = nodemailer.createTransport(buildTransporterConfig());
const mailSubject = subject.trim()
? `[Site Octopus Design] ${subject.trim()}`
: "Nouveau message depuis le site Octopus Design";
const consentLabel = consent ? "✅ Consentement donné" : "❌ Consentement non fourni";
const textBody = `
Nom : ${name}
E-mail : ${email}
Consentement : ${consentLabel}
Message :
${message}
`;
const htmlBody = `
<p><strong>Nom :</strong> ${name}</p>
<p><strong>E-mail :</strong> ${email}</p>
<p><strong>Consentement :</strong> ${consentLabel}</p>
<p><strong>Message :</strong></p>
<p>${message.replace(/\n/g, "<br />")}</p>
`;
// Vérifie la connexion SMTP avant d'essayer d'envoyer
await transporter.verify();
const mailOptions = {
from: `"Octopus Design" <${fromAddress}>`,
to: recipient,
replyTo: email,
subject: mailSubject,
text: textBody,
html: htmlBody,
};
await transporter.sendMail(mailOptions);
console.info(
"✅ Email de contact envoyé avec succès",
JSON.stringify(
{
to: recipient,
replyTo: email,
subject: mailSubject,
},
null,
2
)
);
return res.status(200).json({
message: "Votre message a bien été envoyé. Merci pour votre confiance !",
});
} catch (error) {
console.error(
"❌ Erreur lors de l'envoi du mail de contact :",
error?.stack || error?.message || error
);
return res.status(500).json({
message:
"Impossible d'envoyer votre message pour le moment. Merci de réessayer plus tard.",
});
}
});
export default router;

View File

@ -1,17 +1,37 @@
import express from "express";
import dotenv from "dotenv";
import cloudinaryRoute from "./cloudinaryRoute.js";
import cors from "cors";
import cloudinaryRoute from "./cloudinaryRoute.js";
import contactRoute from "./contactRoute.js";
dotenv.config();
const app = express();
app.use(cors({
// origin: "https://octopusdesign.fr"
origin: ["https://octopusdesign.fr", "http://localhost:3000"]
}));
const allowedOrigins = (process.env.CORS_ALLOWED_ORIGINS || "")
.split(",")
.map((origin) => origin.trim())
.filter(Boolean);
const defaultOrigins = [
"https://octopusdesign.fr",
"https://www.octopusdesign.fr",
"https://preprod.octopusdesign.fr",
"http://localhost:3000",
"http://localhost:5173",
];
const origins = [...new Set([...defaultOrigins, ...allowedOrigins])];
app.use(
cors({
origin: origins,
})
);
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use("/api", cloudinaryRoute);
app.use("/api", contactRoute);
const PORT = process.env.PORT || 3001;
app.listen(PORT, () => {

View File

@ -10,6 +10,7 @@
"axios": "^1.6.7",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2"
"express": "^4.18.2",
"nodemailer": "^6.9.11"
}
}