웹사이트나 앱용 피드백 폼5분 분량
React 앱에 떠 있는 피드백 위젯 만들기
작은 패널을 열고, 답변마다 화면, 앱 버전, 계정을 함께 보내고, 작성 중인 내용을 잃지 않는 React 앱용 플로팅 피드백 버튼입니다.
고정 버튼을 렌더링하고, 브라우저의 Popover API로 만든 패널을 열게 하세요. 그러면 z-index 싸움 없이 앱의 모든 레이어 위에 표시되고 Escape로 닫힙니다. 제출 시 현재 라우트, 앱 버전, 계정 ID, 뷰포트를 폼 데이터에 추가하고 Ikiform SDK로 브라우저에서 POST하세요. 사용자의 이메일은 답장을 원한다고 체크했을 때만 보내세요.
인앱 위젯이 다른 점
페이지 폼은 사람들이 스크롤해서 내려오기를 기다립니다. 위젯은 모든 화면에 있으므로 잘못될 수 있는 부분이 달라집니다. 앱의 모달, 메뉴, 토스트 위에 있어야 합니다. 누군가 바깥을 클릭했을 때 쓰다 만 메시지를 잃으면 안 됩니다. 그리고 사용자가 로그인해 있으므로 누구인지, 어디에 있는지 이미 알고 있으니 묻는 대신 위젯이 그 정보를 보내야 합니다.
- 버튼은 항상 화면 오른쪽 아래에 고정되어 있습니다.
- 클릭하면 브라우저의 top layer에 패널이 열리고 새 시도 키가 만들어집니다.
- 사용자가 표정을 고르고 메모를 씁니다. 패널이 열려 있든 숨겨져 있든 폼의 상태는 마운트된 채 유지됩니다.
- 제출 시 위젯이 라우트, 앱 버전, 계정 ID, 뷰포트를 추가하고 SDK로 POST합니다.
- 버튼을 두 번 눌러도 두 POST가 같은 멱등성 키를 가지므로 Ikiform은 한 번만 저장합니다.
- 사용자가 먼저 바깥을 클릭하거나 Escape를 누르면 패널이 숨겨지고, 다음에 열 때 작성 중인 내용이 그대로 있습니다.
앱용 필드
미리보기
Ikiform의 검증기를 브라우저에서 실행합니다. 아무것도 전송되지 않습니다.
may_reply는 체크박스이며 true 또는 false로 저장됩니다.| 필드 | 출처 | 이유 |
|---|---|---|
rating | 사용자, 표정 다섯 개 | max: 5인 rating 필드. 숫자보다 표정이 더 빨리 고를 수 있습니다 |
message | 사용자 | 선택. 나쁜 표정일 때 가장 유용합니다 |
may_reply, email | 체크박스, 그다음 세션 | 답장을 원할 때만 이메일을 보내며, 알림의 답장은 그 사람에게 갑니다 |
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"는 열릴 때 패널을 top layer에 둡니다.z-index없이 대화상자와 메뉴 위에 표시되고, 사용자가 다른 곳을 클릭하면 닫힙니다.- 패널이 숨겨져도 자식 요소는 마운트된 채로 남으므로, 실수로 바깥을 클릭해도 초안이 남습니다. 전송에 성공하면 감사 문구가 폼을 대체해 내용이 지워지고, 다음에 열면 빈 폼이 표시됩니다.
onToggle은 패널이 열릴 때마다 새 키를 만듭니다.submit()이 이를Idempotency-Key헤더로 보내므로 더블 클릭해도 답변이 하나만 저장됩니다. 멱등성을 참고하세요.- 이메일은 체크박스를 선택했을 때만 설정됩니다. 선택하지 않으면 답변에 계정 ID는 담기지만 주소는 없습니다.
popover의 기본 스타일은 화면 가운데에 배치합니다. 아래 규칙은 대신 버튼 위에 고정합니다.
.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;
}한 번만 마운트하기
로그인한 화면을 감싸는 레이아웃에서 위젯을 렌더링하면 모든 곳에 나타나고 내비게이션에도 유지됩니다. 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 앱에서는 라우터 outlet 옆에 렌더링하면 똑같이 동작합니다.
브라우저가 증명할 수 있는 것과 없는 것
이 위젯은 폼의 공개 ID로 브라우저에서 바로 POST합니다. 피드백에는 적절한 절충입니다. 유지할 서버 라우트가 없고, Ikiform의 meta.user_agent가 사용자의 실제 브라우저가 됩니다. 반면 누구나 원하는 account_id를 POST할 수 있다는 뜻이기도 합니다. 조회용으로만 쓰고, 무언가를 허용하는 데는 절대 쓰지 마세요.
작동 확인하기
- 폼의 Allowed domains에 사이트 도메인을 추가하고, 개발하는 동안에는
localhost도 추가하세요. 비워 두면 어디서든 POST를 받습니다. - 패널을 열고 메모를 입력한 뒤 바깥을 클릭하고 다시 여세요. 메모가 그대로 있습니다.
- 앱에서 모달을 연 다음 위젯을 여세요. 패널이 모달 위에 표시됩니다.
- Escape를 누르세요. 패널이 닫히고 포커스가 피드백 버튼으로 돌아옵니다.
- 표정을 고르고 보내기를 더블 클릭하세요. Submissions에
route,app_version,account_id가 채워진 답변이 하나 표시됩니다. - 답장 체크박스를 선택하고 한 번 보내세요. 알림 이메일의 답장이 사용자에게 갑니다.
주의할 점
- 채팅 버블이나 쿠키 배너 같은 다른 플로팅 요소와 버튼이 겹치지 않게 하세요. 둘 중 하나를 옮기거나 메뉴 항목에서 위젯을 열게 하세요.
- 결제나 에디터처럼 사람들이 집중하는 화면에는 버튼을 보여 주지 마세요. prop으로 숨기세요.
- Popover API는 현재 Chrome, Edge, Firefox, Safari에서 지원됩니다. 오래된 브라우저를 지원해야 한다면 의존하기 전에 확인하세요.
- 코드에서 추가하는 모든 필드는 폼 정의에 있어야 합니다. 없으면 Ikiform이 Unknown field 오류로 POST를 거부합니다.
- 앱 상태 전체를 보내지 마세요. 라우트, 버전, 계정, 크기로 대부분의 질문에 답할 수 있고, 그 이상은 개인정보 문제가 됩니다.
문제 해결
| 문제 | 해결 방법 |
|---|---|
403 origin_not_allowed, 또는 모든 POST가 스팸으로 분류됨 | 페이지의 도메인이 폼의 Allowed domains에 없습니다. 도메인을 추가하고, 로컬 테스트용으로 localhost도 추가하세요. 등록된 도메인의 하위 도메인은 허용됩니다. |
403 challenge_required | 폼의 스팸 검사가 Ikiform으로 설정되어 있는데 fetch POST에 토큰이 없었습니다. 제출할 때마다 @ikiform/sdk의 challenge(formId)로 토큰을 받거나 검사를 끄세요. |
| 패널이 화면 가운데에 나타남 | 스타일시트가 로드되지 않아 브라우저의 기본 popover 스타일이 적용된 것입니다. CSS import를 확인하거나 패널에 inset과 margin을 설정하세요. |
422 validation_failed | POST가 폼의 필드 정의와 맞지 않습니다. error.fields에 각 필드와 이유가 나옵니다. 입력의 name을 필드 ID와 맞추세요. |
질문
popover=auto인 패널을 popoverTarget으로 가리키는 고정 버튼을 렌더링하세요. 열기, 닫기, 포커스는 브라우저가 처리합니다. 패널의 폼은 @ikiform/sdk의 submit()으로 POST하고, 제출 시 라우트와 앱 버전을 추가하세요.
popover는 top layer에 렌더링되므로 z-index 규칙 없이 앱의 대화상자 위에 표시되고, 라이트 디스미스와 Escape가 기본으로 제공됩니다. 피드백 패널은 모달처럼 페이지를 막을 필요가 없습니다.
아니요. 브라우저에서 보내는 것은 무엇이든 사용자가 바꿀 수 있습니다. 맥락을 조회하는 데 쓰고, 신원을 신뢰해야 할 때는 서버에서 시크릿 키로 POST하세요.
아니요. 브라우저에서 Ikiform으로 바로 POST하고, Allowed domains가 폼을 쓸 수 있는 사이트를 제한합니다. 신뢰할 수 있는 신원이 필요할 때만 서버 라우트를 추가하세요.
막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.