リード獲得フォーム1 分で読めます
サービス業向けの相談申し込みフォームを作る
会計士、弁護士、コーチ、クリニック向けの相談申し込みフォームです。連絡方法と時間を尋ね、デリケートな詳細は電話のときに回します。
名前、メールアドレス、連絡方法とおおよその時間帯、大まかな相談内容を尋ね、状況の詳細はすべて電話のときに聞きます。ブラウザーからタイムゾーンを記録すれば、希望された「午前」は相手の午前になります。電話を選んだときだけ電話番号を求めるなど、フォームのスキーマで表せないルールは自分のコードで適用してください。
相談申し込みならではの違い
相談の申し込みは、問題を抱えた人から来ます。税務署からの手紙、賃貸の争い、始めたい事業などです。本人は説明したがり、フォームはそれを促します。そこが落とし穴です。入力した内容はすべて送信データと一緒に保存され、通知メールに表示され、向けた先へ Webhook で送られます。口座番号や事案の詳細は、そのどこにもふさわしくありません。
だからフォームの役割は見た目より狭く、相手が誰か、どう・いつ連絡するか、おおよその内容だけです。それ以外はすべて電話を待ちます。電話なら、何を書き留めてどこに保管するかを自分で管理できます。
- 誰か:名前とメールアドレスを尋ねます。
- どう連絡するか:メール、電話、ビデオを尋ねます。
- いつか:希望日、時間帯、タイムゾーンを尋ねます。
- 何についてか:大まかな相談内容を 1 つ尋ねます。
- 電話では、本人の状況を本人の言葉で聞きます。
- 書類はフォームではなく、自分で選んだ経路で受け取ります。
- 口座番号、ID、記録はフォームに一切入力させません。
- 電話の最後に、対応の可否と料金を伝えます。
フィールド
プレビュー
Ikiform のバリデーターをブラウザーで実行します。何も送信されません。
| フィールド | この形にしている理由 |
|---|---|
contact_method | radio にすれば、最初の連絡を本人が希望した方法で行えます |
phone | スキーマでは任意、電話を選んだときはコードで必須 |
preferred_date | date フィールドは YYYY-MM-DD を保存します。スキーマの min は固定なので、ページがブラウザーで明日を min に設定します |
timezone | ブラウザーで Intl.DateTimeFormat().resolvedOptions().timeZone から入力。例:Europe/Berlin |
note | 上限 500 文字。通知メールには値ごとに 500 文字まで表示されるので、メモ全体が収まります |
条件付きのルールは自分のコードで
Ikiform は各フィールドを個別に検証します。電話のときだけ電話番号を求めるなど、ほかのフィールドに依存するルールはコードで実行します。サーバーの版では確実に適用でき、ブラウザーの版では訪問者を案内し、不要なものは送りません。
スクリプトは、電話 が選ばれたら電話番号の入力欄に required を付け、訪問者自身のカレンダーで明日を最も早い日に設定し、非表示のタイムゾーンを埋めます。
<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>サーバーアクションは、番号のない電話の申し込みを送信前に拒否し、入力したあとで別の方法に切り替えた番号は消して、サンクスページにリダイレクトします。フォームは 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");
}電話番号の入力欄は電話が選ばれたときだけ存在し、空の値は送信前に取り除かれるので、任意のフィールドは空欄ではなく欠けた状態で届きます。
<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>サーバー側の版はシークレットキー付きで送信するので、Ikiform は Allowed domains と人間確認をスキップします。その場合はすべての送信がサーバーの IP から来るため、訪問者ごとに 1 分あたり 5 件という Standard の制限が、全訪問者をまとめて数えることになります。サーバー経由で送信するフォームには Relaxed か Custom のレート制限を選び、IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。サーバー側の fetch は Origin を送らず、Ikiform は無料の localhost テストをそこから判別するので、サーバーの版は訪問者の Origin ヘッダーを転送します。
デリケートな詳細をフォームに書かせない
- 上のフォームのように、ヘルプテキストで伝えましょう。はっきりした指示には、思ったよりも従ってもらえます。
- 身分証の番号、口座番号、医療の詳細のフィールドは追加しないでください。職業上、顧客データに関する規則があるなら、連絡先以外を集める前に確認し、残りはその規則を満たす経路で集めてください。
- フィールド名も重要です。Ikiform は、
password、ssn、cvv、seedを含む名前など、フィールド名が認証情報のように見えると、通知メールを確認のために保留します。スタートアップ向けコンサルティングのフォームのseed_roundというフィールドはこれに引っかかります。funding_stageという名前にしてください。 - Webhook は、全社のチャンネルではなく非公開のチャンネルなど、チーム全員が顧客の名前を読んでよい場所にだけ向けてください。
最初の返信
通知メールは Reply の宛先が訪問者になった状態で届くので、最初の返信は受信トレイから直接送れます。その返信の役割は 1 つ、日時を確定することです。都合を尋ねるより、相手の時間帯の中で具体的な候補を 2 つ出すほうが、承諾してもらいやすくなります。
- 書く前に、相手の時間帯を自分の時刻に換算します。
Australia/Sydneyのmorningは、ロンドンでは前日の夜です。 - 相手のタイムゾーンで候補を 2 つ出し、メールにタイムゾーンを明記して、誰も 1 時間早く来ないようにします。
- 連絡方法を伝えます。教えてもらった番号か、招待と一緒に送るビデオのリンクです。
- 相談内容が自分の業務の範囲外なら、最初の返信でそう伝え、対応できる人を紹介します。その返信も送る価値があります。
動作を確認する
- 電話 を選び、電話番号を空にして送信します。ブラウザーかサーバーがメッセージを出して止めます。
- メールアドレス を選んで送信し、送信データを開きます。
phoneの値はなく、America/Chicagoのようなタイムゾーンが入っています。 - 希望日に今日を選んでみます。日付の選択肢に出てきません。
- 通知メールを確認します。メモが全文表示され、Reply で訪問者宛てに書けます。
- フィールド ID を一覧にし、
password、ssn、cvv、seedを含むものがないことを確認します。
ハマりどころ
- タイムゾーンのない日付。
preferred_dateは時点ではなく暦の日です。電話する前にtimezoneとtime_windowと合わせて読んでください。 - 何も選ばれていないラジオボタン。 チェックされていないラジオボタンのグループは何も送りません。任意の
time_windowならそれで問題なく、contact_methodが メールアドレス にチェックした状態で始まるのはそのためです。 - 予約の確認メールはない。 Ikiform はフォームに入力した人にはメールを送りません。日時は最初の返信で確定します。本人に枠を選んでもらいたいなら、デモ依頼フォームのように予約ツールに引き継いでください。
- 電話番号の形式。 電話番号のフィールドは、入力どおり 6〜15 桁を受け付けます。
+14155550123の形式が必要なシステムから発信するならe164をオンにしてください。その場合、国番号のない番号は拒否されます。
よくある質問
名前、メールアドレス、希望の連絡方法、希望の日時、大まかな相談内容です。本人の状況の全体、書類、各種の番号は相談そのものに回してください。
各フィールドを個別に検証するフィールド定義ではできません。電話が選ばれたらブラウザーで入力欄に required を付け、送信前にサーバーのコードで確認してください。
ブラウザーで Intl.DateTimeFormat().resolvedOptions().timeZone から非表示の timezone フィールドを埋めます。希望日と時間帯の隣に、Europe/London のような IANA の名前が保存されます。
いいえ。Ikiform がメールを送るのはあなたとチームで、送信者ではないので、自動返信や確認メールはありません。Webhook をメールツールに送り、そこから確認メールを送ってください。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com