Formulário de feedback para um site ou app

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

Crie um formulário de feedback dentro do app em Nuxt

Um formulário de feedback dentro do app em Nuxt: uma server route que lê a sessão do usuário, pega a página do Referer e funciona mesmo antes do JavaScript carregar.

Preet SutharFundador, Ikiform

Envie um formulário de duas perguntas para uma server route do Nuxt em server/api/feedback.post.ts. A rota lê o usuário logado com getUserSession do nuxt-auth-utils, pega a página do Referer da requisição, valida a nota com Zod e encaminha tudo ao seu backend de formulários com uma chave secreta guardada em runtimeConfig. Com JavaScript, o formulário envia via $fetch e fica no lugar; sem ele, a mesma rota redireciona de volta com o resultado na URL.

A aba Nuxt do guia principal monta feedback de página para qualquer visitante. Dentro de um app Nuxt com contas, o trabalho é mais restrito e os dados são mais ricos: o usuário está logado, o servidor sabe quem ele é, e o router sabe em qual tela ele está. Esta página monta essa versão. Chaves, domínios permitidos e o resto da configuração do produto estão no guia do Nuxt na documentação.

O que muda num app Nuxt com login

Num formulário público, o navegador decide o que é armazenado. Aqui, o navegador decide só a nota e o comentário. Tudo que diz quem e onde é adicionado pela server route, onde ninguém consegue editar:

CampoDefinido porComo
rating, commentO usuárioRadio buttons e uma caixa de texto
user_id, emailA server routegetUserSession(event) lê o cookie de sessão selado
pageA server routeO header Referer, que o navegador envia em envios da mesma origem com o caminho completo
screenO componente, conferido pela rotauseRoute().name, como projects-id-settings, gerado pelo Nuxt a partir do caminho do arquivo da página

Nomes de rota são uma chave de agrupamento gratuita no Nuxt. A página de configurações de todos os projetos compartilha projects-id-settings, então você pode tirar a média das notas por tela enquanto page mantém o endereço exato.

Como uma única server route do Nuxt cuida do feedback com e sem JavaScriptCom JavaScriptSem JavaScriptFeedbackForm.vue$fetch,corpoJSONfeedback.post.tsorigem,sessão,ZodIkiformchavesecreta,armazena{ sent:true }agradecimentosubstituioformulárioenviodeformuláriosimplescorpourlencodedfeedback.post.tsmesmasverificaçõesIkiformmesmoenvio303 devolta?feedback=sentPOST+ usuário, página200POST+ usuário, páginaredirectComo uma única server route do Nuxt cuida do feedback com e sem JavaScriptCom JavaScriptSem JavaScriptFeedbackForm.vue$fetch, corpo JSONfeedback.post.tsorigem, sessão, ZodIkiformchave secreta, armazena{ sent: true }agradecimento substitui oformulárioenvio de formuláriosimplescorpo urlencodedfeedback.post.tsmesmas verificaçõesIkiformmesmo envio303 de volta?feedback=sentPOST+ usuário, página200POST+ usuário, páginaredirect
  1. Depois da hidratação, o componente impede o envio nativo e envia os campos como JSON com $fetch.
  2. A server route confere o Origin, lê o usuário da sessão, valida a nota e adiciona a página do Referer.
  3. Ela encaminha a resposta ao Ikiform com a chave secreta do runtimeConfig.
  4. Uma requisição JSON recebe { sent: true } ou um erro com mensagem, e o formulário se atualiza no lugar.
  5. Antes da hidratação, o próprio navegador envia o formulário. A rota faz as mesmas verificações e o mesmo envio.
  6. Depois responde com um 303 de volta para a página, com o resultado na query string para o componente mostrar.
A rota distingue os dois pelo content type da requisição. Os dois caminhos armazenam exatamente a mesma resposta.

Configuração do projeto

Isto usa a estrutura do Nuxt 4, com o código do app em app/ e o código do servidor na raiz. Instale nuxt-auth-utils e zod. Se o seu app já faz login de outro jeito, mantenha-o e leia o usuário da sua própria sessão na rota.

Árvore do projeto
app/
  components/FeedbackForm.vue
  pages/projects/[id]/settings.vue
server/
  api/feedback.post.ts
shared/
  types/auth.d.ts
nuxt.config.ts
.env

Declare a chave secreta em runtimeConfig com um padrão vazio. O Nuxt a preenche em runtime a partir de NUXT_IKIFORM_SECRET_KEY e, como a chave não está em public, ela só existe no servidor.

nuxt.config.ts
export default defineNuxtConfig({
  modules: ["nuxt-auth-utils"],
  runtimeConfig: {
    ikiformSecretKey: "",
  },
});
.env
NUXT_IKIFORM_SECRET_KEY=ik_sk_your_secret_key
NUXT_SESSION_PASSWORD=at-least-32-characters-long-random-string

Crie a chave em API keys com o escopo submissions:write. O nuxt-auth-utils sela o cookie de sessão com NUXT_SESSION_PASSWORD, que precisa ter pelo menos 32 caracteres; em desenvolvimento ele grava uma para você na primeira execução. Diga a ele como é o seu usuário, para que session.user.id seja tipado:

