Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions src/app/api/mediation/route.ts → src/app/api/contact/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,18 @@ 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 { familyMessageEmail } from "@/lib/notifications/emails";
import {
getAssociationSettings,
getRecaptchaRuntimeConfig,
} from "@/lib/services/association-settings";
import { verifyRecaptcha } from "@/lib/services/recaptcha";
import { mediationRequestSchema } from "@/lib/validation";
import { familyMessageSchema } from "@/lib/validation";

export const dynamic = "force-dynamic";

/**
* Demande de médiation adressée à l'association par une famille.
* Message d'une famille qui rencontre une difficulté avec l'école.
*
* 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
Expand All @@ -23,7 +23,7 @@ export const dynamic = "force-dynamic";
*/
export async function POST(req: Request) {
try {
const data = mediationRequestSchema.parse(await req.json());
const data = familyMessageSchema.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) {
Expand Down Expand Up @@ -53,7 +53,7 @@ export async function POST(req: Request) {
const sent = await sendEmail({
to: destination,
replyTo: data.email,
...mediationRequestEmail({
...familyMessageEmail({
name: data.name,
email: data.email,
phone: data.phone,
Expand Down
14 changes: 7 additions & 7 deletions src/app/mediation/page.tsx → src/app/contact/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@ import {
import type { Metadata } from "next";
import Link from "next/link";

import { MediationForm } from "@/components/mediation-form";
import { ContactForm } from "@/components/contact-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 ».
* Page publique « On vous écoute ».
*
* 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
Expand All @@ -27,8 +27,8 @@ export const dynamic = "force-dynamic";

export async function generateMetadata(): Promise<Metadata> {
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.`;
const title = `Un souci ? Parlons-en — ${settings.associationName}`;
const description = `Une difficulté avec l’école ? Les parents ${settings.associationName} vous écoutent et font le lien avec l’équipe enseignante.`;
return {
title,
description,
Expand Down Expand Up @@ -60,7 +60,7 @@ const ETAPES = [
},
];

export default async function MediationPage() {
export default async function ContactPage() {
const settings = await getAssociationSettings();
const contactEmail = settings.contactEmail?.trim() || null;

Expand All @@ -77,7 +77,7 @@ export default async function MediationPage() {
<div className="relative mx-auto max-w-7xl px-4 py-16 sm:px-6 sm:py-20">
<span className="inline-flex items-center gap-2 rounded-lg bg-coral-600 px-3 py-2 text-xs font-extrabold uppercase tracking-[0.14em] text-white">
<MessagesSquare className="h-4 w-4" strokeWidth={2.5} />
Médiation
On vous écoute
</span>
<h1 className="mt-7 max-w-3xl text-4xl font-black leading-[1.05] tracking-[-0.05em] sm:text-5xl">
Quelque chose coince avec l’école ?{" "}
Expand Down Expand Up @@ -187,7 +187,7 @@ export default async function MediationPage() {
ne lira ce message.
</p>
<div className="mt-6">
<MediationForm
<ContactForm
contactEmail={contactEmail}
recaptchaSiteKey={
settings.recaptchaReady ? settings.recaptchaSiteKey : null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { api } from "@/lib/client";
import { useRecaptcha } from "@/lib/use-recaptcha";

/**
* Formulaire de demande de médiation.
* Formulaire « on vous écoute ».
*
* 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
Expand All @@ -26,7 +26,7 @@ const SUJETS = [
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({
export function ContactForm({
contactEmail,
recaptchaSiteKey = null,
}: {
Expand All @@ -44,7 +44,7 @@ export function MediationForm({
setLoading(true);
const form = new FormData(event.currentTarget);
try {
await api("/api/mediation", {
await api("/api/contact", {
body: {
name: form.get("name"),
email: form.get("email"),
Expand Down
20 changes: 12 additions & 8 deletions src/components/site-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,19 @@ export async function SiteHeader() {
</span>
</span>
</Link>
<nav className="flex min-w-0 flex-1 flex-wrap items-center justify-end gap-2 text-sm">
<nav className="flex min-w-0 flex-1 flex-wrap items-center justify-end gap-1 text-sm sm:gap-2">
{/* Le rouge est réservé à ce bouton : il n'est ni une alerte ni une
promotion, c'est la porte qu'un parent doit trouver sans chercher
le jour où quelque chose ne va pas. */}
<Link
href="/mediation"
className="inline-flex min-h-10 items-center justify-center gap-2 whitespace-nowrap rounded-xl bg-coral-600 px-3 py-2 text-sm font-bold text-white transition-colors hover:bg-coral-700 focus:outline-none focus-visible:ring-4 focus-visible:ring-coral-200 sm:px-4"
href="/contact"
className="inline-flex min-h-10 items-center justify-center gap-2 whitespace-nowrap rounded-xl bg-coral-600 px-2 py-2 text-sm font-bold text-white transition-colors hover:bg-coral-700 focus:outline-none focus-visible:ring-4 focus-visible:ring-coral-200 sm:px-4"
>
<MessagesSquare className="h-4 w-4" aria-hidden="true" />
Médiation
{/* L'icône ne réapparaît qu'à partir de sm : sur 390 px, ces 22
pixels sont exactement ce qui ferait passer l'en-tête à deux
lignes. */}
<MessagesSquare className="hidden h-4 w-4 shrink-0 sm:block" aria-hidden="true" />
Un souci ?
</Link>
{user ? (
<Link
Expand All @@ -61,15 +64,16 @@ export async function SiteHeader() {
<>
<Link
href="/login"
className="inline-flex min-h-10 items-center rounded-lg px-2.5 py-2 font-bold text-brand-950 transition-colors hover:bg-brand-50 focus:outline-none focus-visible:ring-4 focus-visible:ring-brand-200 sm:px-3"
className="inline-flex min-h-10 items-center whitespace-nowrap rounded-lg px-1.5 py-2 font-bold text-brand-950 transition-colors hover:bg-brand-50 focus:outline-none focus-visible:ring-4 focus-visible:ring-brand-200 sm:px-3"
>
Connexion
</Link>
<Link
href="/register"
className="inline-flex min-h-10 items-center justify-center whitespace-nowrap rounded-xl bg-brand-950 px-3 py-2 text-sm font-bold text-white transition-colors hover:bg-brand-800 focus:outline-none focus-visible:ring-4 focus-visible:ring-brand-200 sm:px-4"
className="inline-flex min-h-10 items-center justify-center whitespace-nowrap rounded-xl bg-brand-950 px-2 py-2 text-sm font-bold text-white transition-colors hover:bg-brand-800 focus:outline-none focus-visible:ring-4 focus-visible:ring-brand-200 sm:px-4"
>
Créer un compte
<span className="sm:hidden">S’inscrire</span>
<span className="hidden sm:inline">Créer un compte</span>
</Link>
</>
)}
Expand Down
14 changes: 7 additions & 7 deletions src/lib/notifications/emails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,8 +169,8 @@ export function mailSettingsTestEmail(
}

/** Message envoyé à l'association depuis la page « Rejoindre l'APEL ». */
/** Intitulés lisibles des sujets de médiation, côté e-mail. */
const MEDIATION_LABELS: Record<string, string> = {
/** Intitulés lisibles des sujets, côté e-mail. */
const SUJET_LABELS: Record<string, string> = {
enseignant: "Relation avec un enseignant",
classe: "Vie de la classe",
periscolaire: "Cantine, garderie, périscolaire",
Expand All @@ -179,11 +179,11 @@ const MEDIATION_LABELS: Record<string, string> = {
};

/**
* Demande de médiation. L'objet reste neutre : cet e-mail arrive dans une boîte
* Message d'une famille en difficulté avec l'école. L'objet reste neutre : cet e-mail arrive dans une boîte
* partagée, et son sujet ne doit pas exposer une situation d'enfant dans une
* liste de messages.
*/
export function mediationRequestEmail(ctx: {
export function familyMessageEmail(ctx: {
name: string;
email: string;
phone?: string | null;
Expand All @@ -193,7 +193,7 @@ export function mediationRequestEmail(ctx: {
identity?: NotificationIdentity;
}): EmailContent {
const association = ctx.identity?.associationName || APP_NAME;
const sujet = MEDIATION_LABELS[ctx.topic] ?? MEDIATION_LABELS.autre;
const sujet = SUJET_LABELS[ctx.topic] ?? SUJET_LABELS.autre;
const lignes = [
`<li>Sujet : <strong>${esc(sujet)}</strong></li>`,
`<li>Nom : <strong>${esc(ctx.name)}</strong></li>`,
Expand All @@ -207,15 +207,15 @@ export function mediationRequestEmail(ctx: {
].join("");

return {
subject: `Demande de médiation — ${ctx.name}`,
subject: `Une famille vous écrit — ${ctx.name}`,
html: layout(
"Une famille demande à être accompagnée",
`<ul>${lignes}</ul>
<p style="white-space:pre-wrap;border-left:3px solid #cbd5e1;padding-left:12px;">${esc(ctx.message)}</p>
<p>${button(`mailto:${esc(ctx.email)}`, "Répondre à la famille")}</p>`,
ctx.identity,
),
text: `Demande de médiation reçue sur le site de ${association}.
text: `Une famille vous écrit depuis le site de ${association}.

Sujet : ${sujet}
Nom : ${ctx.name}
Expand Down
10 changes: 5 additions & 5 deletions src/lib/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,8 +169,8 @@ export const joinRequestSchema = z.object({
recaptchaToken: z.string().max(5000).optional(),
});

/** Sujets proposés à un parent qui demande une médiation. */
export const MEDIATION_TOPICS = [
/** Sujets proposés à un parent qui écrit à l'association. */
export const FAMILY_MESSAGE_TOPICS = [
"enseignant",
"classe",
"periscolaire",
Expand All @@ -179,17 +179,17 @@ export const MEDIATION_TOPICS = [
] as const;

/**
* Demande de médiation : un parent sollicite l'association pour être
* Message d'une famille : un parent sollicite l'association pour être
* accompagné dans une difficulté avec l'école. Rien n'est stocké en base — le
* message part directement dans la boîte de l'association.
*/
export const mediationRequestSchema = z.object({
export const familyMessageSchema = z.object({
name: z.string().trim().min(2, "Votre nom est requis").max(120),
email: z.string().trim().toLowerCase().email("Adresse e-mail invalide"),
phone: z.string().trim().max(40).optional(),
/** Niveau ou classe concernée, jamais le nom de l'enfant. */
schoolClass: z.string().trim().max(80).optional(),
topic: z.enum(MEDIATION_TOPICS).default("autre"),
topic: z.enum(FAMILY_MESSAGE_TOPICS).default("autre"),
message: z
.string()
.trim()
Expand Down
Loading