ウェブサイトやアプリのフィードバックフォーム

ウェブサイトやアプリのフィードバックフォーム2 分で読めます

React アプリにフローティングのフィードバックウィジェットを作る

React アプリ用のフローティングのフィードバックボタン。小さなパネルを開き、回答ごとに画面、アプリのバージョン、アカウントを送り、下書きを失いません。

Preet SutharIkiform 創業者

固定ボタンを表示し、ブラウザーの Popover API で作ったパネルを開きます。z-index と格闘せずにアプリのどのレイヤーよりも上に表示され、Escape で閉じます。送信時に現在のルート、アプリのバージョン、アカウント ID、ビューポートをフォームデータに加え、Ikiform SDK でブラウザーから送信します。ユーザーのメールアドレスは、返信を希望するチェックボックスにチェックが入ったときだけ送ります。

アプリ内ウィジェットならではの違い

ページのフォームは、人がそこまでスクロールするのを待ちます。ウィジェットはすべての画面にあるので、起こりうる問題が変わります。自分のアプリのモーダル、メニュー、トーストより上に表示しなければなりません。誰かが外側をクリックしても、書きかけのメッセージを失ってはいけません。そしてユーザーはサインインしているので、誰がどこにいるかはすでにわかっています。ウィジェットは尋ねる代わりにそれを送るべきです。

フィードバックウィジェットの 1 セッション、クリックから保存までフィードバックボタン固定、右下パネルが開くトップレイヤー、新しい試行キー評価して書く状態はパネル内に保持文脈付きで送信ルート、バージョン、アカウント、ビューポート1 回だけ保存ダブルクリックでも同じキーパネルが隠れる次に開くまで下書きを保持クリックSDK外側クリックか Escapeフィードバックウィジェットの 1 セッション、クリックから保存までフィードバックボタン固定、右下パネルが開くトップレイヤー、新しい試行キー評価して書く状態はパネル内に保持文脈付きで送信ルート、バージョン、アカウント、ビューポート1 回だけ保存ダブルクリックでも同じキーパネルが隠れる次に開くまで下書きを保持クリックSDK外側クリックか Escape
  1. ボタンは常に画面右下に固定表示されています。
  2. クリックするとブラウザーのトップレイヤーにパネルが開き、新しい試行キーが作られます。
  3. ユーザーが顔を選んでメモを書きます。フォームの状態は、パネルが開いていても隠れていてもマウントされたままです。
  4. 送信時に、ウィジェットがルート、アプリのバージョン、アカウント ID、ビューポートを加え、SDK で送信します。
  5. ボタンが 2 回押されても、両方の送信に同じ冪等性キーが付くので、Ikiform は 1 回だけ保存します。
  6. ユーザーが先に外側をクリックするか Escape を押すと、パネルは隠れ、下書きは次回もそのまま残ります。
ライトディスミスは Popover API のデフォルトです。パネルは隠れるだけなので、テキスト欄には入力した内容が残ります。

アプリ向けのフィールド

プレビュー

Ikiform のバリデーターをブラウザーで実行します。何も送信されません。

使い心地はいかがですか?
ユーザーに見えるのは最初の 3 つのフィールドだけです。残りは送信時にコードで追加します。may_reply はチェックボックスで、true か false として保存されます。
フィールド取得元理由
ratingユーザー、5 つの顔max: 5 の rating フィールド。顔は数字より素早く選べます
messageユーザー任意。顔の評価が悪いときに特に役立ちます
may_reply, emailチェックボックス、次にセッションメールアドレスは返信を希望したときだけ送られ、通知の Reply は本人宛てになります
routewindow.location.pathnameどの画面か。クライアント側の遷移のあと、送信時に読み取ります
app_version自分のビルド不満がすでに変更したコードについてのものかどうかがわかります
account_id自分のセッションプランやデータを調べられます。本人確認の証明にはなりません。下記参照
viewportwindow.innerWidth と innerHeightレイアウトの不満はサイズに左右されがちです

コンポーネント

