Formulário de feedback para um site ou app

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

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

Um formulário de feedback dentro do app em Astro: uma action que pega o usuário dos locals do middleware, a página da requisição e a tela do padrão da rota.

Preet SutharFundador, Ikiform

Escreva uma Astro action com accept: "form" que valida a avaliação com Zod, lê o usuário logado de context.locals e encaminha a resposta ao seu backend de formulários com um secret de astro:env. Renderize o formulário com o action apontando para a action, para que ele envie sem JavaScript, e adicione um script curto que chama a mesma action no lugar quando há JavaScript. A página de que a resposta trata vem do Referer da requisição, e Astro.routePattern dá nome à tela.

A aba Astro do guia principal pergunta a qualquer visitante quão útil foi uma página. Esta página coloca o feedback dentro de um app Astro com usuários logados, onde o servidor deve decidir quem escreveu e o formulário só pede uma nota e um comentário. Para chaves e configuração do formulário, o guia do Astro na documentação tem os detalhes.

Quem preenche o quê

Astro actions são endpoints públicos, acessíveis numa URL com o nome da action. Qualquer um pode chamar sendFeedback com um corpo forjado, então a action não pode acreditar num id de usuário que recebe. Separe os valores por quem é confiável para fornecê-los:

ValorDefinido porComo
rating, commentO usuárioValidados pelo schema Zod da action
user_id, emailMiddlewarecontext.locals.user, a partir do cookie de sessão em toda requisição
pageA requisiçãoO header Referer, que é a página onde o formulário está
screenA renderização da páginaAstro.routePattern, como /app/projects/[id]/settings, num input oculto

routePattern é o motivo pelo qual o Astro é agradável aqui. Ele dá nome ao arquivo que renderizou a página, então a tela de configurações de todos os projetos cai sob um rótulo, enquanto page mantém o endereço real. Ele passa pelo navegador, então o schema confere o formato e troca qualquer coisa estranha por unknown.

Dois caminhos pelos quais um formulário de feedback em Astro chega à mesma actionsem JavaScriptcom o scriptPOST doformuláriopara apáginaatualmiddlewarelocals.userdo cookiesendFeedbackZod, depoisfetchpáginarenderizade novogetActionResult()actions.sendFeedback()FormDatadoformuláriomiddlewaremesmocookie,mesmousuáriosendFeedbackmesmohandlerformuláriosubstituído{ data } ou{ error }URL da páginaresultadofetchJSONDois caminhos pelos quais um formulário de feedback em Astro chega à mesma actionsem JavaScriptcom o scriptPOST do formuláriopara a página atualmiddlewarelocals.user do cookiesendFeedbackZod, depois fetchpágina renderiza de novogetActionResult()actions.sendFeedback()FormData do formuláriomiddlewaremesmo cookie, mesmo usuáriosendFeedbackmesmo handlerformulário substituído{ data } ou { error }URL da páginaresultadofetchJSON
  1. Sem JavaScript, o navegador envia o formulário para a página atual, com a action nomeada na URL.
  2. O middleware roda primeiro em toda requisição e coloca o usuário logado em context.locals.
  3. A action valida a avaliação e a tela, adiciona o usuário e a página e envia ao Ikiform com o secret.
  4. O Astro renderiza a página de novo, e Astro.getActionResult() mostra a frase de agradecimento ou o erro.
  5. Com JavaScript, o script chama a mesma action via fetch. O middleware e o handler rodam exatamente como antes.
  6. O script troca o formulário pela frase de agradecimento, ou escreve o erro abaixo dele, sem recarregar.
Um handler, dois transportes. As duas requisições saem da página, então ambas levam o cookie de sessão e a página no Referer.

Configuração do projeto

Árvore do projeto
src/
  actions/index.ts
  components/FeedbackForm.astro
  middleware.ts
  env.d.ts
  pages/app/projects/[id]/settings.astro
astro.config.mjs
.env

Declare o secret no schema do astro:env. Aí o Astro se recusa a iniciar sem ele, tipa o import e o mantém fora do bundle do cliente. Qualquer adapter de servidor funciona; o exemplo usa Node.

astro.config.mjs
import { defineConfig, envField } from "astro/config";
import node from "@astrojs/node";

export default defineConfig({
  adapter: node({ mode: "standalone" }),
  env: {
    schema: {
      IKIFORM_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
    },
  },
});

