Formulário de feedback para um site ou app

Formulário de feedback para um site ou app7 min de leitura

Construa um widget de feedback flutuante para o seu app React

Um botão de feedback flutuante para um app React que abre um pequeno painel, envia a tela, a versão do app e a conta com cada resposta e nunca perde um rascunho.

Preet SutharFundador, Ikiform

Renderize um botão fixo que abre um painel feito com a Popover API do navegador, para que ele fique acima de todas as camadas do seu app sem briga de z-index e feche com Escape. No envio, adicione a rota atual, a versão do app, o id da conta e o viewport aos dados do formulário e envie a partir do navegador com o SDK do Ikiform. Mande o e-mail do usuário só quando ele marcar uma caixa pedindo resposta.

O que muda num widget dentro do app

Um formulário na página espera as pessoas rolarem até ele. Um widget está em todas as telas, o que muda o que pode dar errado. Ele precisa ficar acima dos seus próprios modais, menus e toasts. Não pode perder uma mensagem escrita pela metade quando alguém clica fora dele. E como as pessoas estão logadas, você já sabe quem são e onde estão, então o widget deve enviar isso em vez de perguntar.

A vida de uma sessão do widget de feedback, do clique à resposta armazenadaBotãodefeedbackfixo,cantoinferiordireitopainelabretop layer,nova chaveusuárioavalia eescreveestadofica nopainelenviocomcontextorota,versão,conta,viewportarmazenadouma vezmesmachave nocliqueduplopainelseocultarascunhofica paradepoiscliqueSDKclique fora ou EscapeA vida de uma sessão do widget de feedback, do clique à resposta armazenadaBotão de feedbackfixo, canto inferiordireitopainel abretop layer, nova chaveusuário avalia eescreveestado fica no painelenvio comcontextorota, versão, conta,viewportarmazenado umavezmesma chave no cliqueduplopainel seocultarascunhofica paradepoiscliqueSDKclique fora ou Escape
  1. O botão está sempre na tela, fixo no canto inferior direito.
  2. Clicar nele abre o painel na top layer do navegador e gera uma nova chave de tentativa.
  3. O usuário escolhe uma carinha e escreve uma nota. O estado do formulário continua montado enquanto o painel está aberto ou oculto.
  4. No envio, o widget adiciona a rota, a versão do app, o id da conta e o viewport, e envia com o SDK.
  5. O Ikiform armazena uma única vez, mesmo se o botão for clicado duas vezes, porque os dois envios levam a mesma chave de idempotência.
  6. Se o usuário clicar fora ou apertar Escape antes, o painel se oculta e o rascunho continua lá na próxima vez.
O light dismiss é o padrão da Popover API. Como o painel só se oculta, o campo de texto mantém o que foi digitado.

Campos para um app

Prévia

Roda o validador do Ikiform no seu navegador. Nada é enviado.

Como está indo?
Os usuários só veem os três primeiros campos. O resto é adicionado no código no envio. may_reply é um checkbox, armazenado como true ou false.
CampoVem dePor quê
ratingO usuário, cinco carinhasUm campo rating com max: 5. Carinhas são mais rápidas de escolher que números
messageO usuárioOpcional. Mais útil quando a carinha é ruim
may_reply, emailUm checkbox, depois a sua sessãoO e-mail só é enviado quando a pessoa pede resposta, e o Reply da notificação vai para ela
routewindow.location.pathnameQual tela. Lido no envio, depois de qualquer navegação no lado do cliente
app_versionSeu buildMostra se uma reclamação é sobre código que você já mudou
account_idSua sessãoPermite consultar o plano e os dados da pessoa. Não é prova de identidade, veja abaixo
viewportwindow.innerWidth e innerHeightReclamações de layout costumam depender do tamanho

O componente

