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.
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 botón está siempre en pantalla, fijo en la esquina inferior derecha.
- Al hacer clic se abre el panel en la top layer del navegador y se genera una nueva clave de intento.
- El usuario elige una cara y escribe una nota. El estado del formulario sigue montado mientras el panel está abierto u oculto.
- 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.
- Ikiform lo guarda una sola vez, aunque se pulse el botón dos veces, porque ambos envíos llevan la misma clave de idempotencia.
- Si el usuario hace clic fuera o pulsa Escape antes, el panel se oculta y el borrador sigue ahí la próxima vez.
Campos para una app
Vista previa
Ejecuta el validador de Ikiform en tu navegador. No se envía nada.
may_reply es una casilla, guardada como true o false.| Campo | Viene de | Por qué |
|---|---|---|
rating | El usuario, cinco caras | Un campo rating con max: 5. Las caras se eligen más rápido que los números |
message | El usuario | Opcional. Más útil cuando la cara es mala |
may_reply, email | Una casilla, y luego tu sesión | El email solo se envía cuando piden respuesta, y Responder en la notificación va a ellos |
route | window.location.pathname | Qué pantalla. Se lee al enviar, después de cualquier navegación en el cliente |
app_version | Tu build | Te dice si una queja trata de código que ya has cambiado |
account_id | Tu sesión | Te permite consultar su plan y sus datos. No prueba la identidad; mira más abajo |
viewport | window.innerWidth e innerHeight | Las 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.
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 sinz-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.
onTogglecrea una clave nueva cada vez que se abre el panel.submit()la envía como cabeceraIdempotency-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:
.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:
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.
Comprueba que funciona
- En Allowed domains del formulario, añade el dominio de tu sitio, y
localhostmientras desarrollas. Déjalo vacío para aceptar envíos desde cualquier lugar. - Abre el panel, escribe una nota, haz clic fuera y vuelve a abrirlo. La nota sigue ahí.
- Abre un modal en tu app y luego el widget. El panel aparece por encima del modal.
- Pulsa Escape. El panel se cierra y el foco vuelve al botón de feedback.
- Elige una cara y haz doble clic en Enviar. Submissions muestra una sola respuesta, con
route,app_versionyaccount_idrellenos. - 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
| Problema | Qué hacer |
|---|---|
403 origin_not_allowed, o todos los envíos acaban en spam | El 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_required | La 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 pantalla | La 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_failed | El 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
Renderiza un botón fijo con popoverTarget apuntando a un panel con popover=auto. El navegador se encarga de abrir, cerrar y del foco. Envía el formulario del panel con submit() de @ikiform/sdk y añade la ruta y la versión de la app al enviar.
Los popovers se renderizan en la top layer, así que quedan por encima de tus propios diálogos sin reglas de z-index, y el cierre ligero y Escape vienen gratis. Un panel de feedback no necesita bloquear la página como lo hace un modal.
No. Todo lo que se envía desde el navegador lo puede cambiar el usuario. Úsalo para consultar contexto, y envía desde tu servidor con una clave secreta cuando la identidad tenga que ser fiable.
No. Envía directamente a Ikiform desde el navegador, y Allowed domains limita qué sitios pueden usar el formulario. Añade una ruta de servidor solo si necesitas una identidad fiable.
¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.