Lista de espera para um novo SaaS8 min de leitura
Como montar um formulário de inscrição para beta que faz triagem dos candidatos
Um formulário de candidatura ao beta com perguntas de triagem que o servidor exige, e um script que pontua os candidatos e escolhe cada turma da lista.
Faça algumas perguntas de triagem junto do e-mail e deixe o backend de formulários exigi-las, para que toda candidatura chegue completa e comparável. Depois escolha cada turma da lista com um script curto de pontuação, convide um grupo pequeno e deixe todos os outros esperando a próxima rodada.
Uma lista de espera manda e-mail para todo mundo no dia do lançamento. Um beta faz o contrário: você deixa entrar vinte pessoas, aprende com elas e depois deixa entrar mais vinte. Isso muda o formulário. Você precisa de respostas pelas quais dê para ordenar, não só de um endereço.
O que muda num beta
- É uma seleção. Você convida poucas pessoas por vez, então precisa de um jeito de comparar os candidatos entre si. Escolhas fechadas, como função e tamanho da equipe, ordenam bem. Texto livre não.
- Candidaturas incompletas custam uma vaga. Se alguém pula a pergunta que diz se a pessoa se encaixa, você não consegue escolhê-la nem descartá-la. Torne as perguntas de triagem obrigatórias e faça o servidor recusar envios que as pulem.
- Os convites saem em rodadas. Sua lista precisa lembrar quem você já convidou, para a próxima turma pular essas pessoas. O Ikiform não tem tags, então esse estado fica no seu script ou na sua planilha.
- Quem não foi escolhido ainda conta. Essas pessoas são a sua próxima turma. Diga na frase de agradecimento que os grupos são chamados ao longo do tempo, para que uma caixa de entrada silenciosa não seja lida como um não.
Perguntas de triagem
Seis perguntas é o teto. Cada uma deve mudar quem você convida; se você não sabe dizer como uma resposta faria alguém subir ou descer, corte. Experimente o formulário abaixo. Ele roda a mesma definição de campos que o Ikiform aplica no servidor, então você vê o 422 que uma candidatura incompleta recebe.
Prévia
Roda o validador do Ikiform no seu navegador. Nada é enviado.
use_case precisa de pelo menos 40 caracteres, o que filtra candidaturas de uma palavra só.| Campo | O que ela decide | Por que este tipo |
|---|---|---|
email | Quem convidar | Obrigatório. Um e-mail de trabalho dá uma pista da empresa sem perguntar |
role | Se a pessoa sente o problema que você resolve | Um select com as suas próprias faixas, para você poder dar pesos |
team_size | Se a sua primeira versão serve para o cenário dela | Um radio: uma resposta, sempre comparável |
use_case | Se a pessoa vai usar o beta este mês | Um textarea com tamanho mínimo. O sinal mais honesto do formulário |
current_tool | Quem você está substituindo, e com o que vão comparar você | Texto livre opcional |
interview | Com quem você pode conversar | Um checkbox. Ausente significa false, então ele nunca bloqueia uma candidatura |
422 e um erro unknown_field. Se você adicionar um input oculto como ref, adicione-o também à definição.O formulário
Todas as versões abaixo mostram a mensagem do servidor abaixo do campo a que ela se refere, porque um formulário de triagem falha por mais motivos do que um e-mail inválido. Cada uma mantém o que a pessoa digitou quando um envio é recusado, para ninguém redigitar um parágrafo.
O required e o minlength nativos pegam a maioria das lacunas antes do envio. O formulário simples funciona sem script e cai na sua página de agradecimento; o script mantém as pessoas na página e lista cada erro de campo.
<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>O React reinicia um formulário depois que uma server action roda, o que apagaria uma resposta longa num 422. A action devolve as respostas como values, e cada input as lê de volta via 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>
);
}Sem um schema input, uma Astro Action recebe o FormData cru e o Ikiform faz a validação. O handler devolve os erros de campo como dados em vez de lançar exceção, para a página poder posicionar cada mensagem.
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>
)}A server function encaminha cada entrada de texto. O formulário envia via onSubmit, então os inputs mantêm os valores quando o servidor responde com erros.
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>
);
}A action da rota encaminha o envio e mantém o status do Ikiform com data(). O <Form> envia sem recarregar, então as respostas continuam no lugar.
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() devolve os erros de campo para form. O use:enhance só reinicia o formulário depois de um sucesso, então uma candidatura recusada mantém as respostas. Declare IKIFORM_SECRET_KEY em src/env.ts com defineEnvVars, como na lista de espera com SvelteKit, para que $app/env/private consiga lê-la, e repasse o Origin do navegador para os testes locais contarem como envios gratuitos de localhost.
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}A server route lê o FormData com readFormData e repassa o status do Ikiform. ignoreResponseError faz o $fetch devolver o corpo do erro em vez de lançar exceção.
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>Uma single-page app envia direto ao Ikiform a partir do navegador, então não há chave secreta neste código. Mantenha Allowed domains definido com o seu site.
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>
);
}O mesmo envio pelo navegador num componente Vue. O formulário mantém o action, então ainda envia se o script falhar.
<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 em variáveis de ambiente só do servidor.Escolha uma turma
- As candidaturas chegam completas, porque o Ikiform recusa envios que pulam uma pergunta obrigatória.
- Um script as lê pela API e dá a cada uma uma pontuação por função, tamanho da equipe, tamanho do caso de uso e o checkbox da conversa.
- As 25 primeiras viram a primeira turma e recebem um convite pela sua ferramenta de e-mail.
- Duas semanas depois, rode o script de novo com os e-mails convidados, para que sejam pulados, e escolha o próximo grupo.
- Todos abaixo do corte continuam na lista e têm notícias suas quando chegar a rodada deles.
O script lê todas as candidaturas recebidas com a API REST, mantém a mais nova por e-mail, dá uma pontuação e grava o grupo do topo em cohort.csv. Passe os e-mails que você já convidou como argumentos para a próxima rodada pulá-los. Ele precisa de uma chave secreta com o escopo 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}`);- Mude
ROLEeSIZEde acordo com o público da sua primeira versão. Uma ferramenta para equipes pode dar a maior pontuação a2-10; uma ferramenta individual, a1. - O caso de uso ganha um ponto a cada 25 palavras, até 3. Tamanho não é qualidade, mas quem escreve três frases costuma aparecer.
- Colocar cada valor entre aspas evita que vírgulas nas respostas quebrem o CSV.
- Guarde a lista de convidados num lugar fácil de achar, como um
invited.txtque você passa com$(cat invited.txt).
Envie o convite
Importe o cohort.csv na sua ferramenta de e-mail e envie o convite de lá. O Ikiform não envia e-mail para quem preenche o formulário. Os e-mails de notificação vão só para você e sua equipe, então não há resposta automática, e-mail de confirmação nem double opt-in. Envie esses e-mails pela sua ferramenta de e-mail, disparados por um webhook. Mantenha o primeiro convite curto: o que a pessoa ganha, a única coisa que você quer que ela teste e como falar com você. Cada e-mail de notificação que o Ikiform manda para você tem um Reply que vai para o candidato, o que serve bem para uma mensagem pessoal rápida a quem está perto da linha de corte.
Confira se funciona
- Em Allowed domains do formulário, adicione o domínio do seu site, mais
localhostenquanto desenvolve. Deixe vazio para aceitar envios de qualquer lugar. - Cole a definição de campos na aba Fields do formulário e salve.
- Envie uma candidatura com um caso de uso de uma linha. O formulário mostra a mensagem de tamanho mínimo abaixo desse campo, e nada é armazenado.
- Envie uma candidatura completa. Ela aparece em Submissions com todas as respostas, e
interviewmostratrueoufalse. - Confira a caixa de entrada de cada destinatário das notificações. Cada um recebe um e-mail com os campos, e Reply vai para quem enviou quando o formulário tem um campo
email. - Envie com o honeypot preenchido,
_gotcha=bot. Ele ainda recebe uma resposta de sucesso, mas o envio é armazenado como spam e não dispara e-mail nem webhook. - Rode o script de pontuação com uma chave secreta e confira se o
cohort.csvlista a sua candidatura de teste com a pontuação dela.
Pegadinhas
| Problema | O que fazer |
|---|---|
422 validation_failed | O envio não corresponde à definição de campos do formulário. error.fields aponta cada campo e o motivo. Faça o name do input bater com o id do campo. |
422 com Unknown field para ref, utm_source ou outro input oculto | A definição de campos não o lista. Adicione-o como um campo text opcional, ou renomeie-o com um _ no início para o Ikiform descartá-lo. |
| Uma resposta longa some depois de um erro no Next.js | O React reinicia o formulário depois de uma server action. Devolva as respostas a partir da action e defina defaultValue em cada input, como o exemplo de Next.js faz. |
O script de pontuação lança 401 unauthorized ou 403 forbidden | Um 401 significa que a chave está ausente ou foi revogada; um 403 significa que ela é publicável ou não tem o escopo. Use uma chave secreta (ik_sk_) com o escopo submissions:read. |
| O envio está no painel, mas nenhum e-mail ou webhook chegou | Ele foi armazenado como spam. Spam não envia nada. Abra-o no filtro Spam para ver os motivos e marque-o como não spam para enviar o e-mail e os webhooks dele. |
Perguntas
De três a seis. Cada pergunta deve mudar quem você convida. Escolhas fechadas obrigatórias, como função e tamanho da equipe, ordenam bem; uma pergunta aberta obrigatória com tamanho mínimo mostra quem vai usar o beta.
Sim. Defina os campos na aba Fields do formulário e marque-os como obrigatórios. Um envio sem um deles é recusado com 422 validation_failed, e o erro aponta cada campo, para que o seu formulário possa mostrar a mensagem no lugar.
Não. O Ikiform não tem tags nem status personalizados. Mantenha a lista de convidados onde você envia os convites, ou num arquivo que você passa ao script de pontuação, como mostrado acima.
Não. Todo envio aceito é armazenado. Uma repetição idêntica em até 24 horas aumenta a pontuação de spam, mas não é spam por si só, então remova os duplicados por e-mail na sua lista, no banco de dados ou na exportação CSV.
Não, não há plano gratuito. Testar é grátis: você pode enviar 50 envios de teste por mês a partir de localhost antes de pagar. Para entrar no ar, o Pro começa em US$ 6/mês para 500 envios, ou você pode pagar uma vez 10.000 envios por US$ 49 ou 50.000 envios por US$ 149, com créditos que nunca expiram.
Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.