Formulaire
Le point de contact : deux champs pour commencer, l’agenda à côté, le reste replié, le bouton qui nomme la conversation, et l’envoi par le relais.
Deux champs
Le point de contact de l’accueil, refait le 12 octobre : une grille de la trame. La tête sur toute la largeur, dans une cellule pâle où des cases s’allument ; dessous, deux portes de même rang, le formulaire court et l’agenda. Le formulaire : un e-mail, un type de projet, un bouton, et une mention quand il y en a une.
- Emploi
- Le formulaire court, en bas de l’accueil et de toute page qui finit par une conversation.
- Moment
- Une fois par page, dans une Section de classe tr-contact ; avec un suffixe si la page a déjà un formulaire.
- Jamais
- Avec un champ de plus pour commencer, ni un bouton qui parle d’argent.
Contact
Dites-nous ce que vous voulez lancer.
Un e-mail suffit pour commencer, on vous propose ensuite trente minutes. Ou réservez directement un créneau.
En trente minutes, on sait si on peut vous aider, et comment. Si ce n’est pas pour nous, on vous dit vers qui aller.
Réserver un créneau (nouvel onglet)<Section classe="tr-contact" titreId="contact-spec">
<Contact etiquette={V3.contact.surtitre} titre={V3.contact.titre} sousTitre={V3.contact.sousTitre} corps={V3.contact.corps}
cta={V3.cta} types={V3.contact.types} titreId="contact-spec" suffixe="spec" />
</Section> | Classe | Zone | Valeur |
|---|---|---|
| .tr-contact > .tr-in | la section | overflow hidden : rien ne sort des rails |
| .tr-joindre | la grille | tr-grille, data-lignes : elle se trace à l’arrivée ; une colonne, 1100 px au plus, centrée ; avec l’agenda (.tr-joindre--deux), deux colonnes égales dès 900 px |
| .tr-joindre__texte | la tête | une tr-cel sur toute la largeur (grid-column 1 / -1), fond var(--pale), padding clamp(28px, 4vw, 56px) clamp(22px, 3vw, 48px) ; la Tete à gauche, sans marge basse, le titre sur 20ch, le chapô sur 52ch |
| [data-cellules="calme"] | les cases qui s’allument | les cellules vivantes de trame.ts : des cases de 48 px, deux à la fois, une de plus toutes les 1,4 à 1,9 s ; le canevas (.tr-vivant) est masqué à gauche, vide jusqu’à 48 % de la largeur, plein dès 88 % : les cases s’allument à droite, loin du texte |
| .tr-form | la cellule du formulaire | une porte (.tr-joindre__porte) : dans .tr-joindre, sans largeur maximale ni marge ; padding clamp(22px, 3vw, 36px), fond surface, quatre traits pointillés ; rangées auto puis 1fr : le bouton (.full) se cale en bas |
| .form | la grille des champs | deux colonnes, 14 px d’écart ; une colonne sous 600 px, bouton pleine largeur et centré |
| .field label | le libellé | Inter 600 11 px (--t-label), .14em, capitales, ink-2 |
| .field input, select | le champ | padding 14 px 16 px, fond surface, bord 1 px ink-3, texte à la taille du corps (--t-body, posé par .tr-form) ; survol et focus : bord encre |
| :focus-visible | le focus | contour 2 px vert profond, décalé de 2 px |
| .tr-form .btn | le bouton | plein à l’encre, texte surface ; au survol le vert profond monte dessous en 236 ms |
| .xs.full | la mention | 12,5 px, ink-3, sur toute la largeur |
Le reste, replié
Le formulaire complet de la page contact (section #ecrire) : les deux mêmes champs, puis un pli « Le reste, si vous voulez » avec quatre champs facultatifs. La page le pose sous une tête centrée, entre les sept carrés de .tr-cellules. Il garde l’assemblage des deux portes d’avant le 12 octobre (.tr-portes), que le point de contact a quitté.
<Formulaire cta={V3.cta} types={V3.contact.types} suffixe="spec2" /> <Section classe="tr-contact" id="ecrire" titreId="tr-ecrire">
<div class="tr-cellules" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
<Tete variante="centre" etiquette={C.formulaire.etiquette} titre={C.formulaire.titre} sousTitre={C.formulaire.lead} id="tr-ecrire" />
<Formulaire cta={V3.cta} types={V3.contact.types} />
</Section> | Classe | Zone | Valeur |
|---|---|---|
| .tr-cellules | les carrés autour | posés par la page contact et par la candidature de la page carrières ; Contact ne les pose plus ; sept carrés de 47 px sur une grille de 48 px, détaillés dans Bandes et cellules allumées ; sous 860 px, deux restent, dans les coins du haut |
| .tr-portes | les deux portes | une colonne, 600 px au plus, centrée ; .tr-portes--complet : 720 px |
| .tr-portes--deux | quand l’agenda existe | dès 800 px : deux colonnes, 1,4fr et 1fr, 1000 px au plus avec le formulaire complet ; le formulaire perd son trait de droite |
| .tr-agenda | la cellule de l’agenda | fond pâle, le bouton plein centré, traits pointillés en haut, à droite et en bas ; le lien s’ouvre dans un nouvel onglet |
| .tr-form--complet | la cellule | 720 px au plus ; dans .tr-portes, sans largeur maximale |
| .tr-form__plus summary | le pli | Inter 600 14,5 px, encre, padding 12 px 0, un trait pointillé dessus et dessous, marqueur natif masqué ; focus : contour 2 px vert profond |
| .tr-faq__plus | la croix | 18 px, deux traits de 2 px vert profond ; tourne de 45° quand le pli est ouvert, en 380 ms (var(--mid)) |
| .tr-form__champs | les champs repliés | deux colonnes, 18 px d’écart, 18 px au-dessus ; une colonne sous 600 px |
| .tr-form__fac | « (facultatif) » | graisse 500, sans espacement ni capitales, ink-3 |
| textarea | le contexte | quatre lignes, padding 12 px 14 px, bord ink-3, texte --t-body, redimensionnable en hauteur |
| Champ | Libellé | Attributs |
|---|---|---|
| entreprise | Votre entreprise | text, autocomplete organization, maxlength 200 |
| budget | Votre budget | text, inputmode numeric, maxlength 100 |
| echeance | Votre échéance | select sans choix vide : Dès que possible, Dans les trois mois, Dans l’année, Je ne sais pas encore |
| contexte | Ce qui se passe chez vous | textarea, rows 4, maxlength 10 000, sur toute la largeur |
Les libellés
Les libellés sont dans le composant, les types de projet dans V3.contact.types, le bouton dans V3.cta. Le label est en voix technique, le champ en texte courant.
| Champ | Libellé | Attributs |
|---|---|---|
| Votre e-mail | type email, autocomplete email, placeholder vous@entreprise.fr, maxlength 254, required ; id email-<suffixe> | |
| type | Type de projet | select sans choix vide, les 7 valeurs ci-dessous ; id type-<suffixe> |
| le bouton | Démarrer un projet | type submit, classe btn, flèche ↗ décorative |
| la mention | aucune aujourd’hui : V3.contact.mention est vide | p.xs.full, affichée si la prop mention est donnée |
| Rang | Type de projet | Où |
|---|---|---|
| 1 | Site internet | option du select, valeur égale au libellé |
| 2 | E-commerce | option du select, valeur égale au libellé |
| 3 | Application mobile | option du select, valeur égale au libellé |
| 4 | Logiciel métier | option du select, valeur égale au libellé |
| 5 | IA et automatisation | option du select, valeur égale au libellé |
| 6 | Marketing digital | option du select, valeur égale au libellé |
| 7 | Je ne sais pas encore | option du select, valeur égale au libellé |
Le suffixe des identifiants vaut « trame » sur l’accueil et « contact » dans le formulaire complet. Quand une page pose deux formulaires, elle passe un suffixe à chacun : c’est ce que fait cette page, avec spec, spec2 et spec3.
La seconde porte
Deux portes de même rang, décidées au grill du 9 octobre : laisser son e-mail, ou réserver directement un créneau. L’agenda a son lien depuis le 9 octobre (V3.contact.agenda) : sa porte est la cellule de droite, avec la phrase des trente minutes (V3.contact.corps) puis le bouton. Sans lien, la porte disparaît : la phrase revient sous le titre, et le formulaire prend toute la largeur.
Contact
Dites-nous ce que vous voulez lancer.
Un e-mail suffit pour commencer, on vous propose ensuite trente minutes. Ou réservez directement un créneau.
En trente minutes, on sait si on peut vous aider, et comment. Si ce n’est pas pour nous, on vous dit vers qui aller.
<Contact etiquette={V3.contact.surtitre} titre={V3.contact.titre} sousTitre={V3.contact.sousTitre} corps={V3.contact.corps}
cta={V3.cta} types={V3.contact.types} agenda={null} />
<!-- Sans la prop agenda, le composant lit V3.contact.agenda : le lien de l'agenda, donné par JB le 9 octobre. À null, la porte disparaît. --> | Classe | Zone | Valeur |
|---|---|---|
| .tr-joindre--deux | quand l’agenda existe | dès 900 px : deux colonnes égales, le formulaire à gauche, l’agenda à droite, de même hauteur ; en dessous, l’un sous l’autre |
| .tr-joindre__agenda | la cellule de l’agenda | une tr-cel, sans fond pâle ; flex en colonne, calée à gauche, gap 22 px, padding clamp(22px, 3vw, 36px) ; la phrase en haut, le bouton plein en bas (space-between), à la hauteur du bouton du formulaire ; le lien s’ouvre dans un nouvel onglet |
| .tr-joindre__agenda .tr-corps | la phrase | sans marge, 44ch au plus |
| sous 600 px | écran étroit | le bouton de l’agenda prend toute la largeur, libellé centré, à la ligne s’il le faut, comme celui du formulaire |
| sans agenda | agenda à null | pas de .tr-joindre--deux : une colonne à toutes les largeurs ; le corps passe dans la Tete, sous le chapô |
| V3.contact.agenda | le lien | l’agenda Google de Grinn, donné par JB le 9 octobre ; à null, la porte disparaît |
| V3.contact.boutonAgenda | le libellé | « Réserver un créneau » |
Où ça part
Depuis le 12 octobre, les formulaires partent par le relais du serveur (server/formulaire.mjs) : la page poste la demande en JSON, le relais la vérifie puis l’envoie par e-mail avec l’API de Brevo, et le visiteur arrive sur /merci/, qui n’est pas indexée. Ni fichier ni base : le relais garde seulement en mémoire de quoi compter les envois et repérer les doublons.
Les champs de chaque formulaire sont enveloppés dans FormulaireEnvoi.astro, qui pose la zone d’état en tête, le champ piège en dernier, les textes des états, et le script commun (src/scripts/formulaire.ts), chargé une fois par page, qui branche chaque form[data-envoi]. Le formulaire court (Contact.astro), le complet (Formulaire.astro) et celui de l’accueil précédent (src/components/Formulaire.astro) partent ainsi tous les trois, et les trois spécimens de cette page avec eux.
Le chemin, en ligne : la page poste à /api/contact ; nginx passe /api/ au relais, sur 127.0.0.1:8787 dans le même conteneur, et refuse lui aussi un corps de plus de 32 Ko ; le relais appelle Brevo. Chaque demande partie devient un e-mail de « Site Grinn » aux adresses de FORMULAIRE_DESTINATAIRES, réglées sur le serveur : les champs, « non renseigné » pour ceux qui manquent, le contexte, la date (heure de Paris) et la page d’envoi. « Répondre » écrit au visiteur.
| Attribut | Valeur | Rôle |
|---|---|---|
| data-envoi | /api/contact | l’adresse du relais (ENVOI_FORMULAIRE, src/data/formulaire.ts), où le script poste la demande |
| action, method, enctype | mailto:agence@grinn.tech, post, text/plain | le repli sans script (rubrique 7) |
| novalidate | posé par le script | plus de bulle du navigateur : le script valide à l’envoi et dit lui-même ce qui manque ; sans script, l’attribut est absent et le navigateur valide |
| maxlength | e-mail 254 (les trois formulaires) ; entreprise 200, budget 100, contexte 10 000 (le complet) | ce que le relais garde au plus : rien n’est coupé en silence |
Ce que la page envoie
Le script vérifie l’e-mail avec la règle du relais, la même expression des deux côtés (MAIL) : 254 caractères au plus ; lettres, chiffres et . _ + ' - (l’apostrophe droite) avant l’arobase, sans point au début, à la fin ni doublé ; un domaine avec au moins un point. Seul l’e-mail est exigé. Puis il poste à /api/contact un objet JSON (Content-Type application/json), les valeurs nettoyées comme le relais le ferait (espaces autour et caractères de contrôle retirés, sauts de ligne gardés dans le contexte), sauf le champ piège, envoyé tel quel. Il attend la réponse 25 s au plus.
| Champ | Ce que la page envoie | Ce que le relais en fait |
|---|---|---|
| toujours ; la page ne poste qu’une adresse valide | obligatoire : manquant ou invalide, 400 ; « Répondre » écrit à cette adresse | |
| type | toujours : le menu n’a pas de choix vide, la valeur affichée part même si le visiteur n’y a pas touché | 100 caractères au plus |
| entreprise, budget | dans le formulaire complet seulement, vides ou non | 200 et 100 caractères au plus ; vides : « non renseignée », « non renseigné » |
| echeance | dans le formulaire complet, et seulement si le visiteur a changé le menu (ou si le navigateur le rend changé au retour sur la page) : le menu n’a pas de choix vide | 100 caractères au plus ; absente : « non renseignée » |
| contexte | dans le formulaire complet seulement, sur plusieurs lignes | 10 000 caractères au plus, sauts de ligne gardés ; vide : « non renseigné » |
| site_web | le champ piège, tel quel : vide pour un visiteur | rempli : ok-filtre, rien ne part (rubrique 7) |
| page | location.pathname, le chemin de la page d’envoi | cité dans l’e-mail, après l’origine de la demande |
| tout autre champ | aucun | ignoré |
Ce que le relais répond
Un mot, en texte, que la page lit avec le statut : 200 avec « ok » ou « ok-filtre », 429 avec « limite ». Tout le reste donne le texte erreur, y compris un 200 ou un 429 qui ne porte pas ce mot, comme une page HTML de nginx.
| Réponse | Quand | Ce que fait la page |
|---|---|---|
| 200 ok | la demande est partie chez Brevo | /merci/ ; le bouton garde son libellé d’envoi jusque-là ; la marque grinn-lead est posée en sessionStorage, que rien ne lit encore : la mesure du lead reste à poser |
| 200 ok-filtre | champ piège rempli ; ou doublon : le même e-mail, sans égard aux majuscules, avec les mêmes réponses dans les dix minutes, ou la même demande déjà en route vers Brevo. Rien n’est envoyé | /merci/, et la marque grinn-lead est retirée |
| 429 limite | plus de cinq demandes de la même adresse IP en dix minutes ; seules comptent celles qui sont allées jusqu’à Brevo. Retry-After dit quand réessayer | le texte limite, le bouton rendu |
| 400 refusé | demande illisible, e-mail manquant ou invalide (la page l’a déjà vérifié) | le texte erreur |
| 403 refusé | origine hors de FORMULAIRE_ORIGINES (l’en-tête Origin, ou à défaut Referer) | le texte erreur |
| 405, 413, 415 refusé | autre méthode que POST ; plus de 32 Ko ; corps qui n’est pas du JSON | le texte erreur |
| 500, 502, 503 erreur | erreur inattendue ; Brevo injoignable ou qui refuse l’envoi ; relais incomplet (une variable manque), ou plafond de 100 envois par heure atteint pour tout le site | le texte erreur |
| pas de réponse du relais | page HTML de nginx (502 si le relais ne tourne pas), coupure du réseau, 25 s sans réponse | le texte erreur |
Les états
Le bouton reste actif et rien ne se valide à la frappe : à l’envoi, la page dit ce qui manque, puis ce qui se passe. Les textes sont ceux de P.formulaire (pages-v3.ts, tenu par Sémantique) ; {email} y devient un lien mailto vers MARQUE.courriel, et la variante sansEmail sert si l’adresse vaut null.
| État | Ce qui se voit | Texte | Clé de P.formulaire |
|---|---|---|---|
| au repos | la zone d’état (.tr-form__etat) est là, vide, sortie du flux : rien ne bouge | aucun | aucune |
| bilan | la zone pleine, en tête du formulaire sur toute la largeur : fond var(--bg), trait d’encre de 3 px à gauche ; le titre, puis chaque erreur en lien vers son champ ; le focus va au premier champ refusé ; rien ne part | « Une erreur empêche l’envoi : » ; à partir de deux erreurs, « {n} erreurs empêchent l’envoi : » | bilan.une, bilan.plusieurs ({n} : le nombre d’erreurs ; seul l’e-mail est exigé, la forme à plusieurs ne sert pas aujourd’hui) |
| champ refusé | .field.is-erreur : le bord passe à 2 px d’encre sans rien décaler ; le message dessous (p.tr-form__msg), derrière une case pleine de 8 px ; aria-invalid, et le message relié par aria-describedby | « Indiquez votre adresse e-mail. » (vide) ou « Cette adresse e-mail n’est pas valide. » (refusé par la règle) | champs.emailManquant, champs.emailInvalide |
| envoi | le bouton prend le libellé d’envoi, sans flèche ; .is-envoi pose le pointeur d’attente ; un seul envoi à la fois : un double clic ou Entrée pendant l’envoi ne font pas une seconde demande ; 25 s au plus | « Envoi en cours » | envoi |
| erreur | la zone en tête, l’adresse en lien ; le bouton rendu | « L’envoi n’a pas abouti. Réessayez dans un instant, ou écrivez-nous à agence@grinn.tech. » | erreur.titre, erreur.texte, erreur.sansEmail |
| limite | la zone en tête, l’adresse en lien ; le bouton rendu | « Plusieurs demandes sont déjà parties. Réessayez dans dix minutes, ou écrivez-nous à agence@grinn.tech. » | limite.titre, limite.texte, limite.sansEmail |
| réussite | la page /merci/ ; au retour arrière, le bouton reprend son libellé | celui de /merci/ | aucune |
La zone d’état est une zone aria-live polite et aria-atomic, présente vide dès le chargement : une zone créée au moment de l’annonce n’est pas toujours lue. Vide, elle sort du flux sans quitter l’arbre d’accessibilité. Pleine, elle revient à l’écran si elle en est sortie ou si son haut passe sous l’en-tête fixe : son scroll-margin-top de 96 px la pose sous l’en-tête. L’annonce polie plutôt qu’une alerte, pour ne pas couper celle du champ qui reçoit le focus, reste à valider avec un lecteur d’écran (Accessibilité).
Dans le point de contact, la zone pleine ajoute une rangée à la grille (auto auto 1fr) : le bouton du formulaire reste en bas, aligné sur celui de l’agenda. Pas de rouge, la marque n’en a pas : l’erreur se dit à l’encre. Sur la bande sombre de l’accueil précédent, le message et le bord du champ refusé passent au blanc (var(--surface)) ; la zone garde son fond clair.
Le piège et le repli
Deux cas que le script ne règle pas seul : le robot qui remplit tous les champs, et le visiteur sans JavaScript.
Le champ piège
Un champ site_web (input type text, tabindex -1, autocomplete off), sans libellé, dans un conteneur aria-hidden (.tr-form__piege) posé à 10 000 px à gauche : un visiteur ne le voit pas, ne l’atteint pas au clavier, un lecteur d’écran ne le lit pas. Rempli, c’est un robot : le script l’envoie tel quel, le relais répond ok-filtre sans rien envoyer, et le robot arrive sur /merci/ comme un visiteur. C’est le dernier champ du formulaire, pour fermer le message du repli sans script.
Sans script
Le formulaire porte action mailto:agence@grinn.tech, method post et enctype text/plain (REPLI_FORMULAIRE, tiré de MARQUE.courriel), comme sur Hepix. Sans script, novalidate n’est pas posé : le navigateur exige l’e-mail (required, type email), avec sa propre règle, plus large que celle du relais. Puis il passe la main à la messagerie du visiteur : un message à agence@grinn.tech, dont le corps liste les champs, une ligne nom=valeur chacun, de email à site_web, vide, en dernier. Dans le formulaire complet, l’échéance y figure toujours (Dès que possible si le visiteur n’y a pas touché). Le visiteur envoie lui-même ce message : le relais n’y est pour rien, ni le piège, ni la limite, ni /merci/, ni la marque du lead.
Limites relevées dans Chrome 154 le 12 octobre (Firefox et Safari non essayés) : les accents arrivent abîmés (« métier » en « métier », « € » en « € »), avec ou sans accept-charset, et chaque « & » saisi coupe la ligne ; l’adresse du visiteur arrive intacte. Hepix a le même défaut. Garder ce repli ou en changer est à trancher par JB ; un autre repli demanderait un texte à Sémantique.
Si MARQUE.courriel repasse à null, il n’y a plus de repli : le formulaire perd son action, se poste sans script sur sa propre page, que nginx refuse (405), et la demande est perdue. Un repli serait alors à décider.
En ligne, en local
Le relais tourne dans le conteneur du site, à côté de nginx : server/demarrer-formulaire.sh le lance sous l’utilisateur nginx et le relance s’il s’arrête. Il ne lit ses réglages que dans l’environnement : la clé Brevo n’est ni dans le dépôt ni dans les pages. Le pas-à-pas est dans DEPLOY.md.
À poser dans Dokploy, avant le déploiement
Dans l’onglet Environment de l’application :
| Variable | Obligatoire | Rôle |
|---|---|---|
| BREVO_API_KEY | oui | la clé API Brevo ; jamais dans le dépôt, ni dans un fichier, ni dans un message |
| BREVO_EXPEDITEUR | oui | l’adresse d’envoi, seule, sans nom, validée dans Brevo : expéditeur vérifié, ou domaine grinn.tech authentifié |
| FORMULAIRE_DESTINATAIRES | non | qui reçoit les demandes, adresses séparées par des virgules ; réglée sur le serveur |
| FORMULAIRE_ORIGINES | non | les sites admis à envoyer ; par défaut https://grinn.tech, https://www.grinn.tech, https://staging.site.grinn.tech et http://localhost:4321. La variable remplace toute la liste : à poser seulement si un autre domaine sert le site, avec toutes les origines voulues |
| FORMULAIRE_PORT, BREVO_API_URL | non, à laisser | le port du relais (8787, celui de nginx.conf) et l’adresse de l’API de Brevo, à changer seulement pour un essai |
Sans les deux premières, ou avec une adresse invalide, le relais démarre incomplet et répond 503 à toute demande valide : le visiteur voit le texte erreur. Après le déploiement, /api/contact/etat répond « pret » (« incomplet » si une variable manque ou n’est pas une adresse valide, 502 si le relais ne tourne pas) ; puis un envoi depuis /contact/ doit mener à /merci/, et l’e-mail arriver. Si le journal du conteneur dit que Brevo refuse l’envoi (401), la clé est fausse, ou l’adresse IP du serveur n’est pas encore autorisée dans Brevo.
En développement
astro.config.mjs passe /api au relais lancé à part sur 127.0.0.1:8787 (vite.server.proxy) : le serveur de développement seulement, le build n’en garde rien ; en ligne, c’est nginx. Sans relais, les formulaires du serveur de développement affichent le texte erreur. Le relais se lance avec une fausse clé et un faux Brevo (DEPLOY.md, En local). Parmi les adresses locales, il n’admet par défaut que http://localhost:4321, le port d’astro dev : sur un autre port, comme 4410, le lancer avec FORMULAIRE_ORIGINES=http://localhost:4410, sinon il répond 403 et la page affiche le texte erreur.
RègleDeux champs suffisent pour écrire, et l’agenda est l’autre porte, de même rang ; tout le reste est facultatif, et le dit.