From a5b82dc87b7e7a534ef2e4ee4d3906cf26182fce Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 12:20:21 +0000 Subject: [PATCH] =?UTF-8?q?Ouvre=20une=20porte=20de=20m=C3=A9diation=20ent?= =?UTF-8?q?re=20les=20familles=20et=20l'=C3=A9cole?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un parent qui a un souci avec l'école — une remarque qui passe mal, une organisation qui complique la vie d'une famille — n'avait aucun endroit où s'adresser sur le site. Le seul formulaire public parlait de rejoindre l'association, ce qui n'est pas la même démarche et suppose déjà d'être prêt à s'engager. Un bouton dans l'en-tête public, seul élément rouge du site, mène à une page qui explique en trois temps ce qui se passe — on vous écoute, on en parle avec vous, on porte le sujet auprès de l'enseignant ou de la direction — et propose un formulaire. Le formulaire demande la classe concernée, jamais le nom de l'enfant : pour préparer un rendez-vous avec la direction, le niveau suffit, et le prénom d'un élève dans une boîte partagée est une donnée qu'on peut éviter de collecter. Le sujet de l'e-mail reste neutre pour la même raison : il arrive dans une boîte partagée et ne doit pas exposer une situation dans une liste de messages. La page promet que le message reste entre la famille et le bureau. Cette promesse tient parce que la route API n'écrit rien en base : le message part par e-mail et rien n'est stocké. Une ligne discrète rappelle le 3020 et le 119, qui peuvent agir vite là où une association ne le peut pas. L'en-tête accueillait déjà deux actions ; à trois, rien ne tient sur une ligne de 390 px. Il passe sur deux rangées sous 640 px plutôt que de déborder ou de masquer un lien. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014SfQYBU4xXTeSEHKhHQXdD --- src/app/api/mediation/route.ts | 82 +++++++++++ src/app/mediation/page.tsx | 228 ++++++++++++++++++++++++++++++ src/components/mediation-form.tsx | 222 +++++++++++++++++++++++++++++ src/components/site-header.tsx | 18 ++- src/lib/notifications/emails.ts | 56 ++++++++ src/lib/validation.ts | 35 +++++ 6 files changed, 638 insertions(+), 3 deletions(-) create mode 100644 src/app/api/mediation/route.ts create mode 100644 src/app/mediation/page.tsx create mode 100644 src/components/mediation-form.tsx diff --git a/src/app/api/mediation/route.ts b/src/app/api/mediation/route.ts new file mode 100644 index 0000000..f46c88e --- /dev/null +++ b/src/app/api/mediation/route.ts @@ -0,0 +1,82 @@ +import { NextResponse } from "next/server"; + +import { handleApiError, HttpError } from "@/lib/auth/guards"; +import { sendEmail } from "@/lib/notifications/email"; +import { mediationRequestEmail } from "@/lib/notifications/emails"; +import { + getAssociationSettings, + getRecaptchaRuntimeConfig, +} from "@/lib/services/association-settings"; +import { verifyRecaptcha } from "@/lib/services/recaptcha"; +import { mediationRequestSchema } from "@/lib/validation"; + +export const dynamic = "force-dynamic"; + +/** + * Demande de médiation adressée à l'association par une famille. + * + * Le message part vers l'adresse de contact configurée et n'est écrit nulle + * part : une difficulté avec un enseignant n'a rien à faire dans une base de + * données que des membres peuvent consulter. C'est aussi ce que la page + * promet aux familles, et cette promesse tient au fait qu'il n'y a ici aucun + * `insert`. + */ +export async function POST(req: Request) { + try { + const data = mediationRequestSchema.parse(await req.json()); + + // Robot repéré au champ caché : on répond comme si tout allait bien. + if (data.website && data.website.trim().length > 0) { + return NextResponse.json({ ok: true }); + } + + const recaptcha = await getRecaptchaRuntimeConfig(); + if (recaptcha) { + await verifyRecaptcha({ + secret: recaptcha.secret, + token: data.recaptchaToken, + action: "contact", + minScore: recaptcha.minScore, + ip: req.headers.get("x-forwarded-for")?.split(",")[0]?.trim(), + }); + } + + const settings = await getAssociationSettings(); + const destination = settings.contactEmail?.trim(); + if (!destination) { + throw new HttpError( + 503, + "Le formulaire n’est pas disponible : l’association n’a pas encore renseigné son adresse de contact.", + ); + } + + const sent = await sendEmail({ + to: destination, + replyTo: data.email, + ...mediationRequestEmail({ + name: data.name, + email: data.email, + phone: data.phone, + schoolClass: data.schoolClass, + topic: data.topic, + message: data.message, + identity: { + associationName: settings.associationName, + schoolName: settings.schoolName, + rna: settings.rna, + }, + }), + }); + + if (!sent) { + throw new HttpError( + 502, + `L’envoi a échoué. Écrivez directement à ${destination}, votre message sera lu.`, + ); + } + + return NextResponse.json({ ok: true }); + } catch (error) { + return handleApiError(error); + } +} diff --git a/src/app/mediation/page.tsx b/src/app/mediation/page.tsx new file mode 100644 index 0000000..5398ad4 --- /dev/null +++ b/src/app/mediation/page.tsx @@ -0,0 +1,228 @@ +import { + ArrowLeft, + Ear, + HeartHandshake, + Mail, + MessagesSquare, + ShieldCheck, +} from "lucide-react"; +import type { Metadata } from "next"; +import Link from "next/link"; + +import { MediationForm } from "@/components/mediation-form"; +import { SiteFooter } from "@/components/site-footer"; +import { SiteHeader } from "@/components/site-header"; +import { getAssociationSettings } from "@/lib/services/association-settings"; + +export const dynamic = "force-dynamic"; + +/** + * Page publique « Parler à l'association ». + * + * Un parent qui arrive ici a souvent hésité avant d'écrire. La page dit en + * trois temps ce qui va se passer, promet la discrétion — promesse tenue par le + * fait que la route API n'écrit rien en base — et rappelle les deux numéros + * nationaux qui, eux, peuvent agir vite quand la situation le demande. + */ + +export async function generateMetadata(): Promise { + const settings = await getAssociationSettings(); + const title = `Parler à ${settings.associationName}`; + const description = `Une difficulté avec l’école ? Les parents ${settings.associationName} vous écoutent et vous accompagnent auprès de l’équipe enseignante.`; + return { + title, + description, + openGraph: { title, description, type: "website" }, + }; +} + +const ETAPES = [ + { + icon: Ear, + numero: "01", + titre: "Vous nous racontez", + texte: + "Par écrit ici, ou de vive voix si vous préférez qu’on vous rappelle.", + }, + { + icon: MessagesSquare, + numero: "02", + titre: "On en parle avec vous", + texte: + "Un parent du bureau vous répond, prend le temps de comprendre et vous dit ce qui est possible.", + }, + { + icon: HeartHandshake, + numero: "03", + titre: "On porte le sujet", + texte: + "Auprès de l’enseignant ou de la direction, avec vous, ou en votre nom si vous préférez rester en retrait.", + }, +]; + +export default async function MediationPage() { + const settings = await getAssociationSettings(); + const contactEmail = settings.contactEmail?.trim() || null; + + return ( +
+ + +
+
+
+ +
+
+
+

+ Comment ça se passe +

+

+ Trois temps, et vous gardez la main. +

+
+ +
+ {ETAPES.map(({ icon: Icon, numero, titre, texte }) => ( +
+
+ + + + {numero} + +
+

+ {titre} +

+

+ {texte} +

+
+ ))} +
+
+
+ +
+
+
+ + +
+ {contactEmail ? ( + <> +

+ Racontez-nous +

+

+ Prenez le temps qu’il faut. Personne d’autre que le bureau + ne lira ce message. +

+
+ +
+ + ) : ( + <> +

+ Le formulaire n’est pas disponible pour l’instant +

+

+ L’association n’a pas encore publié d’adresse de contact : + votre message ne partirait nulle part. Repassez d’ici + quelques jours, ou parlez-en à un parent de l’équipe à la + sortie de l’école. +

+ + )} +
+
+ + + + Retour à l’accueil + +
+
+
+ + +
+ ); +} diff --git a/src/components/mediation-form.tsx b/src/components/mediation-form.tsx new file mode 100644 index 0000000..d46545a --- /dev/null +++ b/src/components/mediation-form.tsx @@ -0,0 +1,222 @@ +"use client"; + +import { Send } from "lucide-react"; +import Link from "next/link"; +import { useState } from "react"; + +import { api } from "@/lib/client"; +import { useRecaptcha } from "@/lib/use-recaptcha"; + +/** + * Formulaire de demande de médiation. + * + * Il demande la classe concernée, jamais le nom de l'enfant : pour préparer un + * rendez-vous avec la direction, le niveau suffit, et le prénom d'un élève dans + * une boîte partagée est une donnée qu'on peut éviter de collecter. + */ + +const SUJETS = [ + { valeur: "enseignant", label: "Relation avec un enseignant" }, + { valeur: "classe", label: "Vie de la classe" }, + { valeur: "periscolaire", label: "Cantine, garderie, périscolaire" }, + { valeur: "enfant", label: "Situation de mon enfant" }, + { valeur: "autre", label: "Autre sujet" }, +]; + +const champ = + "min-h-11 w-full rounded-xl border-2 border-slate-200 px-3.5 py-2.5 text-sm font-medium text-slate-900 focus:border-brand-600 focus:outline-none"; + +export function MediationForm({ + contactEmail, + recaptchaSiteKey = null, +}: { + contactEmail: string | null; + recaptchaSiteKey?: string | null; +}) { + const executerRecaptcha = useRecaptcha(recaptchaSiteKey); + const [loading, setLoading] = useState(false); + const [sent, setSent] = useState(false); + const [error, setError] = useState(null); + + async function onSubmit(event: React.FormEvent) { + event.preventDefault(); + setError(null); + setLoading(true); + const form = new FormData(event.currentTarget); + try { + await api("/api/mediation", { + body: { + name: form.get("name"), + email: form.get("email"), + phone: form.get("phone") || undefined, + schoolClass: form.get("schoolClass") || undefined, + topic: form.get("topic"), + message: form.get("message"), + consent: form.get("consent") === "on", + website: form.get("website"), + recaptchaToken: await executerRecaptcha("contact"), + }, + }); + setSent(true); + } catch (err) { + setError((err as Error).message); + } finally { + setLoading(false); + } + } + + if (sent) { + return ( +
+

+ C’est envoyé, merci de votre confiance. +

+

+ Un parent du bureau vous répond par e-mail, en général sous quelques + jours. + {contactEmail ? ( + <> + {" "} + Vous pouvez aussi nous joindre directement à{" "} + + {contactEmail} + + . + + ) : null} +

+
+ ); + } + + return ( +
+ {error && ( +

+ {error} +

+ )} + +
+ + +
+ +
+ + +
+ + + +