75 Commits

Author SHA1 Message Date
sebvtl728 ff5d08ed72 refactor: remove Bilans component and related styles to clean up project structure 2026-06-22 10:45:58 +02:00
sebvtl728 9d8e4e6314 chore: update dependency tree metadata and refine API endpoint structure 2026-05-18 17:04:53 +02:00
sebvtl728 ccc2e1347d refactor: update bundle data and integrate new tool management API endpoints 2026-05-05 23:16:36 +02:00
sebvtl728 3fabebeb85 feat: integrate Moodle API services, add RichTextEditor component, and configure environment variables for course management. 2026-04-09 16:37:09 +02:00
sebvtl728 a264f9b430 mise a jour structure visuel BO 2025-11-06 11:28:07 +01:00
sebvtl728 1890d830e1 mise a jour structure SEO 2025-11-05 01:08:39 +01:00
sebvtl728 5909871dec add tools 2025-11-02 21:36:00 +01:00
sebvtl728 f242bcc5d6 MAJ gestion des pages services 2025-10-26 15:38:17 +01:00
sebvtl728 82406fb046 maj majeur opengraph et page accueil 2025-10-26 03:52:24 +01:00
sebvtl728 f4cd293805 maj majeur opengraph et page accueil 2025-10-26 03:24:39 +01:00
sebvtl728 e7e5c5e3fd integration SEORank 2025-10-26 01:32:28 +02:00
sebvtl728 718eb8353b integration bilans 2025-10-26 00:30:16 +02:00
sebvtl728 a04cc47e26 maj css 2025-07-31 23:27:23 +02:00
sebvtl728 04aa793815 update octopus 2025-07-31 23:06:48 +02:00
sebvtl728 53ae6365f4 Menu octopus 2025-07-31 22:25:04 +02:00
sebvtl728 58ce0af44e update fonction de texte 2025-07-31 21:17:42 +02:00
sebvtl728 88582a89b5 add option de texte 2025-07-31 20:49:20 +02:00
sebvtl728 7c01c6e81d bug affichage post 2025-07-31 20:15:17 +02:00
sebvtl728 51195ac398 maj css 2025-07-31 20:01:45 +02:00
sebvtl728 fb77ca86f9 maj css 2025-07-31 19:28:58 +02:00
sebvtl728 e267591ebb update edit post cloudinary 2025-07-31 18:25:36 +02:00
sebvtl728 2ee6c53a98 correction bug cloudinary 2025-07-31 17:06:15 +02:00
sebvtl728 bfe25828fe creation backend fonction import img cloudinary 2025-07-31 16:39:14 +02:00
sebvtl728 f43e285a9c gestion des images articles cloudinary 2025-07-31 12:29:54 +02:00
sebvtl728 b02a509839 add filtre chapitre 2025-07-21 23:48:50 +02:00
sebvtl728 db8984b971 maj liste cours 2025-07-20 23:48:08 +02:00
sebvtl728 61222e2133 creation coursLecture 2025-07-19 19:24:57 +02:00
sebvtl728 9f5424c89b securité cours + correction bug accueil 2025-07-19 11:27:08 +02:00
sebvtl728 f7e8cfe938 maj listCours add function (page et devoir) 2025-07-18 23:45:08 +02:00
sebvtl728 98530922ef import image cloudinary 2025-07-18 15:54:08 +02:00
sebvtl728 77a7cda77f Gestion de cours 2025-07-18 13:28:10 +02:00
sebvtl728 885922483b accueil Page bug affichage correct ok 2025-07-16 22:08:59 +02:00
sebvtl728 38bafc63f6 accueil full ok 2025-07-16 01:48:25 +02:00
sebvtl728 5432b19604 MAJ fonction BO page accueil et correction de beug mission correctif css 2025-07-15 09:02:42 +02:00
sebvtl728 1042eecfd2 MAJ fonction BO page accueil et correction de beug 2025-07-15 00:40:20 +02:00
sebvtl728 f0e31c4639 Gestion bureau d'études 2025-02-18 21:56:18 +01:00
sebvtl728 94c215a253 MAJ 18022024 2025-02-18 18:35:24 +01:00
sebvtl728 26ecadd238 MAJ PAGES SERVICES DEUX 2025-02-16 22:03:08 +01:00
sebvtl728 a5ee040361 MAJ PAGES SERVICES DEUX 2025-02-16 21:56:51 +01:00
sebvtl728 f93932a038 MAJ PAGES SERVICES TCE 2025-02-16 18:58:48 +01:00
sebvtl728 55c3b64733 MAJ PAGES SERVICES 2025-02-16 17:59:18 +01:00
sebvtl728 31d57e365a MAJ HEADER 2025-02-11 17:55:25 +01:00
sebvtl728 b7a2831ee3 Mise en ligne serveur dist 2025-02-11 17:49:29 +01:00
sebvtl728 8094e445d4 Gestion de page ACF maj fonctionnel 2025-02-11 11:35:41 +01:00
sebvtl728 7df66f13df Gestion de page ACF maj 2025-02-11 00:38:29 +01:00
sebvtl728 038dd3498a Gestion de page ACF maj 2025-02-10 01:30:48 +01:00
sebvtl728 4a4d7104ac Gestion de page ACF 2025-02-10 00:52:15 +01:00
sebvtl728 d1cee7b3ae Gestion de page ACF 2025-02-09 19:27:56 +01:00
sebvtl728 20c2e3500d MAJ Référencement 2025-02-04 21:56:54 +01:00
sebvtl728 709221a0c1 Optimisation Expertises 2025-02-04 21:41:12 +01:00
sebvtl728 d8395f158d MAJ accordeon auto page home 2025-02-04 17:35:10 +01:00
sebvtl728 9ceb17a6f4 add accordeon auto page home 2025-02-04 17:05:12 +01:00
sebvtl728 b24600acd2 Maj des Pages services 2025-02-03 12:26:49 +01:00
sebvtl728 27a2ffdfb2 maj page about 2025-02-03 09:10:15 +01:00
sebvtl728 10c1f53d91 maj template page and pages 2025-02-03 03:53:44 +01:00
sebvtl728 555a63b61d maj template page 2025-02-03 02:02:18 +01:00
sebvtl728 f25b0fa6e4 gestion accueil MAJ avec gestion photo hero 2025-02-02 22:20:15 +01:00
sebvtl728 6eaa0fcf64 gestion accueil MAJ 2025-02-02 20:34:44 +01:00
sebvtl728 f70d21ae71 gestion accueil 2025-02-02 10:22:18 +01:00
sebvtl728 57f618a0e4 maj texte postdetail 2025-02-01 22:09:53 +01:00
sebvtl728 8bccf65623 maj texte postdetail 2025-02-01 22:07:50 +01:00
sebvtl728 55436deb95 maj du texte des articles 2025-01-31 22:40:52 +01:00
sebvtl728 827a6ac092 maj de la gestion des articles add mofif et suppr 2025-01-31 22:25:16 +01:00
sebvtl728 0d42ce54fc Maj gestion des articles 2025-01-31 20:05:03 +01:00
sebvtl728 841161cb9b add function creatPost and login wp 2025-01-31 08:22:48 +01:00
sebvtl728 81ac3d831e add bureauEtude 2025-01-23 23:27:53 +01:00
sebvtl728 c3ab2b54ca MAJ About 2025-01-22 04:05:01 +01:00
sebvtl728 1e4dd3b475 menu stiky, MAJ page Home add Testi... 2025-01-22 03:57:36 +01:00
sebvtl728 cc641c5f5d update Home add Testi 2025-01-21 23:43:32 +01:00
sebvtl728 e605d3915d update About 2025-01-21 17:48:24 +01:00
sebvtl728 ba6603fc6f reindexation serviceDeux 2025-01-21 11:55:01 +01:00
sebvtl728 9eca9041fb add about page 2025-01-21 11:45:50 +01:00
sebvtl728 dc3c6a566b 404 2025-01-18 17:42:28 +01:00
sebvtl728 2d4341311d Fusion de int-OG dans main 2025-01-16 23:16:36 +01:00
sebvtl728 476b227428 MAJ header 2025-01-15 10:13:04 +01:00
76 changed files with 23802 additions and 1182 deletions
Vendored
BIN
View File
Binary file not shown.
+28
View File
@@ -1,2 +1,30 @@
# Octopus-React-Wp
## Formulaire de contact mise en route
Le formulaire (`frontend/src/components/Pages/Contact.jsx`) envoie désormais une requête POST vers lAPI de contact.
Suivez ces étapes pour activer lenvoi de-mails:
1. **Configuration backend**
- Copiez `server/cloudinary-backend/.env.example` vers `server/cloudinary-backend/.env`.
- Renseignez vos identifiants SMTP (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, etc.).
- Facultatif : ajustez `CONTACT_RECIPIENT`, `CONTACT_FROM`, ou ajoutez des domaines supplémentaires dans `CORS_ALLOWED_ORIGINS`.
2. **Installer et lancer le serveur contact**
```bash
cd server/cloudinary-backend
npm install
npm start
```
Par défaut, lAPI est disponible sur `http://localhost:3001/api/contact`.
3. **Configuration du front-end**
- Copiez `frontend/.env.local.example` vers `frontend/.env.local`.
- Ajustez `VITE_CONTACT_API_URL` si nécessaire (par défaut `http://localhost:3001/api/contact` en dev).
- Relancez lapplication React :
```bash
cd frontend
npm run dev
```
En production, assurez-vous que lURL définie dans `VITE_CONTACT_API_URL` pointe vers votre API accessible publiquement (ex. `https://octopusdesign.fr/api/contact`) et que le serveur backend est déployé avec les mêmes variables denvironnement SMTP.
+3
View File
@@ -0,0 +1,3 @@
# URL of the contact API (backend server).
# In development, point to the local Express server.
VITE_CONTACT_API_URL=http://localhost:3001/api/contact
+2
View File
@@ -11,6 +11,8 @@ node_modules
dist
dist-ssr
*.local
.env
.env.*
# Editor directories and files
.vscode/*
+6
View File
@@ -1,3 +1,9 @@
<IfModule mod_headers.c>
Header set Access-Control-Allow-Origin "*"
Header set Access-Control-Allow-Methods "GET, POST, OPTIONS, PUT, DELETE"
Header set Access-Control-Allow-Headers "Content-Type, Authorization"
</IfModule>
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
+17
View File
@@ -6,3 +6,20 @@ 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`.
+88 -13
View File
@@ -1,26 +1,101 @@
<!doctype html>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<title>bureau d’études Maîtrise doeuvre</title>
<title data-rh="true">Centre de formations - metier du numérique</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.ico" />
<!-- ✅ Ajout Open Graph statique -->
<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">
<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" />
<!-- ✅ Twitter Cards -->
<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">
<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>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
<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&apos;appuie sur React et nécessite JavaScript pour l&apos;expérience complète.
Activez-le pour profiter de toutes les fonctionnalités. En attendant,
utilisez ces liens directs&nbsp;:
</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>
</body>
</html>
+1496 -409
View File
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -5,9 +5,12 @@
"type": "module",
"scripts": {
"dev": "vite",
"prebuild": "node scripts/update-noscript.mjs",
"build": "vite build",
"postbuild": "node scripts/prerender.mjs",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"prerender": "node scripts/prerender.mjs"
},
"dependencies": {
"@emotion/react": "^11.14.0",
@@ -15,12 +18,17 @@
"@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"
@@ -37,6 +45,7 @@
"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 -4
View File
@@ -1,5 +1,2 @@
User-agent: *
Disallow: /static/
Allow: /
Sitemap: https://octopusdesign.fr/sitemap.xml
Disallow: /
+149
View File
@@ -0,0 +1,149 @@
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);
});
+70
View File
@@ -0,0 +1,70 @@
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";
+50
View File
@@ -0,0 +1,50 @@
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);
});
+13 -6
View File
@@ -1,12 +1,19 @@
import axios from 'axios';
import axios from "axios";
// Création de l'instance Axios
const API_URL = "https://preprod.octopusdesign.fr/api-octopus/server/wp-json";
// 🔹 Instance Axios pour éviter de répéter l'URL
const api = axios.create({
baseURL: 'https://preprod.octopusdesign.fr/api-octopus/server/wp-json',
withCredentials: true,
baseURL: API_URL,
headers: {
"Content-Type": "application/json"
}
});
// 🔹 Fonction générique pour récupérer le token
export const getToken = () => {
return sessionStorage.getItem("custom_token");
};
// Exportation de l'instance Axios par défaut
// 🔹 Exporter l'instance Axios
export default api;
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

+38
View File
@@ -0,0 +1,38 @@
@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
} */
+374
View File
@@ -0,0 +1,374 @@
@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;
}
+35
View File
@@ -0,0 +1,35 @@
import axios from "axios";
const API_URL = "https://preprod.octopusdesign.fr/api-octopus/server/wp-json";
// ✅ Connexion avec le mot de passe dapplication
export const loginWithAppPassword = async (username, appPassword) => {
try {
const credentials = `${username}:${appPassword}`;
const encodedCredentials = btoa(credentials); // Encodage en Base64
const response = await axios.get(`${API_URL}/wp/v2/users/me`, {
headers: {
"Authorization": `Basic ${encodedCredentials}`
}
});
console.log("✅ Connexion réussie :", response.data);
sessionStorage.setItem("wp_app_token", encodedCredentials);
return response.data;
} catch (error) {
console.error("❌ Erreur lors de la connexion :", error);
return null;
}
};
// ✅ Récupération du token stocké
export const getToken = () => {
return sessionStorage.getItem("wp_app_token") || null;
};
// ✅ Déconnexion
export const logout = () => {
console.log("🚪 Déconnexion...");
sessionStorage.removeItem("wp_app_token");
};
@@ -0,0 +1,287 @@
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;
@@ -0,0 +1,817 @@
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
@@ -0,0 +1,71 @@
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;
+93 -33
View File
@@ -1,12 +1,16 @@
import React from "react";
import React, { useState, useEffect, useMemo } 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,
@@ -17,26 +21,59 @@ 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}{" "}
@@ -44,7 +81,6 @@ const ConstructSection = ({
component="span"
sx={{
fontSize: { xs: "2rem", md: "2rem", lg: "2rem" },
color: "#0e467f",
fontWeight: "bold",
}}
@@ -53,14 +89,9 @@ 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>
@@ -76,13 +107,12 @@ const ConstructSection = ({
dangerouslySetInnerHTML={{ __html: constructText }}
/>
{/* Cadre résumé */}
<Card
sx={{
mt: 4,
backgroundColor: "#0e467f",
color: "#fff",
borderRadius: "12px",
borderRadius: "12px 0 12px 12px",
boxShadow: "0 8px 20px rgba(0, 0, 0, 0.1)",
}}
>
@@ -105,15 +135,20 @@ 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
variant="h3"
component="h3"
variant="h4"
sx={{
fontWeight: "bold",
textAlign: "center",
@@ -124,17 +159,42 @@ const ConstructSection = ({
>
{missionTitle}
</Typography>
)}
<Box
{/* ✅ 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={{
position: "relative",
height: "400px",
backgroundImage: `url('https://via.placeholder.com/600x400')`, // Replace with the image URL or dynamic data
backgroundSize: "cover",
backgroundPosition: "center",
fontWeight: "bold",
fontSize: { xs: "1rem", md: "1.2rem", lg: "1.5rem" },
}}
>
{missionItems.map((item, index) => (
{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) => (
<Box
key={index}
sx={{
+17
View File
@@ -0,0 +1,17 @@
import { deletePost } from "../wordpress";
function DeletePost({ postId, onDelete }) {
const handleDelete = async () => {
if (window.confirm("Es-tu sûr de vouloir supprimer cet article ?")) {
const success = await deletePost(postId);
if (success) {
alert("Article supprimé avec succès !");
onDelete(); // Rafraîchir la liste
}
}
};
return <button onClick={handleDelete}>🗑 Supprimer</button>;
}
export default DeletePost;
+556
View File
@@ -0,0 +1,556 @@
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";
const BUTTON_BASE_SX = Object.freeze({
borderRadius: 999,
textTransform: "none",
fontWeight: 600,
letterSpacing: 0.3,
display: "inline-flex",
alignItems: "center",
gap: 0.75,
transition:
"transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease, border-color 0.2s ease",
"&:hover": {
transform: "translateY(-1px)",
},
"&:active": {
transform: "translateY(0)",
},
});
const BUTTON_SIZE_SX = Object.freeze({
small: {
px: 2.1,
py: 0.55,
fontSize: "0.78rem",
minHeight: 30,
},
medium: {
px: 2.8,
py: 0.8,
fontSize: "0.86rem",
minHeight: 36,
},
large: {
px: 3.4,
py: 1,
fontSize: "0.94rem",
minHeight: 42,
},
});
const BUTTON_VARIANT_SX = Object.freeze({
primary: {
background: "linear-gradient(135deg, #315397, #7bc0ff)",
color: "#ffffff",
border: "1px solid rgba(255,255,255,0.35)",
boxShadow: "0 18px 28px -18px rgba(11, 26, 61, 0.55)",
"&:hover": {
background: "linear-gradient(135deg, #26467d, #6fb6ff)",
boxShadow: "0 22px 32px -18px rgba(11, 26, 61, 0.6)",
},
"&.Mui-disabled": {
color: "rgba(255,255,255,0.72)",
background:
"linear-gradient(135deg, rgba(49,83,151,0.35), rgba(123,192,255,0.35))",
boxShadow: "none",
border: "1px solid rgba(49,83,151,0.18)",
},
},
ghost: {
background: "rgba(255,255,255,0.16)",
color: "rgba(255,255,255,0.92)",
border: "1px solid rgba(255,255,255,0.28)",
backdropFilter: "blur(8px)",
"&:hover": {
background: "rgba(255,255,255,0.24)",
boxShadow: "0 18px 28px -18px rgba(6, 18, 38, 0.45)",
},
},
outline: {
background: "rgba(255,255,255,0.84)",
color: "#315397",
border: "1px solid rgba(49,83,151,0.26)",
"&:hover": {
background: "rgba(255,255,255,0.96)",
borderColor: "rgba(49,83,151,0.46)",
boxShadow: "0 14px 22px -18px rgba(11, 26, 61, 0.4)",
},
},
});
const composeButtonSx = (variant, size = "medium") => ({
...BUTTON_BASE_SX,
...(BUTTON_SIZE_SX[size] || BUTTON_SIZE_SX.medium),
...(BUTTON_VARIANT_SX[variant] || BUTTON_VARIANT_SX.primary),
});
function EditPost() {
const { id } = useParams();
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);
}
};
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 larticle
</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 larticle
</Typography>
) : (
<Box component="form" onSubmit={handleUpdate} noValidate>
<TextField
label="Titre de l'article"
fullWidth
variant="outlined"
value={title}
onChange={(event) => setTitle(event.target.value)}
required
sx={{
mb: 3,
"& .MuiOutlinedInput-root": {
borderRadius: 2,
backgroundColor: "rgba(255,255,255,0.94)",
},
}}
/>
<Box
sx={{
mb: 3,
borderRadius: 2,
overflow: "hidden",
border: "1px solid rgba(49,83,151,0.15)",
backgroundColor: "rgba(255,255,255,0.92)",
}}
>
<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 />
Noubliez pas de relire la mise en forme dans laperçu
WordPress.
</Typography>
</Box>
</Stack>
</Card>
<Card
className="glass-article-card"
sx={{
borderRadius: 4,
overflow: "hidden",
minHeight: 220,
}}
>
<Box
sx={{
backgroundColor: "rgba(255,255,255,0.9)",
p: { xs: 2.5, md: 3 },
display: "flex",
flexDirection: "column",
height: "100%",
gap: 1.5,
}}
>
<Typography
variant="subtitle1"
sx={{ fontWeight: 700, color: "#0b1a3d" }}
>
Aperçu visuel
</Typography>
{imageUrl ? (
<Box
component="img"
src={imageUrl}
alt="Aperçu de limage sélectionnée"
sx={{
width: "100%",
borderRadius: 3,
height: 200,
objectFit: "cover",
boxShadow: "0 20px 28px -24px rgba(12, 29, 74, 0.48)",
}}
/>
) : (
<Box
sx={{
flexGrow: 1,
borderRadius: 3,
border: "1px dashed rgba(49,83,151,0.35)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "rgba(49,83,151,0.6)",
fontStyle: "italic",
}}
>
Aucun visuel sélectionné pour le moment
</Box>
)}
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)" }}
>
Ce visuel illustrera larticle dans la liste des publications
et sur les réseaux sociaux.
</Typography>
</Box>
</Card>
</Stack>
</Grid>
</Grid>
</Box>
</Box>
);
}
export default EditPost;
+170 -196
View File
@@ -1,8 +1,134 @@
import React, { useState } from "react";
import { useState, useRef, useEffect } 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,
@@ -15,16 +141,12 @@ 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, ""); // Supprime les balises HTML
const plainText = text.replace(/<[^>]+>/g, "");
return plainText.length > maxLength
? `${plainText.substring(0, maxLength)}...`
: plainText;
@@ -47,21 +169,7 @@ const Expertices = ({
<Grid container spacing={4}>
{/* Expertise 1 */}
<Grid item xs={12} md={4}>
<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",
}}
>
<Card sx={cardStyle(pictoMaitriseOeuvre)}>
<CardContent>
<Typography
variant="h3"
@@ -78,16 +186,7 @@ const Expertices = ({
</Typography>
<button
onClick={() => handleOpenModal("maitrise_oeuvre")}
style={{
marginTop: "16px",
padding: "8px 16px",
backgroundColor: "#0e467f",
color: "#fff",
border: "none",
borderRadius: "4px",
cursor: "pointer",
textTransform: "none",
}}
style={buttonStyle}
>
En savoir plus
</button>
@@ -97,21 +196,7 @@ const Expertices = ({
{/* Expertise 2 */}
<Grid item xs={12} md={4}>
<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",
}}
>
<Card sx={cardStyle(pictoStructure)}>
<CardContent>
<Typography
variant="h3"
@@ -128,16 +213,7 @@ const Expertices = ({
</Typography>
<button
onClick={() => handleOpenModal("structure")}
style={{
marginTop: "16px",
padding: "8px 16px",
backgroundColor: "#0e467f",
color: "#fff",
border: "none",
borderRadius: "4px",
cursor: "pointer",
textTransform: "none",
}}
style={buttonStyle}
>
En savoir plus
</button>
@@ -147,20 +223,7 @@ const Expertices = ({
{/* Expertise 3 */}
<Grid item xs={12} md={4}>
<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",
}}
>
<Card sx={cardStyle(pictoElectricite)}>
<CardContent>
<Typography
variant="h3"
@@ -177,16 +240,7 @@ const Expertices = ({
</Typography>
<button
onClick={() => handleOpenModal("electricite")}
style={{
marginTop: "16px",
padding: "8px 16px",
backgroundColor: "#0e467f",
color: "#fff",
border: "none",
borderRadius: "4px",
cursor: "pointer",
textTransform: "none",
}}
style={buttonStyle}
>
En savoir plus
</button>
@@ -195,124 +249,44 @@ const Expertices = ({
</Grid>
</Grid>
{/* 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%",
}}
{/* Modaux */}
<ExpertiseModal
modalId="maitrise_oeuvre"
image={pictoMaitriseOeuvre}
title={expertise1Title}
text={expertise1Text}
openModal={openModal}
handleClose={handleCloseModal}
/>
<Typography variant="h4" sx={{ mb: 2, fontWeight: "bold" }}>
{expertise1Title}
</Typography>
<Typography
variant="body1"
sx={{ color: "#333", lineHeight: 1.6 }}
dangerouslySetInnerHTML={{ __html: expertise1Text }}
<ExpertiseModal
modalId="structure"
image={pictoStructure}
title={expertise2Title}
text={expertise2Text}
openModal={openModal}
handleClose={handleCloseModal}
/>
</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%",
}}
<ExpertiseModal
modalId="electricite"
image={pictoElectricite}
title={expertise3Title}
text={expertise3Text}
openModal={openModal}
handleClose={handleCloseModal}
/>
<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;
+3 -2
View File
@@ -64,12 +64,13 @@ const Faq = ({ showFAQ }) => {
}}
>
<Typography
variant="h3"
variant="body2"
sx={{
fontWeight: "bold",
mb: 4,
textAlign: "center",
color: "#0e467f",
fontSize: { xs: "2rem", md: "2rem", lg: "2.5rem",whiteSpace: "pre-line" }
}}
>
FAQ
@@ -102,7 +103,7 @@ const Faq = ({ showFAQ }) => {
{filteredFaqs.length > 0 ? (
filteredFaqs.map((faq, index) => (
<Box key={index} sx={{ mb: 3 }}>
<Typography variant="h4" sx={{ fontWeight: "bold" }}>
<Typography variant="h3" sx={{ fontWeight: "bold",fontSize: { xs: "2rem", md: "2rem", lg: "2rem" } }}>
{faq.question}
</Typography>
<Typography
+8 -13
View File
@@ -16,6 +16,7 @@ import {
const Footer = () => {
return (
<Box
component="footer"
sx={{
backgroundColor: "#0e467f",
color: "white",
@@ -33,36 +34,30 @@ const Footer = () => {
<Typography
variant="h6"
component="h2"
sx={{ fontWeight: "bold", textAlign: { xs: "center", md: "left" } }}
sx={{ fontWeight: "bold", color:"#ffffff", textAlign: { xs: "center", md: "left" } }}
>
IN3 Intégrale Ingénierie Internationale
Octopusdesign
</Typography>
<Typography
variant="body2"
sx={{ marginTop: 1, textAlign: { xs: "center", md: "left" } }}
>
67 Boulevard Winston Churchill,
Route du Perrier,
<br />
72100 Le Mans, France
85270 - Saint Hilaire de Riez
</Typography>
<Typography
variant="body2"
sx={{ marginTop: 1, textAlign: { xs: "center", md: "left" } }}
>
<strong>Téléphone :</strong> 02.43.85.09.01
<strong>Téléphone :</strong> 06.00.00.00.00
</Typography>
<Typography
variant="body2"
sx={{ marginTop: 1, textAlign: { xs: "center", md: "left" } }}
>
<strong>Email :</strong>{" "}
<Link
href="mailto:contact@be-in3.com"
underline="hover"
sx={{ color: "white" }}
>
contact@be-in3.com
</Link>
contact@octopusdesign.fr
</Typography>
</Grid>
@@ -192,7 +187,7 @@ const Footer = () => {
}}
>
<Typography variant="body2">
© {new Date().getFullYear()} IN3 Intégrale Ingénierie Internationale.
© {new Date().getFullYear()} Octopusdesign.
Tous droits réservés.
</Typography>
</Box>
@@ -0,0 +1,669 @@
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 lintroduction, 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 lexpertise Octopus.
</Typography>
</Card>
</Grid>
<Grid item xs={12} md={6}>
<Card className="glass-subcard" sx={{ borderRadius: 4, p: 2.5 }}>
<Typography
variant="caption"
sx={{ color: "rgba(11, 26, 61, 0.65)", fontWeight: 600 }}
>
Expertises spécifiques
</Typography>
<Typography
variant="h4"
sx={{ color: "#0b1a3d", fontWeight: 800, letterSpacing: "-0.5px" }}
>
{stats.expertises}
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)", mt: 0.5 }}
>
Développez vos expertises pour renforcer la crédibilité de léquipe.
</Typography>
</Card>
</Grid>
</Grid>
{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 lexpertise du
bureau détude. Chaque compétence peut contenir un titre et
une description détaillée.
</Typography>
</Box>
<Stack spacing={2}>
{getRepeater("liste_des_competences").map((item, index) => (
<Card
key={index}
className="glass-card"
sx={{ borderRadius: 3, p: { xs: 2, md: 2.5 } }}
>
<Stack spacing={1.5}>
<TextField
label="Titre"
fullWidth
value={item.comp_titre}
onChange={(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 lexpertise"
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;
+483
View File
@@ -0,0 +1,483 @@
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 &quot;Modifier&quot; 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;
+89 -24
View File
@@ -1,4 +1,4 @@
import React, { useState } from "react";
import { 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/logo-in3.svg";
import LogoM from "../assets/logo-in3-mobil.svg";
import Logo from "../assets/centre-formation-logo.avif";
import LogoM from "../assets/centre-formation-logo.avif";
const Header = () => {
const [drawerOpen, setDrawerOpen] = useState(false);
@@ -84,13 +84,14 @@ const Header = () => {
return (
<AppBar
position="static"
position="fixed"
color="transparent"
elevation={0}
sx={{
background: "linear-gradient(to right, #294A9A, #3158b3)",
color: "white",
transition: "all 0.3s ease",
}}
>
<Toolbar>
@@ -122,7 +123,7 @@ const Header = () => {
>
<img
src={Logo}
alt="Bureau d'études Le Mans"
alt="centre de formation numérique"
style={{
width: "50px",
height: "50px",
@@ -149,7 +150,7 @@ const Header = () => {
...commonButtonStyles,
}}
>
Services
Activités
</Button>
<Menu
id="services-menu"
@@ -166,24 +167,45 @@ const Header = () => {
onClick={handleMenuClose}
>
<WorkIcon sx={{ marginRight: 1 }} />
Prestation maitrise oeuvre
Création de site Web
</MenuItem>
<MenuItem
component={Link}
to="/services/formation-ia"
to="/services/formations-web"
onClick={handleMenuClose}
>
<WorkIcon sx={{ marginRight: 1 }} />
Structure béton | charpente métallique / bois
Formations web
</MenuItem>
<MenuItem
component={Link}
to="/services/formation-video"
to="/services/electricite"
onClick={handleMenuClose}
>
<WorkIcon sx={{ marginRight: 1}} />
Électricité
Graphisme
</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
@@ -197,9 +219,9 @@ const Header = () => {
...commonButtonStyles,
}}
>
Réalisations
Actualités
</Button>
<Button
{/* <Button
component={Link}
to="/about"
color="inherit"
@@ -210,7 +232,20 @@ const Header = () => {
...commonButtonStyles,
}}
>
À propos
À Propos
</Button> */}
<Button
component={Link}
to="/nosFormations"
color="inherit"
sx={{
fontWeight: location.pathname === "/bureauEtude" ? "bold" : "normal",
textDecoration:
location.pathname === "/bureauEtude" ? "underline" : "none",
...commonButtonStyles,
}}
>
Formations
</Button>
<Button
component={Link}
@@ -334,28 +369,55 @@ const Header = () => {
selected={location.pathname.includes("/services/prestation-maitrise-oeuvre")}
>
<WorkIcon sx={{ marginRight: 0.5 }} />
<ListItemText primary="Prestation maitrise oeuvre" />
<ListItemText primary="Création site web" />
</ListItem>
<ListItem
button
component={Link}
to="/services/formation-ia"
selected={location.pathname.includes("/services/formation-ia")}
to="/services/formations-web"
selected={location.pathname.includes("/services/formations-web")}
>
<WorkIcon sx={{ marginRight: 0.5 }} />
<ListItemText primary="Formation IA" />
<ListItemText primary="Formations web..." />
</ListItem>
<ListItem
button
component={Link}
to="/services/formation-video"
to="/services/electricite"
selected={location.pathname.includes(
"/services/formation-video"
"/services/electricite"
)}
>
<WorkIcon sx={{ marginRight: 0.5 }} />
<ListItemText primary="Formation Video" />
<ListItemText primary="Graphisme..." />
</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}
@@ -363,9 +425,10 @@ const Header = () => {
selected={location.pathname === "/posts"}
>
<ArticleIcon sx={{ marginRight: 0.5 }} />
<ListItemText primary="Réalisations" />
<ListItemText primary="Actualités..." />
</ListItem>
<ListItem
{/* <ListItem
button
component={Link}
to="/about"
@@ -373,7 +436,8 @@ const Header = () => {
>
<InfoIcon sx={{ marginRight: 0.5 }} />
<ListItemText primary="À propos" />
</ListItem>
</ListItem> */}
<ListItem
button
component={Link}
@@ -383,6 +447,7 @@ const Header = () => {
<ContactMailIcon sx={{ marginRight: 0.5 }} />
<ListItemText primary="Contact" />
</ListItem>
</List>
</Box>
</Drawer>
+3 -1
View File
@@ -1,6 +1,7 @@
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
@@ -60,9 +61,10 @@ 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',
}}
>
Explorer les Articles
Nos Formations
</Button>
</Box>
);
@@ -0,0 +1,35 @@
import React from "react";
const CLOUD_NAME = "dh5qgexjo";
const UPLOAD_PRESET = "preset_articles"; // crée-le dans Cloudinary si ce nest 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 lURL optimisée
} else {
alert("❌ Erreur lors de l'upload");
}
};
return <input type="file" accept="image/*" onChange={handleUpload} />;
};
export default ImageUploaderCloudinary;
+339 -5
View File
@@ -1,11 +1,345 @@
import React from 'react';
import React from "react";
import {
Box,
Typography,
Grid,
Card,
CardContent,
Avatar,
} from "@mui/material";
import BusinessIcon from "@mui/icons-material/Business";
import BuildIcon from "@mui/icons-material/Build";
import FlashOnIcon from "@mui/icons-material/FlashOn";
import SecurityIcon from "@mui/icons-material/Security";
import EngineeringIcon from "@mui/icons-material/Engineering";
import AssessmentIcon from "@mui/icons-material/Assessment";
import ComputerIcon from "@mui/icons-material/Computer";
import LibraryBooksIcon from "@mui/icons-material/LibraryBooks";
import MoneyIcon from "@mui/icons-material/Money";
import PrintIcon from "@mui/icons-material/Print";
import StraightenIcon from "@mui/icons-material/Straighten";
import SettingsInputAntenna from "@mui/icons-material/SettingsInputAntenna";
import BookIcon from "@mui/icons-material/Book";
const About = () => {
const competences = [
{
icon: <BusinessIcon />,
title: "Bureau d’études et Maitrise d’œuvre",
link: "/services/prestation-maitrise-oeuvre",
},
{
icon: <BuildIcon />,
title: "La structure béton / métallique / bois",
link: "/services/structure-beton-charpente-metallique-bois",
},
{
icon: <FlashOnIcon />,
title: "L’électricité",
link: "/services/electricite",
},
{
icon: <SecurityIcon />,
title: "Sécurité incendie (SSI)",
link: "/services/securite-incendie",
},
{
icon: <EngineeringIcon />,
title: "Expertises TCE",
link: "/services/expertises-tce",
},
{
icon: <AssessmentIcon />,
title: "Diagnostics technique",
link: "/services/diagnostics-technique",
},
];
const team = [
{
name: "Ingénieur consultant Génie Civil",
role: "Spécialiste Structures en béton armé, charpentes bois et métalliques",
},
{
name: "Ingénieur Électricité",
role: "courant Forts et Faibles, expert bâtiment TCE",
},
{
name: "Technicienne spécialiste Structures",
role: "Béton armé, TCE, DAO-CAO, dessinatrice TCE",
},
{
name: "Assistante de gestion",
role: "Comptable",
},
{
name: "consultants",
role: "spécialités tel que thermique, SSI",
},
];
const infrastructure = [
{
icon: <ComputerIcon />,
title: "CAO-DAO",
description: "Autocad & ZWCAD Pro",
},
{
icon: <ComputerIcon />,
title: "Modélisation",
description: "Sketchup 3D",
},
{
icon: <LibraryBooksIcon />,
title: "Calculs techniques",
description: "Progiciel interne ou programme du CACT (tango, spot)",
},
{
icon: <MoneyIcon />,
title: "Logiciel de comptabilité",
description: "EBP",
},
];
const materiel = [
{
icon: <ComputerIcon />,
title: "Parc informatique",
},
{
icon: <PrintIcon />,
title: "Traceur plans",
},
{
icon: <StraightenIcon />,
title: "Pied à coulisse",
},
{
icon: <SettingsInputAntenna />,
title: "détecteurs de métaux",
},
{
icon: <BookIcon />,
title: "Bibliothèques techniques (DTU…)",
},
{
icon: <BookIcon />,
title: "Bibliothèques commerciales",
},
];
return (
<div>
<h1>À propos</h1>
<p>Bienvenue sur la page À propos.</p>
</div>
<Box sx={{ padding: "40px 20px" }}>
{/* En-tête */}
<Typography
variant="h1"
sx={{
fontSize: { xs: "3rem", md: "3rem", lg: "3rem" },
fontWeight: "bold",
textAlign: "center",
mb: 4, mt:5
}}
>
La société IN3 !!!
</Typography>
<Typography
variant="body2"
sx={{
textAlign: "center",
mb: 6,
maxWidth: "800px",
mx: "auto",
}}
>
Depuis 2001, Monsieur Stéphane Seillé gère la société quil a créée au
Mans (Sarthe). Lentreprise a su développer son expertise autour des
activités du bureau détudes et de la maîtrise dœuvre.
</Typography>
{/* Compétences */}
<Typography
variant="h2"
sx={{
fontSize: { xs: "2rem", md: "2rem", lg: "2rem" },
fontWeight: "bold",
textAlign: "center",
mb: 3,
}}
>
Nos Compétences
</Typography>
<Grid container spacing={4} justifyContent="center">
{competences.map((competence, index) => (
<Grid item xs={12} sm={6} md={4} key={index}>
<a href={competence.link} style={{ textDecoration: "none" }}>
<Card
sx={{
cursor: "pointer",
textAlign: "center",
padding: 2,
transition: "all 0.3s ease-in-out",
"&:hover": {
backgroundColor: "#0e467f",
color: "#ffffff",
transform: "scale(1.05)",
boxShadow: 6,
},
"&:hover svg": { fill: "#ffffff" },
}}
>
<CardContent>
<Typography variant="span" sx={{ mb: 1 }}>
{competence.icon}
</Typography>
<Typography
variant="h3"
sx={{
fontSize: { xs: "1.1rem", md: "1.1rem", lg: "1.1rem" },
fontWeight: "bold",
}}
>
{competence.title}
</Typography>
</CardContent>
</Card>
</a>
</Grid>
))}
</Grid>
{/* Équipe - Maintenant sur 2 colonnes */}
<Typography
sx={{
background: "linear-gradient(to bottom, #0e467f, transparent)",
}}
>
<Typography
variant="h2"
sx={{
fontWeight: "bold",
textAlign: "center",
mt: 6,
mb: 3,
color: "#ffffff",
pt: 2,
}}
>
Notre Équipe
</Typography>
<Typography
variant="body1"
sx={{ textAlign: "center", mt: 1, mb: 3, color: "#ffffff", pt: 2 }}
>
Léquipe dIN3 est composée de collaborateurs experts dans leurs
domaines :
</Typography>
<Grid container spacing={4} justifyContent="center">
{team.map((member, index) => (
<Grid item xs={12} sm={6} key={index}>
<Card
sx={{
textAlign: "center",
p: 2,
margin: 2,
height: "100",
mb: 2,
}}
>
<CardContent>
<Avatar sx={{ width: 56, height: 56, margin: "auto" }} />
<Typography
variant="h3"
sx={{
fontSize: { xs: "1.1rem", md: "1.1rem", lg: "1.1rem" },
fontWeight: "bold",
mt: 2,
}}
>
{member.name}
</Typography>
<Typography variant="body2">{member.role}</Typography>
</CardContent>
</Card>
</Grid>
))}
</Grid>
</Typography>
{/* Infrastructure */}
<Typography
variant="h2"
sx={{ fontWeight: "bold", textAlign: "center", mt: 6, mb: 3 }}
>
Notre Infrastructure
</Typography>
<Grid container spacing={4} justifyContent="center">
{infrastructure.map((infra, index) => (
<Grid item xs={12} sm={6} md={4} key={index}>
<Card sx={{ textAlign: "center", padding: 2 }}>
<CardContent>
<Typography variant="span" sx={{ mb: 1 }}>
{infra.icon}
</Typography>
<Typography
variant="h3"
sx={{
fontSize: { xs: "1.1rem", md: "1.1rem", lg: "1.1rem" },
fontWeight: "bold",
}}
>
{infra.title}
</Typography>
<Typography variant="body2">{infra.description}</Typography>
</CardContent>
</Card>
</Grid>
))}
</Grid>
<Typography
sx={{
background: "linear-gradient(to top, #6d6e6f, transparent)",
}}
>
<Typography
variant="h3"
sx={{
fontSize: { xs: "2rem", md: "2rem", lg: "2rem" },
fontWeight: "bold",
textAlign: "center",
mt: 6,
mb: 3,
p: 2,
}}
>
Nous sommes équipés de matériel de dernière génération :
</Typography>
<Grid container spacing={4} justifyContent="center">
{materiel.map((infra, index) => (
<Grid item xs={12} sm={6} md={4} key={index}>
<Card sx={{ textAlign: "center", p: 2, margin: 2 }}>
<CardContent>
<Typography variant="span" sx={{ mb: 1 }}>
{infra.icon}
</Typography>
<Typography
variant="h3"
sx={{
fontSize: { xs: "1.1rem", md: "1.1rem", lg: "1.1rem" },
fontWeight: "bold",
}}
>
{infra.title}
</Typography>
<Typography variant="body2">{infra.description}</Typography>
</CardContent>
</Card>
</Grid>
))}
</Grid>
</Typography>
</Box>
);
};
@@ -0,0 +1,200 @@
import React, { useState, useEffect, useMemo } from "react";
import {
Box,
Typography,
Grid,
Card,
CardContent,
Button,
} from "@mui/material";
import api from "../../api";
const BureauEtudes = () => {
const [pageData, setPageData] = useState(null);
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);
setPageData(response.data.acf);
} catch (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={{
backgroundImage: "url('https://picsum.photos/1920/600.webp')",
backgroundSize: "cover",
backgroundPosition: "center",
minHeight: "60vh",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
color: "white",
textAlign: "center",
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>
{/* 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>
<Grid container spacing={4} justifyContent="center">
{pageData.liste_des_competences?.map((competence, index) => (
<Grid item xs={12} sm={6} md={4} key={index}>
<Card
sx={{
textAlign: "center",
padding: 3,
transition: "transform 0.3s, box-shadow 0.3s",
"&:hover": {
transform: "scale(1.05)",
boxShadow: "0px 10px 20px rgba(0, 0, 0, 0.2)",
},
}}
>
<CardContent>
<Typography
variant="h3"
sx={{ fontWeight: "bold", mb: 1, fontSize: "1.8rem" }}
>
{competence.comp_titre}
</Typography>
<Typography variant="body2" sx={{ textAlign: "center" }}>
{competence.comp_description}
</Typography>
</CardContent>
</Card>
</Grid>
))}
</Grid>
</Box>
{/* Compétences Spécifiques */}
<Box sx={{ mt: 6, textAlign: "center", px: 4 }}>
<Typography variant="h2" sx={{ fontSize: "2.5rem", fontWeight: "bold", mb: 3 }}>
Nos Compétences
</Typography>
<Typography variant="body1" sx={{ maxWidth: "900px", mx: "auto" }}>
{pageData.methodologie}
</Typography>
</Box>
{/* Engagement et Accompagnement */}
<Box
sx={{
mt: 6,
py: 6,
px: 4,
textAlign: "center",
background: "linear-gradient(to bottom, #0e467f, #ffffff)",
color: "white",
}}
>
<Typography variant="h2" sx={{ fontSize: "2.5rem", 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>
)}
</Grid>
</Box>
{/* Call-To-Action */}
<Box sx={{ mt: 6, textAlign: "center", mb: 6 }}>
<Button
variant="contained"
color="primary"
href="/contact"
sx={{
padding: "12px 24px",
fontSize: "1.2rem",
fontWeight: "bold",
borderRadius: "8px",
transition: "background-color 0.3s, transform 0.3s",
"&:hover": {
backgroundColor: "#0a3b6b",
transform: "scale(1.05)",
},
}}
>
Nous Contacter
</Button>
</Box>
</Box>
);
};
export default BureauEtudes;
+48 -17
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, lazy, Suspense } from "react";
import React, { useState, useEffect, Suspense } from "react";
import {
Box,
TextField,
@@ -19,9 +19,15 @@ 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."
@@ -35,7 +41,11 @@ const Contact = () => {
});
const [errors, setErrors] = useState({});
const [loading, setLoading] = useState(false);
const [successMessage, setSuccessMessage] = useState(false);
const [alertState, setAlertState] = useState({
open: false,
severity: "success",
message: "",
});
const [openLightbox, setOpenLightbox] = useState(false);
const [showFAQ, setShowFAQ] = useState(false); // État pour afficher/masquer la FAQ
@@ -96,7 +106,7 @@ const Contact = () => {
try {
const response = await fetch(
"https://votre-site.com/wp-json/custom/v1/contact",
CONTACT_API_URL,
{
method: "POST",
headers: {
@@ -107,7 +117,11 @@ const Contact = () => {
);
if (response.ok) {
setSuccessMessage(true);
setAlertState({
open: true,
severity: "success",
message: "Merci ! Votre message a bien été envoyé.",
});
setFormData({
name: "",
email: "",
@@ -120,7 +134,13 @@ const Contact = () => {
throw new Error(errorData.message || "Une erreur est survenue.");
}
} catch (error) {
alert(error.message);
setAlertState({
open: true,
severity: "error",
message:
error.message ||
"Impossible d'envoyer votre message. Veuillez réessayer.",
});
} finally {
setLoading(false);
}
@@ -135,6 +155,13 @@ const Contact = () => {
setOpenLightbox(false);
};
const handleCloseAlert = (_, reason) => {
if (reason === "clickaway") {
return;
}
setAlertState((prev) => ({ ...prev, open: false }));
};
return (
<Box>
{/* SEO */}
@@ -216,14 +243,14 @@ const Contact = () => {
</Typography>
<Typography variant="body1">
<strong>Adresse :</strong>67 Bd Winston Churchill, Le Mans,
<strong>Adresse :</strong>Route du Perrier, Saint HIlaire de Riez,
France
</Typography>
<Typography variant="body1">
<strong>Téléphone :</strong> 02.43.85.09.01
<strong>Téléphone :</strong> 06.00.00.00.00
</Typography>
<Typography variant="body1">
<strong>Email :</strong> contact@be-in3.com
<strong>Email :</strong> contact@octopusdesign.fr
</Typography>
</Box>
@@ -278,7 +305,7 @@ const Contact = () => {
>
<CardContent>
<Typography
variant="h2"
variant="h3"
sx={{
fontSize: { xs: "2rem", md: "2rem", lg: "3rem" },
fontWeight: "bold",
@@ -332,7 +359,7 @@ const Contact = () => {
}}
>
<iframe
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&#39;%C3%A9tudes%20Ing%C3%A9nieries%20Ma%C3%AEtrise%20d&#39;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"
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"
width="100%"
height="400"
title="Carte interactive"
@@ -356,10 +383,9 @@ const Contact = () => {
<Typography
variant="h2"
sx={{
fontSize: { xs: "2rem", md: "2rem", lg: "3rem" }, // Responsive
fontSize: { xs: "2rem", md: "2rem", lg: "2rem" }, // Responsive
fontWeight: "bold",
mb: 2,
fontSize: { xs: "1.5rem", md: "2rem" },
}}
>
Besoin d'une assistance immédiate?
@@ -491,12 +517,17 @@ const Contact = () => {
</form>
<Snackbar
open={successMessage}
open={alertState.open}
autoHideDuration={6000}
onClose={() => setSuccessMessage(false)}
onClose={handleCloseAlert}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
>
<Alert onClose={() => setSuccessMessage(false)} severity="success">
Message envoyé avec succès!
<Alert
onClose={handleCloseAlert}
severity={alertState.severity}
sx={{ width: "100%" }}
>
{alertState.message}
</Alert>
</Snackbar>
</Box>
@@ -0,0 +1,472 @@
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),
});
function CreatePost() {
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [imageUrl, setImageUrl] = useState(null);
const [submitting, setSubmitting] = useState(false);
const navigate = useNavigate();
// Vérification de l'authentification
useEffect(() => {
if (!getToken()) {
navigate("/admin/login");
}
}, [navigate]);
const canPublish =
title.trim().length > 0 && content.trim().length > 0 && Boolean(imageUrl);
const handleSubmit = async (event) => {
event.preventDefault();
if (!canPublish || submitting) {
return;
}
try {
setSubmitting(true);
const imageId = await uploadImageFromUrl(imageUrl);
await createPost(title, content, imageId);
alert("✅ Article publié !");
navigate("/admin/gestion-articles");
} catch (error) {
console.error(
"❌ Erreur création post :",
error?.response?.data || error?.message || error
);
alert("❌ Erreur lors de la création du post.");
} finally {
setSubmitting(false);
}
};
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/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>&lt;h2&gt;</code>) pour
clarifier la structure.<br />
Ajoutez des appels à laction vers vos formations Octopus.
</Typography>
</Box>
</Stack>
</Card>
<Card
className="glass-article-card"
sx={{
borderRadius: 4,
overflow: "hidden",
minHeight: 220,
}}
>
<Box
sx={{
backgroundColor: "rgba(255,255,255,0.9)",
p: { xs: 2.5, md: 3 },
display: "flex",
flexDirection: "column",
height: "100%",
gap: 1.5,
}}
>
<Typography
variant="subtitle1"
sx={{ fontWeight: 700, color: "#0b1a3d" }}
>
Aperçu visuel
</Typography>
{imageUrl ? (
<Box
component="img"
src={imageUrl}
alt="Aperçu de limage sélectionnée"
sx={{
width: "100%",
borderRadius: 3,
height: 200,
objectFit: "cover",
boxShadow: "0 20px 28px -24px rgba(12, 29, 74, 0.48)",
}}
/>
) : (
<Box
sx={{
flexGrow: 1,
borderRadius: 3,
border: "1px dashed rgba(49,83,151,0.35)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "rgba(49,83,151,0.6)",
fontStyle: "italic",
}}
>
Aucun visuel sélectionné pour le moment
</Box>
)}
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)" }}
>
Ce visuel sera utilisé comme image de couverture sur la liste
des articles et les réseaux sociaux.
</Typography>
</Box>
</Card>
</Stack>
</Grid>
</Grid>
</Box>
</Box>
);
}
export default CreatePost;
+525
View File
@@ -0,0 +1,525 @@
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,
} 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),
});
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 daccueil",
description: "Mettez à jour les blocs héros, témoignages et sections clés.",
navigateTo: "/admin/Gestion-Page-Accueil",
icon: <HomeIcon fontSize="inherit" />,
accent: "rgba(123,192,255,0.35)",
},
{
title: "Tools studio",
description: "Centralisez vos outils internes et automatisez les tâches.",
navigateTo: "/admin/tools",
icon: <BuildIcon fontSize="inherit" />,
accent: "rgba(255,214,150,0.32)",
},
{
title: "Articles",
description: "Rédigez, programmez et optimisez les publications du blog.",
navigateTo: "/admin/gestion-articles",
icon: <ArticleIcon fontSize="inherit" />,
accent: "rgba(255,172,236,0.28)",
},
{
title: "Pages services",
description: "Actualisez les contenus ACF pour chaque offre Octopus.",
navigateTo: "/admin/pages-acf",
icon: <SettingsSuggestIcon fontSize="inherit" />,
accent: "rgba(158,241,209,0.32)",
},
{
title: "Bureau d’étude",
description: "Administrez les informations du pôle bureau d’étude.",
navigateTo: "/admin/bureau-etude-acf",
icon: <InsightsIcon fontSize="inherit" />,
accent: "rgba(255,199,186,0.34)",
},
{
title: "Cours & formations",
description: "Gérez le catalogue Moodle et vos ressources pédagogiques.",
navigateTo: "/admin/liste-cours",
icon: <SchoolIcon fontSize="inherit" />,
accent: "rgba(178,219,255,0.34)",
},
];
const QUICK_ACTIONS = [
{
label: "Nouvel article",
hint: "Créez un contenu en un clic",
onClick: () => navigate("/admin/gestion-articles"),
},
{
label: "Synchroniser Moodle",
hint: "Actualisez vos cours et stats",
onClick: () => navigate("/admin/liste-cours"),
},
{
label: "Voir le site",
hint: "Ouvrir octopusdesign.fr",
onClick: () => window.open("https://www.octopusdesign.fr", "_blank"),
},
];
const KPI_CARDS = [
{
label: "Articles publiés",
value: "128",
delta: "+4 ce mois-ci",
},
{
label: "Cours actifs",
value: "36",
delta: "8 en mise à jour",
},
{
label: "Pages services",
value: "12",
delta: "2 révisions en cours",
},
];
return (
<Box
className="glass-dashboard"
sx={{
minHeight: "100vh",
position: "relative",
px: { xs: 2, md: 6 },
pt: { xs: 6, md: 8 },
pb: { xs: 8, md: 10 },
}}
>
<Box className="glass-orb orb-1" />
<Box className="glass-orb orb-2" />
<Box className="glass-orb orb-3" />
<Box sx={{ position: "relative", zIndex: 1, maxWidth: 1240, mx: "auto" }}>
<Stack
direction={{ xs: "column", md: "row" }}
justifyContent="space-between"
alignItems={{ xs: "flex-start", md: "center" }}
spacing={2}
sx={{ mb: 3 }}
>
<Stack spacing={0.5}>
<Typography
variant="overline"
sx={{
letterSpacing: 2,
fontWeight: 700,
color: "rgba(255,255,255,0.8)",
}}
>
Octopus Admin
</Typography>
<Typography
variant="h3"
sx={{
color: "#ffffff",
fontWeight: 800,
letterSpacing: "-0.5px",
}}
>
Tableau de bord centralisé
</Typography>
<Typography
variant="body1"
sx={{
color: "rgba(255,255,255,0.85)",
maxWidth: 540,
}}
>
Accédez à lensemble des outils de gestion : contenus éditoriaux,
pages services, ressources pédagogiques et automatisations.
</Typography>
</Stack>
<Button
variant="contained"
startIcon={<LogoutIcon />}
onClick={handleLogout}
sx={composeButtonSx("ghost")}
>
Déconnexion
</Button>
</Stack>
<Box
className="glass-panel hero-panel"
sx={{
borderRadius: 4,
p: { xs: 3, md: 4 },
mb: 4,
position: "relative",
overflow: "hidden",
}}
>
<Stack
direction={{ xs: "column", md: "row" }}
spacing={{ xs: 3, md: 4 }}
alignItems={{ xs: "flex-start", md: "center" }}
justifyContent="space-between"
>
<Box sx={{ flex: 1 }}>
<Typography
variant="h5"
sx={{
color: "#0b1a3d",
fontWeight: 700,
mb: 1,
}}
>
Priorités du jour
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.7)", lineHeight: 1.6 }}
>
Suivez la progression des mises en ligne, synchronisez Moodle
et gardez un œil sur vos contenus essentiels.
</Typography>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={1}
sx={{ mt: 2, flexWrap: "wrap" }}
>
<Chip
icon={<AutoStoriesIcon fontSize="small" />}
label="Contenus pédagogiques à jour"
sx={{
bgcolor: "rgba(123,192,255,0.16)",
color: "#0b1a3d",
fontWeight: 600,
}}
/>
<Chip
icon={<PublicIcon fontSize="small" />}
label="Octopusdesign.fr en production"
sx={{
bgcolor: "rgba(255,214,150,0.18)",
color: "#0b1a3d",
fontWeight: 600,
}}
/>
</Stack>
</Box>
<Divider
orientation="vertical"
flexItem
sx={{
display: { xs: "none", md: "block" },
borderColor: "rgba(11, 26, 61, 0.08)",
}}
/>
<Box
sx={{
flexBasis: { xs: "100%", md: "40%" },
display: "flex",
flexDirection: "column",
gap: 1.5,
}}
>
<Typography
variant="subtitle2"
sx={{ color: "rgba(11, 26, 61, 0.65)", fontWeight: 700 }}
>
Actions rapides
</Typography>
<Stack spacing={1.2}>
{QUICK_ACTIONS.map((action) => (
<Button
key={action.label}
variant="contained"
onClick={action.onClick}
endIcon={<ArrowForwardIcon fontSize="small" />}
sx={{
...composeButtonSx("subtle", "large"),
justifyContent: "space-between",
width: "100%",
}}
>
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "flex-start" }}>
<Typography variant="body1" sx={{ fontWeight: 700 }}>
{action.label}
</Typography>
<Typography
variant="caption"
sx={{ color: "rgba(49,83,151,0.7)", fontWeight: 500 }}
>
{action.hint}
</Typography>
</Box>
</Button>
))}
</Stack>
</Box>
</Stack>
</Box>
<Grid container spacing={2.5} sx={{ mb: 4 }}>
{KPI_CARDS.map((card) => (
<Grid item xs={12} sm={4} key={card.label}>
<Card
className="glass-subcard"
sx={{
borderRadius: 3,
p: 2.5,
boxShadow: "0 28px 34px -30px rgba(12, 29, 74, 0.52)",
}}
>
<Typography
variant="caption"
sx={{ color: "rgba(11, 26, 61, 0.6)", fontWeight: 600 }}
>
{card.label.toUpperCase()}
</Typography>
<Typography
variant="h4"
sx={{
color: "#0b1a3d",
fontWeight: 800,
letterSpacing: "-0.5px",
}}
>
{card.value}
</Typography>
<Typography
variant="body2"
sx={{ color: "rgba(11, 26, 61, 0.65)", mt: 0.5 }}
>
{card.delta}
</Typography>
</Card>
</Grid>
))}
</Grid>
<Typography
variant="h6"
sx={{ color: "#ffffff", fontWeight: 700, mb: 2 }}
>
Espaces de gestion
</Typography>
<Grid container spacing={2.5}>
{NAV_ITEMS.map((item) => (
<Grid item xs={12} md={6} key={item.title}>
<Card
onClick={() => navigate(item.navigateTo)}
className="glass-subcard"
sx={{
cursor: "pointer",
borderRadius: 3,
overflow: "hidden",
position: "relative",
transition:
"transform 0.3s ease, box-shadow 0.3s ease, border-color 0.3s ease",
"&::before": {
content: '""',
position: "absolute",
inset: 0,
borderRadius: "inherit",
background: `radial-gradient(360px at 18% 24%, ${item.accent}, transparent 72%)`,
opacity: 0.9,
},
"&:hover": {
transform: "translateY(-8px)",
borderColor: "rgba(49,83,151,0.35)",
boxShadow: "0 38px 40px -30px rgba(12, 29, 74, 0.6)",
},
}}
>
<CardContent
sx={{
position: "relative",
zIndex: 1,
display: "flex",
flexDirection: "column",
gap: 1.5,
p: { xs: 3, md: 3.5 },
}}
>
<Box
sx={{
width: 52,
height: 52,
borderRadius: 2,
backgroundColor: "rgba(255,255,255,0.75)",
display: "flex",
alignItems: "center",
justifyContent: "center",
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>
))}
</Grid>
</Box>
</Box>
);
}
export default Dashboard;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,566 @@
// 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(/&nbsp;/gi, " ").trim();
const previewExcerpt = (value = "", limit = 160) => {
const clean = stripHtml(value);
return clean.length > limit ? `${clean.slice(0, limit)}` : clean;
};
const formatDate = (value) => {
if (!value) {
return "Date inconnue";
}
try {
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
+112 -49
View File
@@ -1,12 +1,21 @@
import React, { useState, useEffect } from "react";
import { useState, useEffect } from "react";
import Hero from "../Hero";
import SEO from "../SEO";
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/logo-in3-mobil.svg";
import {
Box,
Grid,
Typography,
Button,
Card,
CardContent,
} from "@mui/material";
import Logo from "../../assets/centre-formation-logo.avif";
import Testi from "../Testi";
import "../../assets/style.css";
const Home = () => {
const [pageData, setPageData] = useState(null);
@@ -20,14 +29,23 @@ const Home = () => {
setIsLoading(true);
setError(null);
const response = await api.get("wp/v2/pages/13?_fields=acf,rank_math_title,rank_math_description");
// 🔥 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 pageContent = response.data;
setPageData(pageContent);
// 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}`);
// 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()}`
);
setHeroImage(mediaResponse.data.source_url);
} else {
setHeroImage(null);
@@ -61,17 +79,26 @@ const Home = () => {
);
}
const { acf, rank_math_title, rank_math_description } = pageData || {};
const { acf, rank_math_title, rank_math_description, link } = pageData || {};
const fallbackUrl =
typeof window !== "undefined" ? window.location.href : "";
const pageUrl = link || fallbackUrl;
return (
<div>
{/* SEO */}
<SEO
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"}
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"
}
/>
{/* Section Héros */}
@@ -86,18 +113,22 @@ 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_items || []}
missionItems={acf?.mission || []}
/>
{/* Section Expertises */}
<Expertises
expertiseTitle={acf?.titre_expertise || "Pourquoi Nous Choisir ?"}
expertiseTitle={
acf?.titre_expertise.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"}
@@ -107,41 +138,26 @@ 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" },
}}
>
Ce que disent nos clients
</Typography>
<Grid container spacing={4}>
<Grid item xs={12} md={6}>
<Card sx={{ boxShadow: 3, padding: 2, backgroundColor: "#f9f9f9" }}>
<CardContent>
<Typography variant="body1" sx={{ fontStyle: "italic" }}>
"Un service exceptionnel, une équipe formidable !"
</Typography>
<Typography variant="body2" sx={{ mt: 2, textAlign: "right", fontWeight: "bold" }}>
- Client 1
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={6}>
<Card sx={{ boxShadow: 3, padding: 2, backgroundColor: "#f9f9f9" }}>
<CardContent>
<Typography variant="body1" sx={{ fontStyle: "italic" }}>
"Des résultats impressionnants pour notre projet."
</Typography>
<Typography variant="body2" sx={{ mt: 2, textAlign: "right", fontWeight: "bold" }}>
- Client 2
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
</Box>
<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">
@@ -157,10 +173,57 @@ const Home = () => {
}}
href="/contact"
>
Contactez-nous dès aujourd'hui
Contactez-nous
</Button>
</Box>
</Box>
{/* 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" },
}}
>
Nos partenaires
</Typography>
<Grid container spacing={4}>
<Grid item xs={12} md={6}>
<Card sx={{ boxShadow: 3, padding: 2, backgroundColor: "#f9f9f9" }}>
<CardContent>
<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>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={6}>
<Card sx={{ boxShadow: 3, padding: 2, backgroundColor: "#f9f9f9" }}>
<CardContent>
<Typography variant="body1" sx={{ fontStyle: "italic" }}>
Des résultats impressionnants pour notre projet.
</Typography>
<Typography
variant="body2"
sx={{ mt: 2, textAlign: "right", fontWeight: "bold" }}
>
- Client 2
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
</Box>
</div>
);
};
+271
View File
@@ -0,0 +1,271 @@
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,
TextField,
Typography,
Stack,
CircularProgress,
Chip,
} from "@mui/material";
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
import "../../assets/styleCours.css";
function TestLogin() {
const [username, setUsername] = useState("");
const [appPassword, setAppPassword] = useState("");
const [token, setToken] = useState(getToken());
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const navigate = useNavigate(); // Hook pour la navigation
const handleLogin = async (e) => {
e.preventDefault();
setError("");
setLoading(true);
const newToken = await loginWithAppPassword(username, appPassword);
if (newToken) {
setToken(newToken);
} else {
setError("Échec de la connexion. Vérifiez vos identifiants.");
}
setLoading(false);
};
const handleLogout = () => {
logout();
setToken(null);
};
const composeButtonSx = (variant = "primary") => ({
borderRadius: 999,
textTransform: "none",
fontWeight: 600,
letterSpacing: 0.3,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: 0.75,
transition:
"transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease, border-color 0.2s ease",
px: 3,
py: 0.85,
minHeight: 42,
...(variant === "primary"
? {
background: "linear-gradient(135deg, #315397, #7bc0ff)",
color: "#ffffff",
border: "1px solid rgba(255,255,255,0.28)",
boxShadow: "0 18px 28px -18px rgba(11,26,61,0.55)",
"&:hover": {
background: "linear-gradient(135deg, #26467d, #6fb6ff)",
boxShadow: "0 22px 32px -18px rgba(11,26,61,0.62)",
},
}
: {
background: "rgba(255,255,255,0.84)",
color: "#315397",
border: "1px solid rgba(49,83,151,0.26)",
"&:hover": {
background: "rgba(255,255,255,0.95)",
borderColor: "rgba(49,83,151,0.46)",
boxShadow: "0 14px 22px -18px rgba(11,26,61,0.4)",
},
}),
});
return (
<Box
className="glass-dashboard"
sx={{
minHeight: "100vh",
position: "relative",
px: { xs: 2, md: 6 },
pt: { xs: 8, md: 12 },
pb: { xs: 8, md: 10 },
}}
>
<Box className="glass-orb orb-1" />
<Box className="glass-orb orb-2" />
<Box className="glass-orb orb-3" />
<Grid
container
spacing={4}
alignItems="center"
justifyContent="center"
sx={{ position: "relative", zIndex: 1, maxWidth: 1180, mx: "auto" }}
>
<Grid item xs={12} md={6}>
<Stack spacing={2.5}>
<Typography
variant="overline"
sx={{
letterSpacing: 2,
fontWeight: 700,
color: "rgba(255,255,255,0.8)",
}}
>
Octopus Admin
</Typography>
<Typography
variant="h3"
sx={{
color: "#ffffff",
fontWeight: 800,
letterSpacing: "-0.5px",
lineHeight: 1.1,
}}
>
Accédez à votre espace sécurisé
</Typography>
<Typography
variant="body1"
sx={{
color: "rgba(255,255,255,0.85)",
lineHeight: 1.6,
maxWidth: 420,
}}
>
Connectez-vous avec vos identifiants dapplication pour gérer les
contenus Octopus : articles, pages services, formations et plus
encore.
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap">
<Chip
label="Authentification sécurisée"
sx={{
bgcolor: "rgba(255,255,255,0.18)",
color: "#ffffff",
fontWeight: 600,
}}
/>
<Chip
label="Accès réservé Octopus Team"
sx={{
bgcolor: "rgba(123,192,255,0.18)",
color: "#ffffff",
fontWeight: 600,
}}
/>
</Stack>
</Stack>
</Grid>
<Grid item xs={12} md={6}>
<Box
className="glass-subcard"
sx={{
borderRadius: 4,
p: { xs: 3, md: 4 },
boxShadow: "0 32px 46px -30px rgba(12, 29, 74, 0.5)",
}}
>
<Stack spacing={3} alignItems="center">
<Box
sx={{
width: 72,
height: 72,
borderRadius: "50%",
bgcolor: "rgba(49,83,151,0.14)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#315397",
boxShadow: "0 18px 28px -20px rgba(12, 29, 74, 0.45)",
}}
>
<LockOutlinedIcon fontSize="large" />
</Box>
<Typography
variant="h5"
sx={{ fontWeight: 700, color: "#0b1a3d" }}
>
{token ? "Bienvenue dans lespace 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%" }}
>
<Stack spacing={2}>
<TextField
label="Nom dutilisateur"
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 dapplication"
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>
</Box>
);
}
export default TestLogin;
@@ -0,0 +1,35 @@
.notfound-container {
text-align: center;
padding: 50px;
}
.notfound-container h1 {
font-size: 100px;
color: red;
margin-bottom: 10px;
}
.notfound-container h2 {
font-size: 30px;
margin-bottom: 10px;
}
.notfound-container p {
font-size: 18px;
margin-bottom: 20px;
}
.back-home {
display: inline-block;
padding: 10px 20px;
background-color: #007BFF;
color: white;
text-decoration: none;
border-radius: 5px;
font-size: 18px;
transition: background 0.3s ease;
}
.back-home:hover {
background-color: #0056b3;
}
@@ -0,0 +1,16 @@
import React from "react";
import { Link } from "react-router-dom";
import "./NotFound.css"; // Style dédié
const NotFound = () => {
return (
<div className="notfound-container">
<h1>404</h1>
<h2>Oups ! Page introuvable</h2>
<p>Il semble que la page que vous recherchez n'existe pas.</p>
<Link to="/" className="back-home">Retour à l'accueil</Link>
</div>
);
};
export default NotFound;
+77 -40
View File
@@ -1,57 +1,94 @@
import React, { useEffect, useState } from 'react';
import { Grid, Card, CardContent, CardMedia, Typography, Box } from '@mui/material';
import api from '../../api'; // Adaptez le chemin vers l'instance Axios
import { 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";
const Post = () => {
const Posts = () => {
const [posts, setPosts] = useState([]);
const navigate = useNavigate();
useEffect(() => {
api.get('wp/v2/posts?_embed')
.then((response) => {
setPosts(response.data);
const fetchPosts = async () => {
try {
const response = await api.get("wp/v2/posts?_fields=id,slug,title,excerpt,content,featured_media");
const postsData = response.data;
// Récupérer les images en vedette
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 };
} catch (error) {
console.error(`Erreur lors de la récupération de l'image pour l'article ${post.id}:`, error);
return { ...post, image: null };
}
}
return { ...post, image: null };
})
.catch((error) => {
console.error('Erreur lors de la récupération des articles :', error);
});
);
setPosts(postsWithImages);
} catch (error) {
console.error("Erreur lors de la récupération des articles :", error);
}
};
fetchPosts();
}, []);
return (
// Fonction pour nettoyer le HTML et récupérer un texte brut
const stripHtmlTags = (html) => {
return html.replace(/(<([^>]+)>)/gi, ""); // Supprime toutes les balises HTML
};
<Box sx={{ padding: 4 }}>
<Typography variant="h3" sx={{ marginBottom: 4, textAlign: 'center' }}>
Nos réalisations
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(/&nbsp;/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>
<Grid container spacing={4}>
<Grid container spacing={4} justifyContent="center">
{posts.map((post) => (
<Grid item xs={12} sm={6} md={4} key={post.id}>
<Card sx={{ maxWidth: 345,
boxShadow: 3,
transition: 'transform 0.3s ease, box-shadow 0.3s ease',
'&:hover': {
transform: 'scale(1.05)',
boxShadow: 6,
},
}}>
{/* Image de l'article */}
{post._embedded?.['wp:featuredmedia']?.[0]?.source_url && (
<CardMedia
component="img"
height="200"
image={post._embedded['wp:featuredmedia'][0].source_url}
alt={post.title.rendered}
/>
<Card
sx={{
cursor: "pointer",
"&:hover": { backgroundColor: "#0e467f", color: "#ffffff", transform: "scale(1.05)", boxShadow: 6 },
}}
onClick={() => navigate(`/post/${post.slug}`)}
>
{post.image && (
<CardMedia component="img" height="200" image={post.image} alt={post.title.rendered} />
)}
<CardContent>
<Typography variant="h5" component="div" gutterBottom>
<Typography variant="h2" sx={{ fontWeight: "bold", fontSize: "1.5em", color:"#315397"}}>
{post.title.rendered}
</Typography>
<Typography
variant="body2"
color="text.secondary"
dangerouslySetInnerHTML={{ __html: post.excerpt.rendered }}
/>
<Typography variant="body2" sx={{ mt: 1 }}>
{truncateText(post.excerpt.rendered || post.content.rendered, 100)}
</Typography>
<Button variant="outlined" sx={{ mt: 2, color: "white", borderColor: "white" }}>
Lire plus
</Button>
</CardContent>
</Card>
</Grid>
@@ -61,4 +98,4 @@ const Post = () => {
);
};
export default Post;
export default Posts;
@@ -0,0 +1,89 @@
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);
}
}
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>
);
}
export default PostList;
@@ -0,0 +1,111 @@
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;
+90 -32
View File
@@ -1,51 +1,109 @@
import React from 'react';
import ServicePageTemplate from '../ServicePageTemplate';
import React, { useState, useEffect } from "react";
import ServicePageTemplate from "../ServicePageTemplate";
import { getPageById } from "../../wordpress";
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 = {
title: 'Formation IA',
subtitle: 'Dominez lintelligence artificielle.',
// 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
description:
'Plongez dans le futur! Notre formation IA vous prépare aux outils modernes dintelligence artificielle comme Python, TensorFlow et OpenAI API. Apprenez à créer des modèles IA performants.',
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é à limage 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
features: [
{
title: 'Fondamentaux de lIA',
description: 'Concepts, algorithmes et applications.',
modalText: 'Découvrez les bases de lIA, des algorithmes aux applications concrètes dans divers domaines.',
modalImage: 'https://picsum.photos/id/200/800/400.webp',
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: 'Python pour lIA',
description: 'Bibliothèques populaires: TensorFlow, PyTorch.',
modalText: 'Apprenez à utiliser Python et ses bibliothèques pour concevoir des modèles dIA puissants.',
modalImage: 'https://picsum.photos/id/201/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: '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',
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",
},
],
image: 'https://picsum.photos/id/239/1920/1080.webp',
ctaText: 'Découvrir la formation IA',
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: "Structure beton",
description: "Un projet e-commerce performant et moderne.",
image: "https://picsum.photos/id/522/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: "Structure bois",
description: "Montrez vos compétences avec un portfolio sur mesure.",
image: "https://picsum.photos/id/521/800/400.webp",
},
],
};
@@ -0,0 +1,141 @@
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;
+48 -30
View File
@@ -1,52 +1,70 @@
import React from 'react';
import ServicePageTemplate from '../ServicePageTemplate';
import React from "react";
import ServicePageTemplate from "../ServicePageTemplate";
const ServiceTrois = () => {
const serviceDetails = {
title: 'Électricité',
subtitle: 'IN3 est un bureau d’études technique spécialisé dans lingénierie électrique du bâtiment.',
description:
'Plongez dans le futur! Notre formation IA vous prépare aux outils modernes dintelligence artificielle comme Python, TensorFlow et OpenAI API. Apprenez à créer des modèles IA performants.',
// Hero
title: "Électricité", // Modifiable individuellement
subtitle: "IN3 est un bureau d’études technique spécialisé dans lingé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 douvrage ?",
description: "Notre activité est lassistance aux maîtres douvrage à 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 dimplantation du matériel et des canalisations schémas électriques unifilaires.\n Analyse des offres et assistance technique au choix de lentreprise d’électricité.",
// Section 2
desirTitle: "Réalisation et assistance électrique",
features: [
{
title: 'Chantier électricité industriel',
description: 'Réalisation dinstallations électriques.',
modalText: 'Réalisation dinstallations é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 dinstallations électriques.",
modalText: "Réalisation dinstallations é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: 'Python pour lIA',
description: 'Bibliothèques populaires: TensorFlow, PyTorch.',
modalText: 'Apprenez à utiliser Python et ses bibliothèques pour concevoir des modèles dIA puissants.',
modalImage: 'https://picsum.photos/id/201/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: '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',
title: "L'électricité & Suivi de chantier",
description: "Assistance à lentreprise et vérification des documents",
modalText: "Assistance à lentreprise et vérification des documents dexécution. Réception des ouvrages exécutés. Nous pouvons également assister lentreprise titulaire du marché à la réalisation des documents dexécution (plans dimplantation, schématique électrique, notes de calculs réglementaires, …)",
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} />;
+53 -26
View File
@@ -1,47 +1,74 @@
import React from 'react';
import ServicePageTemplate from '../ServicePageTemplate';
import React from "react";
import ServicePageTemplate from "../ServicePageTemplate";
const ServiceUn = () => {
const serviceDetails = {
title: 'Prestation-maitrise-oeuvre',
subtitle: 'Apprenez à maîtriser les technologies du web.',
// 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 louvrage, de diriger lexé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
description:
'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.',
"Véritable bras droit du maître douvrage, nous lui proposons une solution technique et esthétique qui permet de réaliser son programme, dans lenveloppe budgétaire et les délais qui lui sont assignés. Une fois son projet validé par le maître douvrage, 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 lentrepreneur (ou des entrepreneurs) se fait à partir dune consultation formalisée où, sur la base dun cahier des charges (notamment le Cahier des Clauses Techniques Particulières), le titulaire faisant loffre la plus adaptée, est choisi par le maître douvrage 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
features: [
{
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: "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: '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: "Les Études dEsquisse \n (ESQ)",
description: "Comprendre leur Importance dans un Projet Architectural",
modalText:
"Lobjectif principal de cette phase est de poser les bases du projet en définissant les grandes lignes de limplantation, de lorganisation des espaces et de lesthétique du bâtiment. Cela inclut : Lanalyse du site : étude de lenvironnement, des accès, de lorientation et des contraintes liées au terrain. L’évaluation des besoins du maître douvrage : 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 ladéquation entre les ambitions du projet et le budget disponible.",
modalImage: "https://picsum.photos/id/301/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',
title: "Pourquoi réaliser une étude davant-projet ?",
description: "Les Études dAvant-Projet (AVP)...",
modalText:
"LAVP a pour objectif de définir les grandes lignes du projet en sappuyant 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",
},
],
image: 'https://picsum.photos/id/1018/1920/1080.webp',
ctaText: 'En savoir plus',
ctaLink: '/contact',
carouselItems: [
{
title: 'Portfolio Modern',
description: 'Montrez vos compétences avec un portfolio unique.',
image: 'https://picsum.photos/id/305/800/400.webp',
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: 'Blog SEO',
description: 'Optimisez vos articles pour le référencement.',
image: 'https://picsum.photos/id/306/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",
},
],
// 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} />;
+229
View File
@@ -0,0 +1,229 @@
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 dautomatisation 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;
+91
View File
@@ -0,0 +1,91 @@
import { 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 [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`);
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 !");
}
} catch (error) {
console.error("❌ Erreur lors de la récupération de l'article :", error);
}
};
fetchPost();
}, [slug]);
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" }}>
Retour
</Button>
<Typography
variant="h1"
sx={{
fontWeight: "bold",
textAlign: "center",
mb: 4,
fontSize: { xs: "3rem", md: "3rem", lg: "3rem" },
}}
>
{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={{
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 }}
/>
</Box>
</Box>
);
};
export default PostDetails;
+83
View File
@@ -0,0 +1,83 @@
import { useState } from "react";
import { uploadImage, createPost } from "../wordpress";
function PostForm() {
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [file, setFile] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async (e) => {
e.preventDefault();
setIsLoading(true);
setError("");
// 1 Vérification des champs
if (!title.trim() || !content.trim() || !file) {
setError("Tous les champs sont requis !");
setIsLoading(false);
return;
}
try {
// 2 Upload de limage
const mediaId = await uploadImage(file);
if (!mediaId) {
setError("Échec de l'upload de l'image !");
setIsLoading(false);
return;
}
// 3 Création de larticle
const success = await createPost(title, content, mediaId);
if (success) {
alert("Article publié avec succès !");
setTitle("");
setContent("");
setFile(null);
} else {
setError("Échec de la publication !");
}
} catch (err) {
console.error("❌ Erreur lors du traitement :", err);
setError("Une erreur est survenue, veuillez réessayer.");
}
setIsLoading(false);
};
return (
<div>
<h2>Créer un nouvel article</h2>
{error && <p style={{ color: "red" }}>{error}</p>}
<form onSubmit={handleSubmit}>
<input
type="text"
placeholder="Titre"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
/>
<textarea
placeholder="Contenu"
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" disabled={isLoading}>
{isLoading ? "Publication..." : "Publier"}
</button>
</form>
</div>
);
}
export default PostForm;
+246 -42
View File
@@ -1,71 +1,275 @@
import React, { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Helmet } from "react-helmet-async";
import axios from "axios";
import api from "../api";
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 = ({
postId,
pageUrl,
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"
defaultOgImage = DEFAULT_OG_IMAGE,
}) => {
const [rankMathHead, setRankMathHead] = useState(null);
const [shouldFallback, setShouldFallback] = useState(true);
const [metaData, setMetaData] = useState({
title: defaultTitle,
description: defaultDescription,
canonicalUrl: defaultCanonicalUrl,
ogTitle: defaultTitle,
ogDescription: defaultDescription,
ogImage: defaultOgImage,
});
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;
useEffect(() => {
if (!postId) return;
if (!rankMathTargetUrl) return;
const fetchMetaData = async () => {
const controller = new AbortController();
const fetchRankMathHead = async () => {
try {
const { data } = await axios.get(
`https://preprod.octopusdesign.fr/api-octopus/server/wp-json/wp/v2/pages/${postId}`
);
const { data } = await api.get("rankmath/v1/getHead", {
params: { url: rankMathTargetUrl },
signal: controller.signal,
});
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
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,
}));
setRankMathHead({
title: headElement.querySelector("title")?.textContent || "",
meta: metaTags,
links: linkTags,
scripts: scriptTags,
});
setShouldFallback(false);
} else {
setRankMathHead(null);
setShouldFallback(true);
}
} catch (error) {
console.error("⚠️ Erreur lors de la récupération des métadonnées SEO :", 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);
}
};
fetchMetaData();
}, [postId]);
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]);
return (
<Helmet>
{/* SEO standard */}
<title>{metaData.title}</title>
<meta name="description" content={metaData.description} />
{metaData.canonicalUrl && <link rel="canonical" href={metaData.canonicalUrl} />}
{!shouldFallback && rankMathNodes}
{shouldFallback && (
<>
<title>{defaultTitle}</title>
<meta name="description" content={defaultDescription} />
<meta name="robots" content={DEFAULT_ROBOTS} />
{resolvedCanonical && (
<link rel="canonical" href={resolvedCanonical} />
)}
{/* 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} />
<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} />
)}
{/* 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} />
<meta name="twitter:title" content={defaultTitle} />
<meta name="twitter:description" content={defaultDescription} />
<meta name="twitter:image" content={defaultOgImage} />
</>
)}
</Helmet>
);
};
+254 -125
View File
@@ -1,4 +1,4 @@
import React, { useState } from "react";
import { useState, useMemo, isValidElement, useEffect } from "react";
import {
Box,
Typography,
@@ -9,32 +9,123 @@ 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({
title: feature.title,
text: feature.modalText || "Texte non disponible.",
image: feature.modalImage || "https://picsum.photos/id/43/800/400.webp", // Image par défaut
});
setModalContent(buildModalPayload(feature));
setLoadingImage(true);
setOpenModal(true);
};
@@ -50,41 +141,64 @@ const ServicePageTemplate = ({
fontFamily: "Arial, sans-serif",
}}
>
{/* Section Attention */}
{/* ✅ SEO META TAGS */}
<Helmet>
<title>{seo?.metaTitle || title}</title>
<meta name="description" content={seo?.metaDescription || ""} />
<meta name="keywords" content={seo?.keywords || ""} />
<meta property="og:title" content={seo?.metaTitle || title} />
<meta property="og:description" content={seo?.metaDescription || ""} />
<meta property="og:image" content={seo?.ogImage || image} />
<meta property="og:type" content="website" />
<meta name="twitter:title" content={seo?.metaTitle || title} />
<meta name="twitter:description" content={seo?.metaDescription || ""} />
<meta name="twitter:image" content={seo?.ogImage || image} />
<meta name="twitter:card" content="summary_large_image" />
</Helmet>
{/* Section Hero */}
<Box
sx={{
backgroundImage: `url(${image})`,
backgroundSize: "cover",
backgroundPosition: "center",
mt: 5,
backgroundImage: heroBackgroundStyle,
backgroundSize: heroLoaded && image ? "cover, cover" : "cover",
backgroundPosition: heroLoaded && image ? "center, center" : "center",
minHeight: "70vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
color: "white",
color: "black",
padding: "20px",
backgroundColor: heroLoaded ? undefined : "#020712",
transition: "background-image 0.8s ease, opacity 0.4s ease",
}}
>
<Box
sx={{
maxWidth: "800px",
backgroundColor: "rgba(255, 255, 255, 0.39)",
p: 5,
borderRadius: 5,
}}
>
<Box sx={{ maxWidth: "800px" }}>
<Typography
variant="h1"
sx={{
fontWeight: "bold",
mb: 2,
fontSize: { xs: '2rem', md: '3rem', lg: '4rem' }, // Responsive
fontSize: {
xs: "2rem",
md: "3rem",
lg: "3rem",
whiteSpace: "pre-line",
},
}}
>
{title}
</Typography>
<Typography
variant="body1"
sx={{
mb: 4
}}
>
{subtitle}
<Typography variant="body1" component="div" sx={{ mb: 4 }}>
{renderRichText(subtitle) ?? subtitle ?? ""}
</Typography>
<Button
href={ctaLink}
@@ -94,7 +208,7 @@ const ServicePageTemplate = ({
sx={{
textTransform: "none",
fontWeight: "bold",
backgroundColor: " #0e467f",
backgroundColor: "#0e467f",
"&:hover": { backgroundColor: "#00bcd4" },
}}
>
@@ -103,27 +217,73 @@ const ServicePageTemplate = ({
</Box>
</Box>
{/* Section Interest */}
<Box sx={{ padding: "40px 20px", textAlign: "center" }}>
<Typography variant="h2"
{/* Section numero 1 */}
<Box sx={{ fontWeight: "5", textAlign: "center" }}>
<Typography
variant="h2"
sx={{
fontWeight: "bold",
mb: 2,
fontSize: { xs: '2rem', md: '3rem', lg: '3rem' }, // Responsive
mb: 7,
fontSize: { xs: "2rem", md: "2rem", lg: "2rem" },
whiteSpace: "pre-line",
}}
>
Pourquoi choisir notre formation ?
{interestTitle}
</Typography>
{descriptionIsElement ? (
<Typography variant="body1" component="div">
{renderRichText(description)}
</Typography>
) : (
<Box
sx={{
display: "flex",
flexDirection: { xs: "column", md: "row" },
gap: 4,
alignItems: "center",
justifyContent: "center",
textAlign: { xs: "center", md: "left" },
}}
>
{descriptionMedia.media?.src && (
<Box
component="img"
src={descriptionMedia.media.src}
alt={descriptionMedia.media.alt || "Illustration"}
sx={{
width: { xs: "100%", md: "42%" },
borderRadius: 4,
objectFit: "cover",
boxShadow: "0 25px 45px rgba(0,0,0,0.25)",
mb: { xs: 2, md: 5 },
mx: { xs: "auto", md: 0 },
marginLeft: { xs: 2, md: 6 } ,
}}
/>
)}
<Typography
variant="body1"
sx={{ maxWidth: "800px", margin: "0 auto", mb: 4 }}
>
{description}
</Typography>
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 Desire */}
{/* Section numero 2 */}
<Box sx={{ backgroundColor: "#ffffff", padding: "40px 20px" }}>
<Typography
variant="h2"
@@ -131,41 +291,25 @@ const ServicePageTemplate = ({
fontWeight: "bold",
textAlign: "center",
mb: 4,
fontSize: { xs: '2rem', md: '3rem', lg: '3rem' }, // Responsive
fontSize: { xs: "2rem", md: "3rem", lg: "2rem" },
}}
>
Ce que nous offrons
{desirTitle}
</Typography>
<Grid container spacing={4} justifyContent="center">
{features.map((feature, index) => (
<Grid item xs={12} md={4} key={index}>
<Card
onClick={() => handleCardClick(feature)}
sx={{
boxShadow: 2,
borderRadius: 2,
cursor: "pointer",
textAlign: "center",
padding: "20px",
backgroundColor: "#f9f9f9",
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,
},
transition: "0.3s",
"&:hover": { transform: "scale(1.05)", boxShadow: 5 },
}}
onClick={() => handleCardClick(feature)}
>
<CardContent>
<Typography
@@ -174,12 +318,13 @@ const ServicePageTemplate = ({
fontWeight: "bold",
mb: 2,
fontSize: 26,
whiteSpace: "pre-line",
}}
>
{feature.title}
</Typography>
<Typography variant="body2" sx={{ color: "#555" }}>
{feature.description}
{getPreviewText(feature)}
</Typography>
</CardContent>
</Card>
@@ -194,9 +339,7 @@ const ServicePageTemplate = ({
onClose={handleCloseModal}
closeAfterTransition
BackdropComponent={Backdrop}
BackdropProps={{
timeout: 500,
}}
BackdropProps={{ timeout: 500 }}
>
<Fade in={openModal}>
<Box
@@ -208,50 +351,39 @@ const ServicePageTemplate = ({
width: "90%",
maxWidth: "600px",
p: 4,
display: "flex",
flexDirection: "column",
alignItems: "center",
textAlign: "center",
background: "rgb(255, 255, 255)",
boxShadow: "0 4px 30px rgba(0, 0, 0, 0.1)",
backdropFilter: "blur(10px)",
background: "white",
borderRadius: 2,
border: "1px solid rgba(255, 255, 255, 0.3)",
color: "rgb(0, 0, 0)",
boxShadow: 3,
}}
>
{loadingImage && <CircularProgress sx={{ mb: 2 }} />}
<img
src={modalContent.image}
alt={modalContent.title}
style={{
width: "100%",
borderRadius: "8px",
marginBottom: "20px",
display: loadingImage ? "none" : "block",
}}
onLoad={() => setLoadingImage(false)}
/>
<Typography variant="h3" sx={{ fontWeight: "bold", mb: 2 }}>
{modalContent.title}
</Typography>
<Typography variant="body1" sx={{ mb: 4 }}>
{modalContent.text}
<Typography variant="body1" sx={{ mb: 4 }} dangerouslySetInnerHTML={{ __html: modalContent.text }} >
</Typography>
<Button
onClick={handleCloseModal}
variant="contained"
color="secondary"
sx={{
textTransform: "none",
fontWeight: "bold",
backgroundColor: "#f44336",
"&:hover": { backgroundColor: "#d32f2f" },
}}
>
Quitter
Retour
</Button>
</Box>
</Fade>
</Modal>
{/* Carousel Section */}
{carouselItems && carouselItems.length > 0 && (
<Box
@@ -261,11 +393,11 @@ const ServicePageTemplate = ({
backgroundColor: "#f9f9f9",
}}
>
<Typography variant="h2"
<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
@@ -302,10 +434,11 @@ const ServicePageTemplate = ({
}}
/>
<CardContent>
<Typography variant="h3"
<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}
@@ -318,48 +451,44 @@ 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 aujourdhui 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;
+57
View File
@@ -0,0 +1,57 @@
import { useState } from "react";
import { loginWithAppPassword, getToken, logout } from "../auth";
function TestLogin() {
const [username, setUsername] = useState("");
const [appPassword, setAppPassword] = useState("");
const [token, setToken] = useState(getToken());
const handleLogin = async (e) => {
e.preventDefault();
const newToken = await loginWithAppPassword(username, appPassword);
if (newToken) {
setToken(newToken);
alert("Connexion réussie !");
} else {
alert("Échec de la connexion !");
}
};
const handleLogout = () => {
logout();
setToken(null);
alert("Déconnexion réussie !");
};
return (
<div>
<h2>Connexion</h2>
{token ? (
<div>
<p> Connecté avec le token : {token}</p>
<button onClick={handleLogout}>Déconnexion</button>
</div>
) : (
<form onSubmit={handleLogin}>
<input
type="text"
placeholder="Nom d'utilisateur"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
<input
type="text"
placeholder="Mot de passe d'application"
value={appPassword}
onChange={(e) => setAppPassword(e.target.value)}
required
/>
<button type="submit">Se connecter</button>
</form>
)}
</div>
);
}
export default TestLogin;
+91
View File
@@ -0,0 +1,91 @@
import React from "react";
import { Box, Typography, Grid, Avatar } from "@mui/material";
const TestimonialSection = () => {
return (
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
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"
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,
},
}}
>
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>
</Box>
</Grid>
</Box>
);
};
export default TestimonialSection;
@@ -0,0 +1,119 @@
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;
+5
View File
@@ -0,0 +1,5 @@
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);
+57 -10
View File
@@ -5,22 +5,43 @@ import { ThemeProvider } from "@mui/material/styles";
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 Search = lazy(() => import('./components/Pages/Search')); // Nouveau composant pour la recherche
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"));
// 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 (
@@ -30,13 +51,39 @@ 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="/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/formation-ia" element={<ServiceDeux />} />
<Route path="/services/formation-video" element={<ServiceTrois />} />
<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="*" 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>
+193
View File
@@ -0,0 +1,193 @@
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;
+48
View File
@@ -0,0 +1,48 @@
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, "");
};
+386
View File
@@ -0,0 +1,386 @@
import axios from "axios";
import { getToken } from "./auth"; // 🔥 Import du token pour l'authentification
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
* @param {File} file - Le fichier image à uploader
* @returns {Promise<number>} - L'ID de l'image uploadée
*/
export async function uploadImage(file) {
const token = getToken();
if (!token) {
console.error("❌ Aucun token trouvé !");
throw new Error("Utilisateur non authentifié.");
}
console.log("📢 Token utilisé pour l'upload :", token);
const formData = new FormData();
formData.append("file", file);
formData.append("title", file.name);
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"
}
});
console.log("✅ Image uploadée avec succès :", response.data);
return response.data.id; // ✅ Retourne l'ID de l'image
} catch (error) {
console.error("❌ Erreur d'upload :", 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 limage 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
* @param {string} title - Le titre du post
* @param {string} content - Le contenu du post
* @param {number} imageId - L'ID de l'image à mettre en avant
* @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();
if (!token) {
console.error("❌ Aucun token trouvé !");
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`,
{
objectType: "post", // ✅ Les pages WordPress sont du type "post".
objectID: objectId,
meta: sanitizedMeta,
},
{
headers: {
Authorization: `Basic ${token}`,
"Content-Type": "application/json",
},
}
);
console.log("✅ Métadonnées Rank Math mises à jour :", response.data);
return response.data;
} catch (error) {
console.error("❌ Erreur mise à jour Rank Math :", error.response?.data || error.message);
throw error;
}
}
+4949
View File
File diff suppressed because one or more lines are too long
+21 -23
View File
@@ -4,25 +4,27 @@ 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
// ✅ Compression Brotli & Gzip fusionnées
viteCompression({
algorithm: 'brotliCompress',
algorithm: 'brotliCompress', // Utilisation de Brotli en priorité
ext: '.br',
threshold: 10240, // Fichiers supérieurs à 10 Ko seront compressés
threshold: 10240, // Compression à partir de 10 Ko
}),
// Compression Gzip
viteCompression({
algorithm: 'gzip',
algorithm: 'gzip', // Compression Gzip en fallback
ext: '.gz',
threshold: 10240,
}),
// Visualiseur de bundle
// ✅ Visualisation des bundles (ouvre `stats.html` après le build)
visualizer({
open: true, // Ouvre automatiquement le fichier généré après le build
open: false, // Mets `true` si tu veux ouvrir le fichier automatiquement
filename: 'stats.html',
template: 'treemap',
}),
@@ -30,7 +32,6 @@ export default defineConfig({
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'),
@@ -39,18 +40,17 @@ export default defineConfig({
build: {
chunkSizeWarningLimit: 1000,
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)
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)
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'; // Chunk spécifique à MUI
return 'vendor_mui'; // ✅ Sépare MUI du reste des dépendances
}
return 'vendor'; // Autres dépendances
return 'vendor'; // ✅ Sépare les dépendances globales
}
},
},
@@ -58,18 +58,16 @@ export default defineConfig({
},
server: {
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
port: 3000, // ✅ Serveur sur le port 3000
open: true, // Ouvre le navigateur au démarrage
cors: true, // ✅ Active les requêtes CORS
},
optimizeDeps: {
include: [
'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
'swiper/react',
'swiper/modules',
],
},
});
BIN
View File
Binary file not shown.
Binary file not shown.
+28
View File
@@ -0,0 +1,28 @@
#
# Backend configuration for contact form emails.
# Copy this file to `.env` and fill in your SMTP details.
#
# SMTP server host (e.g. smtp.gmail.com, smtp.mailgun.org, etc.)
SMTP_HOST=mail.sebvtl.com
# SMTP port (465 for SSL, 587 for TLS)
SMTP_PORT=465
# Use "true" if your provider requires SSL (port 465)
# Otherwise leave to "false" for TLS (port 587)
SMTP_SECURE=true
# SMTP credentials
SMTP_USER=contact@sebvtl.com
SMTP_PASS=Lightwave9.0**
# Optional: override the recipient (defaults to sebastien@octopusdesign.fr)
CONTACT_RECIPIENT=sebastien@octopusdesign.fr
# Optional: override the "from" address visible in the email client
CONTACT_FROM=no-reply@octopusdesign.fr
# Optional: add extra allowed origins for CORS (comma separated)
# Example: CORS_ALLOWED_ORIGINS=https://preprod.octopusdesign.fr,https://admin.octopusdesign.fr
CORS_ALLOWED_ORIGINS=
@@ -0,0 +1,36 @@
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;
+123
View File
@@ -0,0 +1,123 @@
import express from "express";
import nodemailer from "nodemailer";
const router = express.Router();
const buildTransporterConfig = () => {
const host = process.env.SMTP_HOST;
const port = Number(process.env.SMTP_PORT || 587);
const secure =
typeof process.env.SMTP_SECURE === "string"
? process.env.SMTP_SECURE === "true"
: port === 465;
const user = process.env.SMTP_USER;
const pass = process.env.SMTP_PASS;
const baseConfig = {
host,
port,
secure,
};
if (user && pass) {
baseConfig.auth = { user, pass };
}
return baseConfig;
};
router.post("/contact", async (req, res) => {
const { name, email, subject, message, consent } = req.body || {};
console.info("📨 Requête contact reçue", {
name,
email,
subject,
consent,
});
if (!name || !email || !subject || !message) {
return res.status(400).json({
message:
"Merci de compléter tous les champs requis avant d'envoyer votre message.",
});
}
const recipient =
process.env.CONTACT_RECIPIENT || "sebastien@octopusdesign.fr";
const fromAddress =
process.env.CONTACT_FROM ||
process.env.SMTP_FROM ||
process.env.SMTP_USER ||
"no-reply@octopusdesign.fr";
try {
const transporter = nodemailer.createTransport(buildTransporterConfig());
const mailSubject = subject.trim()
? `[Site Octopus Design] ${subject.trim()}`
: "Nouveau message depuis le site Octopus Design";
const consentLabel = consent ? "✅ Consentement donné" : "❌ Consentement non fourni";
const textBody = `
Nom : ${name}
E-mail : ${email}
Consentement : ${consentLabel}
Message :
${message}
`;
const htmlBody = `
<p><strong>Nom :</strong> ${name}</p>
<p><strong>E-mail :</strong> ${email}</p>
<p><strong>Consentement :</strong> ${consentLabel}</p>
<p><strong>Message :</strong></p>
<p>${message.replace(/\n/g, "<br />")}</p>
`;
// Vérifie la connexion SMTP avant d'essayer d'envoyer
await transporter.verify();
const mailOptions = {
from: `"Octopus Design" <${fromAddress}>`,
to: recipient,
replyTo: email,
subject: mailSubject,
text: textBody,
html: htmlBody,
};
await transporter.sendMail(mailOptions);
console.info(
"✅ Email de contact envoyé avec succès",
JSON.stringify(
{
to: recipient,
replyTo: email,
subject: mailSubject,
},
null,
2
)
);
return res.status(200).json({
message: "Votre message a bien été envoyé. Merci pour votre confiance !",
});
} catch (error) {
console.error(
"❌ Erreur lors de l'envoi du mail de contact :",
error?.stack || error?.message || error
);
return res.status(500).json({
message:
"Impossible d'envoyer votre message pour le moment. Merci de réessayer plus tard.",
});
}
});
export default router;
+39
View File
@@ -0,0 +1,39 @@
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
View File
@@ -0,0 +1,979 @@
{
"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"
}
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"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"
}
}
@@ -183,3 +183,142 @@ function ajouter_opengraph_meta_api($data, $post, $context) {
return $data;
}
add_filter('rest_prepare_post', 'ajouter_opengraph_meta_api', 10, 3);
// ✅ Active les erreurs PHP pour le debug (désactive après les tests)
define('WP_DEBUG', true);
define('WP_DEBUG_LOG', true);
define('WP_DEBUG_DISPLAY', false);
@ini_set('log_errors', 1);
@ini_set('display_errors', 0);
// ✅ Charger la librairie JWT
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
// ✅ Configurer le secret JWT dans `wp-config.php`
define('JWT_AUTH_SECRET_KEY', 'change_this_secret_key'); // Change ce secret !
// ✅ Fonction pour générer un token JWT
function custom_generate_jwt_token($user_id) {
$issuedAt = time();
$expiration = $issuedAt + (60 * 60 * 24); // Expire en 24h
$payload = [
'iss' => get_bloginfo('url'),
'iat' => $issuedAt,
'exp' => $expiration,
'user_id' => $user_id
];
return JWT::encode($payload, JWT_AUTH_SECRET_KEY, 'HS256');
}
// ✅ Fonction pour vérifier un token JWT
function custom_authenticate_with_jwt($token) {
try {
$decoded = JWT::decode($token, new Key(JWT_AUTH_SECRET_KEY, 'HS256'));
return get_user_by('ID', $decoded->user_id);
} catch (Exception $e) {
return null;
}
}
// ✅ Route API REST pour la connexion
function custom_authenticate_user(WP_REST_Request $request) {
$parameters = $request->get_json_params();
$username = sanitize_text_field($parameters['username']);
$password = sanitize_text_field($parameters['password']);
$user = wp_authenticate($username, $password);
if (is_wp_error($user)) {
return new WP_Error('authentication_failed', 'Identifiants incorrects.', ['status' => 401]);
}
$token = custom_generate_jwt_token($user->ID);
return [
'user_id' => $user->ID,
'token' => $token,
'email' => $user->user_email,
'role' => $user->roles
];
}
// ✅ Enregistrement des routes API
add_action('rest_api_init', function () {
register_rest_route('custom/v1', '/login', [
'methods' => 'POST',
'callback' => 'custom_authenticate_user',
'permission_callback' => '__return_true',
]);
});
// ✅ Autoriser les uploads et publications via l'API REST
function allow_admin_upload_and_post($allcaps, $cap, $args) {
if (in_array($cap[0], ['upload_files', 'edit_posts', 'publish_posts'])) {
$allcaps[$cap[0]] = true;
}
return $allcaps;
}
add_filter('map_meta_cap', 'allow_admin_upload_and_post', 10, 3);
// ✅ Autoriser CORS (utile pour React)
function custom_cors_headers() {
header("Access-Control-Allow-Origin: *");
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);