Formulario de feedback para un sitio web o una app

Formulario de feedback para un sitio web o una app7 min de lectura

Crea un widget flotante de feedback para tu app React

Un botón flotante de feedback para una app React que abre un panel pequeño, envía la pantalla, la versión de la app y la cuenta con cada respuesta, y nunca pierde un borrador.

Preet SutharFundador, Ikiform

Renderiza un botón fijo que abre un panel basado en la Popover API del navegador, para que quede por encima de todas las capas de tu app sin pelearte con el z-index y se cierre con Escape. Al enviar, añade a los datos del formulario la ruta actual, la versión de la app, el id de la cuenta y el viewport, y envíalo desde el navegador con el SDK de Ikiform. Envía el email del usuario solo cuando marque una casilla pidiendo respuesta.

Qué cambia en un widget dentro de la app

Un formulario en una página espera a que la gente llegue hasta él. Un widget está en todas las pantallas, y eso cambia lo que puede salir mal. Tiene que quedar por encima de tus propios modales, menús y toasts. No debe perder un mensaje a medio escribir cuando alguien hace clic fuera. Y como la gente ha iniciado sesión, ya sabes quién es y dónde está, así que el widget debería enviarlo en lugar de preguntarlo.

El recorrido de una sesión del widget de feedback, del clic a la respuesta guardadaBotóndefeedbackfijo,abajo a laderechase abreel paneltop layer,nuevaclave deintentoelusuariovalora yescribeel estadovive en elpanelenvíoconcontextoruta,versión,cuenta,viewportseguardauna vezmismaclave enun dobleclicel panelseocultaborradorguardadopara lapróximavezclicSDKclic fuera o EscapeEl recorrido de una sesión del widget de feedback, del clic a la respuesta guardadaBotón de feedbackfijo, abajo a laderechase abre el paneltop layer, nueva clavede intentoel usuario valoray escribeel estado vive en elpanelenvío concontextoruta, versión, cuenta,viewportse guarda una vezmisma clave en undoble clicel panelse ocultaborradorguardadopara lapróxima vezclicSDKclic fuera o Escape
  1. El botón está siempre en pantalla, fijo en la esquina inferior derecha.
  2. Al hacer clic se abre el panel en la top layer del navegador y se genera una nueva clave de intento.
  3. El usuario elige una cara y escribe una nota. El estado del formulario sigue montado mientras el panel está abierto u oculto.
  4. Al enviar, el widget añade la ruta, la versión de la app, el id de la cuenta y el viewport, y envía con el SDK.
  5. Ikiform lo guarda una sola vez, aunque se pulse el botón dos veces, porque ambos envíos llevan la misma clave de idempotencia.
  6. Si el usuario hace clic fuera o pulsa Escape antes, el panel se oculta y el borrador sigue ahí la próxima vez.
El cierre ligero (light dismiss) es el comportamiento por defecto de la Popover API. Como el panel solo se oculta, el campo de texto conserva lo escrito.

Campos para una app

Vista previa

Ejecuta el validador de Ikiform en tu navegador. No se envía nada.

¿Qué tal va?
Los usuarios solo ven los tres primeros campos. El resto se añade en código al enviar. may_reply es una casilla, guardada como true o false.
CampoViene dePor qué
ratingEl usuario, cinco carasUn campo rating con max: 5. Las caras se eligen más rápido que los números
messageEl usuarioOpcional. Más útil cuando la cara es mala
may_reply, emailUna casilla, y luego tu sesiónEl email solo se envía cuando piden respuesta, y Responder en la notificación va a ellos
routewindow.location.pathnameQué pantalla. Se lee al enviar, después de cualquier navegación en el cliente
app_versionTu buildTe dice si una queja trata de código que ya has cambiado
account_idTu sesiónTe permite consultar su plan y sus datos. No prueba la identidad; mira más abajo
viewportwindow.innerWidth e innerHeightLas quejas de diseño suelen depender del tamaño

El componente