Ele precisa de @ikiform/sdk e do React 19, que tipa popover, popoverTarget e o evento toggle. O popoverTarget do botão o liga ao painel, então o navegador cuida de abrir, do Escape e de devolver o foco ao botão.

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" coloca o painel na top layer quando aberto. Ele aparece acima dos seus dialogs e menus sem z-index, e fecha quando o usuário clica em outro lugar.
  • Os filhos do painel continuam montados quando ele se oculta, então um rascunho sobrevive a um clique acidental fora. Depois de um envio bem-sucedido, a frase de agradecimento substitui o formulário, o que o limpa, e a próxima abertura mostra um formulário vazio.
  • onToggle gera uma chave nova toda vez que o painel abre. submit() a envia como o header Idempotency-Key, então um clique duplo armazena uma única resposta. Veja idempotência.
  • O e-mail só é definido quando a caixa está marcada. Desmarcada, a resposta leva o id da conta, mas nenhum endereço.

Os estilos padrão do popover o centralizam na tela. Estas regras o fixam acima do botão:

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;
}

Monte uma vez

Renderize o widget no layout que envolve as telas logadas, para que ele apareça em todo lugar e sobreviva à navegação. No App Router do Next.js, o layout é um server component que lê a sessão e passa props simples para o widget no cliente:

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}
    </>
  );
}

Marque o arquivo do widget com "use client" no App Router. Num app Vite ou React Router, renderize-o ao lado do outlet do router. Funciona do mesmo jeito.

O que o navegador consegue e não consegue provar

Este widget envia direto do navegador com o id público do formulário. Essa é a troca certa para feedback: nenhuma rota de servidor para manter, e o meta.user_agent do próprio Ikiform é o navegador real do usuário. Também significa que qualquer um pode enviar o account_id que quiser. Use-o para consultar dados, nunca para conceder algo.

Se você precisa que a conta seja confiável, por exemplo para mostrar o feedback dentro de uma ferramenta de admin, envie a partir do seu servidor com a chave secreta e adicione a conta da sessão lá. As versões de servidor do guia principal mostram o padrão; escolha o limite de taxa Relaxed ou Custom, porque todo envio passa a vir do IP do seu servidor.

Confira se funciona

  1. Em Allowed domains do formulário, adicione o domínio do seu site, mais localhost enquanto desenvolve. Deixe vazio para aceitar envios de qualquer lugar.
  2. Abra o painel, digite uma nota, clique fora e abra de novo. A nota continua lá.
  3. Abra um modal no seu app e depois o widget. O painel aparece acima do modal.
  4. Aperte Escape. O painel fecha e o foco volta para o botão Feedback.
  5. Escolha uma carinha e dê clique duplo em Enviar. Submissions mostra uma única resposta, com route, app_version e account_id preenchidos.
  6. Envie uma vez com a caixa de resposta marcada. O Reply do e-mail de notificação vai para o usuário.

Pegadinhas

  • Mantenha o botão longe dos seus próprios elementos flutuantes, como uma bolha de chat ou um banner de cookies. Mova um deles, ou abra o widget a partir de um item de menu.
  • Não mostre o botão em todas as telas de um checkout ou editor onde as pessoas estão concentradas. Oculte-o ali com uma prop.
  • A Popover API é suportada nas versões atuais do Chrome, Edge, Firefox e Safari. Se você precisa suportar navegadores mais antigos, verifique antes de depender dela.
  • Todo campo adicionado no código precisa existir na definição do formulário, ou o Ikiform recusa o envio com um erro Unknown field.
  • Não mande o estado inteiro do app. Rota, versão, conta e tamanho respondem à maioria das perguntas; qualquer outra coisa é uma questão de privacidade.

Solução de problemas

ProblemaO que fazer
403 origin_not_allowed, ou todo envio cai no spamO domínio da página não está em Allowed domains do formulário. Adicione-o, mais localhost para testes locais. Subdomínios de um domínio listado são permitidos.
403 challenge_requiredA verificação de spam do formulário está como Ikiform e um envio por fetch não tinha token. Obtenha um com challenge(formId) de @ikiform/sdk antes de cada envio, ou desligue a verificação.
O painel aparece no meio da telaA folha de estilos não carregou, então os estilos padrão de popover do navegador se aplicam. Confira o import do CSS, ou defina inset e margin no painel.
422 validation_failedO envio não corresponde à definição de campos do formulário. error.fields aponta cada campo e o motivo. Faça o name do input bater com o id do campo.

Perguntas

Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.