diff --git a/CONFIGURATION_SITE.md b/CONFIGURATION_SITE.md
index 134aaec..47275b0 100644
--- a/CONFIGURATION_SITE.md
+++ b/CONFIGURATION_SITE.md
@@ -16,6 +16,7 @@ my-next-site/
├── cmsbackend/ # Backend Strapi
├── llm-api/ # API FastAPI pour IA
├── start-my-site.ps1 # Script de démarrage
+├── stop-my-site.ps1 # Script d'arrêt propre
└── package.json # Dépendances frontend
```
@@ -29,6 +30,23 @@ cd J:\my-next-site
Ce script lance automatiquement les 3 services dans des fenêtres PowerShell séparées.
+**Améliorations :**
+- Configuration centralisée via un tableau `$services` (plus de duplication entre les 3 blocs).
+- **Détection du port déjà occupé** : si un service tourne déjà, il n'est pas relancé (évite `EADDRINUSE`).
+- **Portabilité** : chemins résolus via `$PSScriptRoot`, pas de `J:\my-next-site` codé en dur.
+- **`-NoExit`** sur chaque fenêtre : le message d'erreur reste visible si un service crashe au démarrage.
+- **Bilan final** : nombre de services lancés / déjà actifs / échecs.
+
+### Arrêt des services
+```powershell
+cd J:\my-next-site
+.\stop-my-site.ps1
+```
+
+Termine les processus qui écoutent les ports **1337** (Strapi), **3000** (Next.js) et **8000** (FastAPI). Ne touche pas aux autres processus Node de ta machine. Les fenêtres PowerShell lancées par `start-my-site.ps1` restent ouvertes (à fermer manuellement). En cas d'échec sur certains PIDs → relancer dans un PowerShell admin.
+
+> Note encodage : les deux scripts PowerShell sont encodés en **UTF-8 avec BOM**. C'est nécessaire pour que Windows PowerShell 5.1 (version par défaut) les lise correctement avec les emojis et accents. PowerShell 7 n'a pas ce souci mais reste compatible avec le BOM.
+
## 🔧 Commandes Manuelles
### 1. Frontend Next.js
diff --git a/app/admin/messages/page.tsx b/app/admin/messages/page.tsx
deleted file mode 100644
index 7ed4721..0000000
--- a/app/admin/messages/page.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import { getApiUrl } from "../../utils/getApiUrl";
-export default async function MessagesPage() {
- const apiUrl = getApiUrl();
- const res = await fetch(`${apiUrl}/api/messages`);
- const { data } = await res.json();
-
- return (
-
- {/* Titre de la page */}
-
📬 Messages reçus
-
- {data.length === 0 ? (
-
Aucun message reçu.
- ) : (
-
- )}
-
- );
- }
\ No newline at end of file
diff --git a/app/api/contact/route.ts b/app/api/contact/route.ts
new file mode 100644
index 0000000..e84ae0b
--- /dev/null
+++ b/app/api/contact/route.ts
@@ -0,0 +1,291 @@
+/**
+ * Endpoint POST /api/contact — reçoit les messages du formulaire de contact
+ * et envoie une notification par email via l'API HTTP Brevo.
+ *
+ * Flux :
+ * Navigateur ─▶ POST /api/contact (cette route, serveur)
+ * ↓ validation + honeypot + rate-limit
+ * POST https://api.brevo.com/v3/smtp/email
+ * ↓
+ * Gmail : CONTACT_TO_EMAIL
+ *
+ * Aucun stockage côté Strapi (voir docs-site-interne/contact-flow.md pour la
+ * décision d'architecture). Si l'envoi Brevo échoue, on renvoie une erreur 502
+ * au client pour qu'il puisse ré-essayer.
+ *
+ * Variables d'environnement requises (voir .env.example) :
+ * - BREVO_API_KEY
+ * - CONTACT_FROM_EMAIL (doit être un expéditeur vérifié dans Brevo)
+ * - CONTACT_FROM_NAME
+ * - CONTACT_TO_EMAIL
+ * - CONTACT_TO_NAME
+ */
+
+import { NextResponse, type NextRequest } from "next/server";
+
+// Force Node runtime : `fetch` est dispo sur l'edge aussi, mais on veut la
+// stdlib Node pour les logs serveur et parce que Brevo peut mettre >1s à
+// répondre (edge a une limite de timeout plus stricte sur Vercel free).
+export const runtime = "nodejs";
+
+// Map en mémoire pour le rate-limit. `Map` où number[] est la
+// liste des timestamps d'envois récents. On nettoie à la volée. Suffisant pour
+// un portfolio — pas besoin de Redis tant qu'on reste sur une instance.
+// Note : en mode serverless (plusieurs lambdas), cette map n'est pas partagée,
+// donc le rate-limit est "best effort". Pour du ship prod sous charge, passer
+// sur Redis ou Upstash.
+const rateLimitStore = new Map();
+const RATE_LIMIT_MAX = 3; // 3 envois max
+const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000; // par tranche de 10 minutes
+
+const MAX_NAME_LENGTH = 120;
+const MAX_EMAIL_LENGTH = 160;
+const MAX_MESSAGE_LENGTH = 5000;
+
+const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+
+type ContactPayload = {
+ name?: string;
+ email?: string;
+ message?: string;
+ // Champ honeypot : rempli = bot. Ne jamais loguer son contenu.
+ website?: string;
+};
+
+function getClientIp(req: NextRequest): string {
+ // Ordre de priorité classique derrière un reverse-proxy (Vercel, Nginx).
+ const xff = req.headers.get("x-forwarded-for");
+ if (xff) return xff.split(",")[0].trim();
+ const xrip = req.headers.get("x-real-ip");
+ if (xrip) return xrip.trim();
+ // Fallback local : pas d'IP extractible (ex. `localhost`), on bucketise tout
+ // le trafic dans un même compartiment. Acceptable en dev.
+ return "unknown";
+}
+
+function checkRateLimit(ip: string): { ok: boolean; retryAfterMs: number } {
+ const now = Date.now();
+ const recent = (rateLimitStore.get(ip) ?? []).filter(
+ (ts) => now - ts < RATE_LIMIT_WINDOW_MS
+ );
+ if (recent.length >= RATE_LIMIT_MAX) {
+ const oldest = recent[0];
+ return { ok: false, retryAfterMs: RATE_LIMIT_WINDOW_MS - (now - oldest) };
+ }
+ recent.push(now);
+ rateLimitStore.set(ip, recent);
+ return { ok: true, retryAfterMs: 0 };
+}
+
+function escapeHtml(raw: string): string {
+ return raw
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+function buildEmailContents(input: {
+ name: string;
+ email: string;
+ message: string;
+ ip: string;
+}) {
+ const dateTime = new Date().toLocaleString("fr-FR", {
+ timeZone: "Europe/Paris",
+ });
+ const safeName = escapeHtml(input.name);
+ const safeEmail = escapeHtml(input.email);
+ const safeMessage = escapeHtml(input.message).replace(/\n/g, "
");
+
+ const subject = `Nouveau message portfolio — ${input.name}`;
+
+ const textContent = [
+ `Nouveau message reçu sur le formulaire de contact.`,
+ ``,
+ `Nom : ${input.name}`,
+ `Email : ${input.email}`,
+ `Date : ${dateTime}`,
+ `IP : ${input.ip}`,
+ ``,
+ `Message :`,
+ input.message,
+ ``,
+ `---`,
+ `Répondre directement à ce mail renverra vers ${input.email}.`,
+ ].join("\n");
+
+ const htmlContent = `
+
+
+
+
+
+ Nouveau message portfolio
+
+
+
+ | Nom |
+ ${safeName} |
+
+
+ | Email |
+ ${safeEmail} |
+
+
+ | Date |
+ ${dateTime} |
+
+
+ | IP |
+ ${escapeHtml(input.ip)} |
+
+
+
+
Message
+
${safeMessage}
+
+
+ Répondre directement à ce mail renverra vers ${safeEmail}.
+
+
+
+
+`.trim();
+
+ return { subject, textContent, htmlContent };
+}
+
+export async function POST(req: NextRequest) {
+ const apiKey = process.env.BREVO_API_KEY;
+ const fromEmail = process.env.CONTACT_FROM_EMAIL;
+ const fromName = process.env.CONTACT_FROM_NAME ?? "Portfolio — nouveau message";
+ const toEmail = process.env.CONTACT_TO_EMAIL;
+ const toName = process.env.CONTACT_TO_NAME ?? "Portfolio";
+
+ if (!apiKey || !fromEmail || !toEmail) {
+ console.error("[/api/contact] Configuration Brevo incomplète", {
+ hasKey: Boolean(apiKey),
+ hasFrom: Boolean(fromEmail),
+ hasTo: Boolean(toEmail),
+ });
+ return NextResponse.json(
+ { ok: false, error: "SERVER_MISCONFIGURED" },
+ { status: 500 }
+ );
+ }
+
+ let payload: ContactPayload;
+ try {
+ payload = (await req.json()) as ContactPayload;
+ } catch {
+ return NextResponse.json(
+ { ok: false, error: "INVALID_JSON" },
+ { status: 400 }
+ );
+ }
+
+ const name = (payload.name ?? "").trim();
+ const email = (payload.email ?? "").trim();
+ const message = (payload.message ?? "").trim();
+ const honeypot = (payload.website ?? "").trim();
+
+ // Honeypot : si un bot a rempli le champ caché, on simule un succès silencieux.
+ // Ne jamais renvoyer une erreur → ça donnerait aux bots un signal pour retry.
+ if (honeypot.length > 0) {
+ console.warn("[/api/contact] Honeypot déclenché, IP :", getClientIp(req));
+ return NextResponse.json({ ok: true }, { status: 200 });
+ }
+
+ if (!name || !email || !message) {
+ return NextResponse.json(
+ { ok: false, error: "MISSING_FIELDS" },
+ { status: 400 }
+ );
+ }
+
+ if (
+ name.length > MAX_NAME_LENGTH ||
+ email.length > MAX_EMAIL_LENGTH ||
+ message.length > MAX_MESSAGE_LENGTH
+ ) {
+ return NextResponse.json(
+ { ok: false, error: "TOO_LONG" },
+ { status: 413 }
+ );
+ }
+
+ if (!EMAIL_REGEX.test(email)) {
+ return NextResponse.json(
+ { ok: false, error: "INVALID_EMAIL" },
+ { status: 400 }
+ );
+ }
+
+ const ip = getClientIp(req);
+ const rate = checkRateLimit(ip);
+ if (!rate.ok) {
+ return NextResponse.json(
+ { ok: false, error: "RATE_LIMITED" },
+ {
+ status: 429,
+ headers: {
+ "Retry-After": String(Math.ceil(rate.retryAfterMs / 1000)),
+ },
+ }
+ );
+ }
+
+ const { subject, textContent, htmlContent } = buildEmailContents({
+ name,
+ email,
+ message,
+ ip,
+ });
+
+ try {
+ const brevoRes = await fetch("https://api.brevo.com/v3/smtp/email", {
+ method: "POST",
+ headers: {
+ "api-key": apiKey,
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ },
+ body: JSON.stringify({
+ sender: { name: fromName, email: fromEmail },
+ to: [{ email: toEmail, name: toName }],
+ // replyTo : clé de confort UX. Cliquer "Répondre" dans Gmail écrit
+ // directement au visiteur, sans aller-retour manuel pour recopier
+ // l'email depuis le corps du mail.
+ replyTo: { email, name },
+ subject,
+ htmlContent,
+ textContent,
+ tags: ["portfolio", "contact-form"],
+ }),
+ });
+
+ if (!brevoRes.ok) {
+ const text = await brevoRes.text();
+ console.error(
+ "[/api/contact] Brevo error",
+ brevoRes.status,
+ brevoRes.statusText,
+ text
+ );
+ return NextResponse.json(
+ { ok: false, error: "BREVO_ERROR" },
+ { status: 502 }
+ );
+ }
+ } catch (err) {
+ console.error("[/api/contact] Brevo fetch failed:", err);
+ return NextResponse.json(
+ { ok: false, error: "BREVO_UNREACHABLE" },
+ { status: 502 }
+ );
+ }
+
+ return NextResponse.json({ ok: true }, { status: 200 });
+}
diff --git a/app/components/ContactForm.tsx b/app/components/ContactForm.tsx
index 47076f3..433d494 100644
--- a/app/components/ContactForm.tsx
+++ b/app/components/ContactForm.tsx
@@ -1,23 +1,33 @@
"use client";
import { useState } from "react";
-import { sendMessage } from "../utils/sendMessage";
/**
- * Formulaire de contact — refonte "Digital Atelier" (étape 8).
+ * Formulaire de contact — refonte "Digital Atelier" (étape 8) + envoi via Brevo (étape 9).
*
- * - Plus de `bg-white shadow-lg rounded-lg` sur le form : il est désormais
- * monté dans la carte vellum de `app/contact/page.js`.
- * - Champs : `bg-surface-container-low`, radius `rounded-tile`, `focus-visible:ring-2 focus-visible:ring-primary`.
- * - CTA jewel : `bg-primary text-on-primary shadow-jewel` avec Material Symbol
- * `send` + effet `-translate-y-0.5` au hover, état disabled en `bg-outline-variant/60`.
- * - Bandeau status Stitch : succès en `primary-fixed`, erreur en `error-container`,
- * chargement en `surface-container`. Chaque état porte une Material Symbol.
+ * Architecture :
+ * Form → POST /api/contact (Next.js server route, voir app/api/contact/route.ts)
+ * ↓
+ * Brevo API HTTP → Gmail
+ *
+ * Plus de passage par Strapi pour les messages : la route serveur valide,
+ * filtre (honeypot + rate-limit), puis envoie une notification email. Voir
+ * `docs-site-interne/contact-flow.md` pour l'architecture détaillée.
+ *
+ * Anti-spam :
+ * - Champ honeypot `website` caché (sr-only + tabindex=-1). Les bots le
+ * remplissent systématiquement, les humains non. Côté serveur, un champ
+ * rempli → succès silencieux (aucun email envoyé).
+ * - Rate-limit côté serveur : 3 envois / 10 min / IP (voir route.ts).
+ * - Validation longueur + format email côté serveur en plus du client.
*/
export default function ContactForm() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [message, setMessage] = useState("");
+ // Honeypot : doit rester vide. S'il est rempli, on soumet quand même pour ne
+ // rien changer côté UX (le serveur ignore silencieusement ces payloads).
+ const [website, setWebsite] = useState("");
const [status, setStatus] = useState("");
const [statusKind, setStatusKind] = useState<
"idle" | "loading" | "success" | "error"
@@ -44,24 +54,55 @@ export default function ContactForm() {
setStatusKind("loading");
try {
- await sendMessage(name, email, message);
+ const res = await fetch("/api/contact", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name, email, message, website }),
+ });
+
+ if (res.status === 429) {
+ setStatus(
+ "Trop d'envois depuis votre IP. Réessayez dans quelques minutes."
+ );
+ setStatusKind("error");
+ return;
+ }
+
+ if (!res.ok) {
+ const data = (await res.json().catch(() => ({}))) as {
+ error?: string;
+ };
+ const errorCode = data.error ?? "UNKNOWN";
+ setStatus(
+ errorCode === "INVALID_EMAIL"
+ ? "Email invalide."
+ : errorCode === "MISSING_FIELDS"
+ ? "Tous les champs sont obligatoires."
+ : errorCode === "TOO_LONG"
+ ? "Message trop long."
+ : "Erreur lors de l'envoi du message."
+ );
+ setStatusKind("error");
+ return;
+ }
+
setStatus("Message envoyé. Merci, je reviens vers vous rapidement.");
setStatusKind("success");
setName("");
setEmail("");
setMessage("");
+ setWebsite("");
} catch (error) {
- setStatus("Erreur lors de l'envoi du message.");
+ console.error("[ContactForm] submit failed:", error);
+ setStatus("Erreur réseau. Vérifiez votre connexion et réessayez.");
setStatusKind("error");
}
};
const statusStyles: Record = {
idle: "",
- loading:
- "bg-surface-container text-on-surface-variant",
- success:
- "bg-primary-fixed/70 text-on-primary-fixed",
+ loading: "bg-surface-container text-on-surface-variant",
+ success: "bg-primary-fixed/70 text-on-primary-fixed",
error: "bg-error-container text-on-error-container",
};
@@ -77,6 +118,32 @@ export default function ContactForm() {
return (