shared/types/auth.d.ts
declare module "#auth-utils" {
  interface User {
    id: string;
    email: string;
  }
}

export {};

Depois cole a definição na aba Fields do formulário. Os dois primeiros campos são o formulário; a server route adiciona o resto, e o campo email faz o Reply de cada notificação ir para o usuário.

Prévia

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

Como esta tela está funcionando para você?

Definido pelo seu servidor a partir da requisição.

A prévia roda o validador do Ikiform, então uma nota 6 mostra o 422 que a rota receberia.

Construa

A server route

server/api/feedback.post.ts
import { z } from "zod";

const Feedback = z.object({
  rating: z.coerce.number().int().min(1).max(5),
  comment: z.string().trim().max(4000).default(""),
  screen: z.string().regex(/^[a-z0-9_-]{1,120}$/).catch("unknown"),
  _gotcha: z.string().default(""),
});

function pageFrom(referer: string | undefined) {
  try {
    const url = new URL(referer ?? "");
    return url.origin + url.pathname;
  } catch {
    return "";
  }
}

export default defineEventHandler(async (event) => {
  const own = getRequestURL(event, { xForwardedHost: true });
  if (getRequestHeader(event, "origin") !== own.origin) {
    throw createError({ status: 403, message: "Posts from other sites are refused." });
  }

  const wantsJson = getRequestHeader(event, "content-type")?.includes("application/json") ?? false;
  const referer = getRequestHeader(event, "referer");
  const back = (outcome: string) => {
    const target = new URL(referer ?? "/", own.origin);
    return sendRedirect(event, `${target.pathname}?feedback=${outcome}`, 303);
  };

  const session = await getUserSession(event);
  if (!session.user) {
    if (wantsJson) throw createError({ status: 401, message: "Your session ended. Sign in again, then send it." });
    return back("signed-out");
  }

  const parsed = await readValidatedBody(event, (body) => Feedback.safeParse(body));
  if (!parsed.success) {
    if (wantsJson) throw createError({ status: 422, message: "Pick a score from 1 to 5." });
    return back("invalid");
  }

  const { ikiformSecretKey } = useRuntimeConfig(event);
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${ikiformSecretKey}`,
      Origin: own.origin,
    },
    body: JSON.stringify({
      ...parsed.data,
      page: pageFrom(referer),
      user_id: session.user.id,
      email: session.user.email,
    }),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);

  const result = await response?.json().catch(() => null);
  if (result?.ok) return wantsJson ? { sent: true } : back("sent");
  if (wantsJson) {
    throw createError({ status: 502, message: result?.error?.message ?? "We couldn't send it. Try again." });
  }
  return back("failed");
});
  • O sufixo .post.ts faz o arquivo responder só a POST.
  • A verificação de Origin roda antes de tudo. readBody, que readValidatedBody usa, também interpreta corpos urlencoded, então sem ela outro site poderia enviar um formulário simples para esta rota com o cookie do seu usuário anexado.
  • getUserSession não lança erro. A própria rota escolhe a resposta: um 401 para $fetch, um redirect para um envio simples. requireUserSession mandaria uma página 401 para um usuário sem JavaScript.
  • z.coerce.number() transforma o "4" urlencoded em 4. .catch("unknown") armazena um screen malformado como unknown em vez de recusar a resposta inteira.
  • back mantém só o caminho do Referer, então um redirect nunca consegue sair do seu site.

O componente

app/components/FeedbackForm.vue
<script setup lang="ts">
const MESSAGES: Record<string, string> = {
  "signed-out": "Your session ended. Sign in again, then send it.",
  invalid: "Pick a score from 1 to 5.",
  failed: "We couldn't send it. Try again in a moment.",
};
const SCORES = ["Broken", "Frustrating", "Fine", "Good", "Great"];

const route = useRoute();
const { loggedIn } = useUserSession();
const screen = computed(() => String(route.name ?? "unknown"));
const outcome = String(route.query.feedback ?? "");
const status = ref<"idle" | "sending" | "sent">(outcome === "sent" ? "sent" : "idle");
const error = ref(MESSAGES[outcome] ?? "");

async function send(event: SubmitEvent) {
  const body = Object.fromEntries(new FormData(event.currentTarget as HTMLFormElement));
  status.value = "sending";
  error.value = "";
  try {
    await $fetch("/api/feedback", { method: "POST", body });
    status.value = "sent";
  } catch (failure) {
    error.value = (failure as { data?: { message?: string } }).data?.message ?? "We couldn't send it. Try again in a moment.";
    status.value = "idle";
  }
}
</script>

<template>
  <template v-if="loggedIn">
    <p v-if="status === 'sent'" role="status">Thanks. We read every one.</p>
    <form v-else method="post" action="/api/feedback" :aria-busy="status === 'sending'" @submit.prevent="send">
      <fieldset>
        <legend>How is this screen working for you?</legend>
        <label v-for="(label, index) in SCORES" :key="label">
          <input type="radio" name="rating" :value="index + 1" required /> {{ label }}
        </label>
      </fieldset>
      <label for="feedback-comment">What would make it better?</label>
      <textarea id="feedback-comment" name="comment" rows="4" maxlength="4000" />
      <input type="hidden" name="screen" :value="screen" />
      <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
      <p v-if="error" role="alert">{{ error }}</p>
      <button :disabled="status === 'sending'">{{ status === "sending" ? "Sending" : "Send feedback" }}</button>
    </form>
  </template>
</template>
  • method e action são atributos de verdade, então antes da hidratação o navegador envia o formulário para a rota. Quando o Vue está rodando, @submit.prevent assume.
  • O corpo JSON avisa a rota de que ela pode responder com JSON. O corpo vem de FormData, então o honeypot e screen vão junto sem código extra.
  • Num $fetch com falha, o data.message do erro é a mensagem do createError na rota.
  • Depois de um envio simples, ?feedback= na URL define o estado inicial, então o agradecimento ou o erro aparece na página recarregada.
  • useUserSession esconde o formulário de visitantes deslogados. É uma conveniência, não uma verificação; quem verifica é a rota.

Coloque <FeedbackForm /> em qualquer página da parte logada do seu app, como app/pages/projects/[id]/settings.vue. O Nuxt faz o auto-import.

Honeypot, verificação humana e limites

O input _gotcha chega ao Ikiform com o resto dos campos, e um preenchido é armazenado como spam. A verificação humana do Ikiform é pulada em envios com chave secreta, e aqui a sessão faz esse papel: a rota recusa quem não está logado. O que sobra é uma conta logada enviando em loop, que o Ikiform só consegue contar pelo endereço do seu servidor.

As versões no servidor enviam com uma chave secreta, então o Ikiform pula Allowed domains e a verificação humana. Todo envio passa a vir do IP do seu servidor, e o limite Standard de 5 por minuto por visitante conta todos os seus visitantes juntos. Escolha o limite de taxa Relaxed ou Custom para um formulário que você envia por um servidor, e mantenha IKIFORM_SECRET_KEY em variáveis de ambiente só do servidor.

Escolhas de renderização

O formulário precisa de renderização no servidor para o resultado sem JavaScript. Numa rota com ssr: false em routeRules, a página fica em branco até o JavaScript rodar, então o envio simples nunca acontece e ?feedback= só é lido no navegador. Tudo bem, isso só remove o fallback. Não coloque o formulário numa rota prerender: true: o HTML é gerado uma vez, então useUserSession não vê ninguém e o formulário fica ausente até a hidratação.

Deploy

HostO que fazer
VercelO Nitro detecta a Vercel, então nenhum preset é necessário. Adicione NUXT_IKIFORM_SECRET_KEY e NUXT_SESSION_PASSWORD em Environment Variables e faça o redeploy.
NetlifyTambém é detectada. Adicione as duas variáveis nas configurações de ambiente do site com o escopo Functions; a server route roda como função.
CloudflareFaça o build com o preset cloudflare_module para Workers, ou conecte o repositório ao Pages, que é detectado. Guarde os dois valores como secrets e coloque-os em .dev.vars para o wrangler dev.

Na Cloudflare, os valores de ambiente só existem enquanto uma requisição roda. A rota lê useRuntimeConfig(event) dentro do handler, que é onde isso precisa estar.

Teste no localhost de graça

Sem plano, um workspace aceita 50 envios de teste por mês a partir de localhost, julgados pelo header Origin. Um fetch de uma rota do Nitro não tem navegador por trás e não envia Origin, a menos que você defina um. Por isso a rota repassa own.origin: no nuxt dev ele é http://localhost:3000 e conta como teste, e em produção é o seu domínio. Sem ele, todo teste local volta com 402 plan_required.

Confira se funciona

  1. Rode nuxt dev, faça login, abra a página de configurações de um projeto e envie uma nota 2 com um comentário.
  2. Em Submissions, confira que user_id e email batem com a sua conta, que screen é projects-id-settings e que page traz o caminho real.
  3. Desligue o JavaScript e envie de novo. A página recarrega com a frase de agradecimento e ?feedback=sent no endereço.
  4. Envie para /api/feedback com curl e sem Origin. A rota responde 403 e nada chega ao Ikiform.
  5. Apague o cookie de sessão e envie a partir da página aberta. O formulário mostra a mensagem de login.

Pegadinhas

ProblemaO que fazer
403 da sua própria rota em produçãoUm proxy muda o host, então getRequestURL discorda do Origin do navegador. Faça o proxy enviar X-Forwarded-Host, que xForwardedHost: true lê.
screen é unknown em todas as respostasNomes de rota com sufixo de idioma, como os do @nuxtjs/i18n, têm caracteres que o padrão recusa. Amplie a regex ou remova o sufixo no componente.
A chave secreta fica vazia em produçãoO nome da variável precisa ser NUXT_ mais a chave em upper snake case, NUXT_IKIFORM_SECRET_KEY, e a chave precisa existir em runtimeConfig.
402 plan_required nos testes locaisO envio saiu sem Origin. Mantenha o header Origin: own.origin e abra o app em localhost.
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.