Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf77437c89 |
@@ -1,30 +1,2 @@
|
||||
# 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 l’API de contact.
|
||||
Suivez ces étapes pour activer l’envoi d’e-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, l’API 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 l’application React :
|
||||
```bash
|
||||
cd frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
En production, assurez-vous que l’URL 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 d’environnement SMTP.
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# URL of the contact API (backend server).
|
||||
# In development, point to the local Express server.
|
||||
VITE_CONTACT_API_URL=http://localhost:3001/api/contact
|
||||
@@ -11,8 +11,6 @@ node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
@@ -6,20 +6,3 @@ Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Configuration Moodle
|
||||
|
||||
Les écrans d'administration (liste de cours, gestion des chapitres et lecture sécurisée) consomment l'API REST de Moodle.
|
||||
Avant de lancer le projet :
|
||||
|
||||
1. Copiez `frontend/.env.example` vers `frontend/.env.local`.
|
||||
2. Renseignez vos propres valeurs pour :
|
||||
|
||||
```
|
||||
VITE_MOODLE_API_URL=https://votre-instance/webservice/rest/server.php
|
||||
VITE_MOODLE_TOKEN=token_personnel
|
||||
```
|
||||
|
||||
> Ne versionnez jamais votre token : `.env` est ignoré par Git.
|
||||
|
||||
Ensuite vous pouvez lancer l'application avec `npm install && npm run dev` depuis le dossier `frontend`.
|
||||
|
||||
+13
-88
@@ -1,101 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title data-rh="true">Centre de formations - metier du numérique</title>
|
||||
<title>bureau d’études Maîtrise d’oeuvre</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<!-- ✅ Ajout Open Graph statique -->
|
||||
<meta
|
||||
data-rh="true"
|
||||
property="og:title"
|
||||
content="Centre de formations web IA"
|
||||
/>
|
||||
<meta
|
||||
data-rh="true"
|
||||
property="og:description"
|
||||
content="Bienvenue sur notre Centre de formations aux métiers du numérique en Vendée"
|
||||
/>
|
||||
<meta
|
||||
data-rh="true"
|
||||
property="og:image"
|
||||
content="https://res.cloudinary.com/dh5qgexjo/image/upload/v1753856912/centre-formation-metier-numerique_iwxjzv.avif"
|
||||
/>
|
||||
<meta data-rh="true" property="og:type" content="website" />
|
||||
<meta data-rh="true" property="og:url" content="https://ton-site.com" />
|
||||
<meta property="og:title" content="Titre par défaut - Bureau d’études">
|
||||
<meta property="og:description" content="Bienvenue sur notre bureau d’études en Maîtrise d’œuvre.">
|
||||
<meta property="og:image" content="https://preprod.octopusdesign.fr/api-octopus/server/wp-content/uploads/2025/01/Construction-logements-au-Mans-01.avif">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://ton-site.com">
|
||||
|
||||
<!-- ✅ Twitter Cards -->
|
||||
<meta data-rh="true" name="twitter:card" content="summary_large_image" />
|
||||
<meta
|
||||
data-rh="true"
|
||||
name="twitter:title"
|
||||
content="Centre de formations web IA"
|
||||
/>
|
||||
<meta
|
||||
data-rh="true"
|
||||
name="twitter:description"
|
||||
content="Bienvenue sur notre Centre de formations aux métiers du numérique en Vendée."
|
||||
/>
|
||||
<meta
|
||||
data-rh="true"
|
||||
name="twitter:image"
|
||||
content="https://res.cloudinary.com/dh5qgexjo/image/upload/v1753856912/centre-formation-metier-numerique_iwxjzv.avif"
|
||||
/>
|
||||
<!-- <link rel="preload" href="/src/assets/vendor-BFTbvl5C.js" as="script"> -->
|
||||
<link rel="preload" href="/assets/index-CfManBR6.css" as="style" />
|
||||
<link rel="stylesheet" href="/assets/index-CfManBR6.css" />
|
||||
<script src="https://unpkg.com/html2pdf.js@0.10.1/dist/html2pdf.bundle.min.js"></script>
|
||||
<script src="https://media-library.cloudinary.com/global/all.js"></script>
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="Titre par défaut - Bureau d’études">
|
||||
<meta name="twitter:description" content="Bienvenue sur notre bureau d’études en Maîtrise d’œuvre.">
|
||||
<meta name="twitter:image" content="https://preprod.octopusdesign.fr/api-octopus/server/wp-content/uploads/2025/01/Construction-logements-au-Mans-01.avif">
|
||||
<link rel="preload" href="/src/assets/vendor-BFTbvl5C.js" as="script">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<noscript>
|
||||
<style>
|
||||
.no-js-wrapper {
|
||||
padding: 1.5rem;
|
||||
font-family: "Helvetica Neue", Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
background: #f5f7fb;
|
||||
color: #0b1f44;
|
||||
}
|
||||
.no-js-wrapper a {
|
||||
color: #0b5ed7;
|
||||
text-decoration: none;
|
||||
}
|
||||
.no-js-wrapper a:hover,
|
||||
.no-js-wrapper a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.no-js-nav {
|
||||
margin: 1rem 0;
|
||||
padding-left: 1.2rem;
|
||||
}
|
||||
</style>
|
||||
<section class="no-js-wrapper">
|
||||
<h2>Navigation sans JavaScript</h2>
|
||||
<p>
|
||||
Le site s'appuie sur React et nécessite JavaScript pour l'expérience complète.
|
||||
Activez-le pour profiter de toutes les fonctionnalités. En attendant,
|
||||
utilisez ces liens directs :
|
||||
</p>
|
||||
<ul class="no-js-nav">
|
||||
<li><a href="/">Accueil</a></li>
|
||||
<li><a href="/services/prestation-maitrise-oeuvre/">Création de site web</a></li>
|
||||
<li><a href="/services/formations-web/">Formations web</a></li>
|
||||
<li><a href="/services/electricite/">Graphisme</a></li>
|
||||
<li><a href="/services/service-securite-incendie/">Podcast</a></li>
|
||||
<li><a href="/services/expertises-tce/">Formations professionnelles</a></li>
|
||||
<li><a href="/nosFormations/">Nos formations</a></li>
|
||||
<li><a href="/posts/">Actualités</a></li>
|
||||
<li><a href="/contact/">Contact</a></li>
|
||||
<li><a href="/search/">Recherche</a></li>
|
||||
<li><a href="/sitemap.xml">Plan du site complet</a></li>
|
||||
</ul>
|
||||
<p>
|
||||
Retrouvez aussi nos informations légales et politiques via le plan du site.
|
||||
</p>
|
||||
</section>
|
||||
</noscript>
|
||||
<script type="module" src="/src/main.jsx" defer></script>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Generated
+288
-1248
File diff suppressed because it is too large
Load Diff
+1
-10
@@ -5,12 +5,9 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"prebuild": "node scripts/update-noscript.mjs",
|
||||
"build": "vite build",
|
||||
"postbuild": "node scripts/prerender.mjs",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"prerender": "node scripts/prerender.mjs"
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
@@ -18,17 +15,12 @@
|
||||
"@mui/icons-material": "^6.3.0",
|
||||
"@mui/material": "^6.3.0",
|
||||
"axios": "^1.7.9",
|
||||
"cloudinary": "^2.7.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"quill": "^1.3.7",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-helmet-async": "^2.0.5",
|
||||
"react-quill": "^2.0.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"react-simple-lightbox": "^1.0.26",
|
||||
"react-toastify": "^11.0.3",
|
||||
"simple-react-lightbox": "^3.6.8",
|
||||
"svgo": "^3.3.2",
|
||||
"swiper": "^10.3.1"
|
||||
@@ -45,7 +37,6 @@
|
||||
"eslint-plugin-react-refresh": "^0.4.16",
|
||||
"globals": "^15.14.0",
|
||||
"postcss": "^8.4.49",
|
||||
"puppeteer": "^24.28.0",
|
||||
"rollup-plugin-visualizer": "^5.14.0",
|
||||
"vite": "^6.0.5",
|
||||
"vite-plugin-compression": "^0.5.1"
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
Disallow: /static/
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://octopusdesign.fr/sitemap.xml
|
||||
@@ -1,149 +0,0 @@
|
||||
import { createServer } from "node:http";
|
||||
import { readFile, stat, writeFile, mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import puppeteer from "puppeteer";
|
||||
import { PUBLIC_ROUTES, NOT_FOUND_ROUTE } from "./routes.mjs";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const distDir = path.resolve(__dirname, "../dist");
|
||||
|
||||
const routes = PUBLIC_ROUTES.filter((route) => route.prerender).map(
|
||||
(route) => route.path
|
||||
);
|
||||
|
||||
const mimeTypes = {
|
||||
".html": "text/html",
|
||||
".js": "application/javascript",
|
||||
".css": "text/css",
|
||||
".json": "application/json",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".avif": "image/avif",
|
||||
".webp": "image/webp",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
".txt": "text/plain",
|
||||
".xml": "application/xml",
|
||||
".map": "application/json",
|
||||
".gz": "application/gzip",
|
||||
".br": "application/brotli",
|
||||
};
|
||||
|
||||
function serveStaticFile(res, filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const contentType = mimeTypes[ext] ?? "application/octet-stream";
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", contentType);
|
||||
return readFile(filePath).then((data) => res.end(data));
|
||||
}
|
||||
|
||||
function createStaticServer() {
|
||||
const server = createServer(async (req, res) => {
|
||||
try {
|
||||
const requestUrl = new URL(req.url ?? "/", "http://localhost");
|
||||
let pathname = decodeURIComponent(requestUrl.pathname);
|
||||
const hasExtension = path.extname(pathname) !== "";
|
||||
|
||||
if (pathname.endsWith("/")) {
|
||||
pathname = pathname.slice(0, -1);
|
||||
}
|
||||
|
||||
let filePath = path.join(distDir, pathname);
|
||||
|
||||
try {
|
||||
const fileStat = await stat(filePath);
|
||||
if (fileStat.isDirectory()) {
|
||||
filePath = path.join(filePath, "index.html");
|
||||
}
|
||||
await serveStaticFile(res, filePath);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (hasExtension) {
|
||||
res.statusCode = 404;
|
||||
res.end("Not found");
|
||||
return;
|
||||
}
|
||||
filePath = path.join(distDir, "index.html");
|
||||
await serveStaticFile(res, filePath);
|
||||
}
|
||||
} catch (error) {
|
||||
res.statusCode = 500;
|
||||
res.end(`Server error: ${error instanceof Error ? error.message : error}`);
|
||||
}
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, () => {
|
||||
const address = server.address();
|
||||
if (address && typeof address === "object") {
|
||||
resolve({ server, port: address.port });
|
||||
} else {
|
||||
resolve({ server, port: 4173 });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
async function renderRoute(browser, baseUrl, route, outputFile) {
|
||||
const page = await browser.newPage();
|
||||
const targetUrl = `${baseUrl}${route}`;
|
||||
|
||||
try {
|
||||
await page.goto(targetUrl, {
|
||||
waitUntil: ["networkidle0", "domcontentloaded"],
|
||||
timeout: 60000,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[prerender] Navigation to ${targetUrl} failed: ${
|
||||
error instanceof Error ? error.message : error
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Donne un léger délai pour laisser React terminer le rendu asynchrone
|
||||
await delay(1500);
|
||||
const html = await page.content();
|
||||
const outputPath =
|
||||
outputFile ??
|
||||
(route === "/"
|
||||
? path.join(distDir, "index.html")
|
||||
: path.join(distDir, route.replace(/^\//, ""), "index.html"));
|
||||
|
||||
await mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await writeFile(outputPath, html, "utf8");
|
||||
|
||||
console.log(
|
||||
`✔ prerendered ${route} -> ${path.relative(distDir, outputPath) || "index.html"}`
|
||||
);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { server, port } = await createStaticServer();
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
const browser = await puppeteer.launch({ headless: true });
|
||||
|
||||
try {
|
||||
for (const route of routes) {
|
||||
await renderRoute(browser, baseUrl, route);
|
||||
}
|
||||
await renderRoute(browser, baseUrl, NOT_FOUND_ROUTE, path.join(distDir, "404.html"));
|
||||
} finally {
|
||||
await browser.close();
|
||||
server.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("[prerender] Failed to prerender routes:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
const withTrailingSlash = (path) => {
|
||||
if (!path || path === "/") return "/";
|
||||
return path.endsWith("/") ? path : `${path}/`;
|
||||
};
|
||||
|
||||
export const PUBLIC_ROUTES = [
|
||||
{ path: "/", href: "/", label: "Accueil", prerender: true },
|
||||
{
|
||||
path: "/services/prestation-maitrise-oeuvre",
|
||||
href: withTrailingSlash("/services/prestation-maitrise-oeuvre"),
|
||||
label: "Création de site web",
|
||||
prerender: true,
|
||||
},
|
||||
{
|
||||
path: "/services/formations-web",
|
||||
href: withTrailingSlash("/services/formations-web"),
|
||||
label: "Formations web",
|
||||
prerender: true,
|
||||
},
|
||||
{
|
||||
path: "/services/electricite",
|
||||
href: withTrailingSlash("/services/electricite"),
|
||||
label: "Graphisme",
|
||||
prerender: true,
|
||||
},
|
||||
{
|
||||
path: "/services/service-securite-incendie",
|
||||
href: withTrailingSlash("/services/service-securite-incendie"),
|
||||
label: "Podcast",
|
||||
prerender: true,
|
||||
},
|
||||
{
|
||||
path: "/services/expertises-tce",
|
||||
href: withTrailingSlash("/services/expertises-tce"),
|
||||
label: "Formations professionnelles",
|
||||
prerender: true,
|
||||
},
|
||||
{
|
||||
path: "/nosFormations",
|
||||
href: withTrailingSlash("/nosFormations"),
|
||||
label: "Nos formations",
|
||||
prerender: true,
|
||||
},
|
||||
{
|
||||
path: "/posts",
|
||||
href: withTrailingSlash("/posts"),
|
||||
label: "Actualités",
|
||||
prerender: true,
|
||||
},
|
||||
{
|
||||
path: "/contact",
|
||||
href: withTrailingSlash("/contact"),
|
||||
label: "Contact",
|
||||
prerender: true,
|
||||
},
|
||||
{
|
||||
path: "/search",
|
||||
href: withTrailingSlash("/search"),
|
||||
label: "Recherche",
|
||||
prerender: true,
|
||||
},
|
||||
{
|
||||
path: "/sitemap.xml",
|
||||
href: "/sitemap.xml",
|
||||
label: "Plan du site complet",
|
||||
prerender: false,
|
||||
},
|
||||
];
|
||||
|
||||
export const NOT_FOUND_ROUTE = "/__404";
|
||||
@@ -1,50 +0,0 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { PUBLIC_ROUTES } from "./routes.mjs";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const indexPath = path.resolve(__dirname, "../index.html");
|
||||
|
||||
const LIST_INDENT = " ";
|
||||
const WRAPPER_INDENT = " ";
|
||||
|
||||
const generateListItems = () =>
|
||||
PUBLIC_ROUTES.map(
|
||||
({ path, href, label }) => {
|
||||
const targetHref = href ?? path;
|
||||
return `${LIST_INDENT}<li><a href="${targetHref}">${label}</a></li>`;
|
||||
}
|
||||
).join("\n");
|
||||
|
||||
async function main() {
|
||||
const originalContent = await readFile(indexPath, "utf8");
|
||||
|
||||
const navPattern = new RegExp(
|
||||
`${WRAPPER_INDENT}<ul class="no-js-nav">[\\s\\S]*?${WRAPPER_INDENT}<\\/ul>`,
|
||||
"m"
|
||||
);
|
||||
const match = originalContent.match(navPattern);
|
||||
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
"Bloc <ul class=\"no-js-nav\"> introuvable dans index.html. Impossible de mettre à jour la navigation noscript."
|
||||
);
|
||||
}
|
||||
|
||||
const replacement = `${WRAPPER_INDENT}<ul class="no-js-nav">\n${generateListItems()}\n${WRAPPER_INDENT}</ul>`;
|
||||
const updatedContent = originalContent.replace(navPattern, replacement);
|
||||
|
||||
if (updatedContent !== originalContent) {
|
||||
await writeFile(indexPath, updatedContent, "utf8");
|
||||
console.log("✔ Mise à jour de la navigation noscript dans index.html");
|
||||
} else {
|
||||
console.log("ℹ Navigation noscript déjà à jour");
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("[update-noscript] Échec de la mise à jour :", error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Box, Typography, Button, CircularProgress } from "@mui/material";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import api from "../api";
|
||||
|
||||
const PostDetails = () => {
|
||||
const { id } = useParams();
|
||||
const [post, setPost] = useState(null);
|
||||
const [image, setImage] = useState(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPost = async () => {
|
||||
try {
|
||||
const response = await api.get(`wp/v2/posts/${id}?_fields=title,content,featured_media,date`);
|
||||
setPost(response.data);
|
||||
|
||||
// Récupération de l'image
|
||||
if (response.data.featured_media) {
|
||||
const mediaResponse = await api.get(`wp/v2/media/${response.data.featured_media}`);
|
||||
setImage(mediaResponse.data.source_url);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erreur lors de la récupération de l'article :", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPost();
|
||||
}, [id]);
|
||||
|
||||
if (!post) {
|
||||
return <CircularProgress sx={{ display: "block", margin: "auto", mt: 5 }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ padding: "40px 20px",display:"flex", flexDirection:"column" }}>
|
||||
<Button onClick={() => navigate(-1)} variant="outlined" sx={{ mt:5, mb: 3, maxWidth:"200px" }}>
|
||||
⬅ Retour
|
||||
</Button>
|
||||
|
||||
<Typography variant="h1" sx={{
|
||||
fontWeight: "bold",
|
||||
textAlign: "center",
|
||||
mb: 4,
|
||||
fontSize: { xs: '3rem', md: '3rem', lg: '3rem' }, // Responsive
|
||||
}}>
|
||||
{post.title.rendered}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display:"flex", alignItems:"center", justifyContent:"center", padding: "40px 20px",margin:"auto" }}>
|
||||
{image && (
|
||||
<Box
|
||||
component="img"
|
||||
src={image}
|
||||
alt={post.title.rendered}
|
||||
sx={{ width: "33%", maxHeight: "400px", objectFit: "cover", borderRadius: "8px", mb: 3 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
<Typography variant="body1" sx={{ maxWidth: "800px", mx: "auto" }} dangerouslySetInnerHTML={{ __html: post.content.rendered }} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default PostDetails;
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 9.9 KiB |
@@ -1,38 +0,0 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap');
|
||||
|
||||
.MuiTypography-h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
font-family: "Nunito", sans-serif !important;
|
||||
font-optical-sizing: auto;
|
||||
font-weight: 800 !important;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/* h2,
|
||||
span {
|
||||
font-size: 2.2em;
|
||||
line-height: 1.1;
|
||||
font-family: "Nunito", sans-serif !important;
|
||||
font-optical-sizing: auto;
|
||||
font-weight: 700 !important;
|
||||
font-style: normal;
|
||||
color: #315397;
|
||||
|
||||
} */
|
||||
|
||||
/* .MuiTypography-h2{
|
||||
color: #ffffff !important;
|
||||
} */
|
||||
|
||||
/* .css-1ulggnf h2,
|
||||
.MuiTypography-span,
|
||||
.MuiTypography-h6{
|
||||
color: #ffffff !important;
|
||||
|
||||
} */
|
||||
|
||||
/* .MuiBox-root .MuiTypography-h2{
|
||||
color: #315397 !important
|
||||
} */
|
||||
|
||||
@@ -1,374 +0,0 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap');
|
||||
|
||||
h1 {
|
||||
font-size: 1.6rem;
|
||||
line-height: 1.1;
|
||||
font-family: "Nunito", sans-serif !important;
|
||||
font-optical-sizing: auto;
|
||||
font-weight: 800 !important;
|
||||
font-style: normal;
|
||||
color: #315397 !important;
|
||||
|
||||
}
|
||||
p,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5{
|
||||
font-family: "Nunito", sans-serif !important;
|
||||
font-optical-sizing: auto;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.c4lv-readingcontext p,
|
||||
.c4lv-readingcontext {
|
||||
font-size: 16px;
|
||||
line-height: 23px;
|
||||
}
|
||||
|
||||
.c4lv-dodontcards .c4l-dodontcards-do {
|
||||
min-width: 200px;
|
||||
max-width: 100%;
|
||||
background: #f1fbf5;
|
||||
border-radius: 10px;
|
||||
padding: 24px 48px 30px 36px;
|
||||
margin: 12px auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.c4lv-dodontcards .c4l-dodontcards-do::before {
|
||||
content: url(https://www.formations.octopusdesign.fr/theme/image.php/boost_magnific/tiny_c4l/1752797278/c4l_docard);
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
.c4lv-dodontcards .c4l-dodontcards-dont {
|
||||
min-width: 200px;
|
||||
max-width: 100%;
|
||||
background: #ffefef;
|
||||
border-radius: 10px;
|
||||
padding: 24px 48px 30px 36px;
|
||||
margin: 12px auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.c4lv-dodontcards .c4l-dodontcards-dont::before {
|
||||
content: url(https://www.formations.octopusdesign.fr/theme/image.php/boost_magnific/tiny_c4l/1752797278/c4l_dontcard);
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
.c4lv-readingcontext p, .c4lv-readingcontext span, .c4lv-readingcontext li {
|
||||
font-size: 16px;
|
||||
line-height: 23px;
|
||||
font-family: "Nunito", sans-serif !important;
|
||||
font-optical-sizing: auto;
|
||||
font-weight: 700 !important;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.2em !important;
|
||||
}
|
||||
|
||||
|
||||
ul li {
|
||||
list-style: inherit !important;
|
||||
}
|
||||
|
||||
/* .css-1ulggnf h2,
|
||||
.MuiTypography-span,
|
||||
.MuiTypography-h6 {
|
||||
color: #ffffff;
|
||||
|
||||
} */
|
||||
|
||||
.c4lv-remember {
|
||||
background-color: #d7d9eb;
|
||||
margin: 24px auto 8px auto;
|
||||
padding: 24px 30px;
|
||||
border: 2px solid #315397;
|
||||
border-radius: 16px 0 16px 16px;
|
||||
color: #315397;
|
||||
font-family: Poppins, sans-serif;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.c4lv-remember::before {
|
||||
content: url(https://res.cloudinary.com/dh5qgexjo/image/upload/v1752833757/Formao_info.5a175e98_drna5l.svg)" À retenir";
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
font-size: 1.5em;
|
||||
font-weight: 600;
|
||||
color: #315397;
|
||||
}
|
||||
|
||||
.c4lv-readingcontext {
|
||||
min-width: 200px;
|
||||
max-width: 75%;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 4px 24px rgb(0 0 0 / .08);
|
||||
box-sizing: border-box;
|
||||
margin: 36px auto;
|
||||
}
|
||||
|
||||
.c4lv-readingcontext {
|
||||
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;
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Box, Typography, CircularProgress } from "@mui/material";
|
||||
import { MoodleApi } from "../../services/moodleApi";
|
||||
const PUBLIC_LINKS_STORAGE_KEY = "listeCoursPublicLinks";
|
||||
|
||||
const CoursLecture = () => {
|
||||
const { token } = useParams();
|
||||
const [content, setContent] = useState(null);
|
||||
const [title, setTitle] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tokenValid, setTokenValid] = useState(true);
|
||||
|
||||
// ✅ Masquer Header/Footer pendant l'affichage
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") {
|
||||
return () => {};
|
||||
}
|
||||
const header = document.querySelector("header");
|
||||
const footer = document.querySelector("footer");
|
||||
const previousHeaderDisplay = header?.style.display;
|
||||
const previousFooterDisplay = footer?.style.display;
|
||||
if (header) header.style.display = "none";
|
||||
if (footer) footer.style.display = "none";
|
||||
|
||||
const handleContextMenu = (event) => event.preventDefault();
|
||||
const handleCopyPaste = (event) => event.preventDefault();
|
||||
document.addEventListener("contextmenu", handleContextMenu);
|
||||
document.addEventListener("copy", handleCopyPaste);
|
||||
document.addEventListener("cut", handleCopyPaste);
|
||||
document.addEventListener("paste", handleCopyPaste);
|
||||
|
||||
const previousUserSelect = document.body.style.userSelect;
|
||||
document.body.style.userSelect = "none";
|
||||
|
||||
return () => {
|
||||
if (header) header.style.display = previousHeaderDisplay || "";
|
||||
if (footer) footer.style.display = previousFooterDisplay || "";
|
||||
document.removeEventListener("contextmenu", handleContextMenu);
|
||||
document.removeEventListener("copy", handleCopyPaste);
|
||||
document.removeEventListener("cut", handleCopyPaste);
|
||||
document.removeEventListener("paste", handleCopyPaste);
|
||||
document.body.style.userSelect = previousUserSelect || "auto";
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ✅ Récupération contenu
|
||||
useEffect(() => {
|
||||
const decodeToken = () => {
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const raw = atob(token);
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
throw new Error("Invalid JSON payload");
|
||||
}
|
||||
const {
|
||||
courseId,
|
||||
itemType = "page",
|
||||
itemId,
|
||||
expiry,
|
||||
linkId = null,
|
||||
} = parsed;
|
||||
if (!courseId || !itemId || !expiry) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
courseId: String(courseId),
|
||||
itemType: itemType || "page",
|
||||
itemId: String(itemId),
|
||||
expiry,
|
||||
linkId,
|
||||
format: "json",
|
||||
};
|
||||
} catch (parseError) {
|
||||
console.warn("Lien public JSON invalide :", parseError);
|
||||
const parts = raw.split("_");
|
||||
if (parts.length === 3) {
|
||||
const [courseId, itemId, expiry] = parts;
|
||||
return {
|
||||
courseId,
|
||||
itemType: "page",
|
||||
itemId,
|
||||
expiry,
|
||||
linkId: null,
|
||||
format: "legacy",
|
||||
};
|
||||
}
|
||||
if (parts.length === 4) {
|
||||
const [courseId, itemType, itemId, expiry] = parts;
|
||||
return {
|
||||
courseId,
|
||||
itemType,
|
||||
itemId,
|
||||
expiry,
|
||||
linkId: null,
|
||||
format: "legacy",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
} catch (decodeError) {
|
||||
console.warn("Impossible de décoder le lien sécurisé :", decodeError);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const decoded = decodeToken();
|
||||
if (!decoded) {
|
||||
setTokenValid(false);
|
||||
setContent("⛔ Lien invalide.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { courseId, itemType, itemId, expiry, format } = decoded;
|
||||
const expiryValue = Number(expiry);
|
||||
if (
|
||||
!courseId ||
|
||||
!itemId ||
|
||||
!Number.isFinite(expiryValue) ||
|
||||
Date.now() > expiryValue
|
||||
) {
|
||||
setTokenValid(false);
|
||||
setContent("⛔ Le cours est terminé !");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedItemType = itemType || "page";
|
||||
const linkStillActive = () => {
|
||||
if (format === "legacy") {
|
||||
return true;
|
||||
}
|
||||
if (typeof window === "undefined") {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const stored = localStorage.getItem(PUBLIC_LINKS_STORAGE_KEY);
|
||||
if (!stored) {
|
||||
return false;
|
||||
}
|
||||
const registry = JSON.parse(stored);
|
||||
const key = `${courseId}-${normalizedItemType}-${itemId}`;
|
||||
const entry = registry?.[key];
|
||||
if (!entry || !entry.active) {
|
||||
return false;
|
||||
}
|
||||
if (entry.token && entry.token !== token) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
entry.expiry !== undefined &&
|
||||
Number.isFinite(Number(entry.expiry)) &&
|
||||
Date.now() > Number(entry.expiry)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Impossible de vérifier l'état du lien public :", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if (!linkStillActive()) {
|
||||
setTokenValid(false);
|
||||
setContent("⛔ Ce lien n'est plus actif.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchPage = async () => {
|
||||
try {
|
||||
const pages = await MoodleApi.getPagesByCourse(Number(courseId));
|
||||
const parsedItemId = parseInt(itemId, 10);
|
||||
const found = pages.find((p) => p.id === parsedItemId);
|
||||
if (found) {
|
||||
setTitle(found.name || "");
|
||||
setContent(found.content || "");
|
||||
} else {
|
||||
setTokenValid(false);
|
||||
setContent("⛔ Page non trouvée.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erreur lors du chargement de la page Moodle :", error);
|
||||
setTokenValid(false);
|
||||
setContent("❌ Erreur lors du chargement.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAssignment = async () => {
|
||||
try {
|
||||
const assignments = await MoodleApi.getAssignmentsByCourse(
|
||||
Number(courseId)
|
||||
);
|
||||
const parsedAssignmentId = parseInt(itemId, 10);
|
||||
const assignment = assignments.find(
|
||||
(a) => a.id === parsedAssignmentId
|
||||
);
|
||||
if (assignment) {
|
||||
setTitle(assignment.name || "");
|
||||
setContent(assignment.intro || "<p>Aucun contenu disponible.</p>");
|
||||
} else {
|
||||
setTokenValid(false);
|
||||
setContent("⛔ Devoir introuvable.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Erreur lors du chargement du devoir Moodle :",
|
||||
error
|
||||
);
|
||||
setTokenValid(false);
|
||||
setContent("❌ Erreur lors du chargement du devoir.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (normalizedItemType === "assignment") {
|
||||
fetchAssignment();
|
||||
} else {
|
||||
fetchPage();
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
padding: 4,
|
||||
textAlign: "center",
|
||||
userSelect: "none",
|
||||
WebkitUserSelect: "none",
|
||||
MozUserSelect: "none",
|
||||
msUserSelect: "none",
|
||||
}}
|
||||
>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!tokenValid) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
height: "100vh",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#f8d7da",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h3"
|
||||
sx={{
|
||||
color: "#721c24",
|
||||
fontWeight: "bold",
|
||||
textAlign: "center",
|
||||
padding: 4,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ padding: 4, mt: 5 }}>
|
||||
{title ? (
|
||||
<Typography variant="h4" sx={{ mb: 3, color: "#315397" }}>
|
||||
{title}
|
||||
</Typography>
|
||||
) : null}
|
||||
<div dangerouslySetInnerHTML={{ __html: content }} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CoursLecture;
|
||||
@@ -1,817 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CircularProgress,
|
||||
Divider,
|
||||
Grid,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import { MoodleApi } from "../../services/moodleApi";
|
||||
import { isMoodleConfigured } from "../../config/moodle";
|
||||
import RichTextEditor from "../common/RichTextEditor";
|
||||
|
||||
const defaultCourseForm = {
|
||||
fullname: "",
|
||||
shortname: "",
|
||||
categoryid: "1",
|
||||
summary: "",
|
||||
};
|
||||
|
||||
const defaultSectionForm = {
|
||||
name: "",
|
||||
summary: "",
|
||||
section: "",
|
||||
};
|
||||
|
||||
const formatSectionName = (section) => {
|
||||
if (!section) {
|
||||
return "Section sans titre";
|
||||
}
|
||||
const number =
|
||||
section.section !== null && section.section !== undefined
|
||||
? Number(section.section)
|
||||
: null;
|
||||
if (section.name?.trim()) {
|
||||
return section.name.trim();
|
||||
}
|
||||
if (Number.isFinite(number)) {
|
||||
return `Chapitre ${number}`;
|
||||
}
|
||||
return "Section sans titre";
|
||||
};
|
||||
|
||||
const mapCourseToForm = (course) => ({
|
||||
fullname: course.fullname || "",
|
||||
shortname: course.shortname || "",
|
||||
categoryid: String(course.categoryid || "1"),
|
||||
summary: course.summary || "",
|
||||
});
|
||||
|
||||
const GestionCours = () => {
|
||||
const [courses, setCourses] = useState([]);
|
||||
const [courseForm, setCourseForm] = useState(defaultCourseForm);
|
||||
const [selectedCourse, setSelectedCourse] = useState(null);
|
||||
const [sections, setSections] = useState([]);
|
||||
const [sectionForm, setSectionForm] = useState(defaultSectionForm);
|
||||
const [selectedSection, setSelectedSection] = useState(null);
|
||||
const [loadingCourses, setLoadingCourses] = useState(true);
|
||||
const [loadingSections, setLoadingSections] = useState(false);
|
||||
const [courseActionLoading, setCourseActionLoading] = useState(false);
|
||||
const [sectionActionLoading, setSectionActionLoading] = useState(false);
|
||||
const [courseFeedback, setCourseFeedback] = useState(null);
|
||||
const [sectionFeedback, setSectionFeedback] = useState(null);
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
|
||||
const handleResetSectionForm = useCallback(() => {
|
||||
setSelectedSection(null);
|
||||
setSectionForm(defaultSectionForm);
|
||||
}, []);
|
||||
|
||||
const handleResetCourseSelection = useCallback(() => {
|
||||
setSelectedCourse(null);
|
||||
setCourseForm(defaultCourseForm);
|
||||
setSections([]);
|
||||
handleResetSectionForm();
|
||||
}, [handleResetSectionForm]);
|
||||
|
||||
const refreshCourses = useCallback(async (preserveCourseId) => {
|
||||
try {
|
||||
if (!isMoodleConfigured()) {
|
||||
return;
|
||||
}
|
||||
setLoadingCourses(true);
|
||||
const data = await MoodleApi.listCourses();
|
||||
const sanitized = (Array.isArray(data) ? data : []).filter(
|
||||
(course) => course.id !== 1
|
||||
);
|
||||
setCourses(sanitized);
|
||||
if (preserveCourseId) {
|
||||
const stillExists = sanitized.find((c) => c.id === preserveCourseId);
|
||||
if (stillExists) {
|
||||
setSelectedCourse(stillExists);
|
||||
setCourseForm(mapCourseToForm(stillExists));
|
||||
} else {
|
||||
handleResetCourseSelection();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erreur lors du chargement des cours :", error);
|
||||
setCourseFeedback({
|
||||
type: "error",
|
||||
message:
|
||||
error?.message || "Impossible de récupérer la liste des cours.",
|
||||
});
|
||||
} finally {
|
||||
setLoadingCourses(false);
|
||||
}
|
||||
}, [handleResetCourseSelection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMoodleConfigured()) {
|
||||
setCourseFeedback({
|
||||
type: "error",
|
||||
message:
|
||||
"Configurez VITE_MOODLE_API_URL et VITE_MOODLE_TOKEN pour utiliser cette interface.",
|
||||
});
|
||||
setLoadingCourses(false);
|
||||
return;
|
||||
}
|
||||
refreshCourses();
|
||||
}, [refreshCourses]);
|
||||
|
||||
const refreshSections = async (courseId) => {
|
||||
if (!courseId) {
|
||||
setSections([]);
|
||||
return;
|
||||
}
|
||||
setLoadingSections(true);
|
||||
try {
|
||||
const data = await MoodleApi.getCourseContents(courseId);
|
||||
const normalized = (Array.isArray(data) ? data : []).map((section) => ({
|
||||
id: section.id,
|
||||
section: section.section,
|
||||
name: formatSectionName(section),
|
||||
summary: section.summary || "",
|
||||
}));
|
||||
setSections(normalized);
|
||||
} catch (error) {
|
||||
console.error("Erreur lors du chargement des sections :", error);
|
||||
setSectionFeedback({
|
||||
type: "error",
|
||||
message:
|
||||
error?.message || "Impossible de récupérer les sections du cours.",
|
||||
});
|
||||
} finally {
|
||||
setLoadingSections(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectCourse = (course) => {
|
||||
setSelectedCourse(course);
|
||||
setCourseForm(mapCourseToForm(course));
|
||||
handleResetSectionForm();
|
||||
refreshSections(course.id);
|
||||
};
|
||||
|
||||
const handleCourseInputChange = (field) => (event) => {
|
||||
const { value } = event.target;
|
||||
setCourseForm((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSummaryChange = (value) => {
|
||||
setCourseForm((prev) => ({
|
||||
...prev,
|
||||
summary: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const slugify = (value) =>
|
||||
value
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^\w\s-]/g, "")
|
||||
.trim()
|
||||
.replace(/\s+/g, "-")
|
||||
.toLowerCase();
|
||||
|
||||
const buildCoursePayload = () => {
|
||||
const fullname = courseForm.fullname.trim();
|
||||
const shortname =
|
||||
courseForm.shortname.trim() || slugify(courseForm.fullname);
|
||||
if (!fullname || !shortname) {
|
||||
throw new Error(
|
||||
"Le titre et l'identifiant court du cours sont obligatoires."
|
||||
);
|
||||
}
|
||||
const payload = {
|
||||
fullname,
|
||||
shortname,
|
||||
summary: courseForm.summary || "",
|
||||
summaryformat: 1,
|
||||
};
|
||||
const normalizedCategory =
|
||||
courseForm.categoryid && !Number.isNaN(Number(courseForm.categoryid))
|
||||
? Number(courseForm.categoryid)
|
||||
: null;
|
||||
if (normalizedCategory !== null) {
|
||||
payload.categoryid = normalizedCategory;
|
||||
}
|
||||
return payload;
|
||||
};
|
||||
|
||||
const handleCreateCourse = async () => {
|
||||
setCourseFeedback(null);
|
||||
try {
|
||||
setCourseActionLoading(true);
|
||||
const payload = buildCoursePayload();
|
||||
await MoodleApi.createCourse(payload);
|
||||
setCourseFeedback({
|
||||
type: "success",
|
||||
message: "Cours créé avec succès.",
|
||||
});
|
||||
setCourseForm(defaultCourseForm);
|
||||
await refreshCourses();
|
||||
} catch (error) {
|
||||
setCourseFeedback({
|
||||
type: "error",
|
||||
message: error?.message || "Impossible de créer le cours.",
|
||||
});
|
||||
} finally {
|
||||
setCourseActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateCourse = async () => {
|
||||
if (!selectedCourse?.id) {
|
||||
return;
|
||||
}
|
||||
setCourseFeedback(null);
|
||||
try {
|
||||
setCourseActionLoading(true);
|
||||
const payload = buildCoursePayload();
|
||||
await MoodleApi.updateCourse({
|
||||
id: Number(selectedCourse.id),
|
||||
...payload,
|
||||
});
|
||||
setCourseFeedback({
|
||||
type: "success",
|
||||
message: "Cours mis à jour.",
|
||||
});
|
||||
await refreshCourses(selectedCourse.id);
|
||||
} catch (error) {
|
||||
setCourseFeedback({
|
||||
type: "error",
|
||||
message: error?.message || "Impossible de mettre à jour le cours.",
|
||||
});
|
||||
} finally {
|
||||
setCourseActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCourse = async (courseArg) => {
|
||||
const targetCourse = courseArg || selectedCourse;
|
||||
if (!targetCourse?.id) {
|
||||
return;
|
||||
}
|
||||
const confirmed = window.confirm(
|
||||
"Supprimer définitivement ce cours et tous ses contenus ?"
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
setCourseFeedback(null);
|
||||
try {
|
||||
setCourseActionLoading(true);
|
||||
await MoodleApi.deleteCourses([targetCourse.id]);
|
||||
setCourseFeedback({
|
||||
type: "success",
|
||||
message: "Cours supprimé.",
|
||||
});
|
||||
if (selectedCourse?.id === targetCourse.id) {
|
||||
handleResetCourseSelection();
|
||||
}
|
||||
await refreshCourses();
|
||||
} catch (error) {
|
||||
setCourseFeedback({
|
||||
type: "error",
|
||||
message: error?.message || "Impossible de supprimer le cours.",
|
||||
});
|
||||
} finally {
|
||||
setCourseActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSectionInputChange = (field) => (event) => {
|
||||
const { value } = event.target;
|
||||
setSectionForm((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleEditSection = (section) => {
|
||||
setSelectedSection(section);
|
||||
setSectionForm({
|
||||
name: section.name || "",
|
||||
summary: section.summary || "",
|
||||
section:
|
||||
section.section !== null && section.section !== undefined
|
||||
? String(section.section)
|
||||
: "",
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateSection = async () => {
|
||||
if (!selectedCourse?.id) {
|
||||
return;
|
||||
}
|
||||
setSectionFeedback(null);
|
||||
try {
|
||||
setSectionActionLoading(true);
|
||||
const payload = {
|
||||
courseid: selectedCourse.id,
|
||||
name: sectionForm.name?.trim() || undefined,
|
||||
summary: sectionForm.summary || "",
|
||||
};
|
||||
if (sectionForm.section) {
|
||||
payload.section = Number(sectionForm.section);
|
||||
}
|
||||
await MoodleApi.createSection(payload);
|
||||
setSectionFeedback({
|
||||
type: "success",
|
||||
message: "Chapitre ajouté.",
|
||||
});
|
||||
handleResetSectionForm();
|
||||
await refreshSections(selectedCourse.id);
|
||||
} catch (error) {
|
||||
setSectionFeedback({
|
||||
type: "error",
|
||||
message: error?.message || "Impossible d'ajouter le chapitre.",
|
||||
});
|
||||
} finally {
|
||||
setSectionActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateSection = async () => {
|
||||
if (!selectedSection?.id || !selectedCourse?.id) {
|
||||
return;
|
||||
}
|
||||
setSectionFeedback(null);
|
||||
try {
|
||||
setSectionActionLoading(true);
|
||||
await MoodleApi.updateSection({
|
||||
id: selectedSection.id,
|
||||
courseid: selectedCourse.id,
|
||||
name: sectionForm.name?.trim() || undefined,
|
||||
summary: sectionForm.summary || "",
|
||||
section:
|
||||
sectionForm.section !== ""
|
||||
? Number(sectionForm.section)
|
||||
: undefined,
|
||||
});
|
||||
setSectionFeedback({
|
||||
type: "success",
|
||||
message: "Chapitre mis à jour.",
|
||||
});
|
||||
handleResetSectionForm();
|
||||
await refreshSections(selectedCourse.id);
|
||||
} catch (error) {
|
||||
setSectionFeedback({
|
||||
type: "error",
|
||||
message: error?.message || "Impossible de mettre à jour le chapitre.",
|
||||
});
|
||||
} finally {
|
||||
setSectionActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSection = async (section) => {
|
||||
if (!selectedCourse?.id || !section?.id) {
|
||||
return;
|
||||
}
|
||||
const confirmed = window.confirm(
|
||||
"Supprimer définitivement ce chapitre ?"
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
setSectionFeedback(null);
|
||||
try {
|
||||
setSectionActionLoading(true);
|
||||
await MoodleApi.deleteSections(selectedCourse.id, [section.id]);
|
||||
setSectionFeedback({
|
||||
type: "success",
|
||||
message: "Chapitre supprimé.",
|
||||
});
|
||||
if (selectedSection?.id === section.id) {
|
||||
handleResetSectionForm();
|
||||
}
|
||||
await refreshSections(selectedCourse.id);
|
||||
} catch (error) {
|
||||
setSectionFeedback({
|
||||
type: "error",
|
||||
message: error?.message || "Impossible de supprimer le chapitre.",
|
||||
});
|
||||
} finally {
|
||||
setSectionActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredCourses = useMemo(() => {
|
||||
if (!searchValue.trim()) {
|
||||
return courses;
|
||||
}
|
||||
const query = searchValue.trim().toLowerCase();
|
||||
return courses.filter((course) =>
|
||||
course.fullname?.toLowerCase().includes(query)
|
||||
);
|
||||
}, [courses, searchValue]);
|
||||
|
||||
const sectionList = useMemo(() => {
|
||||
const clone = [...sections];
|
||||
clone.sort((a, b) => {
|
||||
const valueA =
|
||||
a.section === null || a.section === undefined ? 9999 : a.section;
|
||||
const valueB =
|
||||
b.section === null || b.section === undefined ? 9999 : b.section;
|
||||
return valueA - valueB;
|
||||
});
|
||||
return clone;
|
||||
}, [sections]);
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 4 }}>
|
||||
<Stack spacing={3}>
|
||||
<Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
mb: 1,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4" fontWeight={600}>
|
||||
Gestion des cours et chapitres
|
||||
</Typography>
|
||||
<Button
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={() => refreshCourses(selectedCourse?.id)}
|
||||
disabled={loadingCourses}
|
||||
variant="outlined"
|
||||
>
|
||||
Actualiser
|
||||
</Button>
|
||||
</Box>
|
||||
<Typography color="text.secondary">
|
||||
Créez, modifiez et supprimez vos cours Moodle directement depuis
|
||||
cette interface. Les chapitres correspondent aux sections du cours.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{!isMoodleConfigured() && (
|
||||
<Alert severity="warning">
|
||||
Ajoutez vos identifiants Moodle dans <code>.env.local</code> pour
|
||||
activer cette page.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{courseFeedback && (
|
||||
<Alert severity={courseFeedback.type}>{courseFeedback.message}</Alert>
|
||||
)}
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Stack spacing={2}>
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Liste des cours
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Rechercher un cours"
|
||||
value={searchValue}
|
||||
onChange={(event) => setSearchValue(event.target.value)}
|
||||
/>
|
||||
</Box>
|
||||
<Divider />
|
||||
{loadingCourses ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
py: 4,
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={28} />
|
||||
</Box>
|
||||
) : (
|
||||
<List
|
||||
dense
|
||||
sx={{
|
||||
maxHeight: 420,
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
{filteredCourses.length === 0 && (
|
||||
<ListItem>
|
||||
<ListItemText primary="Aucun cours trouvé." />
|
||||
</ListItem>
|
||||
)}
|
||||
{filteredCourses.map((course) => (
|
||||
<ListItem
|
||||
key={course.id}
|
||||
disablePadding
|
||||
secondaryAction={
|
||||
<IconButton
|
||||
edge="end"
|
||||
onClick={() => handleDeleteCourse(course)}
|
||||
disabled={courseActionLoading}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<ListItemText
|
||||
sx={{
|
||||
px: 2,
|
||||
py: 1,
|
||||
borderRadius: 2,
|
||||
mb: 1,
|
||||
bgcolor:
|
||||
selectedCourse?.id === course.id
|
||||
? "action.selected"
|
||||
: "transparent",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
primary={course.fullname}
|
||||
secondary={`ID: ${course.id}`}
|
||||
onClick={() => handleSelectCourse(course)}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={8}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Stack spacing={3}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6">
|
||||
{selectedCourse ? "Modifier le cours" : "Nouveau cours"}
|
||||
</Typography>
|
||||
{selectedCourse && (
|
||||
<Button
|
||||
startIcon={<AddIcon fontSize="small" />}
|
||||
onClick={handleResetCourseSelection}
|
||||
>
|
||||
Nouveau
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
<TextField
|
||||
label="Titre complet"
|
||||
value={courseForm.fullname}
|
||||
onChange={handleCourseInputChange("fullname")}
|
||||
fullWidth
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
label="Identifiant court"
|
||||
value={courseForm.shortname}
|
||||
onChange={handleCourseInputChange("shortname")}
|
||||
helperText="Laissez vide pour générer automatiquement."
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="Catégorie"
|
||||
type="number"
|
||||
value={courseForm.categoryid}
|
||||
onChange={handleCourseInputChange("categoryid")}
|
||||
fullWidth
|
||||
/>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Résumé du cours
|
||||
</Typography>
|
||||
<RichTextEditor
|
||||
value={courseForm.summary}
|
||||
onChange={handleSummaryChange}
|
||||
minHeight={220}
|
||||
/>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={2} flexWrap="wrap">
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleCreateCourse}
|
||||
disabled={courseActionLoading}
|
||||
>
|
||||
{courseActionLoading && !selectedCourse ? (
|
||||
<CircularProgress size={18} color="inherit" />
|
||||
) : (
|
||||
"Créer"
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleUpdateCourse}
|
||||
disabled={!selectedCourse || courseActionLoading}
|
||||
>
|
||||
{courseActionLoading && selectedCourse ? (
|
||||
<CircularProgress size={18} color="inherit" />
|
||||
) : (
|
||||
"Mettre à jour"
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
color="error"
|
||||
variant="outlined"
|
||||
onClick={handleDeleteCourse}
|
||||
disabled={!selectedCourse || courseActionLoading}
|
||||
>
|
||||
Supprimer
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{selectedCourse && (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Stack spacing={3}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="h6">
|
||||
Chapitres de « {selectedCourse.fullname} »
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Gérez les sections du cours Moodle sélectionné.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={() => refreshSections(selectedCourse.id)}
|
||||
disabled={loadingSections}
|
||||
>
|
||||
Recharger
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{sectionFeedback && (
|
||||
<Alert severity={sectionFeedback.type}>
|
||||
{sectionFeedback.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
{loadingSections ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
py: 4,
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={24} />
|
||||
</Box>
|
||||
) : (
|
||||
<List
|
||||
dense
|
||||
sx={{
|
||||
maxHeight: 360,
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
{sectionList.length === 0 && (
|
||||
<ListItem>
|
||||
<ListItemText primary="Aucun chapitre pour le moment." />
|
||||
</ListItem>
|
||||
)}
|
||||
{sectionList.map((section) => (
|
||||
<ListItem
|
||||
key={`${section.id}-${section.section}`}
|
||||
secondaryAction={
|
||||
<Stack direction="row" spacing={1}>
|
||||
<IconButton
|
||||
edge="end"
|
||||
onClick={() => handleEditSection(section)}
|
||||
>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
edge="end"
|
||||
color="error"
|
||||
onClick={() => handleDeleteSection(section)}
|
||||
disabled={sectionActionLoading}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
<ListItemText
|
||||
primary={section.name}
|
||||
secondary={
|
||||
section.section !== null &&
|
||||
section.section !== undefined
|
||||
? `Position : ${section.section}`
|
||||
: "Position automatique"
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="subtitle1">
|
||||
{selectedSection
|
||||
? "Modifier le chapitre"
|
||||
: "Nouveau chapitre"}
|
||||
</Typography>
|
||||
<TextField
|
||||
label="Titre du chapitre"
|
||||
value={sectionForm.name}
|
||||
onChange={handleSectionInputChange("name")}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="Position (optionnelle)"
|
||||
type="number"
|
||||
value={sectionForm.section}
|
||||
onChange={handleSectionInputChange("section")}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="Résumé"
|
||||
value={sectionForm.summary}
|
||||
onChange={handleSectionInputChange("summary")}
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={4}
|
||||
/>
|
||||
<Stack direction="row" spacing={2} flexWrap="wrap">
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={
|
||||
selectedSection
|
||||
? handleUpdateSection
|
||||
: handleCreateSection
|
||||
}
|
||||
disabled={sectionActionLoading}
|
||||
>
|
||||
{sectionActionLoading ? (
|
||||
<CircularProgress size={18} color="inherit" />
|
||||
) : selectedSection ? (
|
||||
"Mettre à jour"
|
||||
) : (
|
||||
"Créer"
|
||||
)}
|
||||
</Button>
|
||||
{selectedSection && (
|
||||
<Button onClick={handleResetSectionForm}>
|
||||
Annuler
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default GestionCours;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,71 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import axios from "axios";
|
||||
import { Box, Typography, ImageList, ImageListItem } from "@mui/material";
|
||||
|
||||
const BACKEND_URL = "https://api.sveitl.synology.me/api/cloudinary-images"; // adapte pour prod
|
||||
|
||||
const CloudinaryGallerySelector = ({ onSelect }) => {
|
||||
const [images, setImages] = useState([]);
|
||||
const [error, setError] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const BACKEND_URL = import.meta.env.VITE_CLOUDINARY_BACKEND_URL;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchImages = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${BACKEND_URL}/api/cloudinary-images`);
|
||||
const data = response.data;
|
||||
|
||||
// 🔐 sécurisation du format de réponse
|
||||
const validImages = Array.isArray(data)
|
||||
? data
|
||||
: Array.isArray(data.resources)
|
||||
? data.resources
|
||||
: [];
|
||||
|
||||
if (validImages.length === 0) {
|
||||
setError("Aucune image trouvée.");
|
||||
}
|
||||
|
||||
setImages(validImages);
|
||||
} catch (err) {
|
||||
console.error("❌ Erreur Cloudinary complète :", JSON.stringify(err.response?.data || err.message, null, 2));
|
||||
setError("Impossible de récupérer les images depuis le serveur.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchImages();
|
||||
}, []);
|
||||
|
||||
if (loading) return <Typography>Chargement des images Cloudinary...</Typography>;
|
||||
if (error) return <Typography color="error">{error}</Typography>;
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="body1" sx={{ fontWeight: "bold", mb: 1 }}>
|
||||
Galerie Cloudinary :
|
||||
</Typography>
|
||||
|
||||
<ImageList cols={3} gap={12}>
|
||||
{images.map((img) => (
|
||||
<ImageListItem key={img.public_id} onClick={() => onSelect(img.secure_url)}>
|
||||
<img
|
||||
src={img.secure_url.replace("/upload/", "/upload/f_webp,w_300/")}
|
||||
alt={img.public_id}
|
||||
loading="lazy"
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
borderRadius: 8,
|
||||
boxShadow: "0 2px 6px rgba(0,0,0,0.2)",
|
||||
}}
|
||||
/>
|
||||
</ImageListItem>
|
||||
))}
|
||||
</ImageList>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CloudinaryGallerySelector;
|
||||
@@ -1,16 +1,12 @@
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import React from "react";
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
} from "@mui/material";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import api from "../api";
|
||||
|
||||
const ConstructSection = ({
|
||||
constructTitle,
|
||||
@@ -21,59 +17,26 @@ const ConstructSection = ({
|
||||
missionTitle,
|
||||
missionItems,
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [missions, setMissions] = useState(
|
||||
Array.isArray(missionItems) && missionItems.length ? missionItems : []
|
||||
);
|
||||
|
||||
const defaultMissions = useMemo(
|
||||
() => [
|
||||
{ title: "📌 Mission 1", details: "Détails de la mission 1" },
|
||||
{ title: "📌 Mission 2", details: "Détails de la mission 2" },
|
||||
{ title: "📌 Mission 3", details: "Détails de la mission 3" },
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const hydrateMissions = async () => {
|
||||
if (Array.isArray(missionItems) && missionItems.length) {
|
||||
setMissions(missionItems);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await api.get("wp/v2/pages/13?_fields=acf");
|
||||
const fetched = response.data.acf?.mission;
|
||||
if (Array.isArray(fetched) && fetched.length) {
|
||||
setMissions(fetched);
|
||||
} else {
|
||||
setMissions(defaultMissions);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ Erreur lors du chargement des missions :", error);
|
||||
setMissions(defaultMissions);
|
||||
}
|
||||
};
|
||||
|
||||
hydrateMissions();
|
||||
}, [missionItems, defaultMissions]);
|
||||
|
||||
// ✅ Gestion des accordéons
|
||||
const handleAccordionChange = (panel) => (event, isExpanded) => {
|
||||
setExpanded(isExpanded ? panel : false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ py: 6, px: 4, backgroundColor: "#f9f9f9" }}>
|
||||
<Box
|
||||
sx={{
|
||||
py: 6,
|
||||
px: 4,
|
||||
backgroundColor: "#f9f9f9",
|
||||
}}
|
||||
>
|
||||
<Grid container spacing={4}>
|
||||
{/* Left Column */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box>
|
||||
{/* Titre et sous-titre */}
|
||||
<Typography
|
||||
variant="h2"
|
||||
sx={{
|
||||
fontWeight: "bold",
|
||||
fontSize: { xs: "2rem", md: "2.4rem", lg: "3rem" },
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{constructTitle}{" "}
|
||||
@@ -81,6 +44,7 @@ const ConstructSection = ({
|
||||
component="span"
|
||||
sx={{
|
||||
fontSize: { xs: "2rem", md: "2rem", lg: "2rem" },
|
||||
|
||||
color: "#0e467f",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
@@ -89,9 +53,14 @@ const ConstructSection = ({
|
||||
</Typography>
|
||||
</Typography>
|
||||
|
||||
{/* Secteurs */}
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ mt: 2, fontWeight: "bold", color: "#0e467f" }}
|
||||
sx={{
|
||||
mt: 2,
|
||||
fontWeight: "bold",
|
||||
color: "#0e467f",
|
||||
}}
|
||||
>
|
||||
Secteurs : {constructSector}
|
||||
</Typography>
|
||||
@@ -107,12 +76,13 @@ const ConstructSection = ({
|
||||
dangerouslySetInnerHTML={{ __html: constructText }}
|
||||
/>
|
||||
|
||||
{/* Cadre résumé */}
|
||||
<Card
|
||||
sx={{
|
||||
mt: 4,
|
||||
backgroundColor: "#0e467f",
|
||||
color: "#fff",
|
||||
borderRadius: "12px 0 12px 12px",
|
||||
borderRadius: "12px",
|
||||
boxShadow: "0 8px 20px rgba(0, 0, 0, 0.1)",
|
||||
}}
|
||||
>
|
||||
@@ -135,66 +105,36 @@ const ConstructSection = ({
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: "12px",
|
||||
boxShadow: "0 8px 20px rgba(0, 0, 0, 0.1)",
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
height: "400px",
|
||||
backgroundImage: `url('https://picsum.photos/id/52/600/400.webp')`, // Replace with the image URL or dynamic data
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
padding: 3,
|
||||
borderRadius: "15px",
|
||||
}}
|
||||
>
|
||||
{/* ✅ Correction de la duplication du titre */}
|
||||
{missionTitle && (
|
||||
<Typography
|
||||
component="h3"
|
||||
variant="h4"
|
||||
sx={{
|
||||
fontWeight: "bold",
|
||||
textAlign: "center",
|
||||
mt: 3,
|
||||
mb: 2,
|
||||
color: "#0e467f",
|
||||
}}
|
||||
>
|
||||
{missionTitle}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* ✅ Accordéon dynamique */}
|
||||
{missions.length > 0 ? (
|
||||
missions.map((mission, index) => (
|
||||
<Accordion
|
||||
key={index}
|
||||
expanded={expanded === `panel${index}`}
|
||||
onChange={handleAccordionChange(`panel${index}`)}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography
|
||||
component="p"
|
||||
variant="subtitle1"
|
||||
sx={{
|
||||
fontWeight: "bold",
|
||||
fontSize: { xs: "1rem", md: "1.2rem", lg: "1.5rem" },
|
||||
}}
|
||||
>
|
||||
{mission.title}
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Typography>{mission.details}</Typography>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
))
|
||||
) : (
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
Aucune mission définie.
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Background Image et Items */}
|
||||
<Box sx={{ position: "relative", height: "auto", mt: 3 }}>
|
||||
{missionItems?.map((item, index) => (
|
||||
<Typography
|
||||
variant="h3"
|
||||
sx={{
|
||||
fontWeight: "bold",
|
||||
textAlign: "center",
|
||||
mt: 3,
|
||||
mb: 2,
|
||||
color: "#0e467f",
|
||||
}}
|
||||
>
|
||||
{missionTitle}
|
||||
</Typography>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
position: "relative",
|
||||
height: "400px",
|
||||
backgroundImage: `url('https://via.placeholder.com/600x400')`, // Replace with the image URL or dynamic data
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
}}
|
||||
>
|
||||
{missionItems.map((item, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{
|
||||
|
||||
@@ -1,556 +1,34 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { getPostById, updatePost, uploadImageFromUrl } from "../wordpress";
|
||||
import { getToken } from "../auth";
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
TextField,
|
||||
Button,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Stack,
|
||||
Chip,
|
||||
Divider,
|
||||
} from "@mui/material";
|
||||
import { ArrowBack, Publish, Image as ImageIcon, History } from "@mui/icons-material";
|
||||
import ImageUploaderCloudinary from "./ImageUploaderCloudinary";
|
||||
import CloudinaryGallerySelector from "./CloudinaryGallerySelector";
|
||||
import RichTextEditor from "./common/RichTextEditor";
|
||||
import "../assets/styleCours.css";
|
||||
import { useState } from "react";
|
||||
import { updatePost } from "../wordpress";
|
||||
|
||||
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)",
|
||||
},
|
||||
});
|
||||
function EditPost({ post }) {
|
||||
const [title, setTitle] = useState(post.title.rendered);
|
||||
const [content, setContent] = useState(post.content.rendered);
|
||||
|
||||
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();
|
||||
const navigate = useNavigate();
|
||||
const [title, setTitle] = useState("");
|
||||
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(() => {
|
||||
if (!getToken()) navigate("/admin/login");
|
||||
}, [navigate]);
|
||||
|
||||
// Article
|
||||
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);
|
||||
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();
|
||||
}, [id, navigate]);
|
||||
|
||||
// 🧠 convertit URL en File
|
||||
const convertUrlToFile = async (url) => {
|
||||
const res = await fetch(url);
|
||||
const blob = await res.blob();
|
||||
return new File([blob], "image-cloudinary.jpg", { type: blob.type });
|
||||
};
|
||||
|
||||
const toolbarOptions = [
|
||||
[{ header: [1, 2, 3, false] }],
|
||||
["bold", "italic", "underline", "strike"],
|
||||
[{ list: "ordered" }, { list: "bullet" }],
|
||||
["link", "image"],
|
||||
["clean"],
|
||||
];
|
||||
|
||||
const canSubmit =
|
||||
title.trim().length > 0 && content.trim().length > 0 && Boolean(imageUrl);
|
||||
|
||||
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);
|
||||
const handleUpdate = async () => {
|
||||
const success = await updatePost(post.id, title, content);
|
||||
if (success) {
|
||||
alert("Article mis à jour !");
|
||||
} else {
|
||||
alert("Échec de la mise à jour.");
|
||||
}
|
||||
};
|
||||
|
||||
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
|
||||
onClick={() => navigate("/admin/gestion-articles")}
|
||||
variant="contained"
|
||||
startIcon={<ArrowBack />}
|
||||
sx={composeButtonSx("ghost")}
|
||||
>
|
||||
Retour aux articles
|
||||
</Button>
|
||||
<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 l’article
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
color: "rgba(255,255,255,0.85)",
|
||||
maxWidth: 520,
|
||||
}}
|
||||
>
|
||||
Ajustez votre contenu, remplacez le visuel mis en avant et publiez
|
||||
les mises à jour directement sur le site Octopus.
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<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 l’article…
|
||||
</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)",
|
||||
}}
|
||||
>
|
||||
<RichTextEditor
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
minHeight={280}
|
||||
toolbarOptions={toolbarOptions}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<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>
|
||||
|
||||
<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) => {
|
||||
setImageUrl(url);
|
||||
const file = await convertUrlToFile(url);
|
||||
setImageFile(file);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Stack
|
||||
direction={{ xs: "column", sm: "row" }}
|
||||
spacing={1.5}
|
||||
sx={{ pt: 1 }}
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
startIcon={<Publish />}
|
||||
disabled={!canSubmit || submitting}
|
||||
sx={{
|
||||
...composeButtonSx("primary"),
|
||||
minWidth: 220,
|
||||
}}
|
||||
>
|
||||
{submitting ? "Enregistrement…" : "Mettre à jour"}
|
||||
</Button>
|
||||
<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 />
|
||||
• N’oubliez pas de relire la mise en forme dans l’aperç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 l’image 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 l’article dans la liste des publications
|
||||
et sur les réseaux sociaux.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</Box>
|
||||
<div>
|
||||
<h1>Modifier l’article</h1>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
/>
|
||||
<button onClick={handleUpdate}>Mettre à jour</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditPost;
|
||||
export default EditPost;
|
||||
@@ -1,134 +1,8 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { Box, Grid, Typography, Card, CardContent, Modal } from "@mui/material";
|
||||
import pictoMaitriseOeuvre from "../assets/picto-maitrise-oeuvre.avif";
|
||||
import pictoStructure from "../assets/picto-structure.avif";
|
||||
import pictoElectricite from "../assets/picto-electricite.avif";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
// Style commun pour le contenu du modal
|
||||
const modalStyle = {
|
||||
padding: 4,
|
||||
borderRadius: "20px",
|
||||
background: "rgba(255, 255, 255, 0.95)",
|
||||
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.2)",
|
||||
backdropFilter: "blur(10px)",
|
||||
maxWidth: 600,
|
||||
margin: "auto",
|
||||
mt: 4,
|
||||
position: "relative",
|
||||
maxHeight: "80vh",
|
||||
};
|
||||
|
||||
// Style commun pour l'icône affichée en haut à droite dans le modal
|
||||
const iconStyle = {
|
||||
position: "absolute",
|
||||
top: 16,
|
||||
right: 16,
|
||||
width: 80,
|
||||
height: 80,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
borderRadius: "50%",
|
||||
};
|
||||
|
||||
// Style commun pour les boutons "En savoir plus" et "Retour"
|
||||
const buttonStyle = {
|
||||
marginTop: "16px",
|
||||
padding: "8px 16px",
|
||||
backgroundColor: "#0e467f",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
textTransform: "none",
|
||||
};
|
||||
|
||||
// Fonction pour générer le style de la carte en fonction de l'image de fond
|
||||
const cardStyle = (image) => ({
|
||||
boxShadow: 3,
|
||||
padding: 2,
|
||||
textAlign: "center",
|
||||
backgroundColor: "#fff",
|
||||
backgroundImage: `linear-gradient(rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.7)), url(${image})`,
|
||||
backgroundSize: "40%",
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundPosition: "center left",
|
||||
});
|
||||
|
||||
const ScrollableContent = ({ text }) => {
|
||||
const contentRef = useRef(null);
|
||||
const [showHint, setShowHint] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const node = contentRef.current;
|
||||
if (!node) return;
|
||||
|
||||
const updateHintVisibility = () => {
|
||||
const needsScroll = node.scrollHeight > node.clientHeight;
|
||||
const nearBottom =
|
||||
node.scrollTop >= node.scrollHeight - node.clientHeight - 12;
|
||||
setShowHint(needsScroll && !nearBottom);
|
||||
};
|
||||
|
||||
updateHintVisibility();
|
||||
node.addEventListener("scroll", updateHintVisibility);
|
||||
return () => node.removeEventListener("scroll", updateHintVisibility);
|
||||
}, [text]);
|
||||
|
||||
return (
|
||||
<Box sx={{ position: "relative" }}>
|
||||
<Typography
|
||||
ref={contentRef}
|
||||
variant="body1"
|
||||
sx={{
|
||||
color: "#333",
|
||||
lineHeight: 1.6,
|
||||
maxHeight: "60vh",
|
||||
overflowY: "auto",
|
||||
pr: 1,
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: text }}
|
||||
/>
|
||||
{showHint && (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
pt: 4,
|
||||
textAlign: "center",
|
||||
background:
|
||||
"linear-gradient(rgba(255,255,255,0), rgba(255,255,255,0.95))",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ color: "#0e467f", fontWeight: 600, letterSpacing: 0.5 }}
|
||||
>
|
||||
Faites défiler pour tout lire ↓
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Composant Modal réutilisable
|
||||
const ExpertiseModal = ({ modalId, image, title, text, openModal, handleClose }) => (
|
||||
<Modal open={openModal === modalId} onClose={handleClose}>
|
||||
<Box sx={{ ...modalStyle, maxHeight: "80vh" }}>
|
||||
<Box sx={{ ...iconStyle, backgroundImage: `url(${image})` }} />
|
||||
<Typography variant="h4" sx={{ mb: 2, fontWeight: "bold" }}>
|
||||
{title}
|
||||
</Typography>
|
||||
<ScrollableContent text={text} />
|
||||
<button onClick={handleClose} style={buttonStyle}>
|
||||
Retour
|
||||
</button>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
const Expertices = ({
|
||||
expertiseTitle,
|
||||
@@ -141,12 +15,16 @@ const Expertices = ({
|
||||
}) => {
|
||||
const [openModal, setOpenModal] = useState(null);
|
||||
|
||||
const handleOpenModal = (modalId) => setOpenModal(modalId);
|
||||
const handleCloseModal = () => setOpenModal(null);
|
||||
const handleOpenModal = (modalId) => {
|
||||
setOpenModal(modalId);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setOpenModal(null);
|
||||
};
|
||||
|
||||
// Fonction qui supprime les balises HTML et tronque le texte s'il est trop long
|
||||
const truncateText = (text, maxLength) => {
|
||||
const plainText = text.replace(/<[^>]+>/g, "");
|
||||
const plainText = text.replace(/<[^>]+>/g, ""); // Supprime les balises HTML
|
||||
return plainText.length > maxLength
|
||||
? `${plainText.substring(0, maxLength)}...`
|
||||
: plainText;
|
||||
@@ -169,7 +47,21 @@ const Expertices = ({
|
||||
<Grid container spacing={4}>
|
||||
{/* Expertise 1 */}
|
||||
<Grid item xs={12} md={4}>
|
||||
<Card sx={cardStyle(pictoMaitriseOeuvre)}>
|
||||
<Card
|
||||
sx={{
|
||||
backgroundPosition: "center",
|
||||
boxShadow: 3,
|
||||
padding: 2,
|
||||
textAlign: "center",
|
||||
backgroundColor: "#fff",
|
||||
backgroundImage: `
|
||||
linear-gradient(rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.7)),
|
||||
url(${pictoMaitriseOeuvre})`,
|
||||
backgroundSize: "40%",
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundPosition: "center left",
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Typography
|
||||
variant="h3"
|
||||
@@ -186,7 +78,16 @@ const Expertices = ({
|
||||
</Typography>
|
||||
<button
|
||||
onClick={() => handleOpenModal("maitrise_oeuvre")}
|
||||
style={buttonStyle}
|
||||
style={{
|
||||
marginTop: "16px",
|
||||
padding: "8px 16px",
|
||||
backgroundColor: "#0e467f",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
textTransform: "none",
|
||||
}}
|
||||
>
|
||||
En savoir plus
|
||||
</button>
|
||||
@@ -196,7 +97,21 @@ const Expertices = ({
|
||||
|
||||
{/* Expertise 2 */}
|
||||
<Grid item xs={12} md={4}>
|
||||
<Card sx={cardStyle(pictoStructure)}>
|
||||
<Card
|
||||
sx={{
|
||||
backgroundPosition: "center",
|
||||
boxShadow: 3,
|
||||
padding: 2,
|
||||
textAlign: "center",
|
||||
backgroundColor: "#fff",
|
||||
backgroundImage: `
|
||||
linear-gradient(rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.7)),
|
||||
url(${pictoStructure})`,
|
||||
backgroundSize: "40%",
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundPosition: "center left",
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Typography
|
||||
variant="h3"
|
||||
@@ -213,7 +128,16 @@ const Expertices = ({
|
||||
</Typography>
|
||||
<button
|
||||
onClick={() => handleOpenModal("structure")}
|
||||
style={buttonStyle}
|
||||
style={{
|
||||
marginTop: "16px",
|
||||
padding: "8px 16px",
|
||||
backgroundColor: "#0e467f",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
textTransform: "none",
|
||||
}}
|
||||
>
|
||||
En savoir plus
|
||||
</button>
|
||||
@@ -223,7 +147,20 @@ const Expertices = ({
|
||||
|
||||
{/* Expertise 3 */}
|
||||
<Grid item xs={12} md={4}>
|
||||
<Card sx={cardStyle(pictoElectricite)}>
|
||||
<Card
|
||||
sx={{
|
||||
backgroundPosition: "center",
|
||||
boxShadow: 3,
|
||||
padding: 2,
|
||||
textAlign: "center",
|
||||
backgroundColor: "#fff",
|
||||
backgroundImage: `linear-gradient(rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.7)),
|
||||
url(${pictoElectricite})`,
|
||||
backgroundSize: "40%",
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundPosition: "center left",
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Typography
|
||||
variant="h3"
|
||||
@@ -240,7 +177,16 @@ const Expertices = ({
|
||||
</Typography>
|
||||
<button
|
||||
onClick={() => handleOpenModal("electricite")}
|
||||
style={buttonStyle}
|
||||
style={{
|
||||
marginTop: "16px",
|
||||
padding: "8px 16px",
|
||||
backgroundColor: "#0e467f",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
textTransform: "none",
|
||||
}}
|
||||
>
|
||||
En savoir plus
|
||||
</button>
|
||||
@@ -249,44 +195,124 @@ const Expertices = ({
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Modaux */}
|
||||
<ExpertiseModal
|
||||
modalId="maitrise_oeuvre"
|
||||
image={pictoMaitriseOeuvre}
|
||||
title={expertise1Title}
|
||||
text={expertise1Text}
|
||||
openModal={openModal}
|
||||
handleClose={handleCloseModal}
|
||||
/>
|
||||
<ExpertiseModal
|
||||
modalId="structure"
|
||||
image={pictoStructure}
|
||||
title={expertise2Title}
|
||||
text={expertise2Text}
|
||||
openModal={openModal}
|
||||
handleClose={handleCloseModal}
|
||||
/>
|
||||
<ExpertiseModal
|
||||
modalId="electricite"
|
||||
image={pictoElectricite}
|
||||
title={expertise3Title}
|
||||
text={expertise3Text}
|
||||
openModal={openModal}
|
||||
handleClose={handleCloseModal}
|
||||
/>
|
||||
{/* Modal 1 */}
|
||||
<Modal open={openModal === "maitrise_oeuvre"} onClose={handleCloseModal}>
|
||||
<Box
|
||||
sx={{
|
||||
padding: 4,
|
||||
borderRadius: "20px",
|
||||
background: "rgba(255, 255, 255, 0.9)",
|
||||
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.2)",
|
||||
backdropFilter: "blur(10px)",
|
||||
maxWidth: 600,
|
||||
margin: "auto",
|
||||
mt: 4,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 16,
|
||||
right: 16,
|
||||
width: 80,
|
||||
height: 80,
|
||||
backgroundImage: `url(${pictoMaitriseOeuvre})`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
borderRadius: "50%",
|
||||
}}
|
||||
/>
|
||||
<Typography variant="h4" sx={{ mb: 2, fontWeight: "bold" }}>
|
||||
{expertise1Title}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ color: "#333", lineHeight: 1.6 }}
|
||||
dangerouslySetInnerHTML={{ __html: expertise1Text }}
|
||||
/>
|
||||
</Box>
|
||||
</Modal>
|
||||
|
||||
{/* Modal 2 */}
|
||||
<Modal open={openModal === "structure"} onClose={handleCloseModal}>
|
||||
<Box
|
||||
sx={{
|
||||
padding: 4,
|
||||
borderRadius: "20px",
|
||||
background: "rgba(255, 255, 255, 0.9)",
|
||||
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.2)",
|
||||
backdropFilter: "blur(10px)",
|
||||
maxWidth: 600,
|
||||
margin: "auto",
|
||||
mt: 4,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 16,
|
||||
right: 16,
|
||||
width: 80,
|
||||
height: 80,
|
||||
backgroundImage: `url(${pictoStructure})`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
borderRadius: "50%",
|
||||
}}
|
||||
/>
|
||||
<Typography variant="h4" sx={{ mb: 2, fontWeight: "bold" }}>
|
||||
{expertise2Title}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ color: "#333", lineHeight: 1.6 }}
|
||||
dangerouslySetInnerHTML={{ __html: expertise2Text }}
|
||||
/>
|
||||
</Box>
|
||||
</Modal>
|
||||
|
||||
{/* Modal 3 */}
|
||||
<Modal open={openModal === "electricite"} onClose={handleCloseModal}>
|
||||
<Box
|
||||
sx={{
|
||||
padding: 4,
|
||||
borderRadius: "20px",
|
||||
background: "rgba(255, 255, 255, 0.9)",
|
||||
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.2)",
|
||||
backdropFilter: "blur(10px)",
|
||||
maxWidth: 600,
|
||||
margin: "auto",
|
||||
mt: 4,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 16,
|
||||
right: 16,
|
||||
width: 80,
|
||||
height: 80,
|
||||
backgroundImage: `url(${pictoElectricite})`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
borderRadius: "50%",
|
||||
}}
|
||||
/>
|
||||
<Typography variant="h4" sx={{ mb: 2, fontWeight: "bold" }}>
|
||||
{expertise3Title}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ color: "#333", lineHeight: 1.6 }}
|
||||
dangerouslySetInnerHTML={{ __html: expertise3Text }}
|
||||
/>
|
||||
</Box>
|
||||
</Modal>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
Expertices.propTypes = {
|
||||
expertiseTitle: PropTypes.string.isRequired,
|
||||
expertise1Title: PropTypes.string.isRequired,
|
||||
expertise1Text: PropTypes.string.isRequired,
|
||||
expertise2Title: PropTypes.string.isRequired,
|
||||
expertise2Text: PropTypes.string.isRequired,
|
||||
expertise3Title: PropTypes.string.isRequired,
|
||||
expertise3Text: PropTypes.string.isRequired,
|
||||
|
||||
};
|
||||
|
||||
export default Expertices;
|
||||
export default Expertices;
|
||||
@@ -64,13 +64,12 @@ const Faq = ({ showFAQ }) => {
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
variant="h3"
|
||||
sx={{
|
||||
fontWeight: "bold",
|
||||
mb: 4,
|
||||
textAlign: "center",
|
||||
color: "#0e467f",
|
||||
fontSize: { xs: "2rem", md: "2rem", lg: "2.5rem",whiteSpace: "pre-line" }
|
||||
}}
|
||||
>
|
||||
FAQ
|
||||
@@ -103,7 +102,7 @@ const Faq = ({ showFAQ }) => {
|
||||
{filteredFaqs.length > 0 ? (
|
||||
filteredFaqs.map((faq, index) => (
|
||||
<Box key={index} sx={{ mb: 3 }}>
|
||||
<Typography variant="h3" sx={{ fontWeight: "bold",fontSize: { xs: "2rem", md: "2rem", lg: "2rem" } }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: "bold" }}>
|
||||
{faq.question}
|
||||
</Typography>
|
||||
<Typography
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
const Footer = () => {
|
||||
return (
|
||||
<Box
|
||||
component="footer"
|
||||
sx={{
|
||||
backgroundColor: "#0e467f",
|
||||
color: "white",
|
||||
@@ -34,30 +33,36 @@ const Footer = () => {
|
||||
<Typography
|
||||
variant="h6"
|
||||
component="h2"
|
||||
sx={{ fontWeight: "bold", color:"#ffffff", textAlign: { xs: "center", md: "left" } }}
|
||||
sx={{ fontWeight: "bold", textAlign: { xs: "center", md: "left" } }}
|
||||
>
|
||||
Octopusdesign
|
||||
IN3 Intégrale Ingénierie Internationale
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ marginTop: 1, textAlign: { xs: "center", md: "left" } }}
|
||||
>
|
||||
Route du Perrier,
|
||||
67 Boulevard Winston Churchill,
|
||||
<br />
|
||||
85270 - Saint Hilaire de Riez
|
||||
72100 Le Mans, France
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ marginTop: 1, textAlign: { xs: "center", md: "left" } }}
|
||||
>
|
||||
<strong>Téléphone :</strong> 06.00.00.00.00
|
||||
<strong>Téléphone :</strong> 02.43.85.09.01
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ marginTop: 1, textAlign: { xs: "center", md: "left" } }}
|
||||
>
|
||||
<strong>Email :</strong>{" "}
|
||||
contact@octopusdesign.fr
|
||||
<Link
|
||||
href="mailto:contact@be-in3.com"
|
||||
underline="hover"
|
||||
sx={{ color: "white" }}
|
||||
>
|
||||
contact@be-in3.com
|
||||
</Link>
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
@@ -187,7 +192,7 @@ const Footer = () => {
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2">
|
||||
© {new Date().getFullYear()} Octopusdesign.
|
||||
© {new Date().getFullYear()} IN3 Intégrale Ingénierie Internationale.
|
||||
Tous droits réservés.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -1,669 +0,0 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { getToken } from "../auth";
|
||||
import { getPageById, updatePageACF } from "../wordpress";
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
TextField,
|
||||
Button,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Stack,
|
||||
Divider,
|
||||
CircularProgress,
|
||||
} from "@mui/material";
|
||||
import { Add, Delete, ArrowBack, Save } from "@mui/icons-material";
|
||||
import RichTextEditor from "./common/RichTextEditor";
|
||||
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(() => {
|
||||
if (!getToken()) navigate("/admin/login");
|
||||
}, [navigate]);
|
||||
|
||||
// Récupère les champs ACF
|
||||
useEffect(() => {
|
||||
const fetchPage = async () => {
|
||||
try {
|
||||
const pageData = await getPageById(ETUDE_PAGE_ID);
|
||||
if (!pageData?.acf) {
|
||||
setError("❌ Impossible de charger les champs ACF.");
|
||||
return;
|
||||
}
|
||||
console.log("expertises_specifiques:", pageData.acf.expertises_specifiques);
|
||||
setAcfFields(pageData.acf);
|
||||
} catch {
|
||||
setError("⚠ Impossible de récupérer les champs ACF.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchPage();
|
||||
}, []);
|
||||
|
||||
// Mise à jour ACF
|
||||
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"), 1800);
|
||||
} catch (err) {
|
||||
console.error("⚠ Impossible de mettre à jour les champs ACF :", err);
|
||||
setError("⚠ Impossible de mettre à jour les champs ACF.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Modifie un champ simple
|
||||
const handleFieldChange = (field, value) => {
|
||||
setAcfFields(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
// Normalise un repeater en tableau
|
||||
const getRepeater = (field) => {
|
||||
const data = acfFields[field];
|
||||
return Array.isArray(data)
|
||||
? data
|
||||
: data
|
||||
? Object.values(data)
|
||||
: [];
|
||||
};
|
||||
|
||||
// Modifie un item du repeater
|
||||
const handleRepeaterChange = (field, index, subField, value) => {
|
||||
const list = getRepeater(field);
|
||||
const updated = [...list];
|
||||
updated[index] = { ...updated[index], [subField]: value };
|
||||
setAcfFields(prev => ({ ...prev, [field]: updated }));
|
||||
};
|
||||
|
||||
// Ajoute un item au repeater
|
||||
const addRepeaterItem = (field, template) => {
|
||||
const list = getRepeater(field);
|
||||
setAcfFields(prev => ({ ...prev, [field]: [...list, template] }));
|
||||
};
|
||||
|
||||
// Supprime un item du repeater
|
||||
const deleteRepeaterItem = (field, index) => {
|
||||
const list = getRepeater(field);
|
||||
const updated = list.filter((_, i) => i !== index);
|
||||
setAcfFields(prev => ({ ...prev, [field]: updated }));
|
||||
};
|
||||
|
||||
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
|
||||
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 />}
|
||||
onClick={() => navigate("/admin/dashboard")}
|
||||
variant="contained"
|
||||
sx={composeButtonSx("ghost")}
|
||||
>
|
||||
Retour au dashboard
|
||||
</Button>
|
||||
|
||||
<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 l’introduction, la méthodologie et les expertises du
|
||||
bureau d’étude pour garder des informations pertinentes et à jour.
|
||||
</Typography>
|
||||
</Stack>
|
||||
</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 }}
|
||||
>
|
||||
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 l’expertise 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>
|
||||
|
||||
{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)",
|
||||
}}
|
||||
>
|
||||
<RichTextEditor
|
||||
value={acfFields.introduction || ""}
|
||||
onChange={(value) => handleFieldChange("introduction", value)}
|
||||
minHeight={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={(event) =>
|
||||
handleFieldChange("methodologie", event.target.value)
|
||||
}
|
||||
multiline
|
||||
minRows={3}
|
||||
sx={{
|
||||
"& .MuiOutlinedInput-root": {
|
||||
borderRadius: 2,
|
||||
backgroundColor: "rgba(255,255,255,0.94)",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<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 l’expertise 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={(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
|
||||
minRows={3}
|
||||
value={item.comp_description}
|
||||
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 />}
|
||||
onClick={() =>
|
||||
deleteRepeaterItem("liste_des_competences", index)
|
||||
}
|
||||
variant="contained"
|
||||
sx={composeButtonSx("danger", "small")}
|
||||
>
|
||||
Supprimer
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
startIcon={<Add />}
|
||||
variant="contained"
|
||||
onClick={() =>
|
||||
addRepeaterItem("liste_des_competences", {
|
||||
comp_titre: "",
|
||||
comp_description: "",
|
||||
})
|
||||
}
|
||||
sx={composeButtonSx("primary", "small")}
|
||||
>
|
||||
Ajouter une compétence
|
||||
</Button>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<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 de l’expertise"
|
||||
fullWidth
|
||||
value={item.expertise_nom}
|
||||
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
|
||||
minRows={2}
|
||||
value={item.expertise_details}
|
||||
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 />}
|
||||
onClick={() =>
|
||||
deleteRepeaterItem("expertises_specifiques", index)
|
||||
}
|
||||
variant="contained"
|
||||
sx={composeButtonSx("danger", "small")}
|
||||
>
|
||||
Supprimer
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
startIcon={<Add />}
|
||||
variant="contained"
|
||||
onClick={() =>
|
||||
addRepeaterItem("expertises_specifiques", {
|
||||
expertise_nom: "",
|
||||
expertise_details: "",
|
||||
})
|
||||
}
|
||||
sx={composeButtonSx("primary", "small")}
|
||||
>
|
||||
Ajouter une expertise
|
||||
</Button>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
startIcon={<Save />}
|
||||
disabled={submitting}
|
||||
sx={{ ...composeButtonSx("primary"), alignSelf: "flex-end", minWidth: 220 }}
|
||||
>
|
||||
{submitting ? "Enregistrement…" : "Enregistrer les modifications"}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditPageEtude;
|
||||
@@ -1,483 +0,0 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { getToken } from "../auth";
|
||||
import { fetchPages } from "../wordpress";
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
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(() => {
|
||||
if (!getToken()) {
|
||||
navigate("/admin/login");
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
// ✅ Récupérer les pages ACF et exclure la page d'accueil
|
||||
useEffect(() => {
|
||||
const loadPages = async () => {
|
||||
try {
|
||||
const response = await fetchPages();
|
||||
console.log("📢 Données reçues de fetchPages() :", response);
|
||||
|
||||
if (!Array.isArray(response)) {
|
||||
console.error(
|
||||
"❌ La réponse `fetchPages()` n'est pas un tableau :",
|
||||
response
|
||||
);
|
||||
setError("❌ Impossible de charger les pages ACF.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const filteredPages = response.filter(
|
||||
(page) => page.id !== HOME_PAGE_ID && page.id !== ETUDE_PAGE_ID,
|
||||
);
|
||||
setPages(filteredPages);
|
||||
} catch (error) {
|
||||
console.error("❌ Erreur chargement pages ACF :", error);
|
||||
setError("Impossible de charger les pages.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadPages();
|
||||
}, []);
|
||||
|
||||
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
|
||||
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={{
|
||||
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="contained"
|
||||
onClick={() => navigate("/admin/dashboard")}
|
||||
sx={composeButtonSx("ghost")}
|
||||
>
|
||||
Retour au dashboard
|
||||
</Button>
|
||||
|
||||
<Stack spacing={0.5}>
|
||||
<Typography
|
||||
variant="overline"
|
||||
sx={{
|
||||
letterSpacing: 2,
|
||||
fontWeight: 700,
|
||||
color: "rgba(255,255,255,0.8)",
|
||||
}}
|
||||
>
|
||||
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"
|
||||
onClick={() =>
|
||||
window.open("https://it.sveitl.synology.me/", "_blank")
|
||||
}
|
||||
sx={composeButtonSx("primary")}
|
||||
>
|
||||
Ouvrir un ticket
|
||||
</Button>
|
||||
</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 }}
|
||||
>
|
||||
{page.title.rendered}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: "rgba(11, 26, 61, 0.68)", lineHeight: 1.5 }}
|
||||
>
|
||||
Cliquez sur "Modifier" pour ajuster les contenus
|
||||
de cette page service depuis l’éditeur ACF dédié.
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Button
|
||||
startIcon={<Edit />}
|
||||
variant="contained"
|
||||
sx={composeButtonSx("outline", "small")}
|
||||
onClick={() => navigate(`/admin/edit-page/${page.id}`)}
|
||||
>
|
||||
Modifier
|
||||
</Button>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default GestionPagesACF;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
AppBar,
|
||||
Toolbar,
|
||||
@@ -24,8 +24,8 @@ import ArticleIcon from "@mui/icons-material/Article";
|
||||
import InfoIcon from "@mui/icons-material/Info";
|
||||
import ContactMailIcon from "@mui/icons-material/ContactMail";
|
||||
import WorkIcon from "@mui/icons-material/Work";
|
||||
import Logo from "../assets/centre-formation-logo.avif";
|
||||
import LogoM from "../assets/centre-formation-logo.avif";
|
||||
import Logo from "../assets/logo-in3.svg";
|
||||
import LogoM from "../assets/logo-in3-mobil.svg";
|
||||
|
||||
const Header = () => {
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
@@ -123,7 +123,7 @@ const Header = () => {
|
||||
>
|
||||
<img
|
||||
src={Logo}
|
||||
alt="centre de formation numérique"
|
||||
alt="Bureau d'études Le Mans"
|
||||
style={{
|
||||
width: "50px",
|
||||
height: "50px",
|
||||
@@ -150,7 +150,7 @@ const Header = () => {
|
||||
...commonButtonStyles,
|
||||
}}
|
||||
>
|
||||
Activités
|
||||
Services
|
||||
</Button>
|
||||
<Menu
|
||||
id="services-menu"
|
||||
@@ -167,45 +167,24 @@ const Header = () => {
|
||||
onClick={handleMenuClose}
|
||||
>
|
||||
<WorkIcon sx={{ marginRight: 1 }} />
|
||||
Création de site Web
|
||||
Prestation maitrise oeuvre
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem
|
||||
component={Link}
|
||||
to="/services/formations-web"
|
||||
to="/services/structure-beton-charpente-metallique-bois"
|
||||
onClick={handleMenuClose}
|
||||
>
|
||||
<WorkIcon sx={{ marginRight: 1 }} />
|
||||
Formations web
|
||||
Structure béton | charpente métallique / bois
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem
|
||||
component={Link}
|
||||
to="/services/electricite"
|
||||
to="/services/formation-video"
|
||||
onClick={handleMenuClose}
|
||||
>
|
||||
<WorkIcon sx={{ marginRight: 1}} />
|
||||
Graphisme
|
||||
Électricité
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem
|
||||
component={Link}
|
||||
to="/services/service-securite-incendie"
|
||||
onClick={handleMenuClose}
|
||||
>
|
||||
<WorkIcon sx={{ marginRight: 1}} />
|
||||
Podcast
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem
|
||||
component={Link}
|
||||
to="/services/expertises-tce"
|
||||
onClick={handleMenuClose}
|
||||
>
|
||||
<WorkIcon sx={{ marginRight: 1}} />
|
||||
Formations
|
||||
</MenuItem>
|
||||
|
||||
</Menu>
|
||||
|
||||
<Button
|
||||
@@ -219,9 +198,9 @@ const Header = () => {
|
||||
...commonButtonStyles,
|
||||
}}
|
||||
>
|
||||
Actualités
|
||||
Réalisations
|
||||
</Button>
|
||||
{/* <Button
|
||||
<Button
|
||||
component={Link}
|
||||
to="/about"
|
||||
color="inherit"
|
||||
@@ -233,10 +212,10 @@ const Header = () => {
|
||||
}}
|
||||
>
|
||||
À Propos
|
||||
</Button> */}
|
||||
</Button>
|
||||
<Button
|
||||
component={Link}
|
||||
to="/nosFormations"
|
||||
to="/bureauEtude"
|
||||
color="inherit"
|
||||
sx={{
|
||||
fontWeight: location.pathname === "/bureauEtude" ? "bold" : "normal",
|
||||
@@ -245,7 +224,7 @@ const Header = () => {
|
||||
...commonButtonStyles,
|
||||
}}
|
||||
>
|
||||
Formations
|
||||
Bureau d'étude
|
||||
</Button>
|
||||
<Button
|
||||
component={Link}
|
||||
@@ -369,55 +348,28 @@ const Header = () => {
|
||||
selected={location.pathname.includes("/services/prestation-maitrise-oeuvre")}
|
||||
>
|
||||
<WorkIcon sx={{ marginRight: 0.5 }} />
|
||||
<ListItemText primary="Création site web" />
|
||||
<ListItemText primary="Prestation maitrise oeuvre" />
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
button
|
||||
component={Link}
|
||||
to="/services/formations-web"
|
||||
selected={location.pathname.includes("/services/formations-web")}
|
||||
to="/services/formation-ia"
|
||||
selected={location.pathname.includes("/services/formation-ia")}
|
||||
>
|
||||
<WorkIcon sx={{ marginRight: 0.5 }} />
|
||||
<ListItemText primary="Formations web..." />
|
||||
<ListItemText primary="Formation IA" />
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
button
|
||||
component={Link}
|
||||
to="/services/electricite"
|
||||
to="/services/formation-video"
|
||||
selected={location.pathname.includes(
|
||||
"/services/electricite"
|
||||
"/services/formation-video"
|
||||
)}
|
||||
>
|
||||
<WorkIcon sx={{ marginRight: 0.5 }} />
|
||||
<ListItemText primary="Graphisme..." />
|
||||
<ListItemText primary="Formation Video" />
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
button
|
||||
component={Link}
|
||||
to="/services/service-securite-incendie"
|
||||
selected={location.pathname.includes(
|
||||
"/services/service-securite-incendie"
|
||||
)}
|
||||
>
|
||||
<WorkIcon sx={{ marginRight: 0.5 }} />
|
||||
<ListItemText primary="Podcast..." />
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
button
|
||||
component={Link}
|
||||
to="/nosFormations"
|
||||
selected={location.pathname.includes(
|
||||
"/nosFormations"
|
||||
)}
|
||||
>
|
||||
<WorkIcon sx={{ marginRight: 0.5 }} />
|
||||
<ListItemText primary="Formations..." />
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
button
|
||||
component={Link}
|
||||
@@ -425,10 +377,9 @@ const Header = () => {
|
||||
selected={location.pathname === "/posts"}
|
||||
>
|
||||
<ArticleIcon sx={{ marginRight: 0.5 }} />
|
||||
<ListItemText primary="Actualités..." />
|
||||
<ListItemText primary="Réalisations" />
|
||||
</ListItem>
|
||||
|
||||
{/* <ListItem
|
||||
<ListItem
|
||||
button
|
||||
component={Link}
|
||||
to="/about"
|
||||
@@ -436,8 +387,7 @@ const Header = () => {
|
||||
>
|
||||
<InfoIcon sx={{ marginRight: 0.5 }} />
|
||||
<ListItemText primary="À propos" />
|
||||
</ListItem> */}
|
||||
|
||||
</ListItem>
|
||||
<ListItem
|
||||
button
|
||||
component={Link}
|
||||
@@ -447,7 +397,6 @@ const Header = () => {
|
||||
<ContactMailIcon sx={{ marginRight: 0.5 }} />
|
||||
<ListItemText primary="Contact" />
|
||||
</ListItem>
|
||||
|
||||
</List>
|
||||
</Box>
|
||||
</Drawer>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, Button } from '@mui/material';
|
||||
|
||||
|
||||
const Hero = ({ backgroundImage, useGradient, title, titleColor, text, textColor }) => {
|
||||
// Priorité pour appliquer le fond : Image > Dégradé > Aucun fond
|
||||
const backgroundStyle = backgroundImage
|
||||
@@ -61,10 +60,9 @@ const Hero = ({ backgroundImage, useGradient, title, titleColor, text, textColor
|
||||
fontWeight: 'bold',
|
||||
padding: { xs: '10px 20px', md: '15px 30px' }, // Responsive
|
||||
fontSize: { xs: '0.8rem', md: '1rem' },
|
||||
backgroundColor: '#315397',
|
||||
}}
|
||||
>
|
||||
Nos Formations
|
||||
Explorer les Articles
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
const CLOUD_NAME = "dh5qgexjo";
|
||||
const UPLOAD_PRESET = "preset_articles"; // crée-le dans Cloudinary si ce n’est pas encore fait
|
||||
const FOLDER = "articles-octopus";
|
||||
|
||||
const ImageUploaderCloudinary = ({ onUploadSuccess }) => {
|
||||
const handleUpload = async (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("upload_preset", UPLOAD_PRESET);
|
||||
formData.append("folder", FOLDER);
|
||||
|
||||
const res = await fetch(`https://api.cloudinary.com/v1_1/${CLOUD_NAME}/image/upload`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.secure_url) {
|
||||
const transformedUrl = data.secure_url.replace("/upload/", "/upload/f_webp,w_800/");
|
||||
onUploadSuccess(transformedUrl); // ← Ici on t'envoie l’URL optimisée
|
||||
} else {
|
||||
alert("❌ Erreur lors de l'upload");
|
||||
}
|
||||
};
|
||||
|
||||
return <input type="file" accept="image/*" onChange={handleUpload} />;
|
||||
};
|
||||
|
||||
export default ImageUploaderCloudinary;
|
||||
@@ -26,7 +26,7 @@ const About = () => {
|
||||
{
|
||||
icon: <BusinessIcon />,
|
||||
title: "Bureau d’études et Maitrise d’œuvre",
|
||||
link: "/services/prestation-maitrise-oeuvre",
|
||||
link: "/services/maitrise-oeuvre",
|
||||
},
|
||||
{
|
||||
icon: <BuildIcon />,
|
||||
@@ -140,7 +140,7 @@ const About = () => {
|
||||
mb: 4, mt:5
|
||||
}}
|
||||
>
|
||||
La société IN3 !!!
|
||||
La société IN3
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
@@ -15,31 +15,26 @@ const BureauEtudes = () => {
|
||||
useEffect(() => {
|
||||
const fetchPageData = async () => {
|
||||
try {
|
||||
const response = await api.get("wp/v2/pages/272?_fields=acf");
|
||||
console.log("expertises_specifiques:", response.data.acf.expertises_specifiques);
|
||||
const response = await api.get("wp/v2/pages/272?_fields=acf"); // Remplace XXX par l'ID WordPress de la page
|
||||
setPageData(response.data.acf);
|
||||
} catch (error) {
|
||||
console.error("Erreur lors de la récupération des données ACF :", error);
|
||||
console.error(
|
||||
"Erreur lors de la récupération des données ACF :",
|
||||
error
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPageData();
|
||||
}, []);
|
||||
|
||||
const expertises = useMemo(() => {
|
||||
if (!pageData?.expertises_specifiques) return [];
|
||||
return Array.isArray(pageData.expertises_specifiques)
|
||||
? pageData.expertises_specifiques
|
||||
: Object.values(pageData.expertises_specifiques);
|
||||
}, [pageData]);
|
||||
|
||||
if (!pageData) {
|
||||
return <Typography>Chargement...</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Section Héros */}
|
||||
<Box sx={{}}>
|
||||
{/* Section Héros avec Image de Fond */}
|
||||
<Box
|
||||
sx={{
|
||||
backgroundImage: "url('https://picsum.photos/1920/600.webp')",
|
||||
@@ -55,33 +50,29 @@ const BureauEtudes = () => {
|
||||
px: 4,
|
||||
}}
|
||||
>
|
||||
{/* Présentation Générale */}
|
||||
<Box sx={{ mt: 6, textAlign: "center", px: 4 }}>
|
||||
<Typography
|
||||
variant="h1"
|
||||
sx={{
|
||||
fontSize: { xs: "3rem", md: "3rem", lg: "3.5rem" },
|
||||
fontWeight: "bold",
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
Nos Formations
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ maxWidth: "900px", mx: "auto" }}
|
||||
dangerouslySetInnerHTML={{ __html: pageData.introduction }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Présentation Générale */}
|
||||
<Box sx={{ mt: 6, textAlign: "center", px: 4 }}>
|
||||
<Typography variant="h1" sx={{
|
||||
fontSize: { xs: "3rem", md: "3rem", lg: "3.5rem" }, // Responsive
|
||||
fontWeight: "bold",
|
||||
mb: 2,
|
||||
}}>
|
||||
Notre Bureau d'Études
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ maxWidth: "900px", mx: "auto" }}>
|
||||
{pageData.introduction}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
{/* Domaines d'Intervention */}
|
||||
<Box sx={{ mt: 6, px: 4 }}>
|
||||
<Typography
|
||||
variant="h2"
|
||||
sx={{ fontSize: "2.5rem", fontWeight: "bold", textAlign: "center", mb: 3 }}
|
||||
>
|
||||
Nos formations sur mesure
|
||||
<Typography variant="h2" sx={{
|
||||
fontSize: { xs: "2.5rem", md: "2.5rem", lg: "2.5rem" }, // Responsive
|
||||
fontWeight: "bold",
|
||||
textAlign: "center",
|
||||
mb: 3 }}>
|
||||
Nos Domaines d'Intervention
|
||||
</Typography>
|
||||
<Grid container spacing={4} justifyContent="center">
|
||||
{pageData.liste_des_competences?.map((competence, index) => (
|
||||
@@ -98,15 +89,16 @@ const BureauEtudes = () => {
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Typography
|
||||
variant="h3"
|
||||
sx={{ fontWeight: "bold", mb: 1, fontSize: "1.8rem" }}
|
||||
>
|
||||
<Typography variant="h3" sx={{
|
||||
fontWeight: "bold",
|
||||
mb: 1,
|
||||
fontSize: { xs: "1.8rem", md: "1.8rem", lg: "1.8rem" }, // Responsive
|
||||
}}>
|
||||
{competence.comp_titre}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ textAlign: "center" }}>
|
||||
{competence.comp_description}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{
|
||||
textAlign:"center"
|
||||
}}>{competence.comp_description}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
@@ -116,7 +108,11 @@ const BureauEtudes = () => {
|
||||
|
||||
{/* Compétences Spécifiques */}
|
||||
<Box sx={{ mt: 6, textAlign: "center", px: 4 }}>
|
||||
<Typography variant="h2" sx={{ fontSize: "2.5rem", fontWeight: "bold", mb: 3 }}>
|
||||
<Typography variant="h2" sx={{
|
||||
fontSize: { xs: "2.5rem", md: "2.5rem", lg: "2.5rem" }, // Responsive
|
||||
fontWeight: "bold",
|
||||
mb: 3
|
||||
}}>
|
||||
Nos Compétences
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ maxWidth: "900px", mx: "auto" }}>
|
||||
@@ -135,40 +131,41 @@ const BureauEtudes = () => {
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h2" sx={{ fontSize: "2.5rem", fontWeight: "bold", mb: 3 }}>
|
||||
<Typography variant="h2" sx={{
|
||||
fontSize: { xs: "2.5rem", md: "2.5rem", lg: "2.5rem" }, // Responsive
|
||||
fontWeight: "bold",
|
||||
mb: 3
|
||||
}}>
|
||||
Engagement et Accompagnement
|
||||
</Typography>
|
||||
<Grid container spacing={4} justifyContent="center">
|
||||
{expertises.length > 0 ? (
|
||||
expertises.map((expertise, index) => (
|
||||
<Grid item xs={12} sm={6} md={4} key={index}>
|
||||
<Card
|
||||
sx={{
|
||||
textAlign: "center",
|
||||
padding: 3,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.1)",
|
||||
backdropFilter: "blur(10px)",
|
||||
transition: "transform 0.3s, box-shadow 0.3s",
|
||||
"&:hover": {
|
||||
transform: "scale(1.05)",
|
||||
boxShadow: "0px 10px 20px rgba(255, 255, 255, 0.2)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Typography variant="h3" sx={{ fontSize: "1.8rem", fontWeight: "bold", mb: 1 }}>
|
||||
{expertise.expertise_nom}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{expertise.expertise_details}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))
|
||||
) : (
|
||||
<Typography>Aucune expertise spécifique à afficher.</Typography>
|
||||
)}
|
||||
{pageData.expertises_specifiques?.map((expertise, index) => (
|
||||
<Grid item xs={12} sm={6} md={4} key={index}>
|
||||
<Card
|
||||
sx={{
|
||||
textAlign: "center",
|
||||
padding: 3,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.1)",
|
||||
backdropFilter: "blur(10px)",
|
||||
transition: "transform 0.3s, box-shadow 0.3s",
|
||||
"&:hover": {
|
||||
transform: "scale(1.05)",
|
||||
boxShadow: "0px 10px 20px rgba(255, 255, 255, 0.2)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Typography variant="h3" sx={{
|
||||
fontSize: { xs: "1.8rem", md: "1.8rem", lg: "1.8rem" }, // Responsive
|
||||
fontWeight: "bold",
|
||||
mb: 1 }}>
|
||||
{expertise.expertise_nom}
|
||||
</Typography>
|
||||
<Typography variant="body2">{expertise.expertise_details}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
@@ -197,4 +194,4 @@ const BureauEtudes = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default BureauEtudes;
|
||||
export default BureauEtudes;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, Suspense } from "react";
|
||||
import React, { useState, useEffect, lazy, Suspense } from "react";
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
@@ -19,15 +19,9 @@ import {
|
||||
import SEO from "../SEO";
|
||||
import api from "../../api";
|
||||
import Faq from "../Faq";
|
||||
|
||||
const FAQ = lazy(() => import('../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."
|
||||
@@ -41,11 +35,7 @@ const Contact = () => {
|
||||
});
|
||||
const [errors, setErrors] = useState({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [alertState, setAlertState] = useState({
|
||||
open: false,
|
||||
severity: "success",
|
||||
message: "",
|
||||
});
|
||||
const [successMessage, setSuccessMessage] = useState(false);
|
||||
const [openLightbox, setOpenLightbox] = useState(false);
|
||||
|
||||
const [showFAQ, setShowFAQ] = useState(false); // État pour afficher/masquer la FAQ
|
||||
@@ -106,7 +96,7 @@ const Contact = () => {
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
CONTACT_API_URL,
|
||||
"https://votre-site.com/wp-json/custom/v1/contact",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -117,11 +107,7 @@ const Contact = () => {
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
setAlertState({
|
||||
open: true,
|
||||
severity: "success",
|
||||
message: "Merci ! Votre message a bien été envoyé.",
|
||||
});
|
||||
setSuccessMessage(true);
|
||||
setFormData({
|
||||
name: "",
|
||||
email: "",
|
||||
@@ -134,13 +120,7 @@ const Contact = () => {
|
||||
throw new Error(errorData.message || "Une erreur est survenue.");
|
||||
}
|
||||
} catch (error) {
|
||||
setAlertState({
|
||||
open: true,
|
||||
severity: "error",
|
||||
message:
|
||||
error.message ||
|
||||
"Impossible d'envoyer votre message. Veuillez réessayer.",
|
||||
});
|
||||
alert(error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -155,13 +135,6 @@ const Contact = () => {
|
||||
setOpenLightbox(false);
|
||||
};
|
||||
|
||||
const handleCloseAlert = (_, reason) => {
|
||||
if (reason === "clickaway") {
|
||||
return;
|
||||
}
|
||||
setAlertState((prev) => ({ ...prev, open: false }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* SEO */}
|
||||
@@ -243,14 +216,14 @@ const Contact = () => {
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1">
|
||||
<strong>Adresse :</strong>Route du Perrier, Saint HIlaire de Riez,
|
||||
<strong>Adresse :</strong>67 Bd Winston Churchill, Le Mans,
|
||||
France
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
<strong>Téléphone :</strong> 06.00.00.00.00
|
||||
<strong>Téléphone :</strong> 02.43.85.09.01
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
<strong>Email :</strong> contact@octopusdesign.fr
|
||||
<strong>Email :</strong> contact@be-in3.com
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -305,7 +278,7 @@ const Contact = () => {
|
||||
>
|
||||
<CardContent>
|
||||
<Typography
|
||||
variant="h3"
|
||||
variant="h2"
|
||||
sx={{
|
||||
fontSize: { xs: "2rem", md: "2rem", lg: "3rem" },
|
||||
fontWeight: "bold",
|
||||
@@ -359,7 +332,7 @@ const Contact = () => {
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d2732.7778475259584!2d-1.9653890876882651!3d46.769275171004885!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x4804ffa63669919f%3A0x50e5fd73af1e7d87!2sOctopus%20Design!5e0!3m2!1sfr!2sfr!4v1753993974889!5m2!1sfr!2sfr"
|
||||
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d2670.1862696285393!2d0.22040227623964043!3d47.990787261369235!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x47e28ec5f984044d%3A0xa3a21789f4a4eaa6!2sIN3%20Int%C3%A9grale%20Ing%C3%A9nierie%20Internationale%20Bureau%20D'%C3%A9tudes%20Ing%C3%A9nieries%20Ma%C3%AEtrise%20d'oeuvre!5e0!3m2!1sfr!2sfr!4v1736328111099!5m2!1sfr!2sfr%22%20width=%22600%22%20height=%22450%22%20style=%22border:0;%22%20allowfullscreen=%22%22%20loading=%22lazy%22%20referrerpolicy=%22no-referrer-when-downgrade%22"
|
||||
width="100%"
|
||||
height="400"
|
||||
title="Carte interactive"
|
||||
@@ -383,9 +356,10 @@ const Contact = () => {
|
||||
<Typography
|
||||
variant="h2"
|
||||
sx={{
|
||||
fontSize: { xs: "2rem", md: "2rem", lg: "2rem" }, // Responsive
|
||||
fontSize: { xs: "2rem", md: "2rem", lg: "3rem" }, // Responsive
|
||||
fontWeight: "bold",
|
||||
mb: 2,
|
||||
fontSize: { xs: "1.5rem", md: "2rem" },
|
||||
}}
|
||||
>
|
||||
Besoin d'une assistance immédiate ?
|
||||
@@ -517,17 +491,12 @@ const Contact = () => {
|
||||
</form>
|
||||
|
||||
<Snackbar
|
||||
open={alertState.open}
|
||||
open={successMessage}
|
||||
autoHideDuration={6000}
|
||||
onClose={handleCloseAlert}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
|
||||
onClose={() => setSuccessMessage(false)}
|
||||
>
|
||||
<Alert
|
||||
onClose={handleCloseAlert}
|
||||
severity={alertState.severity}
|
||||
sx={{ width: "100%" }}
|
||||
>
|
||||
{alertState.message}
|
||||
<Alert onClose={() => setSuccessMessage(false)} severity="success">
|
||||
Message envoyé avec succès !
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
|
||||
@@ -1,472 +1,54 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { uploadImageFromUrl, createPost } from "../../wordpress";
|
||||
import { getToken } from "../../auth";
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
TextField,
|
||||
Button,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Stack,
|
||||
Chip,
|
||||
Divider,
|
||||
} from "@mui/material";
|
||||
import { ArrowBack, Publish, Image as ImageIcon } from "@mui/icons-material";
|
||||
import ImageUploaderCloudinary from "../ImageUploaderCloudinary";
|
||||
import CloudinaryGallerySelector from "../CloudinaryGallerySelector";
|
||||
import RichTextEditor from "../common/RichTextEditor";
|
||||
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),
|
||||
});
|
||||
import { useState } from "react";
|
||||
import { uploadImage, createPost } from "../../wordpress";
|
||||
|
||||
function CreatePost() {
|
||||
const [title, setTitle] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
const [imageUrl, setImageUrl] = useState(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const [title, setTitle] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
const [file, setFile] = useState(null);
|
||||
|
||||
// Vérification de l'authentification
|
||||
useEffect(() => {
|
||||
if (!getToken()) {
|
||||
navigate("/admin/login");
|
||||
}
|
||||
}, [navigate]);
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const canPublish =
|
||||
title.trim().length > 0 && content.trim().length > 0 && Boolean(imageUrl);
|
||||
try {
|
||||
console.log("📢 Tentative d'upload de l'image...");
|
||||
const imageId = await uploadImage(file);
|
||||
|
||||
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 || error
|
||||
);
|
||||
alert("❌ Erreur lors de la création du post.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
console.log("📢 Création du post avec l'image mise en avant...");
|
||||
await createPost(title, content, imageId);
|
||||
|
||||
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" />
|
||||
alert("✅ Post créé avec succès !");
|
||||
} catch (error) {
|
||||
alert("❌ Erreur lors de la création du post.");
|
||||
}
|
||||
};
|
||||
|
||||
<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="contained"
|
||||
onClick={() => navigate("/admin/gestion-articles")}
|
||||
sx={composeButtonSx("ghost")}
|
||||
>
|
||||
Retour aux articles
|
||||
</Button>
|
||||
<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",
|
||||
}}
|
||||
>
|
||||
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>
|
||||
|
||||
<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
|
||||
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)",
|
||||
}}
|
||||
>
|
||||
<RichTextEditor
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
minHeight={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"
|
||||
startIcon={<Publish />}
|
||||
disabled={!canPublish || submitting}
|
||||
sx={{ ...composeButtonSx("primary"), minWidth: 200 }}
|
||||
>
|
||||
{submitting ? "Publication..." : "Publier l'article"}
|
||||
</Button>
|
||||
<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><h2></code>) pour
|
||||
clarifier la structure.<br />
|
||||
• Ajoutez des appels à l’action 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 l’image 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>
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<h2>Créer un post</h2>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Titre du post"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<textarea
|
||||
placeholder="Contenu du post"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => setFile(e.target.files[0])}
|
||||
required
|
||||
/>
|
||||
<button type="submit">Publier</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreatePost;
|
||||
export default CreatePost;
|
||||
@@ -1,525 +1,83 @@
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { getToken, logout } from "../../auth"; // Importation de la fonction logout
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Button,
|
||||
Stack,
|
||||
Chip,
|
||||
Divider,
|
||||
IconButton,
|
||||
} from "@mui/material";
|
||||
import LogoutIcon from "@mui/icons-material/Logout";
|
||||
import ArticleIcon from "@mui/icons-material/Article";
|
||||
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),
|
||||
});
|
||||
import DashboardIcon from "@mui/icons-material/Dashboard";
|
||||
|
||||
function Dashboard() {
|
||||
const navigate = useNavigate();
|
||||
const token = getToken();
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
navigate("/admin/login"); // Redirige vers la page de connexion si non authentifié
|
||||
}
|
||||
}, [token, navigate]);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout(); // Déconnecte l'utilisateur
|
||||
navigate("/admin/login"); // Redirige vers la page de connexion
|
||||
};
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{
|
||||
title: "Page d’accueil",
|
||||
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",
|
||||
position: "relative",
|
||||
px: { xs: 2, md: 6 },
|
||||
pt: { xs: 6, md: 8 },
|
||||
pb: { xs: 8, md: 10 },
|
||||
backgroundImage: "url('https://source.unsplash.com/1600x900/?technology,office')",
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "20px",
|
||||
}}
|
||||
>
|
||||
<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 à l’ensemble 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
|
||||
<Box
|
||||
sx={{
|
||||
width: "90%",
|
||||
maxWidth: "900px",
|
||||
padding: 4,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.9)",
|
||||
borderRadius: 3,
|
||||
boxShadow: 6,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{/* En-tête */}
|
||||
<Typography variant="h4" sx={{ fontWeight: "bold", mb: 4 }}>
|
||||
<DashboardIcon sx={{ fontSize: 40, color: "#0e467f", mr: 1 }} />
|
||||
Tableau de Bord
|
||||
</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",
|
||||
color: "#315397",
|
||||
boxShadow: "0 16px 26px -20px rgba(11, 26, 61, 0.5)",
|
||||
fontSize: 28,
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
</Box>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{ color: "#0b1a3d", fontWeight: 700 }}
|
||||
>
|
||||
{item.title}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: "rgba(11, 26, 61, 0.68)", lineHeight: 1.6 }}
|
||||
>
|
||||
{item.description}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => navigate(item.navigateTo)}
|
||||
endIcon={<ArrowForwardIcon fontSize="small" />}
|
||||
sx={{
|
||||
...composeButtonSx("primary", "small"),
|
||||
alignSelf: "flex-start",
|
||||
}}
|
||||
>
|
||||
Ouvrir
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
{/* Cartes du Dashboard */}
|
||||
<Grid container spacing={3} justifyContent="center">
|
||||
<Grid item xs={12} sm={6} md={4}>
|
||||
<Card
|
||||
onClick={() => navigate("/posts")}
|
||||
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>
|
||||
</Grid>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default Dashboard;
|
||||
export default Dashboard;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,566 +0,0 @@
|
||||
// ✅ Importations nécessaires
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
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é
|
||||
useEffect(() => {
|
||||
if (!getToken()) {
|
||||
navigate("/admin/login");
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
// ✅ Récupérer les articles et leurs images associées
|
||||
useEffect(() => {
|
||||
const fetchPosts = async () => {
|
||||
try {
|
||||
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(
|
||||
postsData.map(async (post) => {
|
||||
if (post.featured_media) {
|
||||
try {
|
||||
const mediaResponse = await api.get(`wp/v2/media/${post.featured_media}`);
|
||||
return { ...post, image: mediaResponse.data.source_url, imageId: post.featured_media };
|
||||
} catch (error) {
|
||||
console.error(`❌ Erreur chargement image article ${post.id}:`, error);
|
||||
return { ...post, image: null, imageId: null };
|
||||
}
|
||||
}
|
||||
return { ...post, image: null, imageId: null };
|
||||
})
|
||||
);
|
||||
|
||||
setPosts(postsWithImages);
|
||||
} catch (error) {
|
||||
console.error("❌ Erreur chargement des articles :", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPosts();
|
||||
}, []);
|
||||
|
||||
// ✅ Supprimer un article et son image associée
|
||||
const stripHtml = (value = "") =>
|
||||
value.replace(/(<([^>]+)>)/gi, "").replace(/ /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 {
|
||||
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.");
|
||||
}
|
||||
};
|
||||
|
||||
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
|
||||
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: 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={(event) => setSearchTerm(event.target.value)}
|
||||
sx={{
|
||||
maxWidth: 380,
|
||||
"& .MuiOutlinedInput-root": {
|
||||
borderRadius: 999,
|
||||
backgroundColor: "rgba(255,255,255,0.92)",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{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",
|
||||
}}
|
||||
/>
|
||||
<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="contained"
|
||||
onClick={() => handleDeletePost(post.id, post.imageId)}
|
||||
sx={{ ...composeButtonSx("danger", "small") }}
|
||||
>
|
||||
Supprimer
|
||||
</Button>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default GestionArticles;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,17 +5,9 @@ import api from "../../api";
|
||||
import ModernSpinner from "../SpinnerModerne";
|
||||
import Expertises from "../Expertises";
|
||||
import ConstructSection from "../ConstructSection";
|
||||
import {
|
||||
Box,
|
||||
Grid,
|
||||
Typography,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
} from "@mui/material";
|
||||
import Logo from "../../assets/centre-formation-logo.avif";
|
||||
import { Box, Grid, Typography, Button, Card, CardContent } from "@mui/material";
|
||||
import Logo from "../../assets/logo-in3-mobil.svg";
|
||||
import Testi from "../Testi";
|
||||
import "../../assets/style.css";
|
||||
|
||||
const Home = () => {
|
||||
const [pageData, setPageData] = useState(null);
|
||||
@@ -29,23 +21,14 @@ const Home = () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// 🔥 Ajout d'un timestamp pour éviter la mise en cache
|
||||
const response = await api.get(
|
||||
`wp/v2/pages/13?_fields=acf,link,rank_math_title,rank_math_description&_=${new Date().getTime()}`
|
||||
);
|
||||
const response = await api.get("wp/v2/pages/13?_fields=acf,rank_math_title,rank_math_description");
|
||||
const pageContent = response.data;
|
||||
setPageData(pageContent);
|
||||
|
||||
// Récupération de l'image Hero (Cloudinary ou WordPress)
|
||||
if (
|
||||
pageContent.acf?.img_hero_url &&
|
||||
pageContent.acf.img_hero_url.startsWith("http")
|
||||
) {
|
||||
setHeroImage(pageContent.acf.img_hero_url);
|
||||
} else if (pageContent.acf?.img_hero) {
|
||||
const mediaResponse = await api.get(
|
||||
`wp/v2/media/${pageContent.acf.img_hero}?_=${new Date().getTime()}`
|
||||
);
|
||||
// Vérification et récupération de l'image Hero depuis l'API media de WordPress
|
||||
const heroImageId = pageContent.acf?.img_hero;
|
||||
if (heroImageId) {
|
||||
const mediaResponse = await api.get(`wp/v2/media/${heroImageId}`);
|
||||
setHeroImage(mediaResponse.data.source_url);
|
||||
} else {
|
||||
setHeroImage(null);
|
||||
@@ -79,26 +62,19 @@ const Home = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const { acf, rank_math_title, rank_math_description, link } = pageData || {};
|
||||
const fallbackUrl =
|
||||
typeof window !== "undefined" ? window.location.href : "";
|
||||
const pageUrl = link || fallbackUrl;
|
||||
|
||||
|
||||
const { acf, rank_math_title, rank_math_description } = pageData || {};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* SEO */}
|
||||
<SEO
|
||||
pageUrl={pageUrl}
|
||||
defaultTitle={rank_math_title || "centre de formation "}
|
||||
defaultDescription={
|
||||
rank_math_description ||
|
||||
"formation et reconversion professionnelle dans les métiers du numérique"
|
||||
}
|
||||
defaultCanonicalUrl={acf?.canonical_url || pageUrl}
|
||||
defaultOgImage={
|
||||
heroImage ||
|
||||
"https://preprod.octopusdesign.fr/api-octopus/server/wp-content/uploads/2025/01/Construction-logements-au-Mans-01.avif"
|
||||
}
|
||||
postId={13} // ID WordPress valide
|
||||
defaultTitle={rank_math_title || "Accueil - Mon Site"}
|
||||
defaultDescription={rank_math_description || "Bienvenue sur notre site !"}
|
||||
defaultCanonicalUrl={acf?.canonical_url || window.location.href}
|
||||
defaultOgImage={heroImage || "https://preprod.octopusdesign.fr/api-octopus/server/wp-content/uploads/2025/01/Construction-logements-au-Mans-01.avif"}
|
||||
/>
|
||||
|
||||
{/* Section Héros */}
|
||||
@@ -113,22 +89,18 @@ const Home = () => {
|
||||
|
||||
{/* Section Construisons */}
|
||||
<ConstructSection
|
||||
constructTitle={
|
||||
acf?.gdc_construct?.construct_title || "Construisons vos projets"
|
||||
}
|
||||
constructTitle={acf?.gdc_construct?.construct_title || "Construisons vos projets"}
|
||||
constructSubtitle={acf?.gdc_construct?.construct_subtitle || "ensemble"}
|
||||
constructText={acf?.gdc_construct?.construct_text || "Texte par défaut"}
|
||||
constructSector={acf?.gdc_construct?.construct_sector || "Secteurs"}
|
||||
constructNoteText={acf?.gdc_construct?.construct_note || "Note société"}
|
||||
missionTitle={acf?.mission_title || "Nos missions principales"}
|
||||
missionItems={acf?.mission || []}
|
||||
missionItems={acf?.mission_items || []}
|
||||
/>
|
||||
|
||||
{/* Section Expertises */}
|
||||
<Expertises
|
||||
expertiseTitle={
|
||||
acf?.titre_expertise.titre_expertise || "Pourquoi Nous Choisir ?"
|
||||
}
|
||||
expertiseTitle={acf?.titre_expertise || "Pourquoi Nous Choisir ?"}
|
||||
expertise1Title={acf?.gdc_expert?.titre_expertise_1 || "Expertise 1"}
|
||||
expertise1Text={acf?.gdc_expert?.text_expertise_1 || "Text Expertise 1"}
|
||||
expertise2Title={acf?.gdc_expert?.titre_expertise_2 || "Expertise 2"}
|
||||
@@ -137,27 +109,16 @@ const Home = () => {
|
||||
expertise3Text={acf?.gdc_expert?.text_expertise_3 || "Text Expertise 3"}
|
||||
/>
|
||||
|
||||
|
||||
|
||||
{/* Section Témoignages */}
|
||||
<Typography
|
||||
variant="h2"
|
||||
sx={{
|
||||
fontWeight: "bold",
|
||||
mb: 2,
|
||||
textAlign: "center",
|
||||
fontSize: { xs: "2rem", md: "2rem", lg: "3rem" },
|
||||
}}
|
||||
>
|
||||
Ce que disent nos clients
|
||||
</Typography>
|
||||
<Typography variant="h2" sx={{ fontWeight: "bold", mb: 2, textAlign: "center", fontSize: { xs: "2rem", md: "2rem", lg: "3rem" } }}>
|
||||
Ce que disent nos clients
|
||||
</Typography>
|
||||
<Testi />
|
||||
{/* Section Action */}
|
||||
<Box sx={{ py: 6, px: 4, backgroundColor: "#0e467f", color: "#fff" }}>
|
||||
<Typography
|
||||
variant="h2"
|
||||
align="center"
|
||||
gutterBottom
|
||||
sx={{ fontWeight: "bold", mb: 4 }}
|
||||
>
|
||||
<Typography variant="h2" align="center" gutterBottom sx={{ fontWeight: "bold", mb: 4 }}>
|
||||
Prêt à Commencer ?
|
||||
</Typography>
|
||||
<Box textAlign="center">
|
||||
@@ -180,15 +141,7 @@ const Home = () => {
|
||||
|
||||
{/* Section Témoignages */}
|
||||
<Box sx={{ py: 6, px: 4 }}>
|
||||
<Typography
|
||||
variant="h2"
|
||||
sx={{
|
||||
fontWeight: "bold",
|
||||
mb: 2,
|
||||
textAlign: "center",
|
||||
fontSize: { xs: "2rem", md: "2rem", lg: "3rem" },
|
||||
}}
|
||||
>
|
||||
<Typography variant="h2" sx={{ fontWeight: "bold", mb: 2, textAlign: "center", fontSize: { xs: "2rem", md: "2rem", lg: "3rem" } }}>
|
||||
Nos partenaires
|
||||
</Typography>
|
||||
<Grid container spacing={4}>
|
||||
@@ -198,11 +151,8 @@ const Home = () => {
|
||||
<Typography variant="body1" sx={{ fontStyle: "italic" }}>
|
||||
Un service exceptionnel, une équipe formidable
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ mt: 2, textAlign: "right", fontWeight: "bold" }}
|
||||
>
|
||||
- zertides
|
||||
<Typography variant="body2" sx={{ mt: 2, textAlign: "right", fontWeight: "bold" }}>
|
||||
- Client 1
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -213,10 +163,7 @@ const Home = () => {
|
||||
<Typography variant="body1" sx={{ fontStyle: "italic" }}>
|
||||
Des résultats impressionnants pour notre projet.
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ mt: 2, textAlign: "right", fontWeight: "bold" }}
|
||||
>
|
||||
<Typography variant="body2" sx={{ mt: 2, textAlign: "right", fontWeight: "bold" }}>
|
||||
- Client 2
|
||||
</Typography>
|
||||
</CardContent>
|
||||
@@ -228,4 +175,4 @@ const Home = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
export default Home;
|
||||
@@ -1,271 +1,115 @@
|
||||
import { useState } from "react";
|
||||
import { loginWithAppPassword, getToken, logout } from "../../auth";
|
||||
import { useNavigate } from "react-router-dom"; // Import de useNavigate pour la redirection
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
TextField,
|
||||
Typography,
|
||||
Stack,
|
||||
CircularProgress,
|
||||
Chip,
|
||||
Container,
|
||||
Avatar,
|
||||
} 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);
|
||||
// alert("Connexion réussie !");
|
||||
} else {
|
||||
setError("Échec de la connexion. Vérifiez vos identifiants.");
|
||||
alert("Échec de la connexion !");
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
setToken(null);
|
||||
// alert("Déconnexion réussie !");
|
||||
};
|
||||
|
||||
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 },
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Box className="glass-orb orb-1" />
|
||||
<Box className="glass-orb orb-2" />
|
||||
<Box className="glass-orb orb-3" />
|
||||
<Container maxWidth="xs">
|
||||
<Card
|
||||
sx={{
|
||||
padding: 4,
|
||||
boxShadow: 6,
|
||||
borderRadius: 3,
|
||||
textAlign: "center",
|
||||
backdropFilter: "blur(10px)",
|
||||
backgroundColor: "rgba(255, 255, 255, 0.8)",
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Avatar sx={{ margin: "auto", backgroundColor: "#0e467f" }}>
|
||||
<LockOutlinedIcon />
|
||||
</Avatar>
|
||||
<Typography variant="h5" sx={{ fontWeight: "bold", mt: 2 }}>
|
||||
{token ? "Bienvenue !" : "Connexion"}
|
||||
</Typography>
|
||||
|
||||
<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 d’application 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)",
|
||||
}}
|
||||
{token ? (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
onClick={handleLogout}
|
||||
sx={{ mt: 3, width: "100%" }}
|
||||
>
|
||||
<LockOutlinedIcon fontSize="large" />
|
||||
</Box>
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{ fontWeight: 700, color: "#0b1a3d" }}
|
||||
>
|
||||
{token ? "Bienvenue dans l’espace Octopus" : "Connexion"}
|
||||
</Typography>
|
||||
|
||||
{token ? (
|
||||
<Stack spacing={1.5} sx={{ width: "100%" }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => navigate("/admin/dashboard")}
|
||||
sx={{ ...composeButtonSx("primary"), width: "100%" }}
|
||||
>
|
||||
Accéder au tableau de bord
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleLogout}
|
||||
sx={{ ...composeButtonSx("outline"), width: "100%" }}
|
||||
>
|
||||
Déconnexion
|
||||
</Button>
|
||||
</Stack>
|
||||
) : (
|
||||
<Box
|
||||
component="form"
|
||||
onSubmit={handleLogin}
|
||||
sx={{ width: "100%" }}
|
||||
Déconnexion
|
||||
</Button>
|
||||
) : (
|
||||
<form onSubmit={handleLogin}>
|
||||
<TextField
|
||||
label="Nom d'utilisateur"
|
||||
fullWidth
|
||||
margin="normal"
|
||||
variant="outlined"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
label="Mot de passe d'application"
|
||||
fullWidth
|
||||
margin="normal"
|
||||
variant="outlined"
|
||||
type="password"
|
||||
value={appPassword}
|
||||
onChange={(e) => setAppPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
sx={{ mt: 3, width: "100%" }}
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
label="Nom d’utilisateur"
|
||||
fullWidth
|
||||
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"
|
||||
fullWidth
|
||||
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"
|
||||
disabled={loading}
|
||||
sx={{ ...composeButtonSx("primary"), width: "100%" }}
|
||||
>
|
||||
{loading ? (
|
||||
<CircularProgress size={20} sx={{ color: "#ffffff" }} />
|
||||
) : (
|
||||
"Se connecter"
|
||||
)}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
Se connecter
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default TestLogin;
|
||||
export default TestLogin;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Box, Typography, Grid, Card, CardContent, CardMedia, Button } from "@mui/material";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import api from "../../api";
|
||||
@@ -10,7 +10,7 @@ const Posts = () => {
|
||||
useEffect(() => {
|
||||
const fetchPosts = async () => {
|
||||
try {
|
||||
const response = await api.get("wp/v2/posts?_fields=id,slug,title,excerpt,content,featured_media");
|
||||
const response = await api.get("wp/v2/posts?_fields=id,title,excerpt,featured_media");
|
||||
const postsData = response.data;
|
||||
|
||||
// Récupérer les images en vedette
|
||||
@@ -38,32 +38,16 @@ const Posts = () => {
|
||||
fetchPosts();
|
||||
}, []);
|
||||
|
||||
// Fonction pour nettoyer le HTML et récupérer un texte brut
|
||||
const stripHtmlTags = (html) => {
|
||||
return html.replace(/(<([^>]+)>)/gi, ""); // Supprime toutes les balises HTML
|
||||
// Fonction pour tronquer le texte à 70 caractères
|
||||
const truncateText = (text, maxLength) => {
|
||||
const strippedText = text.replace(/(<([^>]+)>)/gi, ""); // Suppression des balises HTML
|
||||
return strippedText.length > maxLength ? `${strippedText.substring(0, maxLength)}...` : strippedText;
|
||||
};
|
||||
|
||||
const decodeHtml = (html) => {
|
||||
const txt = document.createElement("textarea");
|
||||
txt.innerHTML = html;
|
||||
return txt.value;
|
||||
};
|
||||
|
||||
|
||||
|
||||
// Fonction pour tronquer un texte à 100 caractères max
|
||||
const truncateText = (text, maxLength) => {
|
||||
const parser = new DOMParser();
|
||||
const decoded = parser.parseFromString(text, "text/html").body.textContent || "";
|
||||
|
||||
const cleanText = decoded.replace(/ /g, " "); // Optionnel si besoin
|
||||
return cleanText.length > maxLength ? `${cleanText.substring(0, maxLength)}...` : cleanText;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ padding: "40px 20px" }}>
|
||||
<Typography variant="h1" sx={{ fontWeight: "bold", fontSize: "3em", color:"#315397", textAlign: "center", mb: 4, mt:5 }}>
|
||||
L'actualité du numérique
|
||||
<Typography variant="h2" sx={{ fontWeight: "bold", textAlign: "center", mb: 4 }}>
|
||||
Nos Articles
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={4} justifyContent="center">
|
||||
@@ -72,19 +56,19 @@ const truncateText = (text, maxLength) => {
|
||||
<Card
|
||||
sx={{
|
||||
cursor: "pointer",
|
||||
"&:hover": { backgroundColor: "#0e467f", color: "#ffffff", transform: "scale(1.05)", boxShadow: 6 },
|
||||
"&:hover": { backgroundColor: "#0e467f", color: "white", transform: "scale(1.05)", boxShadow: 6 },
|
||||
}}
|
||||
onClick={() => navigate(`/post/${post.slug}`)}
|
||||
onClick={() => navigate(`/post/${post.id}`)}
|
||||
>
|
||||
{post.image && (
|
||||
<CardMedia component="img" height="200" image={post.image} alt={post.title.rendered} />
|
||||
)}
|
||||
<CardContent>
|
||||
<Typography variant="h2" sx={{ fontWeight: "bold", fontSize: "1.5em", color:"#315397"}}>
|
||||
<Typography variant="h5" sx={{ fontWeight: "bold" }}>
|
||||
{post.title.rendered}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mt: 1 }}>
|
||||
{truncateText(post.excerpt.rendered || post.content.rendered, 100)}
|
||||
{truncateText(post.excerpt.rendered, 100)}
|
||||
</Typography>
|
||||
<Button variant="outlined" sx={{ mt: 2, color: "white", borderColor: "white" }}>
|
||||
Lire plus
|
||||
|
||||
@@ -1,89 +1,27 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { fetchPosts, deletePost } from "../../wordpress";
|
||||
import { Box, Typography, Button, CircularProgress, Paper, Grid } from "@mui/material";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import { toast } from "react-toastify";
|
||||
import "react-toastify/dist/ReactToastify.css";
|
||||
|
||||
function PostList() {
|
||||
const [posts, setPosts] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadPosts() {
|
||||
try {
|
||||
const data = await fetchPosts();
|
||||
setPosts(data);
|
||||
} catch (err) {
|
||||
setError("⚠️ Impossible de récupérer les articles.");
|
||||
console.error("Erreur lors de la récupération des articles :", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
const data = await fetchPosts();
|
||||
setPosts(data);
|
||||
}
|
||||
loadPosts();
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (postId) => {
|
||||
if (window.confirm("❌ Es-tu sûr de vouloir supprimer cet article ?")) {
|
||||
try {
|
||||
await deletePost(postId);
|
||||
setPosts(posts.filter((post) => post.id !== postId));
|
||||
toast.success("✅ Article supprimé avec succès !");
|
||||
} catch (err) {
|
||||
console.error("Erreur suppression :", err);
|
||||
toast.error("⚠️ Erreur lors de la suppression.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: "900px", margin: "auto", mt: 5 }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: "bold", textAlign: "center", mb: 3 }}>
|
||||
📋 Liste des Articles
|
||||
</Typography>
|
||||
|
||||
{loading && (
|
||||
<Box sx={{ textAlign: "center", mt: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Typography color="error" textAlign="center" sx={{ mb: 3 }}>
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{!loading && posts.length === 0 && (
|
||||
<Typography textAlign="center" sx={{ mt: 3 }}>
|
||||
Aucun article trouvé.
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{posts.map((post) => (
|
||||
<Grid item xs={12} sm={6} md={4} key={post.id}>
|
||||
<Paper sx={{ padding: 3, boxShadow: 3, textAlign: "center" }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: "bold", mb: 2 }}>
|
||||
{post.title.rendered}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
startIcon={<DeleteIcon />}
|
||||
onClick={() => handleDelete(post.id)}
|
||||
sx={{ fontWeight: "bold" }}
|
||||
>
|
||||
Supprimer
|
||||
</Button>
|
||||
</Paper>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
<div className="post-list">
|
||||
<h2>Liste des Articles</h2>
|
||||
{posts.map((post) => (
|
||||
<div key={post.id} className="post-item">
|
||||
<h3>{post.title.rendered}</h3>
|
||||
<button onClick={() => deletePost(post.id)}>Supprimer</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PostList;
|
||||
export default PostList;
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import ServicePageTemplate from "../ServicePageTemplate";
|
||||
import { getPageById } from "../../wordpress";
|
||||
|
||||
const ServiceCinq = () => {
|
||||
const [acfData, setAcfData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const pageId = 612; // 🔥 Remplace par l'ID réel de la page WordPress
|
||||
|
||||
useEffect(() => {
|
||||
const fetchACFData = async () => {
|
||||
try {
|
||||
const pageData = await getPageById(pageId);
|
||||
setAcfData(pageData.acf);
|
||||
} catch (error) {
|
||||
console.error("❌ Erreur récupération des champs ACF :", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchACFData();
|
||||
}, []);
|
||||
|
||||
// ✅ Affichage du chargement en attendant les données ACF
|
||||
if (loading) {
|
||||
return <p>Chargement...</p>;
|
||||
}
|
||||
|
||||
const getStoredValue = (key) =>
|
||||
typeof window !== "undefined" ? window.localStorage.getItem(key) : null;
|
||||
|
||||
const storedHeroBackground = getStoredValue(`hero_bg_${pageId}`);
|
||||
const storedHeroImage = getStoredValue(`hero_img_${pageId}`);
|
||||
|
||||
const heroBackground =
|
||||
acfData?.hero_background ||
|
||||
acfData?.background_hero ||
|
||||
acfData?.hero_bg ||
|
||||
storedHeroBackground ||
|
||||
"linear-gradient(135deg, #050d1f 0%, #02050d 85%)";
|
||||
|
||||
const heroImage =
|
||||
acfData?.hero_background_image ||
|
||||
acfData?.hero_background_image_url ||
|
||||
acfData?.hero_image_url ||
|
||||
acfData?.hero_image ||
|
||||
acfData?.image_hero ||
|
||||
acfData?.img_hero_url ||
|
||||
acfData?.img_hero ||
|
||||
storedHeroImage ||
|
||||
"https://picsum.photos/id/1026/1920/1080.webp";
|
||||
|
||||
const serviceDetails = {
|
||||
// Hero
|
||||
title: acfData?.titre_principal_tce || "Titre héros !", // ✅ Modifiable individuellement
|
||||
subtitle:
|
||||
acfData?.description_principal_tce ||
|
||||
"<p>Description, héros</p>",
|
||||
|
||||
image: heroImage,
|
||||
heroBackground,
|
||||
ctaText: "En savoir plus",
|
||||
ctaLink: "/contact",
|
||||
|
||||
// section 1
|
||||
interestTitle: acfData?.second_titre_tce || "Titre interet", // ✅ Modifiable individuellement
|
||||
description:
|
||||
acfData?.second_description_tce || "<p>Description interet</p>",
|
||||
// section 2
|
||||
desirTitle: "Titre", // ✅ Modifiable individuellement
|
||||
|
||||
features: [
|
||||
{
|
||||
title: acfData?.titre_carte_one_tce || "Titre !",
|
||||
description: acfData?.description_carte_one_tce || "Description.",
|
||||
modalText: acfData?.description_carte_one_tce || "Description Modal.",
|
||||
modalImage: "https://picsum.photos/id/300/800/400.webp",
|
||||
},
|
||||
{
|
||||
title: acfData?.titre_carte_two_tce || "Titre !",
|
||||
description: acfData?.description_carte_two_tce || "Description.",
|
||||
modalText: acfData?.description_carte_two_tce || "Description Modal.",
|
||||
modalImage: "https://picsum.photos/id/301/800/400.webp",
|
||||
},
|
||||
{
|
||||
title: acfData?.titre_carte_tree || "Titre !",
|
||||
description: acfData?.description_carte_tree || "Description.",
|
||||
modalText: acfData?.description_carte_tree || "Description Modal.",
|
||||
modalImage: "https://picsum.photos/id/302/800/400.webp",
|
||||
},
|
||||
],
|
||||
|
||||
carouselItems: [
|
||||
{
|
||||
title: "Portfolio Modern",
|
||||
description: "Montrez vos compétences avec un portfolio unique.",
|
||||
image: "https://picsum.photos/id/305/800/400.webp",
|
||||
},
|
||||
{
|
||||
title: "Blog SEO",
|
||||
description: "Optimisez vos articles pour le référencement.",
|
||||
image: "https://picsum.photos/id/306/800/400.webp",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return <ServicePageTemplate {...serviceDetails} />;
|
||||
};
|
||||
|
||||
export default ServiceCinq;
|
||||
@@ -1,113 +1,55 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import ServicePageTemplate from "../ServicePageTemplate";
|
||||
import { getPageById } from "../../wordpress";
|
||||
import React from 'react';
|
||||
import ServicePageTemplate from '../ServicePageTemplate';
|
||||
|
||||
const ServiceDeux = () => {
|
||||
const [acfData, setAcfData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const pageId = 671; // 🔥 Remplace par l'ID réel de la page WordPress
|
||||
|
||||
useEffect(() => {
|
||||
const fetchACFData = async () => {
|
||||
try {
|
||||
const pageData = await getPageById(pageId);
|
||||
setAcfData(pageData.acf);
|
||||
} catch (error) {
|
||||
console.error("❌ Erreur récupération des champs ACF :", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchACFData();
|
||||
}, []);
|
||||
|
||||
// ✅ Affichage du chargement en attendant les données ACF
|
||||
if (loading) {
|
||||
return <p>Chargement...</p>;
|
||||
}
|
||||
const getStoredValue = (key) =>
|
||||
typeof window !== "undefined" ? window.localStorage.getItem(key) : null;
|
||||
|
||||
const storedHeroBackground = getStoredValue(`hero_bg_${pageId}`);
|
||||
const storedHeroImage = getStoredValue(`hero_img_${pageId}`);
|
||||
|
||||
const heroBackground =
|
||||
acfData?.hero_background ||
|
||||
acfData?.background_hero ||
|
||||
acfData?.hero_bg ||
|
||||
storedHeroBackground ||
|
||||
"linear-gradient(135deg, #041226 0%, #01040a 90%)";
|
||||
|
||||
const heroImage =
|
||||
acfData?.hero_background_image ||
|
||||
acfData?.hero_background_image_url ||
|
||||
acfData?.hero_image_url ||
|
||||
acfData?.hero_image ||
|
||||
acfData?.image_hero ||
|
||||
acfData?.img_hero_url ||
|
||||
acfData?.img_hero ||
|
||||
storedHeroImage ||
|
||||
"https://picsum.photos/id/240/1920/1080.webp";
|
||||
|
||||
const serviceDetails = {
|
||||
|
||||
// Hero
|
||||
title: acfData?.titre_principal_beton || "formations-web",
|
||||
subtitle:
|
||||
acfData?.description_principal_beton ||
|
||||
"<p>Solidité, Résistance, Durabilité</p>",
|
||||
|
||||
image: heroImage,
|
||||
heroBackground,
|
||||
ctaText: "En savoir plus !",
|
||||
ctaLink: "/contact",
|
||||
|
||||
// section 1
|
||||
interestTitle: acfData?.second_titre_beton ||"Béton ou Charpente :\n Quel Matériau Choisir pour une Construction Durable ?", // ✅ Modifiable individuellement
|
||||
title: 'Structure béton charpente métallique & bois',
|
||||
subtitle: 'Dominez l’intelligence artificielle.',
|
||||
description:
|
||||
acfData?.second_description_beton ||
|
||||
"<p>Le béton est le matériau de construction le plus répandu dans le monde. Il est présent dans tous les secteurs de la construction.</p><p>Longtemps associé à l’image négative des grands ensembles, gris et vieillissants, le béton a réalisé des progrès spectaculaires ces dernières années, tant au niveau de ses performances techniques que des aspects esthétiques.</p><p>Il commence même à entrer dans les foyers comme produit de décoration ! Une charpente est un assemblage de pièces de bois et/ou de métal, servant à soutenir ou couvrir des constructions et faisant partie de la toiture.</p>",
|
||||
|
||||
// section 2
|
||||
desirTitle: acfData?.troisieme_titre_beton || "Titre",// ✅ Modifiable individuellement
|
||||
|
||||
'Plongez dans le futur ! Notre formation IA vous prépare aux outils modernes d’intelligence artificielle comme Python, TensorFlow et OpenAI API. Apprenez à créer des modèles IA performants.',
|
||||
features: [
|
||||
{
|
||||
title: acfData?.titre_carte_one_beton || "Titre !",
|
||||
description: acfData?.description_carte_one_beton || "Description.",
|
||||
modalText: acfData?.description_carte_one_beton || "Description Modal.",
|
||||
modalImage: "https://picsum.photos/id/200/800/400.webp",
|
||||
title: 'Fondamentaux de l’IA',
|
||||
description: 'Concepts, algorithmes et applications.',
|
||||
modalText: 'Découvrez les bases de l’IA, des algorithmes aux applications concrètes dans divers domaines.',
|
||||
modalImage: 'https://picsum.photos/id/200/800/400.webp',
|
||||
},
|
||||
{
|
||||
title: acfData?.titre_carte_two_beton || "Titre !",
|
||||
description: acfData?.description_carte_two_beton || "Description.",
|
||||
modalText: acfData?.description_carte_two_beton || "Description Modal.",
|
||||
modalImage: "https://picsum.photos/id/201/800/400.webp",
|
||||
title: 'Python pour l’IA',
|
||||
description: 'Bibliothèques populaires : TensorFlow, PyTorch.',
|
||||
modalText: 'Apprenez à utiliser Python et ses bibliothèques pour concevoir des modèles d’IA puissants.',
|
||||
modalImage: 'https://picsum.photos/id/201/800/400.webp',
|
||||
},
|
||||
{
|
||||
title: acfData?.titre_carte_tree_beton || "Titre !",
|
||||
description: acfData?.description_carte_tree_beton || "Description.",
|
||||
modalText: acfData?.description_carte_tree_beton || "Description Modal.",
|
||||
modalImage: "https://picsum.photos/id/202/800/400.webp",
|
||||
title: 'Projets Pratiques',
|
||||
description: 'Créez vos propres modèles IA.',
|
||||
modalText: 'Mettez vos compétences en pratique en développant des projets IA réels.',
|
||||
modalImage: 'https://picsum.photos/id/202/800/400.webp',
|
||||
},
|
||||
],
|
||||
|
||||
image: 'https://picsum.photos/id/239/1920/1080.webp',
|
||||
ctaText: 'Découvrir la formation IA',
|
||||
ctaLink: '/contact',
|
||||
carouselItems: [
|
||||
{
|
||||
title: "Structure beton",
|
||||
description: "Un projet e-commerce performant et moderne.",
|
||||
image: "https://picsum.photos/id/522/800/400.webp",
|
||||
title: 'Site E-commerce',
|
||||
description: 'Un projet e-commerce performant et moderne.',
|
||||
image: 'https://picsum.photos/id/453/800/400.webp',
|
||||
},
|
||||
{
|
||||
title: "Structure bois",
|
||||
description: "Montrez vos compétences avec un portfolio sur mesure.",
|
||||
image: "https://picsum.photos/id/521/800/400.webp",
|
||||
title: 'Portfolio Personnel',
|
||||
description: 'Montrez vos compétences avec un portfolio sur mesure.',
|
||||
image: 'https://picsum.photos/id/454/800/400.webp',
|
||||
},
|
||||
{
|
||||
title: 'Blog Dynamique',
|
||||
description: 'Créez un blog interactif et optimisé pour le SEO.',
|
||||
image: 'https://picsum.photos/id/455/800/400.webp',
|
||||
},
|
||||
|
||||
],
|
||||
};
|
||||
|
||||
return <ServicePageTemplate {...serviceDetails} />;
|
||||
};
|
||||
|
||||
export default ServiceDeux;
|
||||
export default ServiceDeux;
|
||||
@@ -1,141 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Helmet } from "react-helmet-async"; // ✅ SEO
|
||||
import ServicePageTemplate from "../ServicePageTemplate";
|
||||
import { getPageById } from "../../wordpress";
|
||||
|
||||
const ServiceQuatre = () => {
|
||||
const [acfData, setAcfData] = useState(null);
|
||||
const [seoData, setSeoData] = useState(null); // ✅ Stocke les données SEO de RankMath
|
||||
const [loading, setLoading] = useState(true);
|
||||
const pageId = 590; // 🔥 ID réel de la page WordPress
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPageData = async () => {
|
||||
try {
|
||||
const pageData = await getPageById(pageId);
|
||||
|
||||
// ✅ Vérification des données ACF
|
||||
if (pageData.acf) {
|
||||
setAcfData(pageData.acf);
|
||||
} else {
|
||||
console.warn("⚠ Aucune donnée ACF trouvée !");
|
||||
}
|
||||
|
||||
// ✅ Récupération des données SEO depuis RankMath
|
||||
if (pageData.rankMath?.assessor?.serpData) {
|
||||
const serpData = pageData.rankMath.assessor.serpData;
|
||||
console.log("📢 Données SEO récupérées :", serpData);
|
||||
|
||||
setSeoData({
|
||||
metaTitle: serpData.title || pageData.title?.rendered || "Titre par défaut",
|
||||
metaDescription: serpData.description || "Description par défaut",
|
||||
keywords: serpData.focusKeywords || "bureau d'études, construction, rénovation",
|
||||
ogImage: serpData.ogImage || "https://picsum.photos/id/1018/1920/1080.webp",
|
||||
});
|
||||
} else {
|
||||
console.warn("⚠ Aucune donnée SEO trouvée dans RankMath !");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ Erreur récupération des champs ACF et SEO :", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPageData();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <p>Chargement...</p>;
|
||||
}
|
||||
|
||||
const getStoredValue = (key) =>
|
||||
typeof window !== "undefined" ? window.localStorage.getItem(key) : null;
|
||||
|
||||
const storedHeroBackground = getStoredValue(`hero_bg_${pageId}`);
|
||||
const storedHeroImage = getStoredValue(`hero_img_${pageId}`);
|
||||
|
||||
const heroBackground =
|
||||
acfData?.hero_background ||
|
||||
acfData?.background_hero ||
|
||||
acfData?.hero_bg ||
|
||||
storedHeroBackground ||
|
||||
"linear-gradient(135deg, #07132a 0%, #01040c 90%)";
|
||||
|
||||
const heroImage =
|
||||
acfData?.hero_background_image ||
|
||||
acfData?.hero_background_image_url ||
|
||||
acfData?.hero_image_url ||
|
||||
acfData?.hero_image ||
|
||||
acfData?.image_hero ||
|
||||
acfData?.img_hero_url ||
|
||||
acfData?.img_hero ||
|
||||
storedHeroImage ||
|
||||
"https://preprod.octopusdesign.fr/api-octopus/server/wp-content/uploads/2025/02/service-securite-incendie.webp";
|
||||
|
||||
const serviceDetails = {
|
||||
title: acfData?.titre_principal || "Titre héros !",
|
||||
subtitle: acfData?.description_principal || "<p>Description, héros</p>",
|
||||
|
||||
image: heroImage,
|
||||
heroBackground,
|
||||
ctaText: "En savoir plus",
|
||||
ctaLink: "/contact",
|
||||
|
||||
interestTitle: acfData?.second_titre || "Titre interet",
|
||||
description: acfData?.second_description || "<p>Description interet</p>",
|
||||
|
||||
desirTitle: "Titre",
|
||||
|
||||
features: [
|
||||
{
|
||||
title: acfData?.titre_carte_one || "Titre !",
|
||||
description: acfData?.description_carte_one || "",
|
||||
modalText: acfData?.description_carte_one || "Description Modal.",
|
||||
modalImage: "https://picsum.photos/id/300/800/400.webp",
|
||||
},
|
||||
{
|
||||
title: acfData?.titre_carte_two || "Titre !",
|
||||
description: acfData?.description_carte_two || "",
|
||||
modalText: acfData?.description_carte_two || "Description Modal.",
|
||||
modalImage: "https://picsum.photos/id/301/800/400.webp",
|
||||
},
|
||||
{
|
||||
title: acfData?.titre_carte_tree || "Titre !",
|
||||
description: acfData?.description_carte_tree || "",
|
||||
modalText: acfData?.description_carte_tree || "Description Modal.",
|
||||
modalImage: "https://picsum.photos/id/302/800/400.webp",
|
||||
},
|
||||
],
|
||||
|
||||
carouselItems: [
|
||||
{
|
||||
title: "Portfolio Modern",
|
||||
description: "Description de l'image",
|
||||
image: "https://preprod.octopusdesign.fr/api-octopus/server/wp-content/uploads/2025/02/securite-incendie.webp",
|
||||
width: 800,
|
||||
height: 400,
|
||||
},
|
||||
{
|
||||
title: "service securité S.S.I",
|
||||
description: "Description de l'image",
|
||||
image: "https://preprod.octopusdesign.fr/api-octopus/server/wp-content/uploads/2025/02/service-securite-ssi.webp",
|
||||
width: 800,
|
||||
height: 400,
|
||||
},
|
||||
],
|
||||
|
||||
// ✅ Ajout du SEO dynamique récupéré via RankMath
|
||||
seo: {
|
||||
metaTitle: seoData?.metaTitle || "Pourquoi faire appel à un bureau d’études ?",
|
||||
metaDescription: seoData?.metaDescription || "Description SEO par défaut",
|
||||
keywords: seoData?.keywords || "bureau d'études, construction, rénovation",
|
||||
ogImage: seoData?.ogImage || "https://preprod.octopusdesign.fr/api-octopus/server/wp-content/uploads/2025/02/service-securite-incendie.webp",
|
||||
},
|
||||
};
|
||||
|
||||
return <ServicePageTemplate {...serviceDetails} />
|
||||
|
||||
};
|
||||
|
||||
export default ServiceQuatre;
|
||||
@@ -1,73 +1,55 @@
|
||||
import React from "react";
|
||||
import ServicePageTemplate from "../ServicePageTemplate";
|
||||
import React from 'react';
|
||||
import ServicePageTemplate from '../ServicePageTemplate';
|
||||
|
||||
const ServiceTrois = () => {
|
||||
const serviceDetails = {
|
||||
// Hero
|
||||
title: "Électricité", // ✅ Modifiable individuellement
|
||||
subtitle: "IN3 est un bureau d’études technique spécialisé dans l’ingénierie électrique du bâtiment.",
|
||||
|
||||
image: "https://picsum.photos/id/1019/1920/1080.webp",
|
||||
heroBackground: "linear-gradient(135deg,#0c101f 0%,#03050b 85%)",
|
||||
ctaText: "En savoir plus",
|
||||
ctaLink: "/contact",
|
||||
|
||||
// Section 1
|
||||
interestTitle: "Comment réussir vos installations électriques \navec une assistance experte en maîtrise d’ouvrage ?",
|
||||
description: "Notre activité est l’assistance aux maîtres d’ouvrage à la réalisation des installations électriques courants forts et courants faibles.\n Définition des besoins / Audit si installations existantes Estimation du coût des ouvrages.\n \n Plans d’implantation du matériel et des canalisations schémas électriques unifilaires.\n Analyse des offres et assistance technique au choix de l’entreprise d’électricité.",
|
||||
|
||||
// Section 2
|
||||
desirTitle: "Réalisation et assistance électrique",
|
||||
|
||||
title: 'Électricité',
|
||||
subtitle: 'IN3 est un bureau d’études technique spécialisé dans l’ingénierie électrique du bâtiment.',
|
||||
description:
|
||||
'Plongez dans le futur ! Notre formation IA vous prépare aux outils modernes d’intelligence artificielle comme Python, TensorFlow et OpenAI API. Apprenez à créer des modèles IA performants.',
|
||||
features: [
|
||||
{
|
||||
title: "Chantier électricité industriel",
|
||||
description: "Réalisation d’installations électriques.",
|
||||
modalText: "Réalisation d’installations électriques pour des sites industriels, incluant le câblage, la pose d’équipements, et la mise en conformité selon les normes en vigueur.",
|
||||
modalImage: "https://picsum.photos/id/200/800/400.webp",
|
||||
title: 'Chantier électricité industriel',
|
||||
description: 'Réalisation d’installations électriques.',
|
||||
modalText: 'Réalisation d’installations électriques pour des sites industriels, incluant le câblage, la pose d’équipements, et la mise en conformité selon les normes en vigueur..',
|
||||
modalImage: 'https://picsum.photos/id/200/800/400.webp',
|
||||
},
|
||||
{
|
||||
title: "Dossier de Consultation",
|
||||
description: "Réalisation du Dossier de Consultation des entreprises (DCE)",
|
||||
modalText: "Réalisation du Dossier de Consultation des entreprises (DCE) cahier des clauses techniques particulières (CCTP) bordereau de décomposition du prix global et forfaitaire (DPGF, servant de base à tous les devis).",
|
||||
modalImage: "https://picsum.photos/id/201/800/400.webp",
|
||||
title: 'Python pour l’IA',
|
||||
description: 'Bibliothèques populaires : TensorFlow, PyTorch.',
|
||||
modalText: 'Apprenez à utiliser Python et ses bibliothèques pour concevoir des modèles d’IA puissants.',
|
||||
modalImage: 'https://picsum.photos/id/201/800/400.webp',
|
||||
},
|
||||
{
|
||||
title: "L'électricité & Suivi de chantier",
|
||||
description: "Assistance à l’entreprise et vérification des documents",
|
||||
modalText: "Assistance à l’entreprise et vérification des documents d’exécution. Réception des ouvrages exécutés. Nous pouvons également assister l’entreprise titulaire du marché à la réalisation des documents d’exécution (plans d’implantation, schématique électrique, notes de calculs réglementaires, …)",
|
||||
modalImage: "https://picsum.photos/id/202/800/400.webp",
|
||||
title: 'Projets Pratiques',
|
||||
description: 'Créez vos propres modèles IA.',
|
||||
modalText: 'Mettez vos compétences en pratique en développant des projets IA réels.',
|
||||
modalImage: 'https://picsum.photos/id/202/800/400.webp',
|
||||
},
|
||||
],
|
||||
|
||||
image: 'https://picsum.photos/id/239/1920/1080.webp',
|
||||
ctaText: 'En savoir plus !',
|
||||
ctaLink: '/contact',
|
||||
carouselItems: [
|
||||
{
|
||||
title: "Site E-commerce",
|
||||
description: "Un projet e-commerce performant et moderne.",
|
||||
image: "https://picsum.photos/id/453/800/400.webp",
|
||||
title: 'Site E-commerce',
|
||||
description: 'Un projet e-commerce performant et moderne.',
|
||||
image: 'https://picsum.photos/id/453/800/400.webp',
|
||||
},
|
||||
{
|
||||
title: "Portfolio Personnel",
|
||||
description: "Montrez vos compétences avec un portfolio sur mesure.",
|
||||
image: "https://picsum.photos/id/454/800/400.webp",
|
||||
title: 'Portfolio Personnel',
|
||||
description: 'Montrez vos compétences avec un portfolio sur mesure.',
|
||||
image: 'https://picsum.photos/id/454/800/400.webp',
|
||||
},
|
||||
{
|
||||
title: "Blog Dynamique",
|
||||
description: "Créez un blog interactif et optimisé pour le SEO.",
|
||||
image: "https://picsum.photos/id/455/800/400.webp",
|
||||
title: 'Blog Dynamique',
|
||||
description: 'Créez un blog interactif et optimisé pour le SEO.',
|
||||
image: 'https://picsum.photos/id/455/800/400.webp',
|
||||
},
|
||||
],
|
||||
|
||||
// ✅ Métadonnées SEO
|
||||
seo: {
|
||||
metaTitle: "Électricité | Expertise en ingénierie électrique",
|
||||
metaDescription: "Découvrez notre expertise en ingénierie électrique et maîtrisez l'installation et la gestion des systèmes électriques.",
|
||||
keywords: "électricité, ingénierie électrique, installation électrique, suivi de chantier",
|
||||
ogImage: "https://picsum.photos/1200/630",
|
||||
},
|
||||
};
|
||||
|
||||
return <ServicePageTemplate {...serviceDetails} />;
|
||||
};
|
||||
|
||||
export default ServiceTrois;
|
||||
export default ServiceTrois;
|
||||
@@ -1,77 +1,50 @@
|
||||
import React from "react";
|
||||
import ServicePageTemplate from "../ServicePageTemplate";
|
||||
import React from 'react';
|
||||
import ServicePageTemplate from '../ServicePageTemplate';
|
||||
|
||||
const ServiceUn = () => {
|
||||
const serviceDetails = {
|
||||
// Hero
|
||||
title:
|
||||
"Pourquoi faire appel à un \n bureau d’études pour concevoir \n ou rénover votre bâtiment ?", // ✅ Modifiable individuellement
|
||||
subtitle:
|
||||
"Notre bureau d’études in3 au Mans conçoit des bâtiments à construire ou à rénover selon le programme fourni par le maître de l’ouvrage, de diriger l’exécution des marchés de travaux, de proposer le règlement des travaux et leur réception. Nos missions sont les suivantes :", // ✅ Modifiable individuellement
|
||||
|
||||
image: "https://picsum.photos/id/1026/1920/1080.webp",
|
||||
heroBackground: "linear-gradient(135deg,#0b1428 0%,#16223f 40%,#04070f 100%)",
|
||||
ctaText: "En savoir plus",
|
||||
ctaLink: "/contact",
|
||||
|
||||
// section 1
|
||||
interestTitle:
|
||||
"Comment le maître d’œuvre garantit-il \n la réussite de votre projet de construction ?", // ✅ Modifiable individuellement
|
||||
title: 'Prestation-maitrise-oeuvre',
|
||||
subtitle: 'Apprenez à maîtriser les technologies du web.',
|
||||
description:
|
||||
"Véritable bras droit du maître d’ouvrage, nous lui proposons une solution technique et esthétique qui permet de réaliser son programme, dans l’enveloppe budgétaire et les délais qui lui sont assignés. Une fois son projet validé par le maître d’ouvrage, le maître d’œuvre est responsable du bon déroulement des travaux et joue un rôle de conseil dans le choix des entreprises qui vont les réaliser. Le choix de l’entrepreneur (ou des entrepreneurs) se fait à partir d’une consultation formalisée où, sur la base d’un cahier des charges (notamment le Cahier des Clauses Techniques Particulières), le titulaire faisant l’offre la plus adaptée, est choisi par le maître d’ouvrage sur proposition du maître d’œuvre compte tenu d’éléments matériels concrets.", // ✅ Modifiable individuellement
|
||||
|
||||
// section 2
|
||||
desirTitle: "Titre Désir", // ✅ Modifiable individuellement
|
||||
|
||||
'Notre formation web vous permet de développer vos compétences en développement frontend, backend et design. Rejoignez notre programme pour apprendre HTML, CSS, JavaScript et bien plus.',
|
||||
features: [
|
||||
{
|
||||
title: "diagnostic \n (DIA)",
|
||||
description: "Les études de diagnostic (DIA)...",
|
||||
modalText:
|
||||
"Les études de diagnostic (DIA), pour le cas de travaux sur un bâtiment existant)",
|
||||
modalImage: "https://picsum.photos/id/300/800/400.webp",
|
||||
title: 'Frontend Development',
|
||||
description: 'HTML, CSS, JavaScript, React et plus encore.',
|
||||
modalText: 'Apprenez à construire des interfaces utilisateurs modernes avec les technologies frontend.',
|
||||
modalImage: 'https://picsum.photos/id/300/800/400.webp',
|
||||
},
|
||||
{
|
||||
title: "Les Études d’Esquisse \n (ESQ)",
|
||||
description: "Comprendre leur Importance dans un Projet Architectural",
|
||||
modalText:
|
||||
"L’objectif principal de cette phase est de poser les bases du projet en définissant les grandes lignes de l’implantation, de l’organisation des espaces et de l’esthétique du bâtiment. Cela inclut : L’analyse du site : étude de l’environnement, des accès, de l’orientation et des contraintes liées au terrain. L’évaluation des besoins du maître d’ouvrage : prise en compte des attentes fonctionnelles et esthétiques. La proposition de plusieurs variantes : différentes approches sont étudiées pour identifier la meilleure solution. Une estimation préliminaire des coûts : pour vérifier l’adéquation entre les ambitions du projet et le budget disponible.",
|
||||
modalImage: "https://picsum.photos/id/301/800/400.webp",
|
||||
title: 'Backend Development',
|
||||
description: 'Node.js, Express, MongoDB et bases de données.',
|
||||
modalText: 'Développez des applications robustes avec des technologies backend performantes.',
|
||||
modalImage: 'https://picsum.photos/id/301/800/400.webp',
|
||||
},
|
||||
{
|
||||
title: "Pourquoi réaliser une étude d’avant-projet ?",
|
||||
description: "Les Études d’Avant-Projet (AVP)...",
|
||||
modalText:
|
||||
"L’AVP a pour objectif de définir les grandes lignes du projet en s’appuyant sur une analyse approfondie des besoins, des contraintes et des attentes des parties prenantes. Elle permet de : Clarifier les objectifs : Identifier précisément les résultats attendus et les enjeux du projet. Évaluer la faisabilité : Étudier la viabilité technique, économique et réglementaire. Définir les solutions possibles : Comparer différentes approches et choisir la meilleure. Établir une première estimation des coûts et délais : Anticiper les ressources nécessaires et éviter les imprévus.",
|
||||
modalImage: "https://picsum.photos/id/302/800/400.webp",
|
||||
title: 'Responsive Design',
|
||||
description: 'Apprenez à créer des designs modernes et adaptatifs.',
|
||||
modalText: 'Maîtrisez les principes du responsive design pour des expériences utilisateur optimales.',
|
||||
modalImage: 'https://picsum.photos/id/302/800/400.webp',
|
||||
},
|
||||
],
|
||||
|
||||
image: 'https://picsum.photos/id/1018/1920/1080.webp',
|
||||
ctaText: 'En savoir plus',
|
||||
ctaLink: '/contact',
|
||||
carouselItems: [
|
||||
{
|
||||
title: "préstation maitrise d'oeuvre",
|
||||
description: "Montrez vos compétences avec un portfolio unique.",
|
||||
image: "https://picsum.photos/id/307/800/400.webp",
|
||||
title: 'Portfolio Modern',
|
||||
description: 'Montrez vos compétences avec un portfolio unique.',
|
||||
image: 'https://picsum.photos/id/305/800/400.webp',
|
||||
},
|
||||
{
|
||||
title: "Bureau d'études maitrise-oeuvre",
|
||||
description: "Optimisez vos articles pour le référencement.",
|
||||
image: "https://picsum.photos/id/308/800/400.webp",
|
||||
title: 'Blog SEO',
|
||||
description: 'Optimisez vos articles pour le référencement.',
|
||||
image: 'https://picsum.photos/id/306/800/400.webp',
|
||||
},
|
||||
],
|
||||
// ✅ Métadonnées SEO
|
||||
seo: {
|
||||
metaTitle:
|
||||
"Pourquoi faire appel à un bureau d’études pour votre construction ou rénovation ? | in3",
|
||||
metaDescription:
|
||||
"Optimisez votre projet de construction ou de rénovation avec notre bureau d’études in3. Expertise, maîtrise d’œuvre et solutions adaptées pour garantir la réussite de votre chantier.",
|
||||
keywords:
|
||||
"bureau d'études, construction, rénovation, maîtrise d’œuvre, étude avant-projet, diagnostic DIA, architecture, travaux, bâtiment",
|
||||
ogImage: "https://picsum.photos/id/1018/1920/1080.webp",
|
||||
},
|
||||
};
|
||||
|
||||
return <ServicePageTemplate {...serviceDetails} />;
|
||||
};
|
||||
|
||||
export default ServiceUn;
|
||||
export default ServiceUn;
|
||||
@@ -1,229 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
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 = [
|
||||
{
|
||||
id: "app-reseau",
|
||||
name: "App Réseau",
|
||||
description: "Application React dédiée à la gestion du réseau.",
|
||||
url: "/app-reseaux/index.html?access-token=OCTO-ADMIN-2024",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
id: "generateur-photo",
|
||||
name: "Generateur Photos",
|
||||
description: "Application de génération d'image.",
|
||||
url: "/app-photos/index.html?access-token=OCTO-ADMIN-2024",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
id: "app-planning",
|
||||
name: "Planning",
|
||||
description: "Application de planification.",
|
||||
url: "https://planning-arinfo.vercel.app/",
|
||||
external: true,
|
||||
},
|
||||
];
|
||||
|
||||
function Tools() {
|
||||
const navigate = useNavigate();
|
||||
const token = getToken();
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
navigate("/admin/login");
|
||||
}
|
||||
}, [token, navigate]);
|
||||
|
||||
const handleOpenApp = (app) => {
|
||||
if (app.external) {
|
||||
window.open(app.url, "_blank", "noopener");
|
||||
return;
|
||||
}
|
||||
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
|
||||
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={composeButtonSx("outline")}
|
||||
>
|
||||
Retour au dashboard
|
||||
</Button>
|
||||
<Stack spacing={0.5}>
|
||||
<Typography
|
||||
variant="overline"
|
||||
sx={{
|
||||
letterSpacing: 2,
|
||||
fontWeight: 700,
|
||||
color: "rgba(255,255,255,0.8)",
|
||||
}}
|
||||
>
|
||||
Hub applicatif Octopus
|
||||
</Typography>
|
||||
<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, générateur
|
||||
de photos et futurs modules d’automatisation Octopus.
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<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} rowSpacing={9}>
|
||||
{toolsApps.map((app) => (
|
||||
<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: "#cfd2d8ff", fontWeight: 700 }}
|
||||
>
|
||||
{app.name}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: "rgba(11, 26, 61, 0.68)", lineHeight: 1.5 }}
|
||||
>
|
||||
{app.description}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Button
|
||||
variant="contained"
|
||||
sx={composeButtonSx("primary")}
|
||||
onClick={() => handleOpenApp(app)}
|
||||
>
|
||||
Ouvrir
|
||||
</Button>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default Tools;
|
||||
@@ -1,88 +1,64 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Box, Typography, Button, CircularProgress } from "@mui/material";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import api from "../api";
|
||||
|
||||
const PostDetails = () => {
|
||||
const { slug } = useParams();
|
||||
const { id } = useParams();
|
||||
const [post, setPost] = useState(null);
|
||||
const [image, setImage] = useState(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPost = async () => {
|
||||
if (!slug) {
|
||||
console.error("❌ Erreur : aucun slug fourni !");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`📢 Requête API pour le slug : ${slug}`);
|
||||
const response = await api.get(`wp/v2/posts?slug=${slug}&_fields=id,title,content,featured_media,date`);
|
||||
const response = await api.get(`wp/v2/posts/${id}?_fields=title,content,featured_media,date`);
|
||||
setPost(response.data);
|
||||
|
||||
if (response.data.length > 0) {
|
||||
const article = response.data[0];
|
||||
setPost(article);
|
||||
|
||||
if (article.featured_media) {
|
||||
console.log(`📢 Récupération de l'image ID : ${article.featured_media}`);
|
||||
const mediaResponse = await api.get(`wp/v2/media/${article.featured_media}`);
|
||||
setImage(mediaResponse.data.source_url);
|
||||
}
|
||||
} else {
|
||||
console.error("❌ Aucun article trouvé avec ce slug !");
|
||||
// Récupération de l'image
|
||||
if (response.data.featured_media) {
|
||||
const mediaResponse = await api.get(`wp/v2/media/${response.data.featured_media}`);
|
||||
setImage(mediaResponse.data.source_url);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ Erreur lors de la récupération de l'article :", error);
|
||||
console.error("Erreur lors de la récupération de l'article :", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPost();
|
||||
}, [slug]);
|
||||
}, [id]);
|
||||
|
||||
if (!post) {
|
||||
return <CircularProgress sx={{ display: "block", margin: "auto", mt: 5 }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ padding: "40px 20px", display: "flex", flexDirection: "column" }}>
|
||||
<Button onClick={() => navigate(-1)} variant="contained" sx={{ mt: 5, mb: 3, maxWidth: "200px" }}>
|
||||
<Box sx={{ padding: "40px 20px",display:"flex", flexDirection:"column" }}>
|
||||
<Button onClick={() => navigate(-1)} variant="outlined" sx={{ mt:5, mb: 3, maxWidth:"200px" }}>
|
||||
⬅ Retour
|
||||
</Button>
|
||||
|
||||
<Typography
|
||||
variant="h1"
|
||||
sx={{
|
||||
fontWeight: "bold",
|
||||
textAlign: "center",
|
||||
mb: 4,
|
||||
fontSize: { xs: "3rem", md: "3rem", lg: "3rem" },
|
||||
}}
|
||||
>
|
||||
<Typography variant="h1" sx={{
|
||||
fontWeight: "bold",
|
||||
textAlign: "center",
|
||||
mb: 4,
|
||||
fontSize: { xs: '3rem', md: '3rem', lg: '3rem' }, // Responsive
|
||||
}}>
|
||||
{post.title.rendered}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", padding: "40px 20px", margin: "auto" }}>
|
||||
{image && (
|
||||
<Box
|
||||
component="img"
|
||||
src={image}
|
||||
alt={post.title.rendered}
|
||||
sx={{ width: "100%", maxHeight: "400px", objectFit: "cover", borderRadius: "10px 0 10px 10px", mb: 3 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box sx={{ display:"flex", alignItems:"center", justifyContent:"center", padding: "40px 20px",margin:"auto" }}>
|
||||
{image && (
|
||||
<Box
|
||||
sx={{
|
||||
maxWidth: "800px",
|
||||
mx: "auto",
|
||||
"& h2": { color: "#315397", mt: 4, mb: 2 },
|
||||
"& h3": { color: "#315397", mt: 3, mb: 1 },
|
||||
"& h4": { color: "#315397", mt: 2, mb: 1 },
|
||||
"& p": { mb: 2 },
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: post.content.rendered }}
|
||||
component="img"
|
||||
src={image}
|
||||
alt={post.title.rendered}
|
||||
sx={{ width: "33%", maxHeight: "400px", objectFit: "cover", borderRadius: "8px", mb: 3 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
<Typography variant="body1" sx={{ maxWidth: "800px", mx: "auto" }} dangerouslySetInnerHTML={{ __html: post.content.rendered }} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
+52
-256
@@ -1,277 +1,73 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import api from "../api";
|
||||
import axios from "axios";
|
||||
|
||||
const PRODUCTION_ORIGIN = "https://octopusdesign.fr";
|
||||
const PREPROD_HOST = "preprod.octopusdesign.fr";
|
||||
const PATH_PREFIX_TO_STRIP = "/api-octopus/server";
|
||||
|
||||
const FORCE_NO_INDEX = true;
|
||||
|
||||
const DEFAULT_OG_IMAGE =
|
||||
"https://preprod.octopusdesign.fr/api-octopus/server/wp-content/uploads/2025/01/Construction-logements-au-Mans-01.avif";
|
||||
const DEFAULT_ROBOTS = FORCE_NO_INDEX ? "noindex, nofollow" : "index, follow";
|
||||
|
||||
const getWindowHref = () =>
|
||||
typeof window !== "undefined" ? window.location.href : "";
|
||||
|
||||
const shouldNormalize = (value) =>
|
||||
typeof value === "string" &&
|
||||
(value.includes(PREPROD_HOST) || value.includes(PATH_PREFIX_TO_STRIP) || value.startsWith("http://127.") || value.includes("localhost"));
|
||||
|
||||
const normalizeToProduction = (value) => {
|
||||
if (typeof value !== "string" || value.length === 0) return value;
|
||||
|
||||
const trimmed = value.trim();
|
||||
const keepTrailingSlash = trimmed.endsWith("/") && !trimmed.endsWith("//");
|
||||
|
||||
try {
|
||||
const reference = new URL(PRODUCTION_ORIGIN);
|
||||
const url = new URL(trimmed, PRODUCTION_ORIGIN);
|
||||
|
||||
const mustForceProductionHost =
|
||||
url.hostname === PREPROD_HOST ||
|
||||
url.hostname === "127.0.0.1" ||
|
||||
url.hostname === "localhost";
|
||||
|
||||
if (mustForceProductionHost) {
|
||||
url.protocol = reference.protocol;
|
||||
url.hostname = reference.hostname;
|
||||
url.port = "";
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith(PATH_PREFIX_TO_STRIP)) {
|
||||
const stripped = url.pathname.replace(PATH_PREFIX_TO_STRIP, "") || "/";
|
||||
url.pathname = stripped;
|
||||
}
|
||||
|
||||
const shouldAppendSlash =
|
||||
!url.pathname.includes(".") &&
|
||||
url.pathname !== "/" &&
|
||||
!url.pathname.endsWith("/") &&
|
||||
(keepTrailingSlash || mustForceProductionHost || url.hostname === reference.hostname);
|
||||
|
||||
if (shouldAppendSlash) {
|
||||
url.pathname = `${url.pathname}/`;
|
||||
}
|
||||
|
||||
if (keepTrailingSlash && url.pathname === "/") {
|
||||
url.pathname = "/";
|
||||
}
|
||||
|
||||
const normalized = url.toString().replace(/([^:]\/)\/+/g, "$1");
|
||||
return normalized;
|
||||
} catch (error) {
|
||||
if (shouldNormalize(trimmed)) {
|
||||
return trimmed
|
||||
.replace("https://preprod.octopusdesign.fr/api-octopus/server", PRODUCTION_ORIGIN)
|
||||
.replace("https://preprod.octopusdesign.fr", PRODUCTION_ORIGIN)
|
||||
.replace("http://127.0.0.1", PRODUCTION_ORIGIN)
|
||||
.replace("http://localhost", PRODUCTION_ORIGIN);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeAttributeUrls = (attributes) => {
|
||||
if (!attributes) return attributes;
|
||||
|
||||
const next = { ...attributes };
|
||||
|
||||
if (shouldNormalize(next.href)) {
|
||||
next.href = normalizeToProduction(next.href);
|
||||
}
|
||||
if (shouldNormalize(next.content)) {
|
||||
next.content = normalizeToProduction(next.content);
|
||||
}
|
||||
|
||||
return next;
|
||||
};
|
||||
|
||||
const SEO = ({
|
||||
pageUrl,
|
||||
defaultTitle = "Titre par défaut",
|
||||
defaultDescription = "Description par défaut",
|
||||
defaultCanonicalUrl = "",
|
||||
defaultOgImage = DEFAULT_OG_IMAGE,
|
||||
const SEO = ({
|
||||
postId,
|
||||
defaultTitle = "Titre par défaut",
|
||||
defaultDescription = "Description par défaut",
|
||||
defaultCanonicalUrl = "",
|
||||
defaultOgImage = "https://preprod.octopusdesign.fr/api-octopus/server/wp-content/uploads/2025/01/Construction-logements-au-Mans-01.avif"
|
||||
}) => {
|
||||
const [rankMathHead, setRankMathHead] = useState(null);
|
||||
const [shouldFallback, setShouldFallback] = useState(true);
|
||||
|
||||
const runtimeUrl = getWindowHref();
|
||||
const rankMathTargetUrl = pageUrl || defaultCanonicalUrl || runtimeUrl;
|
||||
const normalizedPageUrl = normalizeToProduction(pageUrl);
|
||||
const resolvedCanonical =
|
||||
normalizeToProduction(defaultCanonicalUrl) ||
|
||||
normalizedPageUrl ||
|
||||
normalizeToProduction(runtimeUrl) ||
|
||||
PRODUCTION_ORIGIN;
|
||||
const targetUrl =
|
||||
normalizedPageUrl ||
|
||||
resolvedCanonical ||
|
||||
normalizeToProduction(rankMathTargetUrl) ||
|
||||
PRODUCTION_ORIGIN;
|
||||
|
||||
const [metaData, setMetaData] = useState({
|
||||
title: defaultTitle,
|
||||
description: defaultDescription,
|
||||
canonicalUrl: defaultCanonicalUrl,
|
||||
ogTitle: defaultTitle,
|
||||
ogDescription: defaultDescription,
|
||||
ogImage: defaultOgImage,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!rankMathTargetUrl) return;
|
||||
if (!postId) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
const fetchRankMathHead = async () => {
|
||||
const fetchMetaData = async () => {
|
||||
try {
|
||||
const { data } = await api.get("rankmath/v1/getHead", {
|
||||
params: { url: rankMathTargetUrl },
|
||||
signal: controller.signal,
|
||||
});
|
||||
const { data } = await axios.get(
|
||||
`https://preprod.octopusdesign.fr/api-octopus/server/wp-json/wp/v2/pages/${postId}`
|
||||
);
|
||||
|
||||
if (
|
||||
data?.success &&
|
||||
data.head &&
|
||||
typeof window !== "undefined" &&
|
||||
window.DOMParser
|
||||
) {
|
||||
const parser = new window.DOMParser();
|
||||
const parsedDocument = parser.parseFromString(
|
||||
`<!doctype html><html><head>${data.head}</head><body></body></html>`,
|
||||
"text/html"
|
||||
);
|
||||
const headElement = parsedDocument.head;
|
||||
|
||||
const attributesToObject = (element) =>
|
||||
Array.from(element.attributes).reduce((acc, attr) => {
|
||||
acc[attr.name] = attr.value;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const metaTags = Array.from(headElement.querySelectorAll("meta")).map(
|
||||
(meta) => normalizeAttributeUrls(attributesToObject(meta))
|
||||
);
|
||||
const linkTags = Array.from(headElement.querySelectorAll("link")).map(
|
||||
(link) => normalizeAttributeUrls(attributesToObject(link))
|
||||
);
|
||||
const scriptTags = Array.from(
|
||||
headElement.querySelectorAll("script")
|
||||
).map((script) => ({
|
||||
attributes: attributesToObject(script),
|
||||
innerHTML: script.innerHTML,
|
||||
if (data && data.rank_math_og) {
|
||||
setMetaData((prev) => ({
|
||||
...prev,
|
||||
title: data.rank_math_og.og_title || prev.title,
|
||||
description: data.rank_math_og.og_description || prev.description,
|
||||
ogTitle: data.rank_math_og.og_title || prev.title,
|
||||
ogDescription: data.rank_math_og.og_description || prev.description,
|
||||
ogImage: data.rank_math_og.og_image || prev.ogImage,
|
||||
canonicalUrl: data.acf?.canonical_url || window.location.href
|
||||
}));
|
||||
|
||||
setRankMathHead({
|
||||
title: headElement.querySelector("title")?.textContent || "",
|
||||
meta: metaTags,
|
||||
links: linkTags,
|
||||
scripts: scriptTags,
|
||||
});
|
||||
setShouldFallback(false);
|
||||
} else {
|
||||
setRankMathHead(null);
|
||||
setShouldFallback(true);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
console.error(
|
||||
"⚠️ Erreur lors de la récupération des métadonnées Rank Math :",
|
||||
error
|
||||
);
|
||||
}
|
||||
setRankMathHead(null);
|
||||
setShouldFallback(true);
|
||||
console.error("⚠️ Erreur lors de la récupération des métadonnées SEO :", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchRankMathHead();
|
||||
|
||||
return () => controller.abort();
|
||||
}, [targetUrl]);
|
||||
|
||||
const rankMathNodes = useMemo(() => {
|
||||
if (!rankMathHead) return null;
|
||||
|
||||
const sanitizeMeta = (meta = []) => {
|
||||
const filtered = meta.filter((attributes) => {
|
||||
const name = attributes?.name?.toLowerCase();
|
||||
if (name === "robots") {
|
||||
if (FORCE_NO_INDEX) {
|
||||
return false;
|
||||
}
|
||||
const content = attributes.content?.toLowerCase() || "";
|
||||
if (content.includes("noindex") || content.includes("nofollow")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const hasRobotsTag = filtered.some(
|
||||
(attributes) => attributes?.name?.toLowerCase() === "robots"
|
||||
);
|
||||
|
||||
const withRobots = FORCE_NO_INDEX
|
||||
? [...filtered, { name: "robots", content: DEFAULT_ROBOTS }]
|
||||
: hasRobotsTag
|
||||
? filtered
|
||||
: [...filtered, { name: "robots", content: DEFAULT_ROBOTS }];
|
||||
|
||||
return withRobots.map((attributes) => normalizeAttributeUrls(attributes));
|
||||
};
|
||||
|
||||
const sanitizedMeta = sanitizeMeta(rankMathHead.meta);
|
||||
|
||||
const renderMetaTags = sanitizedMeta.map((attributes, index) => (
|
||||
<meta key={`rank-math-meta-${index}`} {...attributes} />
|
||||
));
|
||||
|
||||
const renderLinkTags = rankMathHead.links.map((attributes, index) => (
|
||||
<link key={`rank-math-link-${index}`} {...attributes} />
|
||||
));
|
||||
|
||||
const renderScriptTags = rankMathHead.scripts.map((script, index) => (
|
||||
<script
|
||||
key={`rank-math-script-${index}`}
|
||||
{...script.attributes}
|
||||
dangerouslySetInnerHTML={{ __html: script.innerHTML }}
|
||||
/>
|
||||
));
|
||||
|
||||
return (
|
||||
<>
|
||||
{rankMathHead.title && <title>{rankMathHead.title}</title>}
|
||||
{renderMetaTags}
|
||||
{renderLinkTags}
|
||||
{renderScriptTags}
|
||||
</>
|
||||
);
|
||||
}, [rankMathHead]);
|
||||
fetchMetaData();
|
||||
}, [postId]);
|
||||
|
||||
return (
|
||||
<Helmet>
|
||||
{!shouldFallback && rankMathNodes}
|
||||
|
||||
{shouldFallback && (
|
||||
<>
|
||||
<title>{defaultTitle}</title>
|
||||
<meta name="description" content={defaultDescription} />
|
||||
<meta name="robots" content={DEFAULT_ROBOTS} />
|
||||
{resolvedCanonical && (
|
||||
<link rel="canonical" href={resolvedCanonical} />
|
||||
)}
|
||||
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content={defaultTitle} />
|
||||
<meta property="og:description" content={defaultDescription} />
|
||||
<meta property="og:image" content={defaultOgImage} />
|
||||
{resolvedCanonical && (
|
||||
<meta property="og:url" content={resolvedCanonical} />
|
||||
)}
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={defaultTitle} />
|
||||
<meta name="twitter:description" content={defaultDescription} />
|
||||
<meta name="twitter:image" content={defaultOgImage} />
|
||||
</>
|
||||
)}
|
||||
{/* SEO standard */}
|
||||
<title>{metaData.title}</title>
|
||||
<meta name="description" content={metaData.description} />
|
||||
{metaData.canonicalUrl && <link rel="canonical" href={metaData.canonicalUrl} />}
|
||||
|
||||
{/* Open Graph (Facebook, LinkedIn) */}
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content={metaData.ogTitle} />
|
||||
<meta property="og:description" content={metaData.ogDescription} />
|
||||
<meta property="og:image" content={metaData.ogImage} />
|
||||
<meta property="og:url" content={metaData.canonicalUrl} />
|
||||
|
||||
{/* Twitter Cards */}
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={metaData.ogTitle} />
|
||||
<meta name="twitter:description" content={metaData.ogDescription} />
|
||||
<meta name="twitter:image" content={metaData.ogImage} />
|
||||
</Helmet>
|
||||
);
|
||||
};
|
||||
|
||||
export default SEO;
|
||||
export default SEO;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo, isValidElement, useEffect } from "react";
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
@@ -9,123 +9,32 @@ import {
|
||||
Modal,
|
||||
Fade,
|
||||
Backdrop,
|
||||
CircularProgress,
|
||||
} from "@mui/material";
|
||||
import { Swiper, SwiperSlide } from "swiper/react";
|
||||
import { Navigation, Pagination, Autoplay } from "swiper/modules";
|
||||
import "swiper/css";
|
||||
import "swiper/css/navigation";
|
||||
import "swiper/css/pagination";
|
||||
import PropTypes from "prop-types";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import {
|
||||
extractFirstImageSrc,
|
||||
removeImageTags,
|
||||
stripHtml,
|
||||
} from "../utils/htmlHelpers";
|
||||
|
||||
const DEFAULT_CARD_IMAGE = "https://picsum.photos/id/43/800/400.webp";
|
||||
|
||||
const ServicePageTemplate = ({
|
||||
title,
|
||||
subtitle,
|
||||
interestTitle,
|
||||
description,
|
||||
desirTitle,
|
||||
features,
|
||||
image,
|
||||
heroBackground,
|
||||
ctaText,
|
||||
ctaLink,
|
||||
carouselItems,
|
||||
seo, // ✅ Ajout de l'objet SEO
|
||||
}) => {
|
||||
const [openModal, setOpenModal] = useState(false);
|
||||
const [modalContent, setModalContent] = useState({});
|
||||
const [loadingImage, setLoadingImage] = useState(true);
|
||||
const [heroLoaded, setHeroLoaded] = useState(false);
|
||||
|
||||
const renderRichText = (value) => {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (isValidElement(value)) return value;
|
||||
if (typeof value === "string") {
|
||||
return <span dangerouslySetInnerHTML={{ __html: value }} />;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const descriptionIsElement = isValidElement(description);
|
||||
const rawDescriptionHtml =
|
||||
!descriptionIsElement && typeof description === "string" ? description : "";
|
||||
|
||||
const descriptionMedia = useMemo(() => {
|
||||
if (!rawDescriptionHtml) {
|
||||
return { html: rawDescriptionHtml, media: null };
|
||||
}
|
||||
if (typeof window === "undefined" || !window.DOMParser) {
|
||||
return { html: rawDescriptionHtml, media: null };
|
||||
}
|
||||
const parser = new window.DOMParser();
|
||||
const doc = parser.parseFromString(rawDescriptionHtml, "text/html");
|
||||
const firstImage = doc.body.querySelector("img");
|
||||
if (firstImage) {
|
||||
const media = {
|
||||
src: firstImage.getAttribute("src"),
|
||||
alt: firstImage.getAttribute("alt") || "",
|
||||
};
|
||||
firstImage.remove();
|
||||
return { html: doc.body.innerHTML, media };
|
||||
}
|
||||
return { html: rawDescriptionHtml, media: null };
|
||||
}, [rawDescriptionHtml]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!image) {
|
||||
setHeroLoaded(true);
|
||||
return;
|
||||
}
|
||||
setHeroLoaded(false);
|
||||
const img = new Image();
|
||||
img.src = image;
|
||||
img.onload = () => setHeroLoaded(true);
|
||||
img.onerror = () => setHeroLoaded(true);
|
||||
return () => {
|
||||
img.onload = null;
|
||||
img.onerror = null;
|
||||
};
|
||||
}, [image]);
|
||||
|
||||
const defaultGradient = "linear-gradient(135deg, #0a1930 0%, #020710 85%)";
|
||||
const activeGradient = heroBackground || defaultGradient;
|
||||
const heroBackgroundStyle =
|
||||
heroLoaded && image
|
||||
? `url(${image}), ${activeGradient}`
|
||||
: activeGradient;
|
||||
|
||||
const buildModalPayload = (feature) => {
|
||||
const rawHtml = feature.modalText || feature.description || "";
|
||||
const sanitizedHtml = removeImageTags(rawHtml);
|
||||
const extractedImage = extractFirstImageSrc(rawHtml);
|
||||
const dynamicImage =
|
||||
extractedImage || feature.modalImage || DEFAULT_CARD_IMAGE;
|
||||
|
||||
return {
|
||||
title: feature.title,
|
||||
text: sanitizedHtml || "Texte non disponible.",
|
||||
image: dynamicImage,
|
||||
};
|
||||
};
|
||||
|
||||
const getPreviewText = (feature) => {
|
||||
const rawPreview = feature.description || feature.modalText || "";
|
||||
const textOnly = stripHtml(removeImageTags(rawPreview));
|
||||
if (!textOnly) return "Contenu indisponible.";
|
||||
return textOnly.length > 140 ? `${textOnly.substring(0, 140)}…` : textOnly;
|
||||
};
|
||||
|
||||
const handleCardClick = (feature) => {
|
||||
setModalContent(buildModalPayload(feature));
|
||||
setLoadingImage(true);
|
||||
setModalContent({
|
||||
title: feature.title,
|
||||
text: feature.modalText || "Texte non disponible.",
|
||||
image: feature.modalImage || "https://picsum.photos/id/43/800/400.webp", // Image par défaut
|
||||
});
|
||||
setOpenModal(true);
|
||||
};
|
||||
|
||||
@@ -141,64 +50,41 @@ const ServicePageTemplate = ({
|
||||
fontFamily: "Arial, sans-serif",
|
||||
}}
|
||||
>
|
||||
{/* ✅ SEO META TAGS */}
|
||||
<Helmet>
|
||||
<title>{seo?.metaTitle || title}</title>
|
||||
<meta name="description" content={seo?.metaDescription || ""} />
|
||||
<meta name="keywords" content={seo?.keywords || ""} />
|
||||
<meta property="og:title" content={seo?.metaTitle || title} />
|
||||
<meta property="og:description" content={seo?.metaDescription || ""} />
|
||||
<meta property="og:image" content={seo?.ogImage || image} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="twitter:title" content={seo?.metaTitle || title} />
|
||||
<meta name="twitter:description" content={seo?.metaDescription || ""} />
|
||||
<meta name="twitter:image" content={seo?.ogImage || image} />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
</Helmet>
|
||||
|
||||
{/* Section Hero */}
|
||||
{/* Section Attention */}
|
||||
<Box
|
||||
sx={{
|
||||
mt: 5,
|
||||
backgroundImage: heroBackgroundStyle,
|
||||
backgroundSize: heroLoaded && image ? "cover, cover" : "cover",
|
||||
backgroundPosition: heroLoaded && image ? "center, center" : "center",
|
||||
backgroundImage: `url(${image})`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
minHeight: "70vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
textAlign: "center",
|
||||
color: "black",
|
||||
color: "white",
|
||||
padding: "20px",
|
||||
backgroundColor: heroLoaded ? undefined : "#020712",
|
||||
transition: "background-image 0.8s ease, opacity 0.4s ease",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
maxWidth: "800px",
|
||||
backgroundColor: "rgba(255, 255, 255, 0.39)",
|
||||
p: 5,
|
||||
borderRadius: 5,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h1"
|
||||
sx={{
|
||||
fontWeight: "bold",
|
||||
mb: 2,
|
||||
fontSize: {
|
||||
xs: "2rem",
|
||||
md: "3rem",
|
||||
lg: "3rem",
|
||||
whiteSpace: "pre-line",
|
||||
},
|
||||
<Box sx={{ maxWidth: "800px" }}>
|
||||
<Typography
|
||||
variant="h1"
|
||||
sx={{
|
||||
fontWeight: "bold",
|
||||
mb: 2,
|
||||
fontSize: { xs: '2rem', md: '3rem', lg: '4rem' }, // Responsive
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
|
||||
</Typography>
|
||||
<Typography variant="body1" component="div" sx={{ mb: 4 }}>
|
||||
{renderRichText(subtitle) ?? subtitle ?? ""}
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
mb: 4
|
||||
}}
|
||||
>
|
||||
{subtitle}
|
||||
|
||||
</Typography>
|
||||
<Button
|
||||
href={ctaLink}
|
||||
@@ -208,7 +94,7 @@ const ServicePageTemplate = ({
|
||||
sx={{
|
||||
textTransform: "none",
|
||||
fontWeight: "bold",
|
||||
backgroundColor: "#0e467f",
|
||||
backgroundColor: " #0e467f",
|
||||
"&:hover": { backgroundColor: "#00bcd4" },
|
||||
}}
|
||||
>
|
||||
@@ -217,99 +103,69 @@ const ServicePageTemplate = ({
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Section numero 1 */}
|
||||
<Box sx={{ fontWeight: "5", textAlign: "center" }}>
|
||||
<Typography
|
||||
variant="h2"
|
||||
sx={{
|
||||
fontWeight: "bold",
|
||||
mb: 7,
|
||||
fontSize: { xs: "2rem", md: "2rem", lg: "2rem" },
|
||||
whiteSpace: "pre-line",
|
||||
{/* Section Interest */}
|
||||
<Box sx={{ padding: "40px 20px", textAlign: "center" }}>
|
||||
<Typography variant="h2"
|
||||
sx={{
|
||||
fontWeight: "bold",
|
||||
mb: 2,
|
||||
fontSize: { xs: '2rem', md: '3rem', lg: '3rem' }, // Responsive
|
||||
}}
|
||||
>
|
||||
{interestTitle}
|
||||
Pourquoi choisir notre formation ?
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ maxWidth: "800px", margin: "0 auto", mb: 4 }}
|
||||
>
|
||||
{description}
|
||||
</Typography>
|
||||
{descriptionIsElement ? (
|
||||
<Typography variant="body1" component="div">
|
||||
{renderRichText(description)}
|
||||
</Typography>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: { xs: "column", md: "row" },
|
||||
gap: 4,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
textAlign: { xs: "center", md: "left" },
|
||||
}}
|
||||
>
|
||||
{descriptionMedia.media?.src && (
|
||||
<Box
|
||||
component="img"
|
||||
src={descriptionMedia.media.src}
|
||||
alt={descriptionMedia.media.alt || "Illustration"}
|
||||
sx={{
|
||||
width: { xs: "100%", md: "42%" },
|
||||
borderRadius: 4,
|
||||
objectFit: "cover",
|
||||
boxShadow: "0 25px 45px rgba(0,0,0,0.25)",
|
||||
mb: { xs: 2, md: 5 },
|
||||
mx: { xs: "auto", md: 0 },
|
||||
marginLeft: { xs: 2, md: 6 } ,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Typography
|
||||
variant="body1"
|
||||
component="div"
|
||||
sx={{
|
||||
flex: 1,
|
||||
textAlign: { xs: "center", md: "left" },
|
||||
"& p": {
|
||||
margin: "0 auto",
|
||||
textAlign: "left",
|
||||
lineHeight: 1.65,
|
||||
maxWidth: 760,
|
||||
},
|
||||
"& p + p": {
|
||||
marginTop: 1.2,
|
||||
},
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: descriptionMedia.html || "" }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Section numero 2 */}
|
||||
{/* Section Desire */}
|
||||
<Box sx={{ backgroundColor: "#ffffff", padding: "40px 20px" }}>
|
||||
<Typography
|
||||
variant="h2"
|
||||
sx={{
|
||||
fontWeight: "bold",
|
||||
textAlign: "center",
|
||||
mb: 4,
|
||||
fontSize: { xs: "2rem", md: "3rem", lg: "2rem" },
|
||||
}}
|
||||
sx={{
|
||||
fontWeight: "bold",
|
||||
textAlign: "center",
|
||||
mb: 4,
|
||||
fontSize: { xs: '2rem', md: '3rem', lg: '3rem' }, // Responsive
|
||||
|
||||
}}
|
||||
>
|
||||
{desirTitle}
|
||||
Ce que nous offrons
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={4} justifyContent="center">
|
||||
{features.map((feature, index) => (
|
||||
<Grid item xs={12} md={4} key={index}>
|
||||
<Card
|
||||
onClick={() => handleCardClick(feature)}
|
||||
sx={{
|
||||
cursor: "pointer",
|
||||
boxShadow: 2,
|
||||
borderRadius: 2,
|
||||
textAlign: "center",
|
||||
padding: "20px",
|
||||
backgroundColor: "#f9f9f9",
|
||||
transition: "0.3s",
|
||||
"&:hover": { transform: "scale(1.05)", boxShadow: 5 },
|
||||
cursor: "pointer",
|
||||
transition: "transform 0.3s ease, box-shadow 0.3s ease",
|
||||
"&:hover": {
|
||||
transform: {
|
||||
xs: "none", // Pas d'hover sur mobile
|
||||
md: "scale(1.05)", // Hover sur desktop uniquement
|
||||
},
|
||||
boxShadow: {
|
||||
xs: 2, // Pas d'effet de hover sur mobile
|
||||
md: 5, // Augmentation de l'ombre sur desktop
|
||||
},
|
||||
},
|
||||
"&:active": {
|
||||
transform: "scale(0.98)", // Effet de clic pour le tactile
|
||||
boxShadow: 3,
|
||||
},
|
||||
}}
|
||||
onClick={() => handleCardClick(feature)}
|
||||
>
|
||||
<CardContent>
|
||||
<Typography
|
||||
@@ -318,18 +174,17 @@ const ServicePageTemplate = ({
|
||||
fontWeight: "bold",
|
||||
mb: 2,
|
||||
fontSize: 26,
|
||||
whiteSpace: "pre-line",
|
||||
}}
|
||||
>
|
||||
{feature.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#555" }}>
|
||||
{getPreviewText(feature)}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
<Typography variant="body2" sx={{ color: "#555" }}>
|
||||
{feature.description}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
@@ -339,7 +194,9 @@ const ServicePageTemplate = ({
|
||||
onClose={handleCloseModal}
|
||||
closeAfterTransition
|
||||
BackdropComponent={Backdrop}
|
||||
BackdropProps={{ timeout: 500 }}
|
||||
BackdropProps={{
|
||||
timeout: 500,
|
||||
}}
|
||||
>
|
||||
<Fade in={openModal}>
|
||||
<Box
|
||||
@@ -351,39 +208,50 @@ const ServicePageTemplate = ({
|
||||
width: "90%",
|
||||
maxWidth: "600px",
|
||||
p: 4,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
textAlign: "center",
|
||||
background: "white",
|
||||
background: "rgb(255, 255, 255)",
|
||||
boxShadow: "0 4px 30px rgba(0, 0, 0, 0.1)",
|
||||
backdropFilter: "blur(10px)",
|
||||
borderRadius: 2,
|
||||
boxShadow: 3,
|
||||
border: "1px solid rgba(255, 255, 255, 0.3)",
|
||||
color: "rgb(0, 0, 0)",
|
||||
}}
|
||||
>
|
||||
{loadingImage && <CircularProgress sx={{ mb: 2 }} />}
|
||||
<img
|
||||
src={modalContent.image}
|
||||
alt={modalContent.title}
|
||||
style={{
|
||||
width: "100%",
|
||||
borderRadius: "8px",
|
||||
display: loadingImage ? "none" : "block",
|
||||
marginBottom: "20px",
|
||||
}}
|
||||
onLoad={() => setLoadingImage(false)}
|
||||
/>
|
||||
<Typography variant="h3" sx={{ fontWeight: "bold", mb: 2 }}>
|
||||
{modalContent.title}
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ mb: 4 }} dangerouslySetInnerHTML={{ __html: modalContent.text }} >
|
||||
|
||||
<Typography variant="body1" sx={{ mb: 4 }}>
|
||||
{modalContent.text}
|
||||
</Typography>
|
||||
<Button
|
||||
onClick={handleCloseModal}
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
sx={{
|
||||
textTransform: "none",
|
||||
fontWeight: "bold",
|
||||
backgroundColor: "#f44336",
|
||||
"&:hover": { backgroundColor: "#d32f2f" },
|
||||
}}
|
||||
>
|
||||
Retour
|
||||
Quitter
|
||||
</Button>
|
||||
</Box>
|
||||
</Fade>
|
||||
</Modal>
|
||||
|
||||
{/* Carousel Section */}
|
||||
{carouselItems && carouselItems.length > 0 && (
|
||||
<Box
|
||||
@@ -393,11 +261,11 @@ const ServicePageTemplate = ({
|
||||
backgroundColor: "#f9f9f9",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h2"
|
||||
sx={{
|
||||
<Typography variant="h2"
|
||||
sx={{
|
||||
marginBottom: 4,
|
||||
fontSize: { xs: "2rem", md: "3rem", lg: "3rem" }, // Responsive
|
||||
fontSize: { xs: '2rem', md: '3rem', lg: '3rem' }, // Responsive
|
||||
|
||||
}}
|
||||
>
|
||||
Découvrez nos réalisations
|
||||
@@ -434,11 +302,10 @@ const ServicePageTemplate = ({
|
||||
}}
|
||||
/>
|
||||
<CardContent>
|
||||
<Typography
|
||||
variant="h3"
|
||||
sx={{
|
||||
<Typography variant="h3"
|
||||
sx={{
|
||||
fontWeight: "bold",
|
||||
fontSize: { xs: "1.2rem", md: "2rem", lg: "2.5rem" }, // Responsive
|
||||
fontSize: { xs: '1.2rem', md: '2rem', lg: '2.5rem' }, // Responsive
|
||||
}}
|
||||
>
|
||||
{item.title}
|
||||
@@ -451,44 +318,48 @@ const ServicePageTemplate = ({
|
||||
</Swiper>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Section Action */}
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: "#0e467f",
|
||||
color: "white",
|
||||
textAlign: "center",
|
||||
padding: "40px 20px",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h2"
|
||||
sx={{
|
||||
fontWeight: "bold",
|
||||
mb: 2,
|
||||
fontSize: { xs: '2rem', md: '3rem', lg: '3rem' }, // Responsive
|
||||
|
||||
}}
|
||||
>
|
||||
Prêt à démarrer ?
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" sx={{ mb: 4 }}>
|
||||
Rejoignez-nous dès aujourd’hui et profitez de nos services pour
|
||||
booster votre activité.
|
||||
</Typography>
|
||||
<Button
|
||||
href={ctaLink}
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
size="large"
|
||||
sx={{
|
||||
textTransform: "none",
|
||||
fontWeight: "bold",
|
||||
backgroundColor: "#00bcd4",
|
||||
"&:hover": { backgroundColor: "#0288d1" },
|
||||
}}
|
||||
>
|
||||
{ctaText}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// ✅ Définition des types des props avec PropTypes
|
||||
ServicePageTemplate.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
subtitle: PropTypes.string.isRequired,
|
||||
interestTitle: PropTypes.string.isRequired,
|
||||
description: PropTypes.string.isRequired,
|
||||
desirTitle: PropTypes.string.isRequired,
|
||||
features: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
title: PropTypes.string.isRequired,
|
||||
description: PropTypes.string.isRequired,
|
||||
modalText: PropTypes.string,
|
||||
modalImage: PropTypes.string,
|
||||
})
|
||||
).isRequired,
|
||||
image: PropTypes.string,
|
||||
ctaText: PropTypes.string.isRequired,
|
||||
ctaLink: PropTypes.string.isRequired,
|
||||
carouselItems: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
title: PropTypes.string.isRequired,
|
||||
description: PropTypes.string.isRequired,
|
||||
image: PropTypes.string.isRequired,
|
||||
})
|
||||
),
|
||||
heroBackground: PropTypes.string,
|
||||
seo: PropTypes.object, // ✅ Ajout du SEO en option
|
||||
};
|
||||
|
||||
// ✅ Valeurs par défaut
|
||||
ServicePageTemplate.defaultProps = {
|
||||
carouselItems: [],
|
||||
heroBackground: "linear-gradient(135deg, #0a1930 0%, #020710 85%)",
|
||||
seo: {}, // ✅ SEO par défaut vide
|
||||
};
|
||||
|
||||
export default ServicePageTemplate;
|
||||
|
||||
@@ -11,81 +11,75 @@ const TestimonialSection = () => {
|
||||
padding: "50px 20px",
|
||||
}}
|
||||
>
|
||||
{/* Image à gauche */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box
|
||||
component="img"
|
||||
src="https://picsum.photos/600/600.webp"
|
||||
alt="Bureau d'études & Maîtrise d'oeuvre"
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
borderRadius: { xs: "15px 15px 0 0", md: "15px 0 0 15px" },
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Contenu Témoignage */}
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
md={6}
|
||||
sx={{
|
||||
background: "linear-gradient(to right, #002F6C, #0E467F)",
|
||||
color: "white",
|
||||
padding: "40px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h3"
|
||||
{/* Image à gauche */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box
|
||||
component="img"
|
||||
src="https://picsum.photos/600/600.webp"
|
||||
alt="Illustration"
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
borderRadius: { xs: "15px 15px 0 0", md: "15px 0 0 15px" },
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Contenu Témoignage */}
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
md={6}
|
||||
sx={{
|
||||
fontSize: { xs: "2rem", md: "2rem", lg: "2rem" }, // Responsive
|
||||
fontWeight: "bold",
|
||||
mb: 2,
|
||||
fontStyle: "italic",
|
||||
position: "relative",
|
||||
"&::before": {
|
||||
content: '"“"',
|
||||
fontSize: "50px",
|
||||
position: "absolute",
|
||||
top: "-10px",
|
||||
left: "-5px",
|
||||
opacity: 0.3,
|
||||
},
|
||||
background: "linear-gradient(to right, #002F6C, #0E467F)",
|
||||
color: "white",
|
||||
padding: "40px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
In3 a transformé notre vision en réalité avec une approche passionnée
|
||||
et professionnelle.
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
fontWeight: "bold",
|
||||
mb: 2,
|
||||
fontStyle: "italic",
|
||||
position: "relative",
|
||||
"&::before": {
|
||||
content: '"“"',
|
||||
fontSize: "50px",
|
||||
position: "absolute",
|
||||
top: "-10px",
|
||||
left: "-5px",
|
||||
opacity: 0.3,
|
||||
},
|
||||
}}
|
||||
>
|
||||
In3 a transformé notre vision en réalité avec une approche
|
||||
passionnée et professionnelle.
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: "flex", alignItems: "center", mt: 3 }}>
|
||||
<Avatar
|
||||
alt="client bureau d'étude be-in3 le Mans(72)"
|
||||
src="https://picsum.photos/100.webp"
|
||||
sx={{ width: 56, height: 56, marginRight: 2 }}
|
||||
/>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="span"
|
||||
sx={{
|
||||
fontWeight: "bold",
|
||||
|
||||
}}
|
||||
>
|
||||
Marie Dupont
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Directrice Créative chez TechVision
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", alignItems: "center", mt: 3 }}>
|
||||
<Avatar
|
||||
alt="Marie Dupont"
|
||||
src="https://picsum.photos/100.webp"
|
||||
sx={{ width: 56, height: 56, marginRight: 2 }}
|
||||
/>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: "bold" }}>
|
||||
Marie Dupont
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Directrice Créative chez TechVision
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default TestimonialSection;
|
||||
export default TestimonialSection;
|
||||
@@ -1,119 +0,0 @@
|
||||
import { Box } from "@mui/material";
|
||||
import { useEffect, useRef } from "react";
|
||||
import Quill from "quill";
|
||||
import "quill/dist/quill.snow.css";
|
||||
|
||||
const DEFAULT_TOOLBAR = [
|
||||
[{ header: [1, 2, 3, false] }],
|
||||
["bold", "italic", "underline", "strike"],
|
||||
[{ color: [] }, { background: [] }],
|
||||
[{ list: "ordered" }, { list: "bullet" }],
|
||||
["link", "blockquote", "code-block"],
|
||||
["clean"],
|
||||
];
|
||||
|
||||
const RichTextEditor = ({
|
||||
value = "",
|
||||
onChange,
|
||||
placeholder = "Commencez à écrire…",
|
||||
minHeight = 200,
|
||||
toolbarOptions = DEFAULT_TOOLBAR,
|
||||
sx = {},
|
||||
}) => {
|
||||
const containerRef = useRef(null);
|
||||
const quillRef = useRef(null);
|
||||
const internalUpdateRef = useRef(false);
|
||||
const latestOnChangeRef = useRef(onChange);
|
||||
|
||||
useEffect(() => {
|
||||
latestOnChangeRef.current = onChange;
|
||||
}, [onChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
if (!containerRef.current || quillRef.current) {
|
||||
return;
|
||||
}
|
||||
const quill = new Quill(containerRef.current, {
|
||||
theme: "snow",
|
||||
placeholder,
|
||||
modules: {
|
||||
toolbar: toolbarOptions,
|
||||
},
|
||||
});
|
||||
quillRef.current = quill;
|
||||
|
||||
const handleTextChange = () => {
|
||||
if (internalUpdateRef.current) {
|
||||
return;
|
||||
}
|
||||
const html = quill.root.innerHTML;
|
||||
if (typeof latestOnChangeRef.current === "function") {
|
||||
latestOnChangeRef.current(html);
|
||||
}
|
||||
};
|
||||
|
||||
quill.on("text-change", handleTextChange);
|
||||
|
||||
if (value) {
|
||||
internalUpdateRef.current = true;
|
||||
quill.clipboard.dangerouslyPasteHTML(value);
|
||||
internalUpdateRef.current = false;
|
||||
}
|
||||
|
||||
return () => {
|
||||
quill.off("text-change", handleTextChange);
|
||||
const toolbar = containerRef.current
|
||||
? containerRef.current.previousSibling
|
||||
: null;
|
||||
if (toolbar && toolbar.classList?.contains("ql-toolbar")) {
|
||||
toolbar.remove();
|
||||
}
|
||||
if (containerRef.current) {
|
||||
containerRef.current.innerHTML = "";
|
||||
}
|
||||
quillRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [placeholder]); // initialise une fois (placeholder rarement modifié)
|
||||
|
||||
useEffect(() => {
|
||||
const quill = quillRef.current;
|
||||
if (!quill) {
|
||||
return;
|
||||
}
|
||||
const normalizedValue = value || "";
|
||||
if (normalizedValue === quill.root.innerHTML) {
|
||||
return;
|
||||
}
|
||||
internalUpdateRef.current = true;
|
||||
quill.setContents(quill.clipboard.convert(normalizedValue));
|
||||
internalUpdateRef.current = false;
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
overflow: "hidden",
|
||||
"& .ql-container": {
|
||||
borderColor: "rgba(0,0,0,0.12)",
|
||||
minHeight,
|
||||
},
|
||||
"& .ql-toolbar": {
|
||||
borderColor: "rgba(0,0,0,0.12)",
|
||||
},
|
||||
"& .ql-editor": {
|
||||
minHeight,
|
||||
},
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
<div ref={containerRef} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default RichTextEditor;
|
||||
@@ -1,5 +0,0 @@
|
||||
export const MOODLE_API_URL = import.meta.env.VITE_MOODLE_API_URL || "";
|
||||
export const MOODLE_TOKEN = import.meta.env.VITE_MOODLE_TOKEN || "";
|
||||
|
||||
export const isMoodleConfigured = () =>
|
||||
Boolean(MOODLE_API_URL && MOODLE_TOKEN);
|
||||
+14
-55
@@ -6,42 +6,24 @@ import theme from "./theme";
|
||||
import { HelmetProvider } from "react-helmet-async";
|
||||
import SimpleReactLightbox from "simple-react-lightbox";
|
||||
import NotFound from "./components/Pages/NotFound.jsx";
|
||||
import GestionArticles from "./components/Pages/GestionArticles";
|
||||
import EditPost from "./components/EditPost";
|
||||
import GestionPageAccueil from "./components/Pages/GestionPageAccueil";
|
||||
import GestionPagesACF from "./components/GestionPagesACF";
|
||||
import GestionBureauEtudeACF from "./components/GestionBureauEtudeACF";
|
||||
import EditPageACF from "./components/Pages/EditPageACF";
|
||||
import Tools from "./components/Pages/Tools";
|
||||
|
||||
// Ajoutez les autres pages Admin
|
||||
import GestionCours from "./components/Admin/GestionCours";
|
||||
import ListeCours from "./components/Admin/ListeCours";
|
||||
import CoursLecture from './components/Admin/CoursLecture';
|
||||
|
||||
// Lazy loading des pages
|
||||
const Home = lazy(() => import("./components/Pages/Home.jsx"));
|
||||
const About = lazy(() => import("./components/Pages/About"));
|
||||
const Contact = lazy(() => import("./components/Pages/Contact"));
|
||||
const Post = lazy(() => import("./components/Pages/Post"));
|
||||
const PostDetails = lazy(() => import("./components/PostDetails"));
|
||||
const Search = lazy(() => import("./components/Pages/Search")); // Nouveau composant pour la recherche
|
||||
const BureauEtude = lazy(() => import("./components/Pages/BureauEtude"));
|
||||
const CreatePost = lazy(() => import("./components/Pages/CreatePost"));
|
||||
const PostList = lazy(() => import("./components/Pages/PostList"));
|
||||
const Login = lazy(() => import("./components/Pages/Login"));
|
||||
const Dashboard = lazy(() => import("./components/Pages/Dashboard"));
|
||||
const Home = lazy(() => import('./components/Pages/Home.jsx'));
|
||||
const About = lazy(() => import('./components/Pages/About'));
|
||||
const Contact = lazy(() => import('./components/Pages/Contact'));
|
||||
const Post = lazy(() => import('./components/Pages/Post'));
|
||||
const PostDetails = lazy(() => import('./components/PostDetails')) ;
|
||||
const Search = lazy(() => import('./components/Pages/Search')); // Nouveau composant pour la recherche
|
||||
const BureauEtude = lazy(() => import('./components/Pages/BureauEtude'));
|
||||
|
||||
// Import des services
|
||||
import ServiceUn from "./components/Pages/ServiceUn.jsx";
|
||||
import ServiceDeux from "./components/Pages/ServiceDeux.jsx";
|
||||
import ServiceTrois from "./components/Pages/ServiceTrois.jsx";
|
||||
import ServiceQuatre from "./components/Pages/ServiceQuatre.jsx";
|
||||
import ServiceCinq from "./components/Pages/ServiceCinq.jsx";
|
||||
|
||||
// Import des composants globaux
|
||||
import Header from "./components/Header";
|
||||
import Footer from "./components/Footer";
|
||||
import Footer from './components/Footer';
|
||||
|
||||
function YourApp() {
|
||||
return (
|
||||
@@ -51,39 +33,16 @@ function YourApp() {
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/posts" element={<Post />} />
|
||||
<Route path="/admin/edit-post/:id" element={<EditPost />} />
|
||||
<Route path="/nosFormations" element={<BureauEtude />} />
|
||||
<Route path="/post/:id" element={<PostDetails />} />
|
||||
<Route path="/bureauEtude" element={<BureauEtude />} />
|
||||
<Route path="/about" element={<About />} />
|
||||
<Route path="/contact" element={<Contact />} />
|
||||
<Route path="/search" element={<Search />} />{" "}
|
||||
{/* Route pour les recherches */}
|
||||
<Route path="/search" element={<Search />} /> {/* Route pour les recherches */}
|
||||
{/* Routes pour les services */}
|
||||
<Route
|
||||
path="/services/prestation-maitrise-oeuvre"
|
||||
element={<ServiceUn />}
|
||||
/>
|
||||
<Route
|
||||
path="/services/formations-web"
|
||||
element={<ServiceDeux />}
|
||||
/>
|
||||
<Route path="/services/electricite" element={<ServiceTrois />} />
|
||||
<Route path="/services/service-securite-incendie" element={<ServiceQuatre />} />
|
||||
<Route path="/services/expertises-tce" element={<ServiceCinq />} />
|
||||
<Route path="/admin/create-post" element={<CreatePost />} />
|
||||
<Route path="/post/:slug" element={<PostDetails />} />
|
||||
<Route path="/admin/posts" element={<PostList />} />
|
||||
<Route path="/admin/login" element={<Login />} />
|
||||
<Route path="/admin/dashboard" element={<Dashboard />} />
|
||||
<Route path="/admin/gestion-articles" element={<GestionArticles />} />
|
||||
<Route path="/admin/gestion-page-accueil" element={<GestionPageAccueil />} />
|
||||
<Route path="/services/prestation-maitrise-oeuvre" element={<ServiceUn />} />
|
||||
<Route path="/services/structure-beton-charpente-metallique-bois" element={<ServiceDeux />} />
|
||||
<Route path="/services/formation-video" element={<ServiceTrois />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
<Route path="/admin/pages-acf" element={<GestionPagesACF />} />
|
||||
<Route path="/admin/bureau-etude-acf" element={<GestionBureauEtudeACF />} />
|
||||
<Route path="/admin/edit-page/:id" element={<EditPageACF />} />
|
||||
<Route path="/admin/gestion-cours" element={<GestionCours />} />
|
||||
<Route path="/admin/liste-cours" element={<ListeCours />} />
|
||||
<Route path="/cours/lecture/:token" element={<CoursLecture />} />
|
||||
<Route path="/admin/tools" element={<Tools />} />
|
||||
</Routes>
|
||||
<Footer />
|
||||
</Router>
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
import axios from "axios";
|
||||
import { MOODLE_API_URL, MOODLE_TOKEN } from "../config/moodle";
|
||||
|
||||
const ensureConfig = () => {
|
||||
if (!MOODLE_API_URL || !MOODLE_TOKEN) {
|
||||
throw new Error(
|
||||
"Configuration Moodle manquante. Vérifiez VITE_MOODLE_API_URL et VITE_MOODLE_TOKEN."
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const appendParam = (searchParams, value, key) => {
|
||||
if (value === undefined || value === null || key === undefined) {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((entry, index) => {
|
||||
appendParam(searchParams, entry, `${key}[${index}]`);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (typeof value === "object" && !(value instanceof Date)) {
|
||||
Object.entries(value).forEach(([childKey, childValue]) => {
|
||||
const nextKey = key ? `${key}[${childKey}]` : childKey;
|
||||
appendParam(searchParams, childValue, nextKey);
|
||||
});
|
||||
return;
|
||||
}
|
||||
const normalizedValue =
|
||||
value instanceof Date ? value.getTime().toString() : String(value);
|
||||
searchParams.append(key, normalizedValue);
|
||||
};
|
||||
|
||||
export const callMoodle = async (wsfunction, payload = {}, options = {}) => {
|
||||
ensureConfig();
|
||||
const params = new URLSearchParams();
|
||||
params.append("wstoken", MOODLE_TOKEN);
|
||||
params.append("wsfunction", wsfunction);
|
||||
params.append("moodlewsrestformat", "json");
|
||||
|
||||
Object.entries(payload).forEach(([key, value]) =>
|
||||
appendParam(params, value, key)
|
||||
);
|
||||
|
||||
const response = await axios.post(MOODLE_API_URL, params, {
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
timeout: options.timeout ?? 20000,
|
||||
...options.axios,
|
||||
});
|
||||
|
||||
if (response.data?.exception || response.data?.errorcode) {
|
||||
const message =
|
||||
response.data.message ||
|
||||
response.data.debuginfo ||
|
||||
response.data.errorcode;
|
||||
throw new Error(message || "Erreur Moodle inconnue.");
|
||||
}
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const MoodleApi = {
|
||||
listCourses: (searchValue = "") => {
|
||||
if (searchValue) {
|
||||
return callMoodle("core_course_get_courses_by_field", {
|
||||
field: "search",
|
||||
value: searchValue,
|
||||
}).then((data) => data.courses || []);
|
||||
}
|
||||
return callMoodle("core_course_get_courses").then(
|
||||
(data) => data || []
|
||||
);
|
||||
},
|
||||
getCourseById: async (courseId) => {
|
||||
const response = await callMoodle("core_course_get_courses_by_field", {
|
||||
field: "id",
|
||||
value: Number(courseId),
|
||||
});
|
||||
return response?.courses?.[0] || null;
|
||||
},
|
||||
createCourse: (coursePayload) => {
|
||||
const normalized = {
|
||||
summaryformat: 1,
|
||||
format: "topics",
|
||||
...coursePayload,
|
||||
};
|
||||
if (normalized.categoryid !== undefined) {
|
||||
normalized.categoryid = Number(normalized.categoryid);
|
||||
}
|
||||
return callMoodle("core_course_create_courses", {
|
||||
courses: [normalized],
|
||||
});
|
||||
},
|
||||
updateCourse: (coursePayload) => {
|
||||
const normalized = {
|
||||
summaryformat: 1,
|
||||
...coursePayload,
|
||||
};
|
||||
if (normalized.id !== undefined) {
|
||||
normalized.id = Number(normalized.id);
|
||||
}
|
||||
if (normalized.categoryid !== undefined) {
|
||||
normalized.categoryid = Number(normalized.categoryid);
|
||||
}
|
||||
return callMoodle("core_course_update_courses", {
|
||||
courses: [normalized],
|
||||
});
|
||||
},
|
||||
deleteCourses: (courseIds) =>
|
||||
callMoodle("core_course_delete_courses", {
|
||||
courseids: courseIds,
|
||||
}),
|
||||
getCourseContents: (courseId) =>
|
||||
callMoodle("core_course_get_contents", { courseid: Number(courseId) }),
|
||||
getPagesByCourse: (courseId) =>
|
||||
callMoodle("mod_page_get_pages_by_courses", {
|
||||
courseids: [Number(courseId)],
|
||||
}).then((data) => data.pages || []),
|
||||
getAssignmentsByCourse: (courseId) =>
|
||||
callMoodle("mod_assign_get_assignments", {
|
||||
courseids: [Number(courseId)],
|
||||
}).then((data) => {
|
||||
const normalizedId = Number(courseId);
|
||||
const course = data?.courses?.find((c) => c.id === normalizedId);
|
||||
return course?.assignments || [];
|
||||
}),
|
||||
getH5PActivitiesByCourse: (courseId) =>
|
||||
callMoodle("mod_h5pactivity_get_h5pactivities_by_courses", {
|
||||
courseids: [Number(courseId)],
|
||||
}).then((data) => data.h5pactivities || []),
|
||||
getQuizzesByCourse: (courseId) =>
|
||||
callMoodle("mod_quiz_get_quizzes_by_courses", {
|
||||
courseids: [Number(courseId)],
|
||||
}).then((data) => data.quizzes || []),
|
||||
createSection: (sectionPayload) =>
|
||||
callMoodle("core_course_create_sections", {
|
||||
sections: [
|
||||
{
|
||||
summaryformat: 1,
|
||||
...sectionPayload,
|
||||
},
|
||||
],
|
||||
}),
|
||||
updateSection: (sectionPayload) => {
|
||||
const payload = {
|
||||
summaryformat: 1,
|
||||
...sectionPayload,
|
||||
};
|
||||
if (payload.id !== undefined) {
|
||||
payload.id = Number(payload.id);
|
||||
}
|
||||
if (payload.courseid !== undefined) {
|
||||
payload.courseid = Number(payload.courseid);
|
||||
}
|
||||
if (payload.section !== undefined && payload.section !== null) {
|
||||
const normalizedSection = Number(payload.section);
|
||||
payload.section = Number.isNaN(normalizedSection)
|
||||
? undefined
|
||||
: normalizedSection;
|
||||
}
|
||||
return callMoodle("core_course_update_sections", {
|
||||
sections: [payload],
|
||||
});
|
||||
},
|
||||
deleteSections: (courseId, sectionIds, sectionNumbers) => {
|
||||
const payload = {
|
||||
courseid: Number(courseId),
|
||||
};
|
||||
if (sectionIds?.length) {
|
||||
payload.sectionids = sectionIds.map((id) => Number(id));
|
||||
}
|
||||
if (sectionNumbers?.length) {
|
||||
payload.sectionnumbers = sectionNumbers.map((value) => Number(value));
|
||||
}
|
||||
return callMoodle("core_course_delete_sections", payload);
|
||||
},
|
||||
moveModuleToSection: (moduleId, sectionNumber) => {
|
||||
const payload = {
|
||||
cmid: Number(moduleId),
|
||||
};
|
||||
if (sectionNumber !== null && sectionNumber !== undefined) {
|
||||
const normalized = Number(sectionNumber);
|
||||
if (!Number.isNaN(normalized)) {
|
||||
payload.section = normalized;
|
||||
}
|
||||
}
|
||||
return callMoodle("core_course_edit_module", payload);
|
||||
},
|
||||
};
|
||||
|
||||
export default MoodleApi;
|
||||
@@ -1,48 +0,0 @@
|
||||
const hasDomParser =
|
||||
(typeof window !== "undefined" && typeof window.DOMParser !== "undefined") ||
|
||||
typeof DOMParser !== "undefined";
|
||||
|
||||
const createDocument = (html) => {
|
||||
if (!hasDomParser || !html) return null;
|
||||
const ParserConstructor =
|
||||
typeof DOMParser !== "undefined"
|
||||
? DOMParser
|
||||
: typeof window !== "undefined" && window.DOMParser
|
||||
? window.DOMParser
|
||||
: null;
|
||||
|
||||
if (!ParserConstructor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parser = new ParserConstructor();
|
||||
return parser.parseFromString(html, "text/html");
|
||||
};
|
||||
|
||||
export const extractFirstImageSrc = (html) => {
|
||||
if (!html) return "";
|
||||
const doc = createDocument(html);
|
||||
if (doc) {
|
||||
const img = doc.querySelector("img");
|
||||
if (img) {
|
||||
return img.getAttribute("src") || "";
|
||||
}
|
||||
}
|
||||
|
||||
const match = html.match(/<img[^>]+src=["']([^"']+)["']/i);
|
||||
return match?.[1] || "";
|
||||
};
|
||||
|
||||
export const removeImageTags = (html) => {
|
||||
if (!html) return "";
|
||||
return html.replace(/<img\b[^>]*>/gi, "");
|
||||
};
|
||||
|
||||
export const stripHtml = (html) => {
|
||||
if (!html) return "";
|
||||
const doc = createDocument(html);
|
||||
if (doc) {
|
||||
return doc.body.textContent || "";
|
||||
}
|
||||
return html.replace(/<[^>]*>/g, "");
|
||||
};
|
||||
+27
-332
@@ -1,8 +1,7 @@
|
||||
import axios from "axios";
|
||||
import { getToken } from "./auth"; // 🔥 Import du token pour l'authentification
|
||||
import { getToken } from "./auth"; // 🔥 Import du token depuis auth.js
|
||||
|
||||
const API_URL = "https://preprod.octopusdesign.fr/api-octopus/server/wp-json/wp/v2";
|
||||
const RANKMATH_API_URL = "https://preprod.octopusdesign.fr/api-octopus/server/wp-json/rankmath/v1";
|
||||
|
||||
/**
|
||||
* 🔹 Upload une image et retourne son ID
|
||||
@@ -10,9 +9,9 @@ const RANKMATH_API_URL = "https://preprod.octopusdesign.fr/api-octopus/server/wp
|
||||
* @returns {Promise<number>} - L'ID de l'image uploadée
|
||||
*/
|
||||
export async function uploadImage(file) {
|
||||
const token = getToken();
|
||||
const token = getToken(); // 🔥 Récupération automatique du token
|
||||
if (!token) {
|
||||
console.error("❌ Aucun token trouvé !");
|
||||
console.error("❌ Aucun token trouvé ! L'utilisateur doit se reconnecter.");
|
||||
throw new Error("Utilisateur non authentifié.");
|
||||
}
|
||||
|
||||
@@ -20,63 +19,27 @@ export async function uploadImage(file) {
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("title", file.name);
|
||||
formData.append("title", "Nouvelle image");
|
||||
formData.append("status", "publish");
|
||||
|
||||
try {
|
||||
const response = await axios.post(`${API_URL}/media`, formData, {
|
||||
headers: {
|
||||
"Authorization": `Basic ${token}`, // ✅ Garder `Basic` si ça fonctionnait avant
|
||||
"Content-Type": "multipart/form-data"
|
||||
const response = await axios.post(
|
||||
`${API_URL}/media`,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
"Authorization": `Basic ${token}`, // 🔥 Authentification Basic
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
console.log("✅ Image uploadée avec succès :", response.data);
|
||||
return response.data.id; // ✅ Retourne l'ID de l'image
|
||||
return response.data.id; // Retourne l'ID de l'image
|
||||
} catch (error) {
|
||||
console.error("❌ Erreur d'upload :", error.response?.data || error.message);
|
||||
console.error("❌ Erreur d'upload :", error.response ? error.response.data : error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
export async function uploadImageFromUrl(imageUrl) {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
console.error("❌ Aucun token trouvé !");
|
||||
throw new Error("Utilisateur non authentifié.");
|
||||
}
|
||||
|
||||
try {
|
||||
// On télécharge l’image sous forme de blob
|
||||
const imageBlob = await fetch(imageUrl).then(res => res.blob());
|
||||
const file = new File([imageBlob], "image-cloudinary.webp", { type: "image/webp" });
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("title", "Image importée");
|
||||
formData.append("status", "publish");
|
||||
|
||||
const response = await axios.post(`${API_URL}/media`, formData, {
|
||||
headers: {
|
||||
"Authorization": `Basic ${token}`,
|
||||
"Content-Type": "multipart/form-data"
|
||||
}
|
||||
});
|
||||
|
||||
console.log("✅ Image uploadée depuis Cloudinary :", response.data);
|
||||
return response.data.id;
|
||||
} catch (error) {
|
||||
console.error("❌ Erreur upload depuis Cloudinary :", error.response?.data || error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
export async function fetchPosts() {
|
||||
const response = await fetch("https://preprod.octopusdesign.fr/api-octopus/server/wp-json/wp/v2/posts");
|
||||
if (!response.ok) throw new Error("Erreur lors de la récupération des articles");
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔹 Crée un post WordPress avec une image mise en avant
|
||||
@@ -86,301 +49,33 @@ export async function fetchPosts() {
|
||||
* @returns {Promise<Object>} - Le post créé
|
||||
*/
|
||||
export async function createPost(title, content, imageId) {
|
||||
const token = getToken();
|
||||
if (!token) throw new Error("Utilisateur non authentifié.");
|
||||
|
||||
const response = await axios.post(
|
||||
`${API_URL}/posts`,
|
||||
{
|
||||
title,
|
||||
content,
|
||||
status: "publish",
|
||||
featured_media: imageId, // 🔥 très important
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Authorization": `Basic ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 🔹 Récupère un article WordPress par son ID
|
||||
* @param {number} postId - L'ID du post à récupérer
|
||||
* @returns {Promise<Object>} - Données du post récupéré
|
||||
*/
|
||||
export async function getPostById(postId) {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/posts/${postId}`);
|
||||
console.log("✅ Article récupéré :", response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error("❌ Erreur récupération article :", error.response?.data || error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔹 Met à jour un article WordPress
|
||||
* @param {number} postId - L'ID de l'article à modifier
|
||||
* @param {string} title - Le nouveau titre
|
||||
* @param {string} content - Le nouveau contenu
|
||||
* @returns {Promise<Object>} - L'article mis à jour
|
||||
*/
|
||||
export async function updatePost(postId, title, content, imageId = null) {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
console.error("❌ Aucun token trouvé !");
|
||||
throw new Error("Utilisateur non authentifié.");
|
||||
}
|
||||
|
||||
const dataToSend = {
|
||||
title,
|
||||
content,
|
||||
status: "publish",
|
||||
};
|
||||
|
||||
if (imageId) {
|
||||
dataToSend.featured_media = imageId; // 🔥 Ajoute l'image en vedette
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post(`${API_URL}/posts/${postId}`, dataToSend, {
|
||||
headers: {
|
||||
Authorization: `Basic ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
console.log("✅ Article mis à jour avec succès :", response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error("❌ Erreur mise à jour article :", error.response?.data || error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 🔹 Supprime un article WordPress et son image associée
|
||||
* @param {number} postId - L'ID de l'article à supprimer
|
||||
* @param {number} imageId - L'ID de l'image en vedette à supprimer (facultatif)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function deletePost(postId) {
|
||||
const token = getToken();
|
||||
if (!token) throw new Error("Utilisateur non authentifié.");
|
||||
|
||||
try {
|
||||
console.log(`📢 Suppression article ID: ${postId}...`);
|
||||
|
||||
// 🔍 Récupérer les infos de l'article pour connaître l'image
|
||||
const postRes = await axios.get(`${API_URL}/posts/${postId}`, {
|
||||
headers: {
|
||||
Authorization: `Basic ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
const imageId = postRes.data.featured_media;
|
||||
|
||||
// ✅ Supprimer l'article
|
||||
await axios.delete(`${API_URL}/posts/${postId}?force=true`, {
|
||||
headers: {
|
||||
Authorization: `Basic ${token}`,
|
||||
},
|
||||
});
|
||||
console.log(`✅ Article ${postId} supprimé.`);
|
||||
|
||||
// 🧹 Supprimer l'image si elle existe
|
||||
if (imageId) {
|
||||
await axios.delete(`${API_URL}/media/${imageId}?force=true`, {
|
||||
headers: {
|
||||
Authorization: `Basic ${token}`,
|
||||
},
|
||||
});
|
||||
console.log(`🗑️ Image ${imageId} supprimée.`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("❌ Erreur suppression article ou image :", error.response?.data || error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 🔹 Met à jour les champs ACF d'une page WordPress
|
||||
* @param {number} pageId - L'ID de la page à modifier
|
||||
* @param {object} acfData - Les données des champs ACF à mettre à jour
|
||||
*/
|
||||
export async function updateHomePageACF(pageId, newData) {
|
||||
const token = getToken();
|
||||
const token = getToken(); // 🔥 Récupération automatique du token
|
||||
if (!token) {
|
||||
console.error("❌ Aucun token trouvé !");
|
||||
console.error("❌ Aucun token trouvé ! L'utilisateur doit se reconnecter.");
|
||||
throw new Error("Utilisateur non authentifié.");
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`📢 Tentative de mise à jour des ACF pour la page ${pageId}...`, newData);
|
||||
|
||||
const response = await fetch(
|
||||
`https://preprod.octopusdesign.fr/api-octopus/server/wp-json/wp/v2/pages/${pageId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Authorization": `Basic ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ acf: newData }),
|
||||
}
|
||||
);
|
||||
|
||||
const responseData = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur API: ${JSON.stringify(responseData)}`);
|
||||
}
|
||||
|
||||
console.log("✅ Mise à jour réussie :", responseData);
|
||||
return responseData;
|
||||
} catch (error) {
|
||||
console.error("❌ Erreur mise à jour ACF :", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔹 Récupère une page WordPress par son ID avec ses champs ACF
|
||||
* @param {number} pageId - L'ID de la page à récupérer
|
||||
* @returns {Promise<Object>} - Données de la page avec ACF
|
||||
*/
|
||||
export async function getPageById(pageId) {
|
||||
if (!pageId) {
|
||||
console.error("❌ Erreur : l'ID de la page est `undefined` !");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`https://preprod.octopusdesign.fr/api-octopus/server/wp-json/wp/v2/pages/${pageId}?_fields=id,title,link,acf,_rank_math_seo_meta`
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error("❌ Erreur récupération page :", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔹 Met à jour les champs ACF d'une page WordPress
|
||||
* @param {number} pageId - L'ID de la page à modifier
|
||||
* @param {object} acfData - Les nouvelles données ACF à mettre à jour
|
||||
*/
|
||||
export async function updatePageACF(pageId, acfData) {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
console.error("❌ Aucun token trouvé !");
|
||||
throw new Error("Utilisateur non authentifié.");
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`📢 Mise à jour des ACF pour la page ${pageId}...`, acfData);
|
||||
|
||||
const response = await axios.post(
|
||||
`${API_URL}/pages/${pageId}`,
|
||||
{ acf: acfData }, // Envoi uniquement les champs ACF
|
||||
{
|
||||
headers: {
|
||||
"Authorization": `Basic ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
console.log("✅ Mise à jour ACF réussie :", response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error("❌ Erreur mise à jour ACF :", error.response?.data || error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔹 Récupère la liste des pages WordPress ayant des champs ACF
|
||||
* @returns {Promise<Array>} - Liste des pages
|
||||
*/
|
||||
export async function fetchPages() {
|
||||
try {
|
||||
const response = await fetch("https://preprod.octopusdesign.fr/api-octopus/server/wp-json/wp/v2/pages?_fields=id,title");
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("❌ Erreur lors de la récupération des pages WordPress.");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data || typeof data !== "object" || !Array.isArray(data)) {
|
||||
console.error("❌ Données invalides reçues :", data);
|
||||
return []; // ✅ Retourne un tableau vide pour éviter toute erreur
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("❌ Erreur `fetchPages()` :", error);
|
||||
return []; // ✅ Retourne un tableau vide en cas d'erreur pour éviter le plantage
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔹 Met à jour les métadonnées Rank Math (SEO) d'une page/post.
|
||||
* @param {number} objectId - ID WordPress de la page.
|
||||
* @param {object} meta - Objet des champs Rank Math à modifier.
|
||||
*/
|
||||
export async function updateRankMathMeta(objectId, meta = {}) {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
console.error("❌ Aucun token Rank Math trouvé !");
|
||||
throw new Error("Utilisateur non authentifié.");
|
||||
}
|
||||
|
||||
const sanitizedMeta = Object.entries(meta).reduce((acc, [key, value]) => {
|
||||
const cleanedValue = typeof value === "string" ? value.trim() : value;
|
||||
if (cleanedValue) {
|
||||
acc[key] = cleanedValue;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
if (!Object.keys(sanitizedMeta).length) {
|
||||
console.warn("ℹ️ Aucun champ Rank Math fourni, mise à jour ignorée.");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${RANKMATH_API_URL}/updateMeta`,
|
||||
`${API_URL}/posts`,
|
||||
{
|
||||
objectType: "post", // ✅ Les pages WordPress sont du type "post".
|
||||
objectID: objectId,
|
||||
meta: sanitizedMeta,
|
||||
title: title,
|
||||
content: content,
|
||||
status: "publish",
|
||||
featured_media: imageId, // 🔥 Associer l'image mise en avant
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Basic ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
"Authorization": `Basic ${token}`,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
console.log("✅ Métadonnées Rank Math mises à jour :", response.data);
|
||||
console.log("✅ Post créé avec succès :", response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error("❌ Erreur mise à jour Rank Math :", error.response?.data || error.message);
|
||||
console.error("❌ Erreur lors de la création du post :", error.response ? error.response.data : error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
-4949
File diff suppressed because one or more lines are too long
+25
-23
@@ -4,53 +4,53 @@ import viteCompression from 'vite-plugin-compression';
|
||||
import { visualizer } from 'rollup-plugin-visualizer';
|
||||
import path from 'path';
|
||||
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
// ✅ Plugin React
|
||||
// Plugin React
|
||||
react(),
|
||||
|
||||
// ✅ Compression Brotli & Gzip fusionnées
|
||||
// Compression Brotli
|
||||
viteCompression({
|
||||
algorithm: 'brotliCompress', // Utilisation de Brotli en priorité
|
||||
algorithm: 'brotliCompress',
|
||||
ext: '.br',
|
||||
threshold: 10240, // Compression à partir de 10 Ko
|
||||
threshold: 10240, // Fichiers supérieurs à 10 Ko seront compressés
|
||||
}),
|
||||
// Compression Gzip
|
||||
viteCompression({
|
||||
algorithm: 'gzip', // Compression Gzip en fallback
|
||||
algorithm: 'gzip',
|
||||
ext: '.gz',
|
||||
threshold: 10240,
|
||||
}),
|
||||
|
||||
// ✅ Visualisation des bundles (ouvre `stats.html` après le build)
|
||||
// Visualiseur de bundle
|
||||
visualizer({
|
||||
open: false, // Mets `true` si tu veux ouvrir le fichier automatiquement
|
||||
open: true, // Ouvre automatiquement le fichier généré après le build
|
||||
filename: 'stats.html',
|
||||
template: 'treemap',
|
||||
}),
|
||||
],
|
||||
|
||||
|
||||
resolve: {
|
||||
alias: {
|
||||
// Définit les alias pour des chemins simplifiés
|
||||
'@pages': path.resolve(__dirname, './src/pages'),
|
||||
'@components': path.resolve(__dirname, './src/components'),
|
||||
'@assets': path.resolve(__dirname, './src/assets'),
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
build: {
|
||||
chunkSizeWarningLimit: 1000,
|
||||
target: 'esnext', // ✅ Meilleure compatibilité avec les navigateurs modernes
|
||||
minify: 'esbuild', // ✅ Utilisation d'Esbuild pour un build rapide
|
||||
sourcemap: true, // ✅ Active les source maps en production (utile pour le debug)
|
||||
target: 'esnext', // Cible des navigateurs modernes
|
||||
minify: 'esbuild', // Minification avec esbuild pour une meilleure performance
|
||||
sourcemap: false, // Désactive les sourcemaps en production (activez pour dev si nécessaire)
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
// Création de chunks manuels pour optimiser les performances
|
||||
if (id.includes('node_modules')) {
|
||||
if (id.includes('@mui')) {
|
||||
return 'vendor_mui'; // ✅ Sépare MUI du reste des dépendances
|
||||
return 'vendor_mui'; // Chunk spécifique à MUI
|
||||
}
|
||||
return 'vendor'; // ✅ Sépare les dépendances globales
|
||||
return 'vendor'; // Autres dépendances
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -58,16 +58,18 @@ export default defineConfig({
|
||||
},
|
||||
|
||||
server: {
|
||||
port: 3000, // ✅ Serveur sur le port 3000
|
||||
open: true, // ✅ Ouvre le navigateur au démarrage
|
||||
cors: true, // ✅ Active les requêtes CORS
|
||||
port: 3000, // Définit le port du serveur de développement
|
||||
open: true, // Ouvre automatiquement le navigateur au démarrage
|
||||
cors: true, // Autorise toutes les requêtes CORS
|
||||
},
|
||||
|
||||
optimizeDeps: {
|
||||
include: [
|
||||
'swiper/react',
|
||||
'swiper/modules',
|
||||
'swiper/react', // Inclut swiper/react dans les dépendances optimisées
|
||||
'swiper/modules', // Inclut swiper/modules dans les dépendances optimisées
|
||||
],
|
||||
exclude: [
|
||||
'cssnano', // Exclut les dépendances problématiques comme cssnano
|
||||
],
|
||||
|
||||
},
|
||||
});
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -1,28 +0,0 @@
|
||||
#
|
||||
# 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=
|
||||
@@ -1,36 +0,0 @@
|
||||
import express from "express";
|
||||
import axios from "axios";
|
||||
import dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/cloudinary-images", async (req, res) => {
|
||||
try {
|
||||
const cloudName = process.env.CLOUDINARY_CLOUD_NAME;
|
||||
const apiKey = process.env.CLOUDINARY_API_KEY;
|
||||
const apiSecret = process.env.CLOUDINARY_API_SECRET;
|
||||
const auth = Buffer.from(`${apiKey}:${apiSecret}`).toString("base64");
|
||||
|
||||
const response = await axios.get(`https://api.cloudinary.com/v1_1/${cloudName}/resources/image`, {
|
||||
params: {
|
||||
prefix: "articles-octopus/",
|
||||
max_results: 30,
|
||||
type: "upload",
|
||||
},
|
||||
headers: {
|
||||
Authorization: `Basic ${auth}`,
|
||||
},
|
||||
});
|
||||
console.log("✅ Données Cloudinary reçues :", response.data);
|
||||
|
||||
res.json(response.data.resources);
|
||||
} catch (err) {
|
||||
console.error("❌ Erreur Cloudinary complète :", err.response?.data || err.message);
|
||||
res.status(500).json({ error: "Erreur lors de la récupération des images" });
|
||||
}
|
||||
|
||||
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,123 +0,0 @@
|
||||
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;
|
||||
@@ -1,39 +0,0 @@
|
||||
import express from "express";
|
||||
import dotenv from "dotenv";
|
||||
import cors from "cors";
|
||||
import cloudinaryRoute from "./cloudinaryRoute.js";
|
||||
import contactRoute from "./contactRoute.js";
|
||||
|
||||
dotenv.config();
|
||||
const app = express();
|
||||
|
||||
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, () => {
|
||||
console.log(`✅ Serveur backend Cloudinary lancé sur http://localhost:${PORT}`);
|
||||
});
|
||||
-979
@@ -1,979 +0,0 @@
|
||||
{
|
||||
"name": "cloudinary-proxy-server",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cloudinary-proxy-server",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-types": "~2.1.34",
|
||||
"negotiator": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz",
|
||||
"integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.3",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
|
||||
"integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"content-type": "~1.0.5",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "1.2.0",
|
||||
"http-errors": "2.0.0",
|
||||
"iconv-lite": "0.4.24",
|
||||
"on-finished": "2.4.1",
|
||||
"qs": "6.13.0",
|
||||
"raw-body": "2.5.2",
|
||||
"type-is": "~1.6.18",
|
||||
"unpipe": "1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bound": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"get-intrinsic": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "5.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/content-type": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
|
||||
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cors": {
|
||||
"version": "2.8.5",
|
||||
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
|
||||
"integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"object-assign": "^4",
|
||||
"vary": "^1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/destroy": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/etag": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.21.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
||||
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "1.20.3",
|
||||
"content-disposition": "0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "0.7.1",
|
||||
"cookie-signature": "1.0.6",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"finalhandler": "1.3.1",
|
||||
"fresh": "0.5.2",
|
||||
"http-errors": "2.0.0",
|
||||
"merge-descriptors": "1.0.3",
|
||||
"methods": "~1.1.2",
|
||||
"on-finished": "2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "0.1.12",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "6.13.0",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.2.1",
|
||||
"send": "0.19.0",
|
||||
"serve-static": "1.16.2",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "2.0.1",
|
||||
"type-is": "~1.6.18",
|
||||
"utils-merge": "1.0.1",
|
||||
"vary": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
|
||||
"integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"on-finished": "2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"statuses": "2.0.1",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.10",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.10.tgz",
|
||||
"integrity": "sha512-V7O/fFKM539IC2bweloFWuoiJ9OtI3W2uIqJPWM8IT5xxNyt73QtvVqmSpcDmk07ivmmlKB+rRY0vpQjIYNtKw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
|
||||
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
||||
"integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"depd": "2.0.0",
|
||||
"inherits": "2.0.4",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "2.0.1",
|
||||
"toidentifier": "1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/media-typer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/merge-descriptors": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
||||
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/methods": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ee-first": "1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"forwarded": "0.2.0",
|
||||
"ipaddr.js": "1.9.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.13.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
||||
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.0.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/raw-body": {
|
||||
"version": "2.5.2",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
|
||||
"integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"http-errors": "2.0.0",
|
||||
"iconv-lite": "0.4.24",
|
||||
"unpipe": "1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "0.19.0",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
|
||||
"integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "1.2.0",
|
||||
"encodeurl": "~1.0.2",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"fresh": "0.5.2",
|
||||
"http-errors": "2.0.0",
|
||||
"mime": "1.6.0",
|
||||
"ms": "2.1.3",
|
||||
"on-finished": "2.4.1",
|
||||
"range-parser": "~1.2.1",
|
||||
"statuses": "2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/send/node_modules/encodeurl": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
|
||||
"integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/send/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/serve-static": {
|
||||
"version": "1.16.2",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
|
||||
"integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"parseurl": "~1.3.3",
|
||||
"send": "0.19.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-list": "^1.0.0",
|
||||
"side-channel-map": "^1.0.1",
|
||||
"side-channel-weakmap": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-list": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-map": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-weakmap": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-map": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"media-typer": "0.3.0",
|
||||
"mime-types": "~2.1.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"name": "cloudinary-proxy-server",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"start": "node main.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"nodemailer": "^6.9.11"
|
||||
}
|
||||
}
|
||||
@@ -269,56 +269,4 @@ function custom_cors_headers() {
|
||||
header("Access-Control-Allow-Methods: GET, POST, OPTIONS, PUT, DELETE");
|
||||
header("Access-Control-Allow-Headers: Content-Type, Authorization");
|
||||
}
|
||||
add_action('init', 'custom_cors_headers');
|
||||
|
||||
// 🔹 Autoriser la modification des champs ACF via l'API REST
|
||||
add_action('rest_api_init', function () {
|
||||
register_rest_field(
|
||||
'page', // Type de post
|
||||
'acf', // Champ ACF
|
||||
[
|
||||
'get_callback' => function ($object) {
|
||||
return get_fields($object['id']); // ✅ Vérifie que l'ID est correct
|
||||
},
|
||||
'update_callback' => function ($value, $object) {
|
||||
foreach ($value as $field_key => $field_value) {
|
||||
update_field($field_key, $field_value, $object->ID); // ✅ Correction ici
|
||||
}
|
||||
return true;
|
||||
},
|
||||
'schema' => null,
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
// accordeon
|
||||
function update_acf_mission($post, $request) {
|
||||
if (!function_exists('update_field')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$params = $request->get_json_params();
|
||||
|
||||
if (isset($params['mission']) && is_array($params['mission'])) {
|
||||
update_field('mission', $params['mission'], $post->ID);
|
||||
}
|
||||
}
|
||||
|
||||
add_action('rest_after_insert_page', 'update_acf_mission', 10, 2);
|
||||
|
||||
function add_rankmath_to_rest_api($response, $post, $request) {
|
||||
$seo_meta = get_post_meta($post->ID, 'rank_math_description', true);
|
||||
$seo_title = get_post_meta($post->ID, 'rank_math_title', true);
|
||||
|
||||
if (!empty($seo_meta)) {
|
||||
$response->data['seo_description'] = $seo_meta;
|
||||
}
|
||||
|
||||
if (!empty($seo_title)) {
|
||||
$response->data['seo_title'] = $seo_title;
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
add_filter('rest_prepare_page', 'add_rankmath_to_rest_api', 10, 3);
|
||||
add_action('init', 'custom_cors_headers');
|
||||
Reference in New Issue
Block a user