@ikiform/sdk と、popover、popoverTarget、toggle イベントに型がある React 19 が必要です。ボタンの popoverTarget がパネルと結び付けるので、開く操作、Escape、ボタンへのフォーカスの戻りはブラウザーが処理します。

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" は、開いたパネルをトップレイヤーに置きます。z-index なしでダイアログやメニューの上に表示され、ユーザーがほかの場所をクリックすると閉じます。
  • パネルの子要素は隠れてもマウントされたままなので、うっかり外側をクリックしても下書きは残ります。送信に成功すると、お礼の一文がフォームに置き換わってフォームがクリアされ、次に開くと空のフォームが表示されます。
  • onToggle はパネルを開くたびに新しいキーを作ります。submit() はそれを Idempotency-Key ヘッダーとして送るので、ダブルクリックしても保存される回答は 1 件です。冪等性を参照してください。
  • メールアドレスは、チェックボックスにチェックが入ったときだけ設定されます。チェックがなければ、回答にはアカウント ID が入りますが、アドレスは入りません。

ポップオーバーのデフォルトのスタイルでは画面中央に表示されます。次のルールで、代わりにボタンの上に固定します:

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

1 回だけマウントする

ウィジェットはサインイン後の画面を包むレイアウトで表示します。そうすればどこにでも表示され、ページ遷移でも消えません。Next.js の App Router では、レイアウトはセッションを読み取ってクライアントのウィジェットにプレーンな props を渡すサーバーコンポーネントです:

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

App Router では、ウィジェットのファイルに "use client" を付けます。Vite や React Router のアプリでは、ルーターのアウトレットの隣に表示します。動作は同じです。

ブラウザーが証明できること、できないこと

このウィジェットは、フォームの公開 ID でブラウザーから直接送信します。フィードバックにはこれが適したトレードオフです。保守するサーバールートがなく、Ikiform の meta.user_agent はユーザーの本当のブラウザーになります。ただし、誰でも好きな account_id を送れるということでもあります。情報を調べるために使い、何かの権限を与えるためには決して使わないでください。

アカウントを信頼できる必要があるなら、たとえば管理ツール内にフィードバックを表示するなら、シークレットキーを使ってサーバーから送信し、そこでセッションからアカウントを加えてください。そのパターンはピラーのサーバー版にあります。すべての送信がサーバーの IP から来るので、レート制限は Relaxed か Custom を選んでください。

動作を確認する

  1. フォームの Allowed domains に自分のサイトのドメインを追加し、開発中は localhost も追加します。空のままにすると、どこからの送信も受け付けます。
  2. パネルを開いてメモを入力し、外側をクリックしてから、もう一度開きます。メモはそのまま残っています。
  3. アプリでモーダルを開いてから、ウィジェットを開きます。パネルはモーダルの上に表示されます。
  4. Escape を押します。パネルが閉じ、フォーカスが Feedback ボタンに戻ります。
  5. 表情を 1 つ選んで送信をダブルクリックします。Submissions には回答が 1 件だけ表示され、route、app_version、account_id が埋まっています。
  6. 返信のチェックボックスにチェックを入れて 1 回送信します。通知メールの返信先はユーザーになります。

ハマりどころ

  • ボタンは、チャットのバブルや Cookie バナーなど、自分のサイトの浮いている要素と重ならないようにします。どちらかを動かすか、代わりにメニュー項目からウィジェットを開いてください。
  • チェックアウトやエディターなど、人が集中している画面すべてにボタンを出さないでください。そこでは prop で非表示にします。
  • Popover API は現行の Chrome、Edge、Firefox、Safari で使えます。古いブラウザーに対応する必要があるなら、頼る前に確認してください。
  • コードで追加するフィールドはすべてフォームの定義に存在している必要があります。そうでないと Ikiform は Unknown field エラーで送信を拒否します。
  • アプリの状態をまるごと送らないでください。ルート、バージョン、アカウント、サイズでほとんどの疑問に答えられます。それ以外はプライバシーの問題になります。

トラブルシューティング

問題対処法
403 origin_not_allowed が返る、またはすべての送信がスパムになるページのドメインがフォームの Allowed domains にありません。ドメインと、ローカルテスト用の localhost を追加してください。登録したドメインのサブドメインは許可されます。
403 challenge_requiredフォームのスパムチェックが Ikiform に設定されていて、fetch の送信にトークンがありませんでした。送信のたびに @ikiform/sdk の challenge(formId) でトークンを取得するか、チェックをオフにしてください。
パネルが画面の中央に表示されるスタイルシートが読み込まれず、ブラウザーのデフォルトの popover スタイルが適用されています。CSS の import を確認するか、パネルに inset と margin を設定してください。
422 validation_failed送信がフォームのフィールド定義に合っていません。error.fields に各フィールドと理由が示されます。入力欄の name をフィールド ID に合わせてください。

よくある質問

手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com