Formulaire de feedback pour un site ou une application

Formulaire de feedback pour un site ou une application7 min de lecture

Créer un widget de feedback flottant pour votre application React

Un bouton de feedback flottant pour une application React qui ouvre un petit panneau, envoie l'écran, la version et le compte avec chaque réponse, et ne perd jamais un brouillon.

Preet SutharFondateur, Ikiform

Affichez un bouton fixe qui ouvre un panneau construit sur l'API Popover du navigateur : il passe au-dessus de toutes les couches de votre application sans bataille de z-index et se ferme avec Échap. À l'envoi, ajoutez la route courante, la version de l'application, l'id du compte et le viewport aux données du formulaire, puis envoyez-les depuis le navigateur avec le SDK Ikiform. N'envoyez l'e-mail de l'utilisateur que s'il coche une case pour demander une réponse.

Ce qui change avec un widget dans l'application

Un formulaire de page attend qu'on fasse défiler jusqu'à lui. Un widget est présent sur chaque écran, ce qui change ce qui peut mal tourner. Il doit passer au-dessus de vos propres modales, menus et toasts. Il ne doit pas perdre un message à moitié écrit quand quelqu'un clique à côté. Et comme les gens sont connectés, vous savez déjà qui ils sont et où ils se trouvent : le widget doit donc l'envoyer au lieu de le demander.

La vie d'une session du widget de feedback, du clic à la réponse stockéeBoutondefeedbackfixe, enbas àdroitelepanneaus'ouvretop layer,nouvelleclél'utilisateurnote etécritl'état vitdans lepanneauenvoiaveccontexteroute,version,compte,viewportstockéune foismême clésur undoublecliclepanneausemasquebrouillongardéclicSDKclic extérieur ou ÉchapLa vie d'une session du widget de feedback, du clic à la réponse stockéeBouton defeedbackfixe, en bas à droitele panneau s'ouvretop layer, nouvelleclél'utilisateurnote et écritl'état vit dans lepanneauenvoi aveccontexteroute, version, compte,viewportstocké une foismême clé sur un doubleclicle panneause masquebrouillongardéclicSDKclic extérieur ou Échap
  1. Le bouton est toujours à l'écran, fixé dans le coin inférieur droit.
  2. Un clic ouvre le panneau dans la top layer du navigateur et crée une nouvelle clé de tentative.
  3. L'utilisateur choisit un visage et écrit une note. L'état du formulaire reste monté, que le panneau soit ouvert ou masqué.
  4. À l'envoi, le widget ajoute la route, la version de l'application, l'id du compte et le viewport, et envoie avec le SDK.
  5. Ikiform le stocke une seule fois, même si le bouton est pressé deux fois, car les deux envois portent la même clé d'idempotence.
  6. Si l'utilisateur clique à côté ou appuie d'abord sur Échap, le panneau se masque et le brouillon est toujours là la fois suivante.
La fermeture au clic extérieur est le comportement par défaut de l'API Popover. Comme le panneau est seulement masqué, le champ de texte garde ce qui a été tapé.

Des champs pour une application

Aperçu

Exécute le validateur d'Ikiform dans votre navigateur. Rien n'est envoyé.

Comment ça se passe ?
Les utilisateurs ne voient que les trois premiers champs. Les autres sont ajoutés dans le code à l'envoi. may_reply est une case à cocher, stockée comme true ou false.
ChampProvient dePourquoi
ratingL'utilisateur, cinq visagesUn champ rating avec max: 5. Un visage se choisit plus vite qu'un chiffre
messageLa personne elle-mêmeFacultatif. Surtout utile quand le visage est mécontent
may_reply, emailUne case à cocher, puis votre sessionL'e-mail n'est envoyé que s'ils demandent une réponse, et Répondre dans la notification leur écrit
routewindow.location.pathnameQuel écran. Lu à l'envoi, après toute navigation côté client
app_versionVotre buildVous dit si une plainte concerne du code que vous avez déjà modifié
account_idVotre sessionPermet de retrouver son offre et ses données. Ce n'est pas une preuve d'identité, voir plus bas
viewportwindow.innerWidth et innerHeightLes plaintes de mise en page dépendent souvent de la taille

