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.
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.
- O botão está sempre na tela, fixo no canto inferior direito.
- Clicar nele abre o painel na top layer do navegador e gera uma nova chave de tentativa.
- O usuário escolhe uma carinha e escreve uma nota. O estado do formulário continua montado enquanto o painel está aberto ou oculto.
- No envio, o widget adiciona a rota, a versão do app, o id da conta e o viewport, e envia com o SDK.
- 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.
- Se o usuário clicar fora ou apertar Escape antes, o painel se oculta e o rascunho continua lá na próxima vez.
Campos para um app
Prévia
Roda o validador do Ikiform no seu navegador. Nada é enviado.
may_reply é um checkbox, armazenado como true ou false.| Campo | Vem de | Por quê |
|---|---|---|
rating | O usuário, cinco carinhas | Um campo rating com max: 5. Carinhas são mais rápidas de escolher que números |
message | O usuário | Opcional. Mais útil quando a carinha é ruim |
may_reply, email | Um checkbox, depois a sua sessão | O e-mail só é enviado quando a pessoa pede resposta, e o Reply da notificação vai para ela |
route | window.location.pathname | Qual tela. Lido no envio, depois de qualquer navegação no lado do cliente |
app_version | Seu build | Mostra se uma reclamação é sobre código que você já mudou |
account_id | Sua sessão | Permite consultar o plano e os dados da pessoa. Não é prova de identidade, veja abaixo |
viewport | window.innerWidth e innerHeight | Reclamaçõ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.
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 semz-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.
onTogglegera uma chave nova toda vez que o painel abre.submit()a envia como o headerIdempotency-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:
.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:
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.
Confira se funciona
- Em Allowed domains do formulário, adicione o domínio do seu site, mais
localhostenquanto desenvolve. Deixe vazio para aceitar envios de qualquer lugar. - Abra o painel, digite uma nota, clique fora e abra de novo. A nota continua lá.
- Abra um modal no seu app e depois o widget. O painel aparece acima do modal.
- Aperte Escape. O painel fecha e o foco volta para o botão Feedback.
- Escolha uma carinha e dê clique duplo em Enviar. Submissions mostra uma única resposta, com
route,app_versioneaccount_idpreenchidos. - 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
| Problema | O que fazer |
|---|---|
403 origin_not_allowed, ou todo envio cai no spam | O 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_required | A 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 tela | A 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_failed | O 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
Renderize um botão fixo com popoverTarget apontando para um painel com popover=auto. O navegador cuida de abrir, fechar e do foco. Envie o formulário do painel com submit() de @ikiform/sdk e adicione a rota e a versão do app no envio.
Popovers são renderizados na top layer, então ficam acima dos seus próprios dialogs sem regras de z-index, e o light dismiss e o Escape vêm de graça. Um painel de feedback não precisa bloquear a página como um modal faz.
Não. Qualquer coisa enviada do navegador pode ser alterada pelo usuário. Use-o para consultar contexto, e envie a partir do seu servidor com uma chave secreta quando a identidade precisar ser confiável.
Não. Ele envia direto ao Ikiform a partir do navegador, e Allowed domains limita quais sites podem usar o formulário. Adicione uma rota de servidor só se você precisar de identidade confiável.
Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.