Feedback-Formular für eine Website oder App

Feedback-Formular für eine Website oder App6 Min. Lesezeit

Schwebendes Feedback-Widget für deine React-App bauen

Ein schwebender Feedback-Button für eine React-App, der ein kleines Panel öffnet, Ansicht, App-Version und Konto mit jeder Antwort sendet und nie einen Entwurf verliert.

Preet SutharGründer, Ikiform

Rendere einen fixierten Button, der ein Panel auf Basis der Popover API des Browsers öffnet, damit es ohne z-index-Kampf über jeder Ebene deiner App liegt und sich mit Escape schließt. Ergänze beim Absenden die aktuelle Route, App-Version, Konto-ID und den Viewport in den Formulardaten und sende sie mit dem Ikiform SDK aus dem Browser. Sende die E-Mail des Nutzers nur, wenn er ein Häkchen setzt und um eine Antwort bittet.

Was an einem Widget in der App anders ist

Ein Formular auf der Seite wartet, bis Leute dorthin scrollen. Ein Widget ist auf jeder Ansicht da, und das ändert, was schiefgehen kann. Es muss über deinen eigenen Modals, Menüs und Toasts liegen. Es darf keine halb geschriebene Nachricht verlieren, wenn jemand daneben klickt. Und weil die Leute angemeldet sind, weißt du schon, wer sie sind und wo sie sind, also sollte das Widget das senden, statt danach zu fragen.

Der Ablauf einer Widget-Sitzung, vom Klick bis zur gespeicherten AntwortFeedback-Buttonfixiert,untenrechtsPanelöffnetsichTop Layer,neuerVersuchsschlüsselNutzerbewertetundschreibtStatelebt imPanelmitKontextabsendenRoute,Version,Konto,ViewporteinmalgespeichertgleicherKey beiDoppelklickPanelwirdverstecktEntwurfbleibtfürsnächsteÖffnenKlickSDKKlick daneben oder EscapeDer Ablauf einer Widget-Sitzung, vom Klick bis zur gespeicherten AntwortFeedback-Buttonfixiert, unten rechtsPanel öffnet sichTop Layer, neuerVersuchsschlüsselNutzer bewertetund schreibtState lebt im Panelmit KontextabsendenRoute, Version, Konto,Viewporteinmalgespeichertgleicher Key beiDoppelklickPanelwirdverstecktEntwurfbleibt fürsnächsteÖffnenKlickSDKKlick daneben oder Escape
  1. Der Button ist immer sichtbar, fixiert in der unteren rechten Ecke.
  2. Ein Klick öffnet das Panel im Top Layer des Browsers und erzeugt einen neuen Versuchsschlüssel.
  3. Der Nutzer wählt ein Gesicht und schreibt eine Notiz. Der State des Formulars bleibt gemountet, solange das Panel offen oder versteckt ist.
  4. Beim Absenden ergänzt das Widget Route, App-Version, Konto-ID und Viewport und sendet mit dem SDK.
  5. Ikiform speichert sie einmal, auch wenn der Button zweimal gedrückt wird, weil beide Posts denselben Idempotency-Key tragen.
  6. Klickt der Nutzer vorher daneben oder drückt Escape, wird das Panel versteckt, und der Entwurf ist beim nächsten Mal noch da.
Light Dismiss ist das Standardverhalten der Popover API. Weil das Panel nur versteckt wird, behält das Textfeld, was eingetippt wurde.

Felder für eine App

Vorschau

Führt den Validator von Ikiform in deinem Browser aus. Es wird nichts gesendet.

Wie läuft es?
Nutzer sehen nur die ersten drei Felder. Der Rest wird beim Absenden im Code ergänzt. may_reply ist eine Checkbox, gespeichert als true oder false.
FeldStammt vonWarum
ratingDem Nutzer, fünf GesichterEin rating-Feld mit max: 5. Gesichter sind schneller gewählt als Zahlen
messageDem NutzerOptional. Am nützlichsten bei einem schlechten Gesicht
may_reply, emailEiner Checkbox, dann deiner SessionDie E-Mail wird nur gesendet, wenn um eine Antwort gebeten wird, und Antworten in der Benachrichtigung geht an den Nutzer
routewindow.location.pathnameWelche Ansicht. Beim Absenden gelesen, nach jeder clientseitigen Navigation
app_versionDeinem BuildZeigt dir, ob sich eine Beschwerde auf Code bezieht, den du schon geändert hast
account_idDeiner SessionLässt dich Tarif und Daten nachschlagen. Kein Identitätsnachweis, siehe unten
viewportwindow.innerWidth und innerHeightLayout-Beschwerden hängen oft von der Größe ab

Die Komponente