Coloque IKIFORM_SECRET_KEY=ik_sk_... em .env para o trabalho local. Crie a chave em API keys com o escopo submissions:write. Depois cole esta definição na aba Fields do formulá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.

O schema da action espelha esses limites, então a maior parte da entrada inválida é recusada antes de custar uma requisição.

Construa

Coloque o usuário em locals

O middleware roda antes de toda página e action. Leia a sessão ali uma vez, com o que o seu código de auth oferecer, e todo handler recebe o resultado. readSession representa esse código. As sessões do próprio Astro também funcionam: leia context.session na action.

src/middleware.ts
import { defineMiddleware } from "astro:middleware";
import { readSession } from "./lib/auth";

export const onRequest = defineMiddleware(async (context, next) => {
  context.locals.user = await readSession(context.cookies.get("session")?.value);
  return next();
});
src/env.d.ts
declare namespace App {
  interface Locals {
    user: { id: string; email: string } | null;
  }
}

A action

src/actions/index.ts
import { ActionError, defineAction } from "astro:actions";
import { IKIFORM_SECRET_KEY } from "astro:env/server";
import { z } from "astro/zod";

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

export const server = {
  sendFeedback: defineAction({
    accept: "form",
    input: z.object({
      rating: z.coerce.number().int().min(1, "Pick a score from 1 to 5.").max(5, "Pick a score from 1 to 5."),
      comment: z.string().max(4000).nullish(),
      screen: z.string().regex(/^\/[a-z0-9/[\]._-]{0,119}$/).catch("unknown"),
      _gotcha: z.string().nullish(),
    }),
    handler: async (input, context) => {
      const user = context.locals.user;
      if (!user) throw new ActionError({ code: "UNAUTHORIZED", message: "Your session ended. Sign in again, then send it." });

      const origin = context.request.headers.get("origin");
      const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
        method: "POST",
        headers: {
          Accept: "application/json",
          "Content-Type": "application/json",
          Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
          ...(origin ? { Origin: origin } : {}),
        },
        body: JSON.stringify({
          rating: input.rating,
          comment: input.comment ?? "",
          page: pageFrom(context.request.headers.get("referer")),
          screen: input.screen,
          user_id: user.id,
          email: user.email,
          _gotcha: input._gotcha ?? "",
        }),
        signal: AbortSignal.timeout(8000),
      }).catch(() => null);

      const result = await response?.json().catch(() => null);
      if (result?.ok) return { sent: true };
      throw new ActionError({
        code: "BAD_GATEWAY",
        message: result?.error?.message ?? "We couldn't send it. Try again in a moment.",
      });
    },
  }),
};
  • O segundo argumento do handler é o contexto da action. Ele tem locals, request e cookies, mas não redirect, então uma sessão ausente vira um erro UNAUTHORIZED que o formulário consegue mostrar.
  • .catch("unknown") em screen transforma um rótulo forjado em unknown em vez de derrubar a resposta inteira.
  • Inputs vazios do formulário chegam a uma action form como null, e por isso comment e o honeypot são nullish.
  • Tanto o envio do formulário quanto a chamada do script são feitos pela página, então Referer traz o endereço dela em ambos os casos. pageFrom mantém a origem e o caminho.
  • Origin é repassado para que os testes locais contem como localhost. Mais sobre isso abaixo.

O componente do formulário

src/components/FeedbackForm.astro
---
import { actions, isInputError } from "astro:actions";

const result = Astro.getActionResult(actions.sendFeedback);
const scores = ["Broken", "Frustrating", "Fine", "Good", "Great"];
---

