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} +

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