리드 수집 폼4분 분량
서비스 업체용 상담 신청 폼 만들기
회계사, 변호사, 코치, 클리닉을 위한 상담 신청 폼입니다. 연락 방법과 시간을 묻고, 민감한 내용은 통화에서 다룹니다.
이름, 이메일, 연락 방법, 대략적인 시간, 그리고 넓은 주제를 묻고, 상황에 대한 세부 내용은 모두 통화로 남겨 두세요. 브라우저에서 시간대를 기록하면 요청한 오전이 그 사람의 오전이 됩니다. 전화 통화를 골랐을 때만 전화번호를 받는 것처럼 폼 스키마로 표현할 수 없는 규칙은 내 코드에서 강제하세요.
상담 신청이 다른 점
상담 신청은 문제를 가진 사람에게서 옵니다. 세무서 통지서, 임대 분쟁, 시작하려는 사업 같은 것들입니다. 그들은 설명하고 싶어 하고, 폼이 그렇게 하라고 권합니다. 그게 함정입니다. 입력한 내용은 무엇이든 제출과 함께 저장되고, 알림 이메일에 표시되고, 웹훅으로 지정한 곳 어디로든 전송됩니다. 계좌 번호나 사건 세부 내용은 그 어디에도 있어서는 안 됩니다.
그래서 폼의 일은 보기보다 좁습니다. 누구인지, 어떻게 언제 연락할지, 대략 무엇에 관한 것인지입니다. 나머지는 무엇을 기록하고 어디에 보관할지 내가 통제할 수 있는 통화에서 다룹니다.
- 폼은 누구인지 묻습니다. 이름과 이메일입니다.
- 연락 방법을 묻습니다. 이메일, 전화, 화상입니다.
- 언제인지 묻습니다. 선호하는 날, 시간대, 그리고 그 사람의 표준 시간대입니다.
- 무엇에 관한 것인지 넓은 주제 하나로 묻습니다.
- 통화에서 본인의 말로 상황을 듣습니다.
- 서류는 폼이 아니라 내가 고른 채널로 받습니다.
- 계좌 번호, 식별 번호, 기록은 절대 폼에 입력하지 않습니다.
- 통화는 적합 여부와 비용에 대한 답으로 끝납니다.
필드
미리보기
Ikiform의 검증기를 브라우저에서 실행합니다. 아무것도 전송되지 않습니다.
| 필드 | 이런 형태인 이유 |
|---|---|
contact_method | radio이므로 첫 연락이 요청한 방식으로 이루어집니다 |
phone | 스키마에서는 선택, 전화 통화를 고르면 코드에서 필수 |
preferred_date | date 필드는 YYYY-MM-DD를 저장합니다. 스키마의 min은 고정이므로 페이지가 브라우저에서 내일을 min으로 설정합니다 |
timezone | 브라우저에서 Intl.DateTimeFormat().resolvedOptions().timeZone으로 채웁니다. 예: Europe/Berlin |
note | 최대 500자. 알림 이메일은 값마다 최대 500자를 보여 주므로 메모 전체가 들어갑니다 |
내 코드의 조건부 규칙
Ikiform은 각 필드를 따로 검증합니다. 전화 통화일 때만 전화번호처럼 다른 필드에 의존하는 규칙은 내 코드에서 실행합니다. 서버 버전은 이를 실제로 강제할 수 있고, 브라우저 버전은 방문자를 안내하고 필요 없는 것은 보내지 않습니다.
스크립트는 Phone call을 고르면 전화번호 입력에 required를 설정하고, 방문자 자신의 달력 기준 내일을 가장 이른 날로 설정하고, hidden 시간대를 채웁니다.
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST" id="consult">
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required />
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<fieldset>
<legend>How should we reach you?</legend>
<label><input type="radio" name="contact_method" value="email" checked /> Email</label>
<label><input type="radio" name="contact_method" value="phone" /> Phone call</label>
<label><input type="radio" name="contact_method" value="video" /> Video call</label>
</fieldset>
<label for="phone">Phone</label>
<input id="phone" name="phone" type="tel" autocomplete="tel" />
<label for="topic">What is it about?</label>
<select id="topic" name="topic" required>
<option value="new_business">Starting a business</option>
<option value="tax">Tax and bookkeeping</option>
<option value="other">Something else</option>
</select>
<label for="preferred_date">Preferred day</label>
<input id="preferred_date" name="preferred_date" type="date" />
<fieldset>
<legend>Best time</legend>
<label><input type="radio" name="time_window" value="morning" /> Morning</label>
<label><input type="radio" name="time_window" value="afternoon" /> Afternoon</label>
<label><input type="radio" name="time_window" value="evening" /> Evening</label>
</fieldset>
<input type="hidden" name="timezone" />
<label for="note">Anything we should know first?</label>
<textarea id="note" name="note" rows="3" maxlength="500"></textarea>
<p>A sentence or two. Please don't include account numbers or personal records.</p>
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<button>Request a consultation</button>
</form>
<script>
const form = document.getElementById("consult");
const phone = form.elements.phone;
const tomorrow = new Date(Date.now() + 86400000);
const local = new Date(tomorrow.getTime() - tomorrow.getTimezoneOffset() * 60000);
form.elements.timezone.value = Intl.DateTimeFormat().resolvedOptions().timeZone;
form.elements.preferred_date.min = local.toISOString().slice(0, 10);
form.addEventListener("change", () => {
phone.required = form.elements.contact_method.value === "phone";
});
</script>서버 액션은 POST 전에 번호 없는 전화 통화를 거부하고, 입력했다가 다른 방식으로 바꾼 번호는 지우고, 감사 페이지로 리디렉션합니다. useActionState로 폼을 렌더링하고 state.error를 표시하세요.
"use server";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
const KEYS = ["name", "email", "contact_method", "phone", "topic", "preferred_date", "time_window", "timezone", "note", "_gotcha"];
export async function requestConsultation(_previous: Record<string, string>, formData: FormData) {
const values = Object.fromEntries(KEYS.map((key) => [key, String(formData.get(key) ?? "").trim()]));
if (values.contact_method === "phone" && !values.phone) {
return { ...values, error: "Add a phone number, or choose email or video." };
}
if (values.contact_method !== "phone") values.phone = "";
const origin = (await headers()).get("origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify(values),
});
const result = await response.json().catch(() => null);
if (!result?.ok) {
const fields: Record<string, string[]> = result?.error?.fields ?? {};
const [field, messages] = Object.entries(fields)[0] ?? [];
return { ...values, error: field ? `${field}: ${messages?.[0]}` : (result?.error?.message ?? "Couldn't send. Try again.") };
}
redirect("/consultation/thanks");
}전화번호 입력은 전화 통화를 골랐을 때만 존재하고, 빈 값은 POST 전에 버려지므로 선택 필드는 빈 값이 아니라 아예 없는 상태로 도착합니다.
<script setup lang="ts">
import { computed, reactive, ref } from "vue";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const request = reactive({
name: "",
email: "",
contact_method: "email",
phone: "",
topic: "new_business",
preferred_date: "",
time_window: "",
note: "",
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
});
const status = ref<"idle" | "sending" | "sent">("idle");
const error = ref("");
const wantsCall = computed(() => request.contact_method === "phone");
async function send() {
status.value = "sending";
error.value = "";
const body = Object.fromEntries(Object.entries(request).filter(([, value]) => value !== ""));
if (!wantsCall.value) delete body.phone;
try {
const response = await fetch(ENDPOINT, {
method: "POST",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const result = await response.json();
if (result.ok) {
status.value = "sent";
return;
}
const first = Object.entries(result.error.fields ?? {})[0] as [string, string[]] | undefined;
error.value = first ? `${first[0]}: ${first[1][0]}` : result.error.message;
} catch {
error.value = "Couldn't reach us. Try again, or call the office.";
}
status.value = "idle";
}
</script>
<template>
<p v-if="status === 'sent'" role="status">Thanks, {{ request.name }}. We'll contact you by {{ request.contact_method }} within one working day.</p>
<form v-else @submit.prevent="send">
<label for="name">Name</label>
<input id="name" v-model="request.name" autocomplete="name" required />
<label for="email">Email</label>
<input id="email" v-model="request.email" type="email" autocomplete="email" required />
<fieldset>
<legend>How should we reach you?</legend>
<label><input v-model="request.contact_method" type="radio" value="email" /> Email</label>
<label><input v-model="request.contact_method" type="radio" value="phone" /> Phone call</label>
<label><input v-model="request.contact_method" type="radio" value="video" /> Video call</label>
</fieldset>
<template v-if="wantsCall">
<label for="phone">Phone</label>
<input id="phone" v-model="request.phone" type="tel" autocomplete="tel" required />
</template>
<label for="topic">What is it about?</label>
<select id="topic" v-model="request.topic">
<option value="new_business">Starting a business</option>
<option value="tax">Tax and bookkeeping</option>
<option value="other">Something else</option>
</select>
<label for="preferred_date">Preferred day</label>
<input id="preferred_date" v-model="request.preferred_date" type="date" />
<label for="note">Anything we should know first?</label>
<textarea id="note" v-model="request.note" rows="3" maxlength="500" />
<p v-if="error" role="alert">{{ error }}</p>
<button :disabled="status === 'sending'">{{ status === "sending" ? "Sending" : "Request a consultation" }}</button>
</form>
</template>서버 측 버전은 시크릿 키로 POST하므로 Ikiform이 Allowed domains와 사람 확인을 건너뜁니다. 그러면 모든 POST가 서버의 IP에서 오고, 방문자당 분당 5회인 Standard 리밋이 모든 방문자를 함께 셉니다. 서버를 거쳐 POST하는 폼에는 Relaxed나 Custom 레이트 리밋을 고르고, IKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요. 서버 버전은 방문자의 Origin 헤더를 전달합니다. 서버 측 fetch는 이 헤더를 보내지 않는데, Ikiform은 이 헤더로 무료 localhost 테스트를 판별하기 때문입니다.
민감한 정보는 폼에서 빼기
- 위 폼처럼 도움말에 적으세요. 사람들은 생각보다 분명한 안내를 잘 따릅니다.
- 주민등록번호 같은 식별 번호, 계좌 번호, 의료 정보 필드는 추가하지 마세요. 직업상 고객 데이터에 관한 규정이 있다면 연락처 이외의 것을 수집하기 전에 확인하고, 나머지는 규정을 충족하는 채널로 받으세요.
- 필드 이름도 중요합니다. Ikiform은 필드 이름이 자격 증명처럼 보이면 알림 이메일을 검토를 위해 보류합니다.
password,ssn,cvv,seed가 들어간 이름이 그렇습니다. 스타트업 컨설팅 폼의seed_round필드가 여기에 걸립니다.funding_stage로 이름을 지으세요. - 웹훅은 팀 전체가 고객 이름을 봐도 되는 곳, 예를 들어 전사 채널이 아닌 비공개 채널로만 보내세요.
첫 답장
알림 이메일의 Reply가 방문자를 가리키므로 첫 답장은 내 받은편지함에서 바로 보낼 수 있습니다. 첫 답장의 일은 하나, 시간 확정입니다. 언제가 좋은지 묻는 것보다 요청한 시간대 안의 구체적인 시간 두 개를 제안하는 편이 수락하기 쉽습니다.
- 쓰기 전에 그 사람의 시간대를 내 시간으로 바꾸세요.
Australia/Sydney의morning은 런던의 전날 저녁입니다. - 그 사람의 시간대로 두 시간을 제안하고 이메일에 시간대를 명시하세요. 그래야 아무도 한 시간 일찍 오지 않습니다.
- 어떻게 연락할지 말하세요. 알려 준 번호인지, 초대와 함께 보내는 화상 링크인지입니다.
- 주제가 내 업무 범위 밖이라면 첫 답장에서 그렇게 말하고 할 수 있는 사람을 추천하세요. 그런 답장도 보낼 가치가 있습니다.
작동 확인하기
- Phone call을 고르고 전화번호를 비운 채 제출하세요. 브라우저나 서버가 메시지와 함께 막습니다.
- Email을 고르고 제출한 뒤 제출 내용을 여세요.
phone값은 없고,America/Chicago같은 내 시간대가 있습니다. - 선호하는 날로 오늘을 골라 보세요. 날짜 선택기에 나오지 않습니다.
- 알림 이메일을 확인하세요. 메모가 전부 표시되고 Reply가 방문자에게 작성됩니다.
- 필드 ID를 나열해
password,ssn,cvv,seed가 들어간 것이 없는지 확인하세요.
주의할 점
- 시간대 없는 날짜.
preferred_date는 특정 시점이 아니라 달력의 날짜입니다. 연락하기 전에timezone,time_window와 함께 읽으세요. - 아무것도 선택되지 않은 라디오. 선택되지 않은 라디오 그룹은 아무것도 보내지 않습니다. 선택 입력인
time_window에는 괜찮고,contact_method가 Email이 선택된 상태로 시작하는 이유입니다. - 예약 확인은 없습니다. Ikiform은 폼을 작성한 사람에게 이메일을 보내지 않습니다. 첫 답장이 시간을 확정합니다. 사람들이 직접 시간을 고르게 하려면 데모 요청 폼처럼 스케줄러로 넘기세요.
- 전화번호 형식. 전화번호 필드는 입력한 그대로 6~15자리 숫자를 받습니다.
+14155550123형식이 필요한 시스템에서 전화를 건다면e164를 켜세요. 그러면 국가 코드 없는 번호는 거부됩니다.
질문
이름, 이메일, 원하는 연락 방법, 선호하는 날과 시간, 넓은 주제입니다. 상황 전체, 서류, 각종 번호는 상담에서 다루세요.
필드 정의로는 안 됩니다. 필드 정의는 각 필드를 따로 검증합니다. 전화를 고르면 브라우저에서 입력에 required를 설정하고, POST 전에 서버 코드에서 확인하세요.
브라우저에서 Intl.DateTimeFormat().resolvedOptions().timeZone으로 hidden timezone 필드를 채우세요. 요청한 날짜와 시간대 옆에 Europe/London 같은 IANA 이름이 저장됩니다.
아니요. Ikiform은 제출자가 아니라 여러분과 팀에게 이메일을 보내므로, 자동 응답이나 확인 이메일이 없습니다. 이메일 도구로 웹훅을 보내고 거기서 확인 이메일을 발송하세요.
막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.