Aller au contenu
Démarrer un projet
Menu

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)
SpécimenLa section contact de l’accueil, avec les textes de V3.contact, le bouton V3.cta et le lien de l’agenda. Dès 900 px, le formulaire et l’agenda côte à côte, les deux boutons alignés en bas ; en dessous, l’un sous l’autre. Les identifiants des champs portent le suffixe spec. Ce formulaire est branché comme les autres : envoyé depuis cette page en ligne, il part pour de bon.
Appelsrc/components/trame/Contact.astro
<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>
ClasseZoneValeur
.tr-contact > .tr-inla sectionoverflow hidden : rien ne sort des rails
.tr-joindrela grilletr-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__textela têteune 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’allumentles 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-formla cellule du formulaireune 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
.formla grille des champsdeux colonnes, 14 px d’écart ; une colonne sous 600 px, bouton pleine largeur et centré
.field labelle libelléInter 600 11 px (--t-label), .14em, capitales, ink-2
.field input, selectle champpadding 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-visiblele focuscontour 2 px vert profond, décalé de 2 px
.tr-form .btnle boutonplein à l’encre, texte surface ; au survol le vert profond monte dessous en 236 ms
.xs.fullla mention12,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é.

Le reste, si vous voulez
SpécimenLe formulaire complet, replié, et la cellule de l’agenda à côté dès 800 px. Ouvrez le pli : l’entreprise, le budget, l’échéance et le contexte, tous marqués (facultatif). Branché lui aussi.
Appelsrc/components/trame/Formulaire.astro
<Formulaire cta={V3.cta} types={V3.contact.types} suffixe="spec2" />
Appelsrc/pages/contact.astro
<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>
ClasseZoneValeur
.tr-cellulesles carrés autourposé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-portesles deux portesune colonne, 600 px au plus, centrée ; .tr-portes--complet : 720 px
.tr-portes--deuxquand l’agenda existedè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-agendala cellule de l’agendafond 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--completla cellule720 px au plus ; dans .tr-portes, sans largeur maximale
.tr-form__plus summaryle pliInter 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__plusla croix18 px, deux traits de 2 px vert profond ; tourne de 45° quand le pli est ouvert, en 380 ms (var(--mid))
.tr-form__champsles champs repliésdeux 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
textareale contextequatre lignes, padding 12 px 14 px, bord ink-3, texte --t-body, redimensionnable en hauteur
ChampLibelléAttributs
entrepriseVotre entreprisetext, autocomplete organization, maxlength 200
budgetVotre budgettext, inputmode numeric, maxlength 100
echeanceVotre échéanceselect sans choix vide : Dès que possible, Dans les trois mois, Dans l’année, Je ne sais pas encore
contexteCe qui se passe chez voustextarea, 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.

ChampLibelléAttributs
emailVotre e-mailtype email, autocomplete email, placeholder vous@entreprise.fr, maxlength 254, required ; id email-<suffixe>
typeType de projetselect sans choix vide, les 7 valeurs ci-dessous ; id type-<suffixe>
le boutonDémarrer un projettype submit, classe btn, flèche ↗ décorative
la mentionaucune aujourd’hui : V3.contact.mention est videp.xs.full, affichée si la prop mention est donnée
RangType de projetOù
1Site internetoption du select, valeur égale au libellé
2E-commerceoption du select, valeur égale au libellé
3Application mobileoption du select, valeur égale au libellé
4Logiciel métieroption du select, valeur égale au libellé
5IA et automatisationoption du select, valeur égale au libellé
6Marketing digitaloption du select, valeur égale au libellé
7Je ne sais pas encoreoption 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.

SpécimenLe même bloc Contact sans agenda (agenda à null) : la phrase sous le chapô, dans la cellule pâle, et le formulaire seul sur toute la largeur de la grille.
Appelsrc/components/trame/Contact.astro
<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. -->
ClasseZoneValeur
.tr-joindre--deuxquand l’agenda existedè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__agendala cellule de l’agendaune 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-corpsla phrasesans marge, 44ch au plus
sous 600 pxécran étroitle bouton de l’agenda prend toute la largeur, libellé centré, à la ligne s’il le faut, comme celui du formulaire
sans agendaagenda à nullpas de .tr-joindre--deux : une colonne à toutes les largeurs ; le corps passe dans la Tete, sous le chapô
V3.contact.agendale lienl’agenda Google de Grinn, donné par JB le 9 octobre ; à null, la porte disparaît
V3.contact.boutonAgendale 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.

AttributValeurRôle
data-envoi/api/contactl’adresse du relais (ENVOI_FORMULAIRE, src/data/formulaire.ts), où le script poste la demande
action, method, enctypemailto:agence@grinn.tech, post, text/plainle repli sans script (rubrique 7)
novalidateposé par le scriptplus 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
maxlengthe-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.

ChampCe que la page envoieCe que le relais en fait
emailtoujours ; la page ne poste qu’une adresse valideobligatoire : manquant ou invalide, 400 ; « Répondre » écrit à cette adresse
typetoujours : 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, budgetdans le formulaire complet seulement, vides ou non200 et 100 caractères au plus ; vides : « non renseignée », « non renseigné »
echeancedans 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 vide100 caractères au plus ; absente : « non renseignée »
contextedans le formulaire complet seulement, sur plusieurs lignes10 000 caractères au plus, sauts de ligne gardés ; vide : « non renseigné »
site_weble champ piège, tel quel : vide pour un visiteurrempli : ok-filtre, rien ne part (rubrique 7)
pagelocation.pathname, le chemin de la page d’envoicité dans l’e-mail, après l’origine de la demande
tout autre champaucunignoré

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éponseQuandCe que fait la page
200 okla 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-filtrechamp 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 limiteplus 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éessayerle 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 JSONle texte erreur
500, 502, 503 erreurerreur inattendue ; Brevo injoignable ou qui refuse l’envoi ; relais incomplet (une variable manque), ou plafond de 100 envois par heure atteint pour tout le sitele texte erreur
pas de réponse du relaispage HTML de nginx (502 si le relais ne tourne pas), coupure du réseau, 25 s sans réponsele 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.

ÉtatCe qui se voitTexteClé de P.formulaire
au reposla zone d’état (.tr-form__etat) est là, vide, sortie du flux : rien ne bougeaucunaucune
bilanla 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
envoile 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
erreurla 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
limitela 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éussitela 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 :

VariableObligatoireRôle
BREVO_API_KEYouila clé API Brevo ; jamais dans le dépôt, ni dans un fichier, ni dans un message
BREVO_EXPEDITEURouil’adresse d’envoi, seule, sans nom, validée dans Brevo : expéditeur vérifié, ou domaine grinn.tech authentifié
FORMULAIRE_DESTINATAIRESnonqui reçoit les demandes, adresses séparées par des virgules ; réglée sur le serveur
FORMULAIRE_ORIGINESnonles 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_URLnon, à laisserle 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.