# Framer

> 코드 없이 웹훅 목적지로 Framer 폼을 Ikiform에 보내거나, 브라우저에서 POST하는 코드 컴포넌트로 직접 폼을 렌더링하세요.

Framer 폼은 외부 action URL로 POST할 수 없지만, 제출마다 웹훅으로 보낼 수는 있습니다. 그래서 방법이 두 가지입니다. Framer 폼을 유지하고 Ikiform을 웹훅으로 추가하거나, 방문자의 브라우저에서 POST하는 작은 코드 컴포넌트를 넣으세요.

|   | 웹훅을 쓰는 Framer 폼 | 코드 컴포넌트 |
| --- | --- | --- |
| POST를 보내는 주체 | Framer의 서버 | 방문자의 브라우저 |
| 코드 | 없음 | React 파일 하나 |
| 허용 도메인 | 비워 두세요. 서버 POST에는 `Origin`이 없습니다 | Framer 도메인과 비교 |
| 허니팟 | 사용할 수 없음 | 포함 |
| 레이트 리밋 집계 기준 | Framer 서버의 주소 | 방문자별 |
| Ikiform 스팸 검사 | 끈 상태로 두기 | 끈 상태로 두기 |

## 웹훅을 쓰는 Framer 폼

### 1단계: 입력 이름 정하기

Framer는 각 입력의 이름을 키로 하는 JSON을 보내므로, 입력에 붙인 이름이 Ikiform의 필드 이름이 됩니다. `email`, `message`처럼 소문자 ID를 사용하고, [필드 정의](https://ikiform.com/ko/docs/fields.md)가 있다면 맞추세요.

### 2단계: Ikiform을 웹훅으로 추가하기

캔버스에서 폼을 선택하세요. 오른쪽 사이드바의 **Send To** 옆에서 **Add…** > **Webhook**을 고르고 엔드포인트를 붙여 넣으세요. Framer는 `https://` URL을 요구하며, 엔드포인트는 이미 그 형식입니다.

`Webhook URL`:

```text
https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx
```

Framer는 시크릿으로 요청에 서명할 수 있습니다. Ikiform은 `Framer-Signature` 헤더를 확인하지 않으므로 여기서 시크릿은 선택 사항입니다.

### 3단계: Ikiform 폼 설정 조정하기

- **Allowed domains**는 비워 두세요. Framer는 `Origin` 헤더 없이 서버에서 POST하므로, 도메인을 설정하면 모든 POST가 `origin_mismatch`로 스팸 처리되거나 **Reject** 설정 시 거부됩니다.
- 스팸 검사는 끈 상태로 두세요. 토큰 없는 JSON POST는 `403 challenge_required`를 받으며, Framer에는 확인을 실행할 방법이 없습니다.
- **Relaxed** [레이트 리밋](https://ikiform.com/ko/docs/rate-limits.md)을 고르세요. Ikiform은 IP 주소와 폼별로 POST를 세는데, 모든 POST가 방문자가 아니라 Framer의 서버에서 옵니다.

Framer는 `2xx`가 아닌 모든 응답을 실패로 보고 최대 5번 재시도합니다. Ikiform은 제출이 저장되면 `201`로 응답합니다. `422 validation_failed`처럼 Ikiform이 거부한 POST는 재시도할 때마다 똑같이 실패하므로, 기다리지 말고 원인을 고치세요.

## 코드 컴포넌트

코드 컴포넌트는 페이지에 실제 폼을 렌더링하므로, 브라우저가 POST하고 Ikiform은 여러분의 사이트를 출처로 봅니다. **Assets** 패널에서 **Code**를 열고 코드 파일을 만든 뒤 다음을 붙여 넣으세요.

`IkiformContact.tsx`:

```tsx
import { addPropertyControls, ControlType } from "framer";
import { type CSSProperties, type FormEvent, useState } from "react";

const field: CSSProperties = {
  font: "inherit",
  padding: "10px 12px",
  borderRadius: 8,
  border: "1px solid rgba(0, 0, 0, 0.15)",
};

export default function IkiformContact({ formId, successText }: { formId: string; successText: string }) {
  const [status, setStatus] = useState<"idle" | "sending" | "sent" | "error">("idle");
  const [message, setMessage] = useState("");

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setStatus("sending");
    try {
      const response = await fetch(`https://ikiform.com/f/${formId}`, {
        method: "POST",
        headers: { Accept: "application/json" },
        body: new FormData(event.currentTarget),
      });
      const result = await response.json();
      if (result.ok) return setStatus("sent");
      setMessage(result.error.message);
    } catch {
      setMessage("Couldn't reach the server. Try again.");
    }
    setStatus("error");
  }

  if (status === "sent") return <p>{successText}</p>;

  return (
    <form onSubmit={onSubmit} style={{ display: "grid", gap: 12, width: "100%" }}>
      <input name="email" type="email" placeholder="Email" autoComplete="email" required style={field} />
      <textarea name="message" placeholder="Message" rows={4} style={field} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {status === "error" ? <p role="alert">{message}</p> : null}
      <button type="submit" disabled={status === "sending"} style={field}>
        {status === "sending" ? "Sending" : "Send"}
      </button>
    </form>
  );
}