Sie braucht @ikiform/sdk und React 19, das popover, popoverTarget und das toggle-Event typisiert. Das popoverTarget des Buttons verknüpft ihn mit dem Panel, sodass der Browser Öffnen, Escape und die Rückgabe des Fokus an den Button übernimmt.

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" legt das Panel beim Öffnen in den Top Layer. Es erscheint ohne z-index über deinen Dialogen und Menüs und schließt sich, wenn der Nutzer woanders hinklickt.
  • Die Kinder des Panels bleiben gemountet, wenn es versteckt wird, sodass ein Entwurf einen versehentlichen Klick daneben übersteht. Nach erfolgreichem Senden ersetzt die Dankeszeile das Formular, was es leert, und beim nächsten Öffnen erscheint ein leeres Formular.
  • onToggle erzeugt bei jedem Öffnen des Panels einen frischen Key. submit() sendet ihn als Idempotency-Key-Header, sodass ein Doppelklick eine Antwort speichert. Siehe Idempotenz.
  • Die E-Mail wird nur gesetzt, wenn das Häkchen gesetzt ist. Ohne Häkchen trägt die Antwort die Konto-ID, aber keine Adresse.

Die Standardstyles des Popovers zentrieren es auf dem Bildschirm. Diese Regeln heften es stattdessen über den Button:

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

Einmal mounten

Rendere das Widget im Layout, das die Ansichten für angemeldete Nutzer umschließt, damit es überall erscheint und Navigation übersteht. Im Next.js App Router ist das Layout eine Server-Komponente, die die Session liest und einfache Props an das Client-Widget übergibt:

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

Markiere die Widget-Datei im App Router mit "use client". In einer Vite- oder React-Router-App renderst du es neben dem Outlet deines Routers. Es funktioniert genauso.

Was der Browser beweisen kann und was nicht

Dieses Widget sendet mit der öffentlichen ID des Formulars direkt aus dem Browser. Für Feedback ist das der richtige Kompromiss: keine Server-Route zu pflegen, und Ikiforms eigenes meta.user_agent ist der echte Browser des Nutzers. Es bedeutet aber auch, dass jeder eine beliebige account_id senden kann. Nutze sie zum Nachschlagen, nie, um etwas zu gewähren.

Muss das Konto vertrauenswürdig sein, etwa um Feedback in einem Admin-Tool anzuzeigen, sende von deinem Server mit dem Secret Key und ergänze das Konto dort aus der Session. Die Server-Versionen der Hauptanleitung zeigen das Muster; wähle das Rate-Limit Relaxed oder Custom, denn jeder Post kommt dann von der IP deines Servers.

Prüfen, ob es funktioniert

  1. Füge unter Allowed domains des Formulars die Domain deiner Site hinzu, plus localhost, während du entwickelst. Lass das Feld leer, um Posts von überall anzunehmen.
  2. Öffne das Panel, tippe eine Notiz, klick daneben und öffne es erneut. Die Notiz ist noch da.
  3. Öffne ein Modal in deiner App, dann das Widget. Das Panel erscheint über dem Modal.
  4. Drück Escape. Das Panel schließt sich, und der Fokus liegt wieder auf dem Feedback-Button.
  5. Wähle ein Gesicht und doppelklicke auf Senden. Submissions zeigt eine Antwort, mit befüllten route, app_version und account_id.
  6. Sende einmal mit gesetztem Antwort-Häkchen. Antworten in der Benachrichtigungs-E-Mail geht an den Nutzer.

Stolperfallen

  • Halte den Button von deinen eigenen schwebenden Elementen fern, etwa einer Chat-Bubble oder einem Cookie-Banner. Verschieb eins davon oder öffne das Widget stattdessen über einen Menüpunkt.
  • Zeig den Button nicht auf jeder Ansicht eines Checkouts oder Editors, in dem sich Leute konzentrieren. Blende ihn dort mit einer Prop aus.
  • Die Popover API wird in aktuellem Chrome, Edge, Firefox und Safari unterstützt. Musst du ältere Browser unterstützen, prüfe das, bevor du dich darauf verlässt.
  • Jedes im Code ergänzte Feld muss in der Definition des Formulars existieren, sonst lehnt Ikiform den Post mit einem Unknown-field-Fehler ab.
  • Sende nicht den ganzen App-State. Route, Version, Konto und Größe beantworten die meisten Fragen; alles andere ist eine Datenschutzfrage.

Fehlerbehebung

ProblemWas zu tun ist
403 origin_not_allowed, oder jeder Post landet im SpamDie Domain der Seite steht nicht unter Allowed domains des Formulars. Füge sie hinzu, plus localhost für lokale Tests. Subdomains einer eingetragenen Domain sind erlaubt.
403 challenge_requiredDie Spamprüfung des Formulars steht auf Ikiform, und ein fetch-Post hatte kein Token. Hol dir vor jedem Absenden eins mit challenge(formId) aus @ikiform/sdk oder schalte die Prüfung aus.
Das Panel erscheint in der Mitte des BildschirmsDas Stylesheet wurde nicht geladen, also gelten die Standardstyles des Browsers für Popover. Prüf den CSS-Import oder setze inset und margin auf dem Panel.
422 validation_failedDer Post passt nicht zur Felddefinition des Formulars. error.fields nennt jedes Feld und den Grund. Gleiche den name des Eingabefelds an die Feld-ID an.

Fragen

Hängst du bei einem Schritt fest oder willst eine Anleitung für deinen Fall? Schreib an hello@ikiform.com.