Le composant

Il a besoin de @ikiform/sdk et de React 19, qui type popover, popoverTarget et l'événement toggle. Le popoverTarget du bouton le relie au panneau, donc le navigateur gère l'ouverture, Échap et le retour du focus sur le bouton.

components/feedback-widget.tsx
import { submit } from "@ikiform/sdk";
import { type FormEvent, type ToggleEvent, useId, useRef, useState } from "react";
import "./feedback-widget.css";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";
const FACES = ["Awful", "Bad", "Okay", "Good", "Great"];

interface Props {
  accountId: string;
  email: string;
  appVersion: string;
}

export function FeedbackWidget({ accountId, email, appVersion }: Props) {
  const panelId = useId();
  const attempt = useRef("");
  const [status, setStatus] = useState<"idle" | "sending" | "sent">("idle");
  const [error, setError] = useState("");

  function onToggle(event: ToggleEvent<HTMLDivElement>) {
    if (event.newState !== "open") return;
    attempt.current = crypto.randomUUID();
    if (status === "sent") setStatus("idle");
  }

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    if (data.get("may_reply") === "true") data.set("email", email);
    data.set("route", window.location.pathname);
    data.set("app_version", appVersion);
    data.set("account_id", accountId);
    data.set("viewport", `${window.innerWidth}x${window.innerHeight}`);
    setStatus("sending");
    setError("");
    const result = await submit(FORM_ID, data, { idempotencyKey: attempt.current });
    if (result.ok) {
      setStatus("sent");
      return;
    }
    setError(result.error.fields?.rating?.[0] ?? result.error.message);
    setStatus("idle");
  }

  return (
    <>
      <button type="button" className="feedback-trigger" popoverTarget={panelId}>
        Feedback
      </button>
      <div id={panelId} popover="auto" className="feedback-panel" onToggle={onToggle} aria-label="Send feedback">
        {status === "sent" ? (
          <p role="status">Thanks, that helps.</p>
        ) : (
          <form onSubmit={onSubmit}>
            <fieldset>
              <legend>How is it going?</legend>
              {FACES.map((face, index) => (
                <label key={face}>
                  <input type="radio" name="rating" value={index + 1} required /> {face}
                </label>
              ))}
            </fieldset>
            <label htmlFor={`${panelId}-message`}>Tell us more</label>
            <textarea id={`${panelId}-message`} name="message" rows={4} maxLength={4000} />
            <label>
              <input type="checkbox" name="may_reply" value="true" /> The team can reply to me by email
            </label>
            <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
            {error ? <p role="alert">{error}</p> : null}
            <button disabled={status === "sending"}>{status === "sending" ? "Sending" : "Send"}</button>
          </form>
        )}
      </div>
    </>
  );
}
  • popover="auto" place le panneau dans la top layer quand il est ouvert. Il s'affiche au-dessus de vos dialogues et menus sans z-index, et se ferme quand l'utilisateur clique ailleurs.
  • Les enfants du panneau restent montés quand il est masqué : un brouillon survit donc à un clic extérieur accidentel. Après un envoi réussi, la ligne de remerciement remplace le formulaire, ce qui le vide, et l'ouverture suivante affiche un formulaire vierge.
  • onToggle crée une nouvelle clé à chaque ouverture du panneau. submit() l'envoie dans l'en-tête Idempotency-Key, donc un double clic ne stocke qu'une réponse. Voir idempotence.
  • L'e-mail n'est renseigné que si la case est cochée. Décochée, la réponse porte l'id du compte mais aucune adresse.

Les styles par défaut du popover le centrent à l'écran. Ces règles l'ancrent plutôt au-dessus du bouton :

components/feedback-widget.css
.feedback-trigger {
  position: fixed;
  right: 16px;
  bottom: 16px;
}

.feedback-panel {
  inset: auto 16px 64px auto;
  margin: 0;
  width: min(360px, calc(100vw - 32px));
  max-height: calc(100dvh - 96px);
  overflow: auto;
}

Le monter une seule fois

Affichez le widget dans le layout qui enveloppe les écrans connectés, pour qu'il apparaisse partout et survive à la navigation. Dans le Next.js App Router, le layout est un server component qui lit la session et passe des props simples au widget client :