{result?.data?.sent ? (
  <p role="status">Thanks. We read every one.</p>
) : (
  <form method="POST" action={actions.sendFeedback} data-feedback>
    <fieldset>
      <legend>How is this screen working for you?</legend>
      {scores.map((label, index) => (
        <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"></textarea>
    <input type="hidden" name="screen" value={Astro.routePattern} />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <p role="alert">
      {result?.error && (isInputError(result.error) ? result.error.fields.rating?.[0] : result.error.message)}
    </p>
    <button>Send feedback</button>
  </form>
)}

<script>
  import { actions } from "astro:actions";

  const form = document.querySelector<HTMLFormElement>("form[data-feedback]");
  form?.addEventListener("submit", async (event) => {
    event.preventDefault();
    const button = form.querySelector("button")!;
    const alert = form.querySelector("[role=alert]")!;
    button.disabled = true;
    button.textContent = "Sending";
    const { error } = await actions.sendFeedback(new FormData(form));
    if (!error) {
      form.replaceWith(Object.assign(document.createElement("p"), { role: "status", textContent: "Thanks. We read every one." }));
      return;
    }
    alert.textContent = error.message;
    button.disabled = false;
    button.textContent = "Send feedback";
  });
</script>
  • action={actions.sendFeedback} faz o formulário funcionar sem nenhum JavaScript. O Astro cuida do envio e renderiza a página com o resultado.
  • isInputError separa falhas do schema, que têm fields, de erros que o handler lançou, como a mensagem de sessão.
  • O script importa actions de astro:actions e chama o mesmo handler com os dados do formulário. O Astro faz o bundle, então não há arquivo extra.
  • Um envio sem JavaScript deixa a página como resultado de um POST, e um refresh pede para reenviar. Se isso incomodar, getActionContext no middleware permite guardar o resultado e redirecionar.

A página

src/pages/app/projects/[id]/settings.astro
---
import AppLayout from "../../../../layouts/AppLayout.astro";
import FeedbackForm from "../../../../components/FeedbackForm.astro";

export const prerender = false;

if (!Astro.locals.user) return Astro.redirect("/sign-in");
---

<AppLayout title="Project settings">
  <h1>Project settings</h1>
  <aside>
    <FeedbackForm />
  </aside>
</AppLayout>

Modo de renderização

Uma form action só funciona numa página renderizada sob demanda. Páginas pré-renderizadas são geradas uma vez, então não há requisição para onde enviar. Com a saída estática padrão, marque cada página do app com export const prerender = false, ou defina output: "server" se a maior parte do site fica atrás de login. O middleware ainda roda nas páginas pré-renderizadas no build, onde não há cookie, então locals.user é null ali.

Honeypot, verificação humana e limites

O input oculto _gotcha passa adiante, e um preenchido é armazenado como spam. A verificação humana nunca roda num envio com chave secreta, e a exigência de login faz esse papel. O Astro confere o Origin dos envios de formulário para páginas sob demanda por padrão, então outro site não consegue enviar o formulário no navegador de um usuário.

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.

Deploy

HostAdapterO segredo
Vercel@astrojs/vercelAdicione IKIFORM_SECRET_KEY nas variáveis de ambiente do projeto. Se você usa sessões do Astro, configure um driver de armazenamento; a Vercel não tem padrão.
Netlify@astrojs/netlifyAdicione nas variáveis de ambiente do site. As sessões usam Netlify Blobs por padrão.
Cloudflare@astrojs/cloudflareRode npx wrangler secret put IKIFORM_SECRET_KEY e coloque a chave em .dev.vars localmente. astro:env/server a lê, assim como env de cloudflare:workers. As sessões usam um binding KV chamado SESSION.

Desde o Astro 6, Astro.locals.runtime.env não existe mais no adapter da Cloudflare. Código que lia a chave dali precisa do import astro:env acima.

Testes locais gratuitos

Um workspace sem plano recebe 50 envios de teste gratuitos por mês a partir de localhost, e o Ikiform os reconhece pelo header Origin. O navegador envia http://localhost:4321 ao astro dev, mas o fetch da própria action ao Ikiform não envia origem. A linha Origin repassa a do navegador, então os testes locais saem de graça, e em produção o header é o seu próprio domínio.

Confira se funciona

  1. Rode astro dev, faça login, abra a página de configurações de um projeto e envie um 2 com um comentário.
  2. Em Submissions, confira que screen é /app/projects/[id]/settings, que page tem o id real e que user_id e email são os seus.
  3. Desligue o JavaScript e envie de novo. A página recarrega com a frase de agradecimento.
  4. Apague o cookie de sessão e envie. O formulário mostra a mensagem de login.
  5. Responda ao e-mail de notificação. Ele vai para o usuário logado.

Pegadinhas

ProblemaO que fazer
O formulário envia, mas a página não mostra resultadoA página está pré-renderizada. Adicione export const prerender = false ou mude para output: "server".
IKIFORM_SECRET_KEY is missing quando o build começaOs secrets são validados sempre que astro:env/server é importado. Forneça a variável ao build, ou um placeholder ali e o valor real em runtime.
402 plan_required nos testes locaisO Origin repassado está vazio ou não é localhost. Abra o app em localhost:4321 e mantenha a linha Origin.
locals.user é sempre null em produçãoO nome ou o domínio do cookie muda entre ambientes, ou a página ainda está pré-renderizada. Faça log de context.cookies.has("session") no middleware.
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.