Necesita @ikiform/sdk y React 19, que tipa popover, popoverTarget y el evento toggle. El popoverTarget del botón lo vincula al panel, así que el navegador se encarga de abrirlo, de Escape y de devolver el foco al botón.

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" pone el panel en la top layer cuando está abierto. Se muestra por encima de tus diálogos y menús sin z-index, y se cierra cuando el usuario hace clic en otro sitio.
  • Los hijos del panel siguen montados cuando se oculta, así que un borrador sobrevive a un clic fuera accidental. Tras un envío correcto, la línea de agradecimiento sustituye al formulario, lo que lo vacía, y la siguiente apertura muestra un formulario vacío.
  • onToggle crea una clave nueva cada vez que se abre el panel. submit() la envía como cabecera Idempotency-Key, así que un doble clic guarda una sola respuesta. Consulta idempotencia.
  • El email solo se rellena cuando la casilla está marcada. Sin marcar, la respuesta lleva el id de la cuenta pero ninguna dirección.

Los estilos por defecto del popover lo centran en la pantalla. Estas reglas lo fijan encima del botón:

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

Móntalo una vez

Renderiza el widget en el layout que envuelve las pantallas con sesión iniciada, para que aparezca en todas partes y sobreviva a la navegación. En el App Router de Next.js, el layout es un server component que lee la sesión y pasa props simples al widget de 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}
    </>
  );
}

Marca el archivo del widget con "use client" en el App Router. En una app con Vite o React Router, renderízalo junto al outlet del router. Funciona igual.

Lo que el navegador puede y no puede probar

Este widget envía directamente desde el navegador con el id público del formulario. Para el feedback es el equilibrio correcto: no hay ruta de servidor que mantener, y el meta.user_agent de Ikiform es el navegador real del usuario. También significa que cualquiera puede enviar el account_id que quiera. Úsalo para consultar cosas, nunca para conceder nada.

Si necesitas que la cuenta sea fiable, por ejemplo para mostrar el feedback en una herramienta de administración, envía desde tu servidor con la clave secreta y añade allí la cuenta a partir de la sesión. Las versiones de servidor de la guía principal muestran el patrón; elige el límite de frecuencia Relaxed o Custom, porque entonces todos los envíos llegan desde la IP de tu servidor.

Comprueba que funciona

  1. En Allowed domains del formulario, añade el dominio de tu sitio, y localhost mientras desarrollas. Déjalo vacío para aceptar envíos desde cualquier lugar.
  2. Abre el panel, escribe una nota, haz clic fuera y vuelve a abrirlo. La nota sigue ahí.
  3. Abre un modal en tu app y luego el widget. El panel aparece por encima del modal.
  4. Pulsa Escape. El panel se cierra y el foco vuelve al botón de feedback.
  5. Elige una cara y haz doble clic en Enviar. Submissions muestra una sola respuesta, con route, app_version y account_id rellenos.
  6. Envía una vez con la casilla de respuesta marcada. El Responder del email de notificación va al usuario.

Errores comunes

  • Mantén el botón separado de tus propios elementos flotantes, como una burbuja de chat o un banner de cookies. Mueve uno de ellos, o abre el widget desde un elemento de menú.
  • No muestres el botón en cada pantalla de un checkout o un editor donde la gente está concentrada. Ocúltalo ahí con una prop.
  • La Popover API es compatible con las versiones actuales de Chrome, Edge, Firefox y Safari. Si tienes que dar soporte a navegadores antiguos, compruébalo antes de depender de ella.
  • Cada campo añadido en código debe existir en la definición del formulario, o Ikiform rechaza el envío con un error de campo desconocido.
  • No envíes todo el estado de la app. Ruta, versión, cuenta y tamaño responden a la mayoría de preguntas; cualquier otra cosa es una cuestión de privacidad.

Solución de problemas

ProblemaQué hacer
403 origin_not_allowed, o todos los envíos acaban en spamEl dominio de la página no está en Allowed domains del formulario. Añádelo, y localhost para las pruebas locales. Los subdominios de un dominio de la lista están permitidos.
403 challenge_requiredLa verificación antispam del formulario está en Ikiform y un envío con fetch no llevaba token. Consigue uno con challenge(formId) de @ikiform/sdk antes de cada envío, o desactiva la verificación.
El panel aparece en el centro de la pantallaLa hoja de estilos no se cargó, así que se aplican los estilos por defecto del popover del navegador. Revisa el import del CSS, o define inset y margin en el panel.
422 validation_failedEl envío no coincide con la definición de campos del formulario. error.fields indica cada campo y el motivo. Haz que el name del input coincida con el id del campo.

Preguntas

¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.