웹사이트나 앱용 피드백 폼

웹사이트나 앱용 피드백 폼5분 분량

React 앱에 떠 있는 피드백 위젯 만들기

작은 패널을 열고, 답변마다 화면, 앱 버전, 계정을 함께 보내고, 작성 중인 내용을 잃지 않는 React 앱용 플로팅 피드백 버튼입니다.

Preet SutharIkiform 창업자

고정 버튼을 렌더링하고, 브라우저의 Popover API로 만든 패널을 열게 하세요. 그러면 z-index 싸움 없이 앱의 모든 레이어 위에 표시되고 Escape로 닫힙니다. 제출 시 현재 라우트, 앱 버전, 계정 ID, 뷰포트를 폼 데이터에 추가하고 Ikiform SDK로 브라우저에서 POST하세요. 사용자의 이메일은 답장을 원한다고 체크했을 때만 보내세요.

인앱 위젯이 다른 점

페이지 폼은 사람들이 스크롤해서 내려오기를 기다립니다. 위젯은 모든 화면에 있으므로 잘못될 수 있는 부분이 달라집니다. 앱의 모달, 메뉴, 토스트 위에 있어야 합니다. 누군가 바깥을 클릭했을 때 쓰다 만 메시지를 잃으면 안 됩니다. 그리고 사용자가 로그인해 있으므로 누구인지, 어디에 있는지 이미 알고 있으니 묻는 대신 위젯이 그 정보를 보내야 합니다.

피드백 위젯 세션 하나의 흐름, 클릭부터 저장까지피드백 버튼고정, 오른쪽 아래패널 열림top layer,새 시도 키사용자가평가하고 작성상태는 패널 안에맥락과 함께제출라우트, 버전,계정, 뷰포트한 번만 저장더블 클릭에도 같은키패널 숨김다음에 열 때까지초안 유지클릭SDK바깥 클릭 또는 Escape피드백 위젯 세션 하나의 흐름, 클릭부터 저장까지피드백 버튼고정, 오른쪽 아래패널 열림top layer, 새 시도 키사용자가 평가하고 작성상태는 패널 안에맥락과 함께 제출라우트, 버전, 계정, 뷰포트한 번만 저장더블 클릭에도 같은 키패널 숨김다음에 열 때까지 초안유지클릭SDK바깥 클릭 또는 Escape
  1. 버튼은 항상 화면 오른쪽 아래에 고정되어 있습니다.
  2. 클릭하면 브라우저의 top layer에 패널이 열리고 새 시도 키가 만들어집니다.
  3. 사용자가 표정을 고르고 메모를 씁니다. 패널이 열려 있든 숨겨져 있든 폼의 상태는 마운트된 채 유지됩니다.
  4. 제출 시 위젯이 라우트, 앱 버전, 계정 ID, 뷰포트를 추가하고 SDK로 POST합니다.
  5. 버튼을 두 번 눌러도 두 POST가 같은 멱등성 키를 가지므로 Ikiform은 한 번만 저장합니다.
  6. 사용자가 먼저 바깥을 클릭하거나 Escape를 누르면 패널이 숨겨지고, 다음에 열 때 작성 중인 내용이 그대로 있습니다.
라이트 디스미스는 Popover API의 기본 동작입니다. 패널은 숨겨지기만 하므로 텍스트 필드에 입력한 내용이 남아 있습니다.

앱용 필드

미리보기

Ikiform의 검증기를 브라우저에서 실행합니다. 아무것도 전송되지 않습니다.

사용해 보니 어떠세요?
사용자에게는 처음 세 필드만 보입니다. 나머지는 제출 시 코드에서 추가합니다. may_reply는 체크박스이며 true 또는 false로 저장됩니다.
필드출처이유
rating사용자, 표정 다섯 개max: 5인 rating 필드. 숫자보다 표정이 더 빨리 고를 수 있습니다
message사용자선택. 나쁜 표정일 때 가장 유용합니다
may_reply, email체크박스, 그다음 세션답장을 원할 때만 이메일을 보내며, 알림의 답장은 그 사람에게 갑니다
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"는 열릴 때 패널을 top layer에 둡니다. z-index 없이 대화상자와 메뉴 위에 표시되고, 사용자가 다른 곳을 클릭하면 닫힙니다.
  • 패널이 숨겨져도 자식 요소는 마운트된 채로 남으므로, 실수로 바깥을 클릭해도 초안이 남습니다. 전송에 성공하면 감사 문구가 폼을 대체해 내용이 지워지고, 다음에 열면 빈 폼이 표시됩니다.
  • onToggle은 패널이 열릴 때마다 새 키를 만듭니다. submit()이 이를 Idempotency-Key 헤더로 보내므로 더블 클릭해도 답변이 하나만 저장됩니다. 멱등성을 참고하세요.
  • 이메일은 체크박스를 선택했을 때만 설정됩니다. 선택하지 않으면 답변에 계정 ID는 담기지만 주소는 없습니다.

popover의 기본 스타일은 화면 가운데에 배치합니다. 아래 규칙은 대신 버튼 위에 고정합니다.

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

한 번만 마운트하기

로그인한 화면을 감싸는 레이아웃에서 위젯을 렌더링하면 모든 곳에 나타나고 내비게이션에도 유지됩니다. 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 앱에서는 라우터 outlet 옆에 렌더링하면 똑같이 동작합니다.

브라우저가 증명할 수 있는 것과 없는 것

이 위젯은 폼의 공개 ID로 브라우저에서 바로 POST합니다. 피드백에는 적절한 절충입니다. 유지할 서버 라우트가 없고, Ikiform의 meta.user_agent가 사용자의 실제 브라우저가 됩니다. 반면 누구나 원하는 account_id를 POST할 수 있다는 뜻이기도 합니다. 조회용으로만 쓰고, 무언가를 허용하는 데는 절대 쓰지 마세요.

계정을 신뢰해야 한다면, 예를 들어 관리자 도구 안에서 피드백을 보여 주려면, 서버에서 시크릿 키로 POST하고 거기서 세션의 계정을 추가하세요. 필러의 서버 버전에 패턴이 있습니다. 모든 POST가 서버의 IP에서 오게 되므로 Relaxed나 Custom 레이트 리밋을 고르세요.

작동 확인하기

  1. 폼의 Allowed domains에 사이트 도메인을 추가하고, 개발하는 동안에는 localhost도 추가하세요. 비워 두면 어디서든 POST를 받습니다.
  2. 패널을 열고 메모를 입력한 뒤 바깥을 클릭하고 다시 여세요. 메모가 그대로 있습니다.
  3. 앱에서 모달을 연 다음 위젯을 여세요. 패널이 모달 위에 표시됩니다.
  4. Escape를 누르세요. 패널이 닫히고 포커스가 피드백 버튼으로 돌아옵니다.
  5. 표정을 고르고 보내기를 더블 클릭하세요. Submissions에 route, app_version, account_id가 채워진 답변이 하나 표시됩니다.
  6. 답장 체크박스를 선택하고 한 번 보내세요. 알림 이메일의 답장이 사용자에게 갑니다.

주의할 점

  • 채팅 버블이나 쿠키 배너 같은 다른 플로팅 요소와 버튼이 겹치지 않게 하세요. 둘 중 하나를 옮기거나 메뉴 항목에서 위젯을 열게 하세요.
  • 결제나 에디터처럼 사람들이 집중하는 화면에는 버튼을 보여 주지 마세요. 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_failedPOST가 폼의 필드 정의와 맞지 않습니다. error.fields에 각 필드와 이유가 나옵니다. 입력의 name을 필드 ID와 맞추세요.

질문

막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.