addPropertyControls(IkiformContact, {
  formId: { type: ControlType.String, title: "Form ID", defaultValue: "frm_xxxxxxxxxxxxxxxx" },
  successText: { type: ControlType.String, title: "Success text", defaultValue: "Thanks, we got it." },
});
```

- 컴포넌트를 페이지로 끌어다 놓고 속성 패널에서 **Form ID**를 설정하세요. 입력 스타일은 `field`를 편집해 바꾸거나, 요소를 직접 만든 것으로 교체하세요.
- `Accept: application/json`을 보내면 Ikiform이 JSON으로 응답하므로, 컴포넌트가 결과를 그 자리에 보여 줄 수 있습니다.
- 컴포넌트는 `fetch`로 POST하므로 폼의 스팸 검사는 끈 상태로 두세요. 허니팟과 스팸 점수 산정은 계속 실행됩니다.

> [사이트 빌더 임베드](https://ikiform.com/ko/docs/guides/site-builders.md#embed)를 **Embed** 컴포넌트의 **HTML** 섹션에 붙여 넣을 수도 있습니다. Framer가 이를 프레임 안에 넣으면 Ikiform의 감사 및 오류 페이지가 그 안에서 열리지 않으므로, 임베드는 `target="_top"`을 사용합니다. 코드 컴포넌트는 프레임을 아예 쓰지 않습니다.

## 허용 도메인

코드 컴포넌트와 임베드에만 해당합니다. 웹훅을 쓸 때는 허용 도메인을 비워 두세요.

| 폼이 실행되는 곳 | Allowed domains에 추가할 값 |
| --- | --- |
| Framer 하위 도메인 | `your-project.framer.app`, 오래된 프로젝트는 `your-project.framer.website` |
| 커스텀 도메인 | `example.com`, `www.example.com`도 포함 |
| Framer 편집기 미리보기 | 없음. 대신 게시된 사이트에서 테스트하세요. |

- `framer.app`이나 `framer.website`만 단독으로 추가하지 마세요. 다른 사람의 사이트를 포함해 그 호스트의 모든 사이트가 일치하게 됩니다.

## 테스트 체크리스트

1. 사이트를 게시하세요. Framer는 게시된 사이트의 폼에 대해서만 웹훅을 보냅니다.
2. 폼을 제출하고 Ikiform에서 제출을 확인하세요. 웹훅을 쓰면 필드가 입력 이름과 정확히 일치해야 합니다.
3. 코드 컴포넌트라면 허용 도메인을 켜고, 게시된 도메인에서 제출한 뒤 스팸으로 표시되지 않았는지 확인하세요.

## 문제 해결

| 증상 | 원인 | 해결 |
| --- | --- | --- |
| 모든 웹훅 제출이 `origin_mismatch`로 스팸에 들어감 | **Allowed domains**가 설정되어 있고, Framer 서버의 POST에는 출처가 없음 | 이 폼의 허용 도메인을 비우거나 코드 컴포넌트로 바꾸세요. |
| Framer가 웹훅 전송 실패를 보고함 | Ikiform이 POST를 거부함: `403 challenge_required`, `403 origin_not_allowed` 또는 `422 validation_failed` | 스팸 검사를 끄고, 허용 도메인을 비우고, 입력 이름을 필드에 맞추세요. |
| 제출이 많은 폼에서 `429 rate_limited` | Framer 서버의 POST가 적은 수의 IP 주소를 공유함 | **Relaxed**나 Custom 한도를 고르세요. |
| 컴포넌트가 서버에 연결할 수 없다고 표시함 | 광고 차단기나 개인정보 보호 확장 프로그램이 요청을 막았거나, **Form ID**가 비어 있음 | 시크릿 창에서 시도하고 속성이 설정되었는지 확인하세요. |
| 제출이 `origin_mismatch`와 함께 스팸 필터에 들어감 | POST한 페이지가 폼의 허용 도메인에 없음 | 위 표의 호스트를 추가하세요. **Reject**를 골랐다면 같은 POST가 대신 `403 origin_not_allowed`를 받습니다. |

## 관련 문서

- [React](https://ikiform.com/ko/docs/guides/react.md): 대기, 오류, 성공 상태.
- [사이트 빌더](https://ikiform.com/ko/docs/guides/site-builders.md): 어떤 빌더에서든 작동하는 임베드.
- [레이트 리밋](https://ikiform.com/ko/docs/rate-limits.md): 프리셋과 허용량이 다시 채워지는 방식.
- [스팸 방지](https://ikiform.com/ko/docs/spam.md): 허니팟, 점수 산정, 사람 확인.