app/(app)/layout.tsx
import type { ReactNode } from "react";
import { FeedbackWidget } from "@/components/feedback-widget";
import { getSession } from "@/lib/session";

export default async function AppLayout({ children }: { children: ReactNode }) {
  const session = await getSession();
  return (
    <>
      {children}
      {session ? (
        <FeedbackWidget
          accountId={session.accountId}
          email={session.email}
          appVersion={process.env.NEXT_PUBLIC_APP_VERSION ?? "dev"}
        />
      ) : null}
    </>
  );
}

Marquez le fichier du widget avec "use client" dans l'App Router. Dans une application Vite ou React Router, affichez-le à côté de l'outlet de votre routeur. Il fonctionne de la même façon.

Ce que le navigateur peut prouver ou non

Ce widget envoie directement depuis le navigateur avec l'id public du formulaire. C'est le bon compromis pour du feedback : aucune route serveur à maintenir, et le meta.user_agent d'Ikiform est le vrai navigateur de l'utilisateur. Mais cela veut aussi dire que n'importe qui peut envoyer n'importe quel account_id. Servez-vous-en pour retrouver des informations, jamais pour accorder quoi que ce soit.

Si le compte doit être fiable, par exemple pour afficher le feedback dans un outil d'administration, envoyez depuis votre serveur avec la clé secrète et ajoutez-y le compte depuis la session. Les versions serveur du guide principal montrent comment faire ; choisissez la limite de débit Relaxed ou Custom, car chaque envoi vient alors de l'IP de votre serveur.

Vérifiez que tout fonctionne

  1. Dans Allowed domains du formulaire, ajoutez le domaine de votre site, plus localhost pendant le développement. Laissez vide pour accepter les envois de partout.
  2. Ouvrez le panneau, tapez une note, cliquez à côté, puis rouvrez-le. La note est toujours là.
  3. Ouvrez une modale dans votre application, puis le widget. Le panneau s'affiche au-dessus de la modale.
  4. Appuyez sur Échap. Le panneau se ferme et le focus revient sur le bouton Feedback.
  5. Choisissez un visage et double-cliquez sur Envoyer. Submissions affiche une seule réponse, avec route, app_version et account_id remplis.
  6. Envoyez une fois avec la case de réponse cochée. Le bouton Répondre de l'e-mail de notification écrit à l'utilisateur.

Pièges

  • Éloignez le bouton de vos propres éléments flottants, comme une bulle de chat ou une bannière de cookies. Déplacez l'un d'eux, ou ouvrez plutôt le widget depuis un élément de menu.
  • N'affichez pas le bouton sur chaque écran d'un checkout ou d'un éditeur où les gens sont concentrés. Masquez-le là avec une prop.
  • L'API Popover est prise en charge par les versions actuelles de Chrome, Edge, Firefox et Safari. Si vous devez prendre en charge des navigateurs plus anciens, vérifiez avant de vous y fier.
  • Chaque champ ajouté dans le code doit exister dans la définition du formulaire, sinon Ikiform refuse l'envoi avec une erreur Unknown field.
  • N'envoyez pas tout l'état de l'application. Route, version, compte et taille répondent à la plupart des questions ; le reste pose une question de confidentialité.

Dépannage

ProblèmeQue faire
403 origin_not_allowed, ou tous les envois finissent en spamLe domaine de la page n'est pas dans les Allowed domains du formulaire. Ajoutez-le, plus localhost pour les tests en local. Les sous-domaines d'un domaine listé sont autorisés.
403 challenge_requiredLe spam check du formulaire est réglé sur Ikiform et un envoi fetch n'avait pas de jeton. Obtenez-en un avec challenge(formId) de @ikiform/sdk avant chaque envoi, ou désactivez la vérification.
Le panneau apparaît au milieu de l'écranLa feuille de style ne s'est pas chargée, donc ce sont les styles par défaut du popover qui s'appliquent. Vérifiez l'import CSS, ou définissez inset et margin sur le panneau.
422 validation_failedL'envoi ne correspond pas à la définition des champs du formulaire. error.fields nomme chaque champ et la raison. Faites correspondre le name de l'input à l'id du champ.

Questions

Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.