ウェブサイトやアプリのフィードバックフォーム2 分で読めます
React アプリにフローティングのフィードバックウィジェットを作る
React アプリ用のフローティングのフィードバックボタン。小さなパネルを開き、回答ごとに画面、アプリのバージョン、アカウントを送り、下書きを失いません。
固定ボタンを表示し、ブラウザーの Popover API で作ったパネルを開きます。z-index と格闘せずにアプリのどのレイヤーよりも上に表示され、Escape で閉じます。送信時に現在のルート、アプリのバージョン、アカウント ID、ビューポートをフォームデータに加え、Ikiform SDK でブラウザーから送信します。ユーザーのメールアドレスは、返信を希望するチェックボックスにチェックが入ったときだけ送ります。
アプリ内ウィジェットならではの違い
ページのフォームは、人がそこまでスクロールするのを待ちます。ウィジェットはすべての画面にあるので、起こりうる問題が変わります。自分のアプリのモーダル、メニュー、トーストより上に表示しなければなりません。誰かが外側をクリックしても、書きかけのメッセージを失ってはいけません。そしてユーザーはサインインしているので、誰がどこにいるかはすでにわかっています。ウィジェットは尋ねる代わりにそれを送るべきです。
- ボタンは常に画面右下に固定表示されています。
- クリックするとブラウザーのトップレイヤーにパネルが開き、新しい試行キーが作られます。
- ユーザーが顔を選んでメモを書きます。フォームの状態は、パネルが開いていても隠れていてもマウントされたままです。
- 送信時に、ウィジェットがルート、アプリのバージョン、アカウント ID、ビューポートを加え、SDK で送信します。
- ボタンが 2 回押されても、両方の送信に同じ冪等性キーが付くので、Ikiform は 1 回だけ保存します。
- ユーザーが先に外側をクリックするか Escape を押すと、パネルは隠れ、下書きは次回もそのまま残ります。
アプリ向けのフィールド
プレビュー
Ikiform のバリデーターをブラウザーで実行します。何も送信されません。
may_reply はチェックボックスで、true か false として保存されます。| フィールド | 取得元 | 理由 |
|---|---|---|
rating | ユーザー、5 つの顔 | max: 5 の rating フィールド。顔は数字より素早く選べます |
message | ユーザー | 任意。顔の評価が悪いときに特に役立ちます |
may_reply, email | チェックボックス、次にセッション | メールアドレスは返信を希望したときだけ送られ、通知の Reply は本人宛てになります |
route | window.location.pathname | どの画面か。クライアント側の遷移のあと、送信時に読み取ります |
app_version | 自分のビルド | 不満がすでに変更したコードについてのものかどうかがわかります |
account_id | 自分のセッション | プランやデータを調べられます。本人確認の証明にはなりません。下記参照 |
viewport | window.innerWidth と innerHeight | レイアウトの不満はサイズに左右されがちです |
コンポーネント
@ikiform/sdk と、popover、popoverTarget、toggle イベントに型がある React 19 が必要です。ボタンの popoverTarget がパネルと結び付けるので、開く操作、Escape、ボタンへのフォーカスの戻りはブラウザーが処理します。
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 が入りますが、アドレスは入りません。
ポップオーバーのデフォルトのスタイルでは画面中央に表示されます。次のルールで、代わりにボタンの上に固定します:
.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 を渡すサーバーコンポーネントです:
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 を送れるということでもあります。情報を調べるために使い、何かの権限を与えるためには決して使わないでください。
動作を確認する
- フォームの Allowed domains に自分のサイトのドメインを追加し、開発中は
localhostも追加します。空のままにすると、どこからの送信も受け付けます。 - パネルを開いてメモを入力し、外側をクリックしてから、もう一度開きます。メモはそのまま残っています。
- アプリでモーダルを開いてから、ウィジェットを開きます。パネルはモーダルの上に表示されます。
- Escape を押します。パネルが閉じ、フォーカスが Feedback ボタンに戻ります。
- 表情を 1 つ選んで送信をダブルクリックします。Submissions には回答が 1 件だけ表示され、
route、app_version、account_idが埋まっています。 - 返信のチェックボックスにチェックを入れて 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 に合わせてください。 |
よくある質問
固定配置のボタンを描画し、popoverTarget で popover=auto を持つパネルを指定します。開閉とフォーカスはブラウザーが処理します。パネルのフォームは @ikiform/sdk の submit() で送信し、送信時にルートとアプリのバージョンを加えます。
popover はトップレイヤーに描画されるので、z-index のルールなしで自分のダイアログより上に表示され、外側クリックや Escape で閉じる動作も最初から付いてきます。フィードバックパネルは、モーダルのようにページを塞ぐ必要がありません。
いいえ。ブラウザーから送られるものはすべてユーザーが書き換えられます。文脈を調べるために使い、本人確認を信頼する必要がある場合はサーバーからシークレットキー付きで送信してください。
いいえ。ブラウザーから直接 Ikiform に送信し、Allowed domains でフォームを使えるサイトを制限します。信頼できる本人確認が必要な場合にだけ、サーバールートを追加してください。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com