From d37d2a9187f496419adf386fc4a0e24359b245bf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 06:59:58 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=C2=AB=20de=20l'=C3=89cole=20Sainte-Marie?= =?UTF-8?q?=20=C2=BB,=20et=20non=20=C2=AB=20d'=C3=89cole=20Sainte-Marie=20?= =?UTF-8?q?=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La page Rejoindre annonçait aux visiteurs qu'elle réunissait « les parents d'élèves d'École Notre Dame des Flots ». L'élision seule ne suffit pas : quand le nom de l'établissement commence par un nom commun — école, collège, lycée, institution — c'est l'article qui manque, pas l'apostrophe. L'aide pose désormais l'article qui convient au nom commun rencontré, et s'en tient à l'élision quand le nom n'en porte pas. Douze cas éprouvés, dont le nom réel de l'établissement servi : de l'École, du Collège, du Lycée, de l'Institut, de l'Institution, de la Maison, du Groupe scolaire, mais aussi de Sainte-Marie, d'Ozanam et de Notre-Dame des Flots. Elle sort du fichier de la page pour vivre dans src/lib : l'élision d'un nom d'établissement n'appartient pas à un écran, et le prochain texte qui le cite n'aura pas à la réinventer. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014SfQYBU4xXTeSEHKhHQXdD --- src/app/rejoindre/page.tsx | 10 +----- src/lib/etablissement.ts | 68 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 9 deletions(-) create mode 100644 src/lib/etablissement.ts diff --git a/src/app/rejoindre/page.tsx b/src/app/rejoindre/page.tsx index fb21964..4a148b4 100644 --- a/src/app/rejoindre/page.tsx +++ b/src/app/rejoindre/page.tsx @@ -20,6 +20,7 @@ import { SiteHeader } from "@/components/site-header"; import { WhatsappInviteCard } from "@/components/whatsapp-invite-card"; import { getUpcomingPublishedEvents } from "@/lib/data"; import { formatDateTime, formatLongDateTime } from "@/lib/dates"; +import { deEtablissement } from "@/lib/etablissement"; import { getAssociationSettings } from "@/lib/services/association-settings"; export const dynamic = "force-dynamic"; @@ -124,15 +125,6 @@ const ETAPES = [ }, ]; -/** - * « de École Sainte-Marie » : beaucoup d'établissements portent un nom qui - * commence par une voyelle, et la phrase se lit mal. Élision minimale, sans - * prétendre couvrir tous les cas de la langue. - */ -function deEtablissement(nom: string): string { - return /^[aeiouyàâäéèêëîïôöûü]/i.test(nom.trim()) ? `d’${nom}` : `de ${nom}`; -} - const AMORCES = [ "Je peux donner un coup de main sur un événement, prévenez-moi.", "Comptez sur moi au prochain rendez-vous.", diff --git a/src/lib/etablissement.ts b/src/lib/etablissement.ts new file mode 100644 index 0000000..e584693 --- /dev/null +++ b/src/lib/etablissement.ts @@ -0,0 +1,68 @@ +/** + * « de » devant le nom d'un établissement scolaire. + * + * L'application sert plusieurs associations, et les noms d'établissement ne se + * ressemblent pas : « École Notre Dame des Flots », « Collège Saint-Joseph », + * « Sainte-Marie ». La règle d'élision seule donnait « les parents d'élèves + * d'École Notre Dame des Flots », qu'aucun parent n'écrirait — il manque + * l'article devant le nom commun. + * + * On ne traite que ce qu'on sait traiter : quand le nom commence par un nom + * commun d'établissement, on pose l'article qui lui convient ; sinon on s'en + * tient à l'élision, qui suffit pour « de Sainte-Marie » ou « d'Ozanam ». + */ + +interface NomCommun { + /** Début du nom, en minuscules sans accents. */ + prefixe: string; + genre: "m" | "f"; + /** Vrai quand le mot commence par une voyelle : l'article s'élide. */ + voyelle: boolean; +} + +const NOMS_COMMUNS: NomCommun[] = [ + { prefixe: "ecole", genre: "f", voyelle: true }, + { prefixe: "institution", genre: "f", voyelle: true }, + { prefixe: "maison", genre: "f", voyelle: false }, + { prefixe: "college", genre: "m", voyelle: false }, + { prefixe: "lycee", genre: "m", voyelle: false }, + { prefixe: "groupe scolaire", genre: "m", voyelle: false }, + { prefixe: "ensemble scolaire", genre: "m", voyelle: true }, + { prefixe: "institut", genre: "m", voyelle: true }, + { prefixe: "cours", genre: "m", voyelle: false }, +]; + +function sansAccents(valeur: string): string { + return valeur.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase(); +} + +/** Vrai quand « de » s'élide devant ce mot. */ +function commenceParUneVoyelle(nom: string): boolean { + return /^[aeiouyàâäéèêëîïôöûü]/i.test(nom.trim()); +} + +/** + * Rend « de l'École Sainte-Marie », « du Collège Saint-Joseph », « de + * Sainte-Marie » ou « d'Ozanam », selon le nom reçu. + */ +export function deEtablissement(nom: string): string { + const propre = nom.trim(); + if (!propre) return ""; + + const compare = sansAccents(propre); + const commun = NOMS_COMMUNS.find( + (c) => + compare === c.prefixe || + compare.startsWith(`${c.prefixe} `) || + compare.startsWith(`${c.prefixe}-`), + ); + + if (commun) { + // « Institut » est masculin et commence par une voyelle : « de l'Institut », + // et non « du Institut ». L'élision l'emporte sur le genre. + if (commun.voyelle) return `de l’${propre}`; + return commun.genre === "m" ? `du ${propre}` : `de la ${propre}`; + } + + return commenceParUneVoyelle(propre) ? `d’${propre}` : `de ${propre}`; +} From 20c0c59c36a68b0e2f1ffc1c62bf5e11d5490fb8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 07:42:32 +0000 Subject: [PATCH 2/2] =?UTF-8?q?Adh=C3=A9rer=20devient=20une=20porte=20?= =?UTF-8?q?=C3=A0=20part=20enti=C3=A8re=20sur=20/rejoindre?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La page ne parlait que de bénévolat. Le mot « adhérer » n'y figurait pas une seule fois — ni dans le titre, ni dans un bouton, ni dans le formulaire — et ce formulaire arrivait après six sections de préparatifs et de créneaux. Un parent venu devenir membre repartait par le seul bouton foncé permanent du site, « Créer un compte », qui mène à l'espace de gestion. Adhérer et donner un coup de main sont désormais traités comme deux démarches indépendantes, de même poids : - le titre nomme les deux gestes, et deux cartes jumelles les ouvrent dans le premier écran — mêmes classes, même bouton, aucune « bonne » réponse, chacune avec sa preuve (le prochain rendez-vous daté d'un côté, le numéro RNA de l'autre) ; - le formulaire remonte de la septième à la troisième section ; neuf liens y mènent au lieu de deux, et chacun dit où il va (↓ on descend, ↑ on remonte, ↗ on change de page) ; - il s'ouvre sur « Vous nous écrivez pour… », quatre options lues et non repliées dans une liste : c'est ce menu qui apprend l'adhésion à qui ne la soupçonnait pas. Rien n'est coché d'avance, les deux portes cochent à la place du parent, et re-cliquer la même ancre ré-arme le choix — suivre un fragment déjà atteint n'émet aucun hashchange ; - l'intention porte jusqu'à l'objet de l'e-mail du bureau et lui dit quoi répondre ; le parent reçoit une copie, parce que rien n'est écrit en base et que l'écran de confirmation meurt avec l'onglet ; - le bouton de l'en-tête devient « Espace organisateurs ». Aucun montant de cotisation, aucune fédération nommée, aucune règle statutaire : l'application sert plusieurs associations et ces choses-là varient. La page dit ce qu'adhérer n'oblige pas — ni réunion, ni permanence, ni stand — et renvoie au bureau pour le reste. Vérifié sur une instance réelle : formulaire à 2,5 écrans du haut au lieu de 4,6 ; « adhérer » 16 fois au lieu de 0 ; les deux portes au-dessus de la ligne de flottaison à 390 px ; aucun débordement de 320 à 1920 px ; les deux e-mails reçus (« Demande d'adhésion — … » au bureau, accusé au parent) ; les cas sans adresse de contact, sans agenda et sans place libre conservent leurs replis. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014SfQYBU4xXTeSEHKhHQXdD --- src/app/api/join/route.ts | 30 +- src/app/register/page.tsx | 2 +- src/app/rejoindre/page.tsx | 832 ++++++++++++++++++++------------ src/components/join-form.tsx | 236 ++++++++- src/components/site-header.tsx | 7 +- src/lib/notifications/emails.ts | 94 +++- src/lib/validation.ts | 28 ++ 7 files changed, 893 insertions(+), 336 deletions(-) diff --git a/src/app/api/join/route.ts b/src/app/api/join/route.ts index a5f4d8c..e5fbf4c 100644 --- a/src/app/api/join/route.ts +++ b/src/app/api/join/route.ts @@ -2,7 +2,10 @@ import { NextResponse } from "next/server"; import { handleApiError, HttpError } from "@/lib/auth/guards"; import { sendEmail } from "@/lib/notifications/email"; -import { joinRequestEmail } from "@/lib/notifications/emails"; +import { + joinRequestAckEmail, + joinRequestEmail, +} from "@/lib/notifications/emails"; import { getAssociationSettings, getRecaptchaRuntimeConfig, @@ -55,6 +58,7 @@ export async function POST(req: Request) { email: data.email, phone: data.phone, message: data.message, + intention: data.intention, identity: { associationName: settings.associationName, schoolName: settings.schoolName, @@ -70,6 +74,30 @@ export async function POST(req: Request) { ); } + // Copie au parent : sans écriture en base, l'écran de confirmation est la + // seule trace de sa démarche, et il meurt avec l'onglet. L'échec de cet + // envoi-là ne doit jamais faire échouer la demande : le bureau a déjà reçu + // le message, c'est ce qui compte. + try { + await sendEmail({ + to: data.email, + replyTo: destination, + ...joinRequestAckEmail({ + name: data.name, + message: data.message, + intention: data.intention, + contactEmail: destination, + identity: { + associationName: settings.associationName, + schoolName: settings.schoolName, + rna: settings.rna, + }, + }), + }); + } catch { + // Silence volontaire : voir ci-dessus. + } + return NextResponse.json({ ok: true }); } catch (error) { return handleApiError(error); diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx index 11d9948..d75f5dc 100644 --- a/src/app/register/page.tsx +++ b/src/app/register/page.tsx @@ -16,7 +16,7 @@ export default async function RegisterPage() { return ( diff --git a/src/app/rejoindre/page.tsx b/src/app/rejoindre/page.tsx index 4a148b4..9265fb4 100644 --- a/src/app/rejoindre/page.tsx +++ b/src/app/rejoindre/page.tsx @@ -1,6 +1,8 @@ import { + ArrowDown, ArrowLeft, - ArrowRight, + ArrowUp, + ArrowUpRight, CalendarDays, Coins, Hand, @@ -8,10 +10,10 @@ import { Mail, MapPin, MessagesSquare, + UserPlus, Users, } from "lucide-react"; import type { Metadata } from "next"; -import Image from "next/image"; import Link from "next/link"; import { JoinForm } from "@/components/join-form"; @@ -28,21 +30,32 @@ export const dynamic = "force-dynamic"; /** * Page publique « Rejoindre l'association ». * - * Elle s'adresse à un parent qui ne connaît personne : elle répond d'abord à ce - * qui le retient, montre ensuite que l'association existe pour de vrai — les - * rendez-vous réellement publiés, les places qui restent — puis lui laisse deux - * portes, l'une basse (prendre un créneau sans compte), l'autre écrite. + * Elle sépare deux gestes que la page confondait : adhérer, c'est devenir + * membre pour l'année scolaire ; donner un coup de main, c'est venir sur un + * rendez-vous. On peut faire l'un sans l'autre, et le parent venu adhérer ne + * trouvait auparavant le mot nulle part — ni dans le titre, ni dans les + * boutons, et le formulaire arrivait après six sections de bénévolat. + * + * D'où l'ordre d'aujourd'hui : les deux portes nommées dans le premier écran, + * ce que chacune veut dire juste en dessous, puis le formulaire. Tout ce qui + * prouve que l'association existe — l'agenda réel, les places restantes — passe + * après, parce que c'est une preuve, pas un préalable. * * Règle de confidentialité : getUpcomingPublishedEvents() rapporte aussi les * inscriptions des bénévoles (nom, e-mail, jeton d'annulation). Tout reste dans * ce composant serveur ; ne transmettre à un composant client que des primitives * déjà calculées, jamais un événement ou un créneau entier. + * + * Règle multi-association : l'application sert plusieurs APEL. Aucun montant de + * cotisation, aucune fédération nommée, aucune règle statutaire (qui vote, ce + * qui se compte par famille) n'est écrit en dur ici — ces choses varient d'une + * association à l'autre, et la page renvoie au bureau pour les dire. */ export async function generateMetadata(): Promise { const settings = await getAssociationSettings(); const title = `Rejoindre ${settings.associationName}`; - const description = `On prépare les rendez-vous de l’année, on donne un coup de main le jour venu : ce que fabriquent les parents ${deEtablissement(settings.schoolName)} avec ${settings.associationName}.`; + const description = `Adhérer à l’association ou donner un coup de main sur un rendez-vous : deux gestes séparés, chez les parents d’élèves ${deEtablissement(settings.schoolName)}.`; return { title, description, @@ -50,6 +63,22 @@ export async function generateMetadata(): Promise { }; } +/** Ce qu'adhérer veut dire, sans rien affirmer qui dépende des statuts. */ +const CE_QU_ADHERER_VEUT_DIRE = [ + "Régler la cotisation annuelle et devenir membre de l’association pour l’année scolaire.", + "Adhérer ne vous engage à aucune présence : ni réunion, ni permanence, ni stand.", + "La cotisation vaut pour la famille.", + "Les membres sont convoqués à l’assemblée générale, où l’on présente les comptes et ce qui est prévu.", + "Le montant et la façon de régler sont fixés par l’association pour l’année en cours : écrivez-nous, on vous le dit.", +]; + +const CE_QUE_LE_COUP_DE_MAIN_VEUT_DIRE = [ + "Venir sur un rendez-vous et prendre une tâche, sans devenir membre.", + "Deux heures suffisent : un créneau, une tâche, et c’est tout.", + "On choisit son créneau sur la page du rendez-vous, sans créer de compte.", + "Un lien dans l’e-mail de confirmation libère le créneau si la semaine tourne autrement.", +]; + /** * Le déroulé d'un rendez-vous, et non des événements nommés : l'application * sert plusieurs associations, et rien ne garantit qu'une école donnée tienne @@ -110,27 +139,6 @@ const MISSIONS = [ }, ]; -const ETAPES = [ - { - titre: "Vous écrivez", - texte: "Deux ou trois lignes suffisent : qui vous êtes, ce qui vous intrigue.", - }, - { - titre: "On vous répond", - texte: "Un parent de l’équipe vous répond par e-mail.", - }, - { - titre: "Vous décidez", - texte: "Vous choisissez un créneau, et on se retrouve sur place.", - }, -]; - -const AMORCES = [ - "Je peux donner un coup de main sur un événement, prévenez-moi.", - "Comptez sur moi au prochain rendez-vous.", - "J’aimerais m’impliquer davantage, comment ça marche ?", -]; - /** Teintes des tuiles de mission, pour éviter quatre boîtes blanches identiques. */ const TEINTES_MISSIONS = [ { @@ -159,6 +167,14 @@ const TEINTES_MISSIONS = [ }, ]; +/** + * Un bouton dit où il mène : ↓ on descend dans la page, ↑ on remonte, ↗ on + * change de page. La plainte d'origine tenait pour moitié à ça — on cliquait + * « Écrire à l'association » sans savoir si la page allait changer. + */ +const BOUTON_PLEIN = + "group inline-flex min-h-12 items-center justify-center gap-2 rounded-xl bg-brand-950 px-5 py-3 text-sm font-extrabold text-white transition-colors hover:bg-brand-800 focus:outline-none focus-visible:ring-4 focus-visible:ring-brand-200"; + export default async function RejoindrePage() { const [settings, events] = await Promise.all([ getAssociationSettings(), @@ -204,12 +220,16 @@ export default async function RejoindrePage() { const contactEmail = settings.contactEmail?.trim() || null; const rna = settings.rna?.trim() || null; + // Sans adresse de contact publiée, le formulaire n'existe pas et /api/join + // répond 503 : aucun bouton ne doit alors promettre qu'on peut écrire. + const ecritureOuverte = contactEmail !== null; + return (
- {/* ————— Héros : la promesse, deux portes d'action, et la preuve chiffrée ————— */} + {/* ————— Héros : les deux gestes nommés, et deux portes de même taille ————— */}
- {/* ————— Respiration : une phrase, un aplat, aucune demande ————— */} -
-
- {/* ————— Le mur des « oui, mais… » : le seul bloc qui tient debout sans données ————— */} -
+ {/* ————— Ce que chacun des deux gestes veut dire, avant d'écrire ————— */} +

- Une année à l’école + Avant de vous décider

- Ce qu’on fabrique entre parents. + Ce que ça veut dire, précisément.

-
    - {MOMENTS.map(({ titre, texte }, index) => ( -
  • -

    - {titre} +

    +
    +

    + Devenir membre +

    +
      + {CE_QU_ADHERER_VEUT_DIRE.map((ligne) => ( +
    • +
    • + ))} +
    +

    + Vous ne savez pas si votre famille est déjà adhérente cette + année ? Écrivez-nous, on vérifie. +

    + {ecritureOuverte && ( + + Demander à adhérer + + (plus bas sur cette page) + + )} +
    + +
    +

    + Venir prêter main-forte +

    +
      + {CE_QUE_LE_COUP_DE_MAIN_VEUT_DIRE.map((ligne) => ( +
    • +
    • + ))} +
    +

    + Vous ne savez pas encore quand vous serez libre ? + Dites-le-nous : on vous prévient quand il manque des bras. +

    + +
    +
    + +

    + On peut adhérer sans jamais tenir un stand, et donner un coup de + main sans être adhérent. Les deux se choisissent séparément, et + les deux comptent. +

    +
+
+ + {/* ————— Le formulaire, remonté ici : plus aucune section ne s'interpose ————— */} +
+
+
+ {/* Sur téléphone, le formulaire passe devant : l'aside poussé + en tête coûtait six cents pixels avant le premier champ, à un + parent qui vient précisément pour écrire. En grand écran les + deux sont côte à côte et l'ordre redevient naturel. */} + + +
+ {/* Les deux portes atterrissent ici. Cibles focalisables : un + lecteur d'écran et un clavier suivent le saut, au lieu de + rester dans le héros pendant que la page défile. */} +
+
+ +
+ {contactEmail ? ( + <> +

+ Écrire à {settings.associationName} +

+

+ Quelques mots suffisent. +

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

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

+ + )} +
- {/* ————— La porte basse : les rendez-vous réels, où l'on prend un créneau sans compte ————— */} -
+ {/* ————— Respiration : une phrase, un aplat, aucune demande ————— */} +
+
+ + {/* ————— La preuve : les rendez-vous réels, où l'on prend un créneau sans compte ————— */} +
@@ -453,15 +665,18 @@ export default async function RejoindrePage() { L’agenda fait une pause

- L’agenda fait une pause. La prochaine date apparaîtra ici ; - écrivez-nous, on vous préviendra. + La prochaine date apparaîtra ici + {ecritureOuverte + ? " ; écrivez-nous, on vous préviendra." + : "."}

- - Écrire à l’association - + {ecritureOuverte && ( + + Prévenez-moi à la prochaine date + + (plus haut sur cette page) + + )}
) : ( <> @@ -512,7 +727,14 @@ export default async function RejoindrePage() { {event.restantes > 0 && !event.reunion ? "Se proposer" : "Voir le rendez-vous"} - +
@@ -523,7 +745,7 @@ export default async function RejoindrePage() {
- +

@@ -534,22 +756,73 @@ export default async function RejoindrePage() { {events.length > 3 && ( Voir tous les rendez-vous - +

)} + + {ecritureOuverte && ( +
+

+ Vous préférez adhérer sans prendre de créneau ? +

+ + Je veux adhérer + + (plus haut sur cette page) + +
+ )}
- {/* ————— Les missions : la théorie, compacte et rétrogradée ————— */} + {/* ————— Ce qu'on fabrique : le seul bloc qui tient debout sans données ————— */}
-
+
+

+ Une année à l’école +

+

+ Ce qu’on fabrique entre parents. +

+
+ +
    + {MOMENTS.map(({ titre, texte }, index) => ( +
  • +

    + {titre} +

    +
    +
    +
  • + ))} +
+
+
+ + {/* ————— Les missions : la théorie, compacte et rétrogradée ————— */} +
+
+

L’association, en bref

@@ -597,7 +870,10 @@ export default async function RejoindrePage() { {/* ————— Le groupe WhatsApp : le canal où tout se suit au fil de l'eau ————— */} {settings.whatsappGroupUrl && ( -
+
)} - {/* ————— Le coupon-réponse : ce qui se passe après l'envoi, puis le formulaire ————— */} -
+ {/* ————— Rappel final : les deux mêmes portes, pour qui a tout lu ————— */} +
-
- - -
- {contactEmail ? ( - <> -

- Écrire à {settings.associationName} -

-

- Quelques mots suffisent. -

- -
-

- Trois façons de commencer -

-
    - {AMORCES.map((amorce) => ( -
  • -
  • - ))} -
-
- -
- -
- - ) : ( - <> -

- 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. -

- - )} -
+ ) : ( + + Voir les rendez-vous + + (plus haut sur cette page) + + )}
Retour à l’accueil diff --git a/src/components/join-form.tsx b/src/components/join-form.tsx index 704946c..2dc5974 100644 --- a/src/components/join-form.tsx +++ b/src/components/join-form.tsx @@ -2,10 +2,97 @@ import { Send } from "lucide-react"; import Link from "next/link"; -import { useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { api } from "@/lib/client"; import { useRecaptcha } from "@/lib/use-recaptcha"; +import { JOIN_INTENTIONS, type JoinIntention } from "@/lib/validation"; + +/** + * Le formulaire public de « Rejoindre l'association ». + * + * Il commence par demander ce qui amène le visiteur, et c'est tout l'objet de + * la refonte : la page confondait deux démarches indépendantes — devenir membre + * et venir donner un coup de main un samedi. Un parent venu adhérer ne trouvait + * nulle part le mot « adhérer », et le bureau recevait des demandes d'adhésion + * rédigées comme des propositions de bénévolat. + * + * Les options sont lues, pas repliées dans une liste déroulante : c'est ce + * menu-là qui apprend l'adhésion à qui ne la soupçonnait pas. Aucune n'est + * cochée d'avance, et `required` fait le reste — la validation native suffit, + * comme pour les autres champs. + */ + +const INTENTIONS: { + valeur: JoinIntention; + titre: string; + aide: string; +}[] = [ + { + valeur: "adherer", + titre: "Je souhaite adhérer à l’association", + aide: "Devenir membre pour cette année scolaire.", + }, + { + valeur: "coup_de_main", + titre: "Je peux donner un coup de main", + aide: "Venir sur un rendez-vous, sans adhérer.", + }, + { + valeur: "les_deux", + titre: "Les deux", + aide: "Adhérer, et venir quand il manque des bras.", + }, + { + valeur: "question", + titre: "J’ai une question", + aide: "Sur l’association, l’adhésion, un rendez-vous.", + }, +]; + +/** + * Les deux portes de la page mènent ici. Le pré-cochage écoute le clic autant + * que le hash : re-cliquer sur l'ancre déjà atteinte n'émet aucun + * `hashchange`, et c'est exactement le parcours du parent qui redescend, + * change d'avis, puis remonte cliquer « Je veux adhérer ». + */ +const PAR_ANCRE: Record = { + "#adherer": "adherer", + "#coup-de-main": "coup_de_main", +}; + +const AMORCES: Record = { + adherer: [ + "Je souhaite adhérer à l’association : quelle est la marche à suivre ?", + "Est-ce que ma famille est déjà adhérente pour cette année scolaire ?", + "Je voudrais adhérer, mais je n’ai pas de temps à donner cette année — est-ce que c’est possible ?", + ], + coup_de_main: [ + "Je peux donner un coup de main sur un rendez-vous, prévenez-moi.", + "Comptez sur moi au prochain rendez-vous.", + ], + les_deux: [ + "Je souhaite adhérer, et je peux venir donner un coup de main quand il manque des bras.", + ], + question: [ + "J’aimerais comprendre ce que fait l’association avant de m’engager.", + ], +}; + +const PLACEHOLDERS: Record = { + adherer: + "Le prénom de votre enfant, sa classe, et votre question s’il y en a une…", + coup_de_main: "Ce que vous pouvez faire, les moments où vous êtes libre…", + les_deux: "Le prénom de votre enfant, sa classe, et vos disponibilités…", + question: "Ce que vous aimeriez savoir…", +}; + +const LIBELLES_ENVOI: Record = { + adherer: "Envoyer ma demande d’adhésion", + coup_de_main: "Envoyer ma proposition", + les_deux: "Envoyer ma demande", + question: "Envoyer ma question", +}; export function JoinForm({ contactEmail, @@ -19,17 +106,65 @@ export function JoinForm({ }) { const executerRecaptcha = useRecaptcha(recaptchaSiteKey); const [loading, setLoading] = useState(false); - const [sent, setSent] = useState(false); + const [sent, setSent] = useState(null); const [error, setError] = useState(null); + const [intention, setIntention] = useState(null); + const [message, setMessage] = useState(""); + const groupe = useRef(null); + + // Une porte vient d'être empruntée : on coche à la place du parent, et on + // amène le focus sur l'option retenue pour qui navigue au clavier. Le + // défilement reste au navigateur ; les deux ne se battent pas. + // + // Le focus attend l'image suivante à dessein : suivre un lien de fragment + // déplace le focus sur la cible, et cela se produit APRÈS le gestionnaire de + // clic. Focaliser tout de suite, c'est se faire écraser une ligne plus loin. + const emprunter = useCallback((choix: JoinIntention, focaliser: boolean) => { + setIntention(choix); + if (!focaliser) return; + requestAnimationFrame(() => { + groupe.current + ?.querySelector(`input[value="${choix}"]`) + ?.focus({ preventScroll: true }); + }); + }, []); + + useEffect(() => { + const depuisHash = (focaliser: boolean) => { + const choix = PAR_ANCRE[window.location.hash]; + if (choix) emprunter(choix, focaliser); + }; + depuisHash(false); + + const surHash = () => depuisHash(true); + const surClic = (event: MouseEvent) => { + const lien = (event.target as Element | null)?.closest?.("a[href]"); + const href = lien?.getAttribute("href"); + if (href && PAR_ANCRE[href]) emprunter(PAR_ANCRE[href], true); + }; + + window.addEventListener("hashchange", surHash); + document.addEventListener("click", surClic); + return () => { + window.removeEventListener("hashchange", surHash); + document.removeEventListener("click", surClic); + }; + }, [emprunter]); async function onSubmit(event: React.FormEvent) { event.preventDefault(); setError(null); setLoading(true); const form = new FormData(event.currentTarget); + const choisie = form.get("intention"); try { await api("/api/join", { body: { + intention: + typeof choisie === "string" && + (JOIN_INTENTIONS as readonly string[]).includes(choisie) + ? choisie + : undefined, name: form.get("name"), email: form.get("email"), phone: form.get("phone") || undefined, @@ -39,7 +174,7 @@ export function JoinForm({ recaptchaToken: await executerRecaptcha("contact"), }, }); - setSent(true); + setSent(intention ?? "inconnue"); } catch (err) { setError((err as Error).message); } finally { @@ -48,14 +183,19 @@ export function JoinForm({ } if (sent) { + const adhesion = sent === "adherer" || sent === "les_deux"; return (

- Message envoyé, merci ! + {adhesion + ? "Demande d’adhésion envoyée, merci !" + : "Message envoyé, merci !"}

- Un parent de l’équipe vous répondra par e-mail. Sans nouvelles d’ici - quelques jours, n’hésitez pas à relancer + {adhesion + ? "Un parent du bureau vous répond par e-mail avec le montant de la cotisation pour cette année scolaire et la marche à suivre pour régler." + : "Un parent de l’équipe vous répondra par e-mail."}{" "} + Comptez quelques jours ; sans nouvelles, n’hésitez pas à relancer {contactEmail ? ( <> {" "} @@ -70,6 +210,15 @@ export function JoinForm({ ) : null} .

+ {sent === "coup_de_main" && ( +

+ En attendant, vous pouvez déjà voir les créneaux ouverts :{" "} + + les prochains rendez-vous + + . +

+ )} {whatsappGroupUrl && (

En attendant notre réponse, vous pouvez déjà suivre ce qui se @@ -90,6 +239,8 @@ export function JoinForm({ ); } + const amorces = intention ? AMORCES[intention] : null; + return (

{error && ( @@ -98,6 +249,41 @@ export function JoinForm({

)} +
+ + Vous nous écrivez pour… + +
+ {INTENTIONS.map(({ valeur, titre, aide }) => ( + + ))} +
+
+
+ {amorces && ( +
+

+ Vous pouvez recopier une de ces phrases +

+
    + {amorces.map((amorce) => ( +
  • + +
  • + ))} +
+
+ )} +