새 SaaS용 대기자 명단5분 분량
지원자를 선별하는 베타 신청 폼 만드는 방법
서버가 강제하는 선별 질문이 있는 베타 신청 폼과, 지원자에게 점수를 매겨 목록에서 기수별 참가자를 고르는 스크립트입니다.
이메일 옆에 선별 질문을 몇 개 두고, 폼 백엔드가 이를 강제하게 해서 모든 신청이 빠짐없이, 비교 가능한 형태로 도착하게 하세요. 그런 다음 짧은 점수 스크립트로 목록에서 기수별 참가자를 고르고, 소규모 그룹을 초대하고, 나머지는 다음 차례를 기다리게 하세요.
대기자 명단은 출시일에 모두에게 이메일을 보냅니다. 베타는 정반대입니다. 20명을 들이고, 그들에게서 배우고, 다시 20명을 들입니다. 그래서 폼이 달라집니다. 주소만이 아니라 정렬할 수 있는 답변이 필요합니다.
베타에서 달라지는 점
- 선발입니다. 한 번에 몇 명씩 초대하므로 지원자끼리 순위를 매길 방법이 필요합니다. 역할이나 팀 규모 같은 선택형 질문은 깔끔하게 정렬됩니다. 자유 입력은 그렇지 않습니다.
- 불완전한 신청은 자리를 낭비합니다. 적합한지 알려 주는 질문을 누군가 건너뛰면 그 사람을 뽑을 수도, 뺄 수도 없습니다. 선별 질문을 필수로 만들고, 이를 건너뛴 POST는 서버가 거부하게 하세요.
- 초대는 여러 차례에 걸쳐 나갑니다. 다음 기수가 이미 초대한 사람을 건너뛰도록 목록이 누구를 초대했는지 기억해야 합니다. Ikiform에는 태그가 없으므로 그 상태는 스크립트나 시트에 둡니다.
- 뽑히지 않은 사람도 중요합니다. 그들이 다음 기수입니다. 감사 문구에서 그룹별로 차례차례 초대한다고 알려 주세요. 그래야 조용한 받은편지함을 거절로 받아들이지 않습니다.
선별 질문
질문은 여섯 개가 한계입니다. 질문마다 누구를 초대할지에 영향을 줘야 합니다. 어떤 답이 그 사람의 순위를 어떻게 올리거나 내릴지 말할 수 없다면 빼세요. 아래 폼을 써 보세요. Ikiform이 서버에서 적용하는 것과 같은 필드 정의를 실행하므로, 불완전한 신청이 받는 422를 볼 수 있습니다.
미리보기
Ikiform의 검증기를 브라우저에서 실행합니다. 아무것도 전송되지 않습니다.
use_case 답변은 최소 40자가 필요하므로 한 단어짜리 신청은 걸러집니다.| 필드 | 판단하는 것 | 이 타입을 쓰는 이유 |
|---|---|---|
email | 누구를 초대할지 | 필수. 업무용 이메일은 묻지 않아도 회사를 짐작하게 해 줍니다 |
role | 해결하려는 문제를 실제로 겪고 있는지 | 직접 정한 구간이 있는 select라서 가중치를 줄 수 있습니다 |
team_size | 첫 버전이 그들의 환경에 맞는지 | radio: 답이 하나라 항상 비교할 수 있습니다 |
use_case | 이번 달에 베타를 쓸지 | 최소 길이가 있는 textarea. 폼에서 가장 솔직한 신호입니다 |
current_tool | 무엇을 대체하는지, 무엇과 비교할지 | 선택 자유 입력 |
interview | 누구와 이야기할 수 있는지 | checkbox. 없으면 false이므로 신청을 막지 않습니다 |
422와 unknown_field 오류로 거부합니다. ref 같은 숨겨진 입력을 추가한다면 정의에도 추가하세요.폼
선별 폼은 잘못된 이메일 말고도 실패할 이유가 많으므로, 아래 모든 버전은 서버의 메시지를 해당 필드 아래에 보여 줍니다. 또 POST가 거부되어도 입력한 내용을 유지하므로 아무도 문단을 다시 쓸 필요가 없습니다.
네이티브 required와 minlength가 POST 전에 대부분의 누락을 잡아냅니다. 일반 폼은 스크립트 없이 작동하고 감사 페이지로 이동하며, 스크립트를 쓰면 사람들이 페이지에 머물고 필드 오류가 하나하나 표시됩니다.
<form id="beta-form" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
<label for="email">Work email</label>
<input id="email" name="email" type="email" autocomplete="email" required>
<label for="role">Your role</label>
<select id="role" name="role" required>
<option value="">Choose one</option>
<option value="founder">Founder</option>
<option value="engineer">Engineer</option>
<option value="product">Product manager</option>
<option value="designer">Designer</option>
<option value="other">Something else</option>
</select>
<fieldset>
<legend>Team size</legend>
<label><input type="radio" name="team_size" value="1" required> Only me</label>
<label><input type="radio" name="team_size" value="2-10"> 2 to 10</label>
<label><input type="radio" name="team_size" value="11-50"> 11 to 50</label>
<label><input type="radio" name="team_size" value="51+"> More than 50</label>
</fieldset>
<label for="use_case">What would you use it for first?</label>
<textarea id="use_case" name="use_case" rows="4" minlength="40" maxlength="1000" required></textarea>
<label for="current_tool">What do you use today?</label>
<input id="current_tool" name="current_tool" maxlength="120">
<label><input type="checkbox" name="interview" value="true"> I'm open to a 20-minute call</label>
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden>
<ul id="beta-errors" role="alert"></ul>
<button>Apply for the beta</button>
</form><script>
const form = document.getElementById("beta-form");
const errors = document.getElementById("beta-errors");
const labels = { email: "Work email", role: "Role", team_size: "Team size", use_case: "First use", current_tool: "Current tool" };
form.addEventListener("submit", async (event) => {
event.preventDefault();
const button = form.querySelector("button");
button.disabled = true;
errors.replaceChildren();
try {
const response = await fetch(form.action, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});
const result = await response.json();
if (result.ok) {
form.outerHTML = "<p role=\"status\">Thanks for applying. We invite testers in small groups and will email you either way.</p>";
return;
}
const fields = Object.entries(result.error.fields ?? {});
const lines = fields.length ? fields.map(([id, messages]) => `${labels[id] ?? id}: ${messages[0]}`) : [result.error.message];
errors.replaceChildren(...lines.map((line) => Object.assign(document.createElement("li"), { textContent: line })));
} catch {
errors.textContent = "Couldn't reach the server. Your answers are still here; try again.";
}
button.disabled = false;
});
</script>React는 서버 액션이 실행된 뒤 폼을 초기화하므로 422가 나면 긴 답변이 지워집니다. 액션이 답변을 values로 돌려주고, 각 입력이 defaultValue로 다시 읽어 들입니다.
"use server";
import { headers } from "next/headers";
export interface ApplyState {
ok: boolean;
message: string;
fields?: Record<string, string[]>;
values?: Record<string, string>;
}
export async function apply(_previous: ApplyState, formData: FormData): Promise<ApplyState> {
const answers = Object.fromEntries(
[...formData].filter((entry): entry is [string, string] => typeof entry[1] === "string" && !entry[0].startsWith("$ACTION")),
);
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(answers),
});
const result = await response.json().catch(() => null);
if (result?.ok) return { ok: true, message: "Thanks for applying. We invite testers in small groups and will email you either way." };
return { ok: false, message: result?.error?.message ?? "Couldn't send your application. Try again.", fields: result?.error?.fields, values: answers };
}"use client";
import { useActionState } from "react";
import { type ApplyState, apply } from "./actions";
const SIZES = [
["1", "Only me"],
["2-10", "2 to 10"],
["11-50", "11 to 50"],
["51+", "More than 50"],
];
function FieldError({ messages }: { messages?: string[] }) {
return messages?.[0] ? <p role="alert">{messages[0]}</p> : null;
}
export function BetaForm() {
const [state, action, pending] = useActionState(apply, { ok: false, message: "" } as ApplyState);
const errors = state.fields ?? {};
const values = state.values ?? {};
if (state.ok) return <p role="status">{state.message}</p>;
return (
<form action={action}>
<label htmlFor="email">Work email</label>
<input id="email" name="email" type="email" autoComplete="email" required defaultValue={values.email} />
<FieldError messages={errors.email} />
<label htmlFor="role">Your role</label>
<select id="role" name="role" required defaultValue={values.role}>
<option value="">Choose one</option>
<option value="founder">Founder</option>
<option value="engineer">Engineer</option>
<option value="product">Product manager</option>
<option value="designer">Designer</option>
<option value="other">Something else</option>
</select>
<FieldError messages={errors.role} />
<fieldset>
<legend>Team size</legend>
{SIZES.map(([size, label]) => (
<label key={size}>
<input type="radio" name="team_size" value={size} required defaultChecked={values.team_size === size} /> {label}
</label>
))}
</fieldset>
<FieldError messages={errors.team_size} />
<label htmlFor="use_case">What would you use it for first?</label>
<textarea id="use_case" name="use_case" rows={4} minLength={40} maxLength={1000} required defaultValue={values.use_case} />
<FieldError messages={errors.use_case} />
<label htmlFor="current_tool">What do you use today?</label>
<input id="current_tool" name="current_tool" maxLength={120} defaultValue={values.current_tool} />
<label>
<input type="checkbox" name="interview" value="true" defaultChecked={values.interview === "true"} /> I'm open to a 20-minute call
</label>
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{state.message && !state.fields ? <p role="alert">{state.message}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Apply for the beta"}</button>
</form>
);
}input 스키마가 없으면 Astro Action은 원본 FormData를 받고 검증은 Ikiform이 합니다. 핸들러는 예외를 던지지 않고 필드 오류를 데이터로 반환하므로 페이지가 각 메시지를 제자리에 표시할 수 있습니다.
import { defineAction } from "astro:actions";
import { getSecret } from "astro:env/server";
export const server = {
apply: defineAction({
accept: "form",
handler: async (formData: FormData, context) => {
const answers = Object.fromEntries([...formData].filter((entry) => typeof entry[1] === "string"));
const origin = context.request.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 ${getSecret("IKIFORM_SECRET_KEY")}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify(answers),
});
const result = await response.json();
if (result.ok) return { ok: true, fields: {} as Record<string, string[]>, message: "" };
return { ok: false, fields: (result.error.fields ?? {}) as Record<string, string[]>, message: String(result.error.message) };
},
}),
};---
import { actions } from "astro:actions";
export const prerender = false;
const result = Astro.getActionResult(actions.apply)?.data;
const errors = result?.fields ?? {};
const sizes = [["1", "Only me"], ["2-10", "2 to 10"], ["11-50", "11 to 50"], ["51+", "More than 50"]];
---
{result?.ok ? (
<p role="status">Thanks for applying. We invite testers in small groups and will email you either way.</p>
) : (
<form method="POST" action={actions.apply}>
<label for="email">Work email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
{errors.email && <p role="alert">{errors.email[0]}</p>}
<label for="role">Your role</label>
<select id="role" name="role" required>
<option value="">Choose one</option>
<option value="founder">Founder</option>
<option value="engineer">Engineer</option>
<option value="product">Product manager</option>
<option value="designer">Designer</option>
<option value="other">Something else</option>
</select>
<fieldset>
<legend>Team size</legend>
{sizes.map(([size, label]) => <label><input type="radio" name="team_size" value={size} required /> {label}</label>)}
</fieldset>
<label for="use_case">What would you use it for first?</label>
<textarea id="use_case" name="use_case" rows="4" minlength="40" maxlength="1000" required></textarea>
{errors.use_case && <p role="alert">{errors.use_case[0]}</p>}
<label for="current_tool">What do you use today?</label>
<input id="current_tool" name="current_tool" maxlength="120" />
<label><input type="checkbox" name="interview" value="true" /> I'm open to a 20-minute call</label>
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{result && !result.ok && !Object.keys(errors).length && <p role="alert">{result.message}</p>}
<button>Apply for the beta</button>
</form>
)}서버 함수가 모든 텍스트 항목을 전달합니다. 폼은 onSubmit으로 제출하므로 서버가 오류로 응답해도 입력값이 유지됩니다.
import { createFileRoute } from "@tanstack/react-router";
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";
const applyForBeta = createServerFn({ method: "POST" })
.validator((data: unknown) => {
if (!(data instanceof FormData)) throw new Error("Expected form data");
return Object.fromEntries([...data].filter((entry): entry is [string, string] => typeof entry[1] === "string"));
})
.handler(async ({ data: answers }) => {
const origin = getRequestHeader("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(answers),
});
const result = await response.json();
if (result.ok) return { ok: true, message: "Thanks for applying. We invite testers in small groups and will email you either way.", fields: {} };
return { ok: false, message: String(result.error.message), fields: (result.error.fields ?? {}) as Record<string, string[]> };
});
export const Route = createFileRoute("/beta")({ component: Beta });
const SIZES = [
["1", "Only me"],
["2-10", "2 to 10"],
["11-50", "11 to 50"],
["51+", "More than 50"],
];
function FieldError({ messages }: { messages?: string[] }) {
return messages?.[0] ? <p role="alert">{messages[0]}</p> : null;
}
function Beta() {
const applyFn = useServerFn(applyForBeta);
const [state, setState] = useState<{ ok: boolean; message: string; fields: Record<string, string[]> } | null>(null);
const [pending, setPending] = useState(false);
const errors = state?.fields ?? {};
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setPending(true);
try {
setState(await applyFn({ data: new FormData(event.currentTarget) }));
} catch {
setState({ ok: false, message: "Couldn't send your application. Try again.", fields: {} });
} finally {
setPending(false);
}
}
if (state?.ok) return <p role="status">{state.message}</p>;
return (
<form method="post" onSubmit={onSubmit}>
<label htmlFor="email">Work email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
<FieldError messages={errors.email} />
<label htmlFor="role">Your role</label>
<select id="role" name="role" required>
<option value="">Choose one</option>
<option value="founder">Founder</option>
<option value="engineer">Engineer</option>
<option value="product">Product manager</option>
<option value="designer">Designer</option>
<option value="other">Something else</option>
</select>
<FieldError messages={errors.role} />
<fieldset>
<legend>Team size</legend>
{SIZES.map(([size, label]) => (
<label key={size}>
<input type="radio" name="team_size" value={size} required /> {label}
</label>
))}
</fieldset>
<FieldError messages={errors.team_size} />
<label htmlFor="use_case">What would you use it for first?</label>
<textarea id="use_case" name="use_case" rows={4} minLength={40} maxLength={1000} required />
<FieldError messages={errors.use_case} />
<label htmlFor="current_tool">What do you use today?</label>
<input id="current_tool" name="current_tool" maxLength={120} />
<label>
<input type="checkbox" name="interview" value="true" /> I'm open to a 20-minute call
</label>
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{state && !Object.keys(errors).length ? <p role="alert">{state.message}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Apply for the beta"}</button>
</form>
);
}라우트 action이 POST를 전달하고 data()로 Ikiform의 상태 코드를 유지합니다. <Form>은 새로 고침 없이 제출하므로 답변이 그대로 남습니다.
import { data, Form, useNavigation } from "react-router";
import type { Route } from "./+types/beta";
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const answers = Object.fromEntries([...formData].filter((entry) => typeof entry[1] === "string"));
const origin = request.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(answers),
});
const result = await response.json();
if (result.ok) return { ok: true, message: "Thanks for applying. We invite testers in small groups and will email you either way.", fields: {} };
return data(
{ ok: false, message: String(result.error.message), fields: (result.error.fields ?? {}) as Record<string, string[]> },
{ status: response.status },
);
}
const SIZES = [
["1", "Only me"],
["2-10", "2 to 10"],
["11-50", "11 to 50"],
["51+", "More than 50"],
];
function FieldError({ messages }: { messages?: string[] }) {
return messages?.[0] ? <p role="alert">{messages[0]}</p> : null;
}
export default function Beta({ actionData }: Route.ComponentProps) {
const pending = useNavigation().state === "submitting";
const errors: Record<string, string[]> = actionData?.fields ?? {};
if (actionData?.ok) return <p role="status">{actionData.message}</p>;
return (
<Form method="post">
<label htmlFor="email">Work email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
<FieldError messages={errors.email} />
<label htmlFor="role">Your role</label>
<select id="role" name="role" required>
<option value="">Choose one</option>
<option value="founder">Founder</option>
<option value="engineer">Engineer</option>
<option value="product">Product manager</option>
<option value="designer">Designer</option>
<option value="other">Something else</option>
</select>
<FieldError messages={errors.role} />
<fieldset>
<legend>Team size</legend>
{SIZES.map(([size, label]) => (
<label key={size}>
<input type="radio" name="team_size" value={size} required /> {label}
</label>
))}
</fieldset>
<FieldError messages={errors.team_size} />
<label htmlFor="use_case">What would you use it for first?</label>
<textarea id="use_case" name="use_case" rows={4} minLength={40} maxLength={1000} required />
<FieldError messages={errors.use_case} />
<label htmlFor="current_tool">What do you use today?</label>
<input id="current_tool" name="current_tool" maxLength={120} />
<label>
<input type="checkbox" name="interview" value="true" /> I'm open to a 20-minute call
</label>
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{actionData && !Object.keys(errors).length ? <p role="alert">{actionData.message}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Apply for the beta"}</button>
</Form>
);
}fail()이 필드 오류를 form으로 돌려줍니다. use:enhance는 성공한 뒤에만 폼을 초기화하므로 거부된 신청은 답변이 유지됩니다. SvelteKit 대기자 명단처럼 src/env.ts에서 defineEnvVars로 IKIFORM_SECRET_KEY를 선언해 $app/env/private가 읽을 수 있게 하고, 로컬 테스트가 무료 localhost POST로 집계되도록 브라우저의 Origin을 전달하세요.
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import type { Actions } from "./$types";
export const actions = {
default: async ({ request, fetch, url }) => {
const formData = await request.formData();
const answers = Object.fromEntries([...formData].filter((entry) => typeof entry[1] === "string"));
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
Origin: request.headers.get("origin") ?? url.origin,
},
body: JSON.stringify(answers),
});
const result = await response.json();
if (!result.ok) {
return fail(response.status, {
message: String(result.error.message),
fields: (result.error.fields ?? {}) as Record<string, string[]>,
});
}
return { applied: true };
},
} satisfies Actions;<script lang="ts">
import { enhance } from "$app/forms";
import type { PageProps } from "./$types";
let { form }: PageProps = $props();
const sizes = [["1", "Only me"], ["2-10", "2 to 10"], ["11-50", "11 to 50"], ["51+", "More than 50"]];
</script>
{#if form?.applied}
<p role="status">Thanks for applying. We invite testers in small groups and will email you either way.</p>
{:else}
<form method="POST" use:enhance>
<label for="email">Work email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
{#if form?.fields?.email}<p role="alert">{form.fields.email[0]}</p>{/if}
<label for="role">Your role</label>
<select id="role" name="role" required>
<option value="">Choose one</option>
<option value="founder">Founder</option>
<option value="engineer">Engineer</option>
<option value="product">Product manager</option>
<option value="designer">Designer</option>
<option value="other">Something else</option>
</select>
<fieldset>
<legend>Team size</legend>
{#each sizes as [size, label]}
<label><input type="radio" name="team_size" value={size} required /> {label}</label>
{/each}
</fieldset>
<label for="use_case">What would you use it for first?</label>
<textarea id="use_case" name="use_case" rows="4" minlength="40" maxlength="1000" required></textarea>
{#if form?.fields?.use_case}<p role="alert">{form.fields.use_case[0]}</p>{/if}
<label for="current_tool">What do you use today?</label>
<input id="current_tool" name="current_tool" maxlength="120" />
<label><input type="checkbox" name="interview" value="true" /> I'm open to a 20-minute call</label>
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{#if form?.message && !form?.fields?.email && !form?.fields?.use_case}<p role="alert">{form.message}</p>{/if}
<button>Apply for the beta</button>
</form>
{/if}서버 라우트가 readFormData로 FormData를 읽고 Ikiform의 상태 코드를 그대로 전달합니다. ignoreResponseError는 $fetch가 예외를 던지는 대신 오류 본문을 반환하게 합니다.
export default defineEventHandler(async (event) => {
const formData = await readFormData(event);
const answers = Object.fromEntries([...formData].filter((entry) => typeof entry[1] === "string"));
const { ikiformSecretKey } = useRuntimeConfig(event);
const origin = getRequestHeader(event, "origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${ikiformSecretKey}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify(answers),
});
const result = await response.json();
setResponseStatus(event, response.status);
return result.ok
? { ok: true, message: "Thanks for applying. We invite testers in small groups and will email you either way.", fields: {} }
: { ok: false, message: String(result.error.message), fields: result.error.fields ?? {} };
});<script setup lang="ts">
const sizes = [["1", "Only me"], ["2-10", "2 to 10"], ["11-50", "11 to 50"], ["51+", "More than 50"]];
const state = ref<{ ok: boolean; message: string; fields: Record<string, string[]> } | null>(null);
const pending = ref(false);
async function apply(event: SubmitEvent) {
pending.value = true;
try {
state.value = await $fetch("/api/beta", {
method: "POST",
body: new FormData(event.target as HTMLFormElement),
ignoreResponseError: true,
});
} catch {
state.value = { ok: false, message: "Couldn't send your application. Try again.", fields: {} };
}
pending.value = false;
}
</script>
<template>
<p v-if="state?.ok" role="status">{{ state.message }}</p>
<form v-else method="post" @submit.prevent="apply">
<label for="email">Work email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<p v-if="state?.fields.email" role="alert">{{ state.fields.email[0] }}</p>
<label for="role">Your role</label>
<select id="role" name="role" required>
<option value="">Choose one</option>
<option value="founder">Founder</option>
<option value="engineer">Engineer</option>
<option value="product">Product manager</option>
<option value="designer">Designer</option>
<option value="other">Something else</option>
</select>
<fieldset>
<legend>Team size</legend>
<label v-for="[size, label] in sizes" :key="size">
<input type="radio" name="team_size" :value="size" required /> {{ label }}
</label>
</fieldset>
<label for="use_case">What would you use it for first?</label>
<textarea id="use_case" name="use_case" rows="4" minlength="40" maxlength="1000" required />
<p v-if="state?.fields.use_case" role="alert">{{ state.fields.use_case[0] }}</p>
<label for="current_tool">What do you use today?</label>
<input id="current_tool" name="current_tool" maxlength="120" />
<label><input type="checkbox" name="interview" value="true" /> I'm open to a 20-minute call</label>
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<button :disabled="pending">{{ pending ? "Sending" : "Apply for the beta" }}</button>
</form>
</template>싱글 페이지 앱은 브라우저에서 Ikiform으로 바로 POST하므로 이 코드에는 시크릿 키가 없습니다. Allowed domains를 내 사이트로 설정해 두세요.
import { type FormEvent, useState } from "react";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const SIZES = [
["1", "Only me"],
["2-10", "2 to 10"],
["11-50", "11 to 50"],
["51+", "More than 50"],
];
function FieldError({ messages }: { messages?: string[] }) {
return messages?.[0] ? <p role="alert">{messages[0]}</p> : null;
}
export function BetaForm() {
const [status, setStatus] = useState<"idle" | "sending" | "applied">("idle");
const [errors, setErrors] = useState<Record<string, string[]>>({});
const [message, setMessage] = useState("");
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setStatus("sending");
setErrors({});
setMessage("");
try {
const response = await fetch(ENDPOINT, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(event.currentTarget),
});
const result = await response.json();
if (result.ok) return setStatus("applied");
setErrors(result.error.fields ?? {});
setMessage(result.error.message);
} catch {
setMessage("Couldn't reach the server. Try again.");
}
setStatus("idle");
}
if (status === "applied") return <p role="status">Thanks for applying. We invite testers in small groups and will email you either way.</p>;
return (
<form action={ENDPOINT} method="post" onSubmit={onSubmit}>
<label htmlFor="email">Work email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
<FieldError messages={errors.email} />
<label htmlFor="role">Your role</label>
<select id="role" name="role" required>
<option value="">Choose one</option>
<option value="founder">Founder</option>
<option value="engineer">Engineer</option>
<option value="product">Product manager</option>
<option value="designer">Designer</option>
<option value="other">Something else</option>
</select>
<FieldError messages={errors.role} />
<fieldset>
<legend>Team size</legend>
{SIZES.map(([size, label]) => (
<label key={size}>
<input type="radio" name="team_size" value={size} required /> {label}
</label>
))}
</fieldset>
<FieldError messages={errors.team_size} />
<label htmlFor="use_case">What would you use it for first?</label>
<textarea id="use_case" name="use_case" rows={4} minLength={40} maxLength={1000} required />
<FieldError messages={errors.use_case} />
<label htmlFor="current_tool">What do you use today?</label>
<input id="current_tool" name="current_tool" maxLength={120} />
<label>
<input type="checkbox" name="interview" value="true" /> I'm open to a 20-minute call
</label>
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{message && !Object.keys(errors).length ? <p role="alert">{message}</p> : null}
<button disabled={status === "sending"}>{status === "sending" ? "Sending" : "Apply for the beta"}</button>
</form>
);
}같은 브라우저 POST를 Vue 컴포넌트로 작성한 것입니다. 폼이 action을 유지하므로 스크립트가 실패해도 POST됩니다.
<script setup lang="ts">
import { ref } from "vue";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const sizes = [["1", "Only me"], ["2-10", "2 to 10"], ["11-50", "11 to 50"], ["51+", "More than 50"]];
const status = ref<"idle" | "sending" | "applied">("idle");
const errors = ref<Record<string, string[]>>({});
const message = ref("");
async function apply(event: SubmitEvent) {
status.value = "sending";
try {
const response = await fetch(ENDPOINT, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(event.target as HTMLFormElement),
});
const result = await response.json();
if (result.ok) {
status.value = "applied";
return;
}
errors.value = result.error.fields ?? {};
message.value = result.error.message;
} catch {
message.value = "Couldn't reach the server. Try again.";
}
status.value = "idle";
}
</script>
<template>
<p v-if="status === 'applied'" role="status">Thanks for applying. We invite testers in small groups and will email you either way.</p>
<form v-else :action="ENDPOINT" method="post" @submit.prevent="apply">
<label for="email">Work email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<p v-if="errors.email" role="alert">{{ errors.email[0] }}</p>
<label for="role">Your role</label>
<select id="role" name="role" required>
<option value="">Choose one</option>
<option value="founder">Founder</option>
<option value="engineer">Engineer</option>
<option value="product">Product manager</option>
<option value="designer">Designer</option>
<option value="other">Something else</option>
</select>
<fieldset>
<legend>Team size</legend>
<label v-for="[size, label] in sizes" :key="size">
<input type="radio" name="team_size" :value="size" required /> {{ label }}
</label>
</fieldset>
<label for="use_case">What would you use it for first?</label>
<textarea id="use_case" name="use_case" rows="4" minlength="40" maxlength="1000" required />
<p v-if="errors.use_case" role="alert">{{ errors.use_case[0] }}</p>
<label for="current_tool">What do you use today?</label>
<input id="current_tool" name="current_tool" maxlength="120" />
<label><input type="checkbox" name="interview" value="true" /> I'm open to a 20-minute call</label>
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="message && !Object.keys(errors).length" role="alert">{{ message }}</p>
<button :disabled="status === 'sending'">{{ status === "sending" ? "Sending" : "Apply for the beta" }}</button>
</form>
</template>IKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요.기수 선발하기
- 필수 질문을 건너뛴 POST는 Ikiform이 거부하므로 신청은 빠짐없이 도착합니다.
- 스크립트가 API로 신청을 읽고 역할, 팀 규모, 사용 사례의 길이, 통화 체크박스로 각각 점수를 매깁니다.
- 상위 25명이 첫 기수가 되어 이메일 도구에서 초대를 받습니다.
- 2주 뒤 초대한 이메일을 넣고 스크립트를 다시 실행해 그들을 건너뛰고 다음 그룹을 고릅니다.
- 기준선 아래의 모든 사람은 목록에 남아 있다가 자기 차례가 오면 연락을 받습니다.
스크립트는 REST API로 받은 신청을 모두 읽고, 이메일마다 최신 신청만 남겨 점수를 매기고, 상위 그룹을 cohort.csv에 씁니다. 다음 차례에서 건너뛰도록 이미 초대한 이메일을 인수로 넘기세요. submissions:read 스코프가 있는 시크릿 키가 필요합니다.
import { writeFileSync } from "node:fs";
import { createClient } from "@ikiform/sdk";
const ikiform = createClient({ apiKey: process.env.IKIFORM_SECRET_KEY });
const COHORT_SIZE = 25;
const invited = new Set(process.argv.slice(2).map((email) => email.toLowerCase()));
const ROLE = { founder: 3, engineer: 3, product: 2, designer: 1, other: 0 };
const SIZE = { "2-10": 3, "11-50": 2, "1": 1, "51+": 1 };
const people = new Map();
for await (const submission of ikiform.submissions.iterate({ formId: "frm_xxxxxxxxxxxxxxxx", status: "received", limit: 100 })) {
const answers = submission.fields;
const email = String(answers.email ?? "").trim().toLowerCase();
if (!email || invited.has(email) || people.has(email)) continue;
const words = String(answers.use_case ?? "").split(/\s+/).length;
const score =
(ROLE[answers.role] ?? 0) +
(SIZE[answers.team_size] ?? 0) +
Math.min(3, Math.floor(words / 25)) +
(answers.interview === true ? 2 : 0) +
(answers.current_tool ? 1 : 0);
people.set(email, { email, score, role: answers.role, team_size: answers.team_size });
}
const ranked = [...people.values()].sort((a, b) => b.score - a.score);
const cohort = ranked.slice(0, COHORT_SIZE);
const quote = (value) => `"${String(value ?? "").replaceAll('"', '""')}"`;
const rows = cohort.map((person) => [person.email, person.score, person.role, person.team_size].map(quote).join(","));
writeFileSync("cohort.csv", ["email,score,role,team_size", ...rows].join("\n"));
console.log(`${ranked.length} applicants, ${cohort.length} in this cohort, lowest score ${cohort.at(-1)?.score}`);- 첫 버전의 대상에 맞게
ROLE과SIZE를 바꾸세요. 팀용 도구라면2-10에, 1인용 도구라면1에 가장 높은 점수를 줄 수 있습니다. - 사용 사례는 25단어마다 1점, 최대 3점을 받습니다. 길이가 곧 품질은 아니지만, 세 문장을 쓰는 사람은 대개 실제로 참여합니다.
- 모든 값을 따옴표로 감싸면 답변 속 쉼표가 CSV를 깨뜨리지 않습니다.
- 초대한 목록은
$(cat invited.txt)로 넘기는invited.txt처럼 찾기 쉬운 곳에 보관하세요.
초대 보내기
cohort.csv를 이메일 도구로 가져와 거기서 초대를 보내세요. Ikiform은 폼을 작성한 사람에게 이메일을 보내지 않습니다. 알림 이메일은 여러분과 팀에게만 가므로 자동 응답, 확인 이메일, 더블 옵트인이 없습니다. 이런 이메일은 웹훅을 트리거로 이메일 도구에서 보내세요. 첫 초대는 짧게 쓰세요. 무엇을 받는지, 꼭 써 봤으면 하는 한 가지, 연락 방법이면 됩니다. Ikiform이 보내는 알림 이메일마다 지원자에게 가는 Reply가 있어서, 기준선 근처의 사람들에게 짧은 개인 메모를 보내기에 좋습니다.
작동 확인하기
- 폼의 Allowed domains에 사이트 도메인을 추가하고, 개발 중에는
localhost도 추가하세요. 비워 두면 어디서 온 POST든 받습니다. - 필드 정의를 폼의 Fields 탭에 붙여 넣고 저장하세요.
- 사용 사례를 한 줄만 써서 신청해 보세요. 폼이 그 필드 아래에 최소 길이 메시지를 보여 주고, 아무것도 저장되지 않습니다.
- 빠짐없이 작성한 신청을 제출하세요. 모든 답변과 함께 Submissions에 나타나고,
interview는true또는false입니다. - 알림 수신자마다 받은편지함을 확인하세요. 각자 필드가 담긴 이메일을 받으며, 폼에
email필드가 있으면 Reply(답장)는 제출자에게 갑니다. - 허니팟을 채워서
_gotcha=bot으로 POST해 보세요. 여전히 성공 응답을 받지만, 제출은 스팸으로 저장되고 이메일이나 웹훅을 보내지 않습니다. - 시크릿 키로 점수 스크립트를 실행하고
cohort.csv에 테스트 신청이 점수와 함께 있는지 확인하세요.
주의할 점
| 문제 | 해결 방법 |
|---|---|
422 validation_failed | POST가 폼의 필드 정의와 맞지 않습니다. error.fields에 각 필드와 이유가 나옵니다. 입력의 name을 필드 ID와 맞추세요. |
ref, utm_source 또는 다른 숨겨진 입력에 대해 Unknown field와 함께 422 | 필드 정의에 없습니다. 선택 text 필드로 추가하거나, Ikiform이 버리도록 이름 앞에 _를 붙이세요. |
| Next.js에서 오류 후 긴 답변이 사라짐 | React는 서버 액션 후 폼을 초기화합니다. Next.js 예제처럼 액션에서 답변을 반환하고 각 입력에 defaultValue를 설정하세요. |
점수 스크립트가 401 unauthorized 또는 403 forbidden을 던짐 | 401은 키가 없거나 폐기되었다는 뜻이고, 403은 퍼블리셔블 키이거나 스코프가 없다는 뜻입니다. submissions:read 스코프가 있는 시크릿 키(ik_sk_)를 쓰세요. |
| 제출은 대시보드에 있는데 이메일이나 웹훅이 오지 않음 | 스팸으로 저장된 것입니다. 스팸은 아무것도 보내지 않습니다. Spam 필터에서 열어 이유를 확인하고, 스팸 아님으로 표시하면 이메일과 웹훅이 전송됩니다. |
질문
3~6개입니다. 질문마다 누구를 초대할지에 영향을 줘야 합니다. 역할이나 팀 규모 같은 필수 선택형 질문은 정렬하기 좋고, 최소 길이가 있는 필수 서술형 질문 하나는 누가 베타를 실제로 쓸지 알려 줍니다.
네. 폼의 Fields 탭에서 필드를 정의하고 필수로 표시하세요. 하나라도 빠진 POST는 422 validation_failed로 거부되고, 오류에 각 필드가 나오므로 폼이 메시지를 제자리에 보여 줄 수 있습니다.
아니요. Ikiform에는 태그나 사용자 지정 상태가 없습니다. 위에서 보여 준 것처럼 초대한 목록은 초대를 보내는 곳이나 점수 스크립트에 넘기는 파일에 보관하세요.
아니요. 받아들인 POST는 모두 저장됩니다. 24시간 안의 동일한 반복은 스팸 점수를 올리지만 그것만으로 스팸이 되지는 않으므로, 목록, 데이터베이스, CSV 내보내기에서 이메일 기준으로 중복을 제거하세요.
아니요, 무료 플랜은 없습니다. 테스트는 무료입니다. 결제 전에 localhost에서 매달 테스트 제출 50건을 보낼 수 있습니다. 실제 운영하려면 제출 500건에 월 $6부터인 Pro를 쓰거나, 한 번 결제로 제출 10,000건에 $49 또는 50,000건에 $149를 낼 수 있으며, 크레딧은 만료되지 않습니다.
막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.