Liste d'attente pour un nouveau SaaS8 min de lecture
Comment créer un formulaire d'inscription à une bêta qui sélectionne les candidats
Un formulaire de candidature bêta avec des questions de sélection appliquées côté serveur, et un script qui note les candidats et choisit chaque cohorte.
Posez quelques questions de sélection à côté de l'e-mail, et laissez le backend de formulaires les faire respecter pour que chaque candidature arrive complète et comparable. Choisissez ensuite chaque cohorte dans la liste avec un court script de notation, invitez un petit groupe, et laissez les autres attendre le prochain tour.
Une liste d'attente écrit à tout le monde le jour du lancement. Une bêta fait l'inverse : vous faites entrer vingt personnes, apprenez d'elles, puis en faites entrer vingt autres. Cela change le formulaire. Il vous faut des réponses que vous pouvez trier, pas seulement une adresse.
Ce qui change pour une bêta
- C'est une sélection. Vous invitez quelques personnes à la fois : il vous faut donc un moyen de classer les candidats entre eux. Les choix fermés, comme le rôle et la taille de l'équipe, se trient proprement. Le texte libre, non.
- Une candidature incomplète coûte une place. Si quelqu'un saute la question qui vous dit s'il correspond, vous ne pouvez ni le choisir ni l'écarter. Rendez les questions de sélection obligatoires, et faites refuser par le serveur les envois qui les sautent.
- Les invitations partent par vagues. Votre liste doit se souvenir de qui vous avez déjà invité, pour que la cohorte suivante les ignore. Ikiform n'a pas de tags : cet état vit donc dans votre script ou votre tableur.
- Les personnes non retenues comptent toujours. C'est votre prochaine cohorte. Dites-leur dans la phrase de remerciement que les groupes sont ouverts au fil du temps, pour qu'une boîte silencieuse ne soit pas prise pour un refus.
Questions de sélection
Six questions, c'est le maximum. Chacune doit changer qui vous invitez ; si vous ne savez pas dire comment une réponse ferait monter ou descendre quelqu'un, supprimez-la. Essayez le formulaire ci-dessous. Il exécute la même définition de champs qu'Ikiform applique côté serveur : vous voyez donc le 422 que reçoit une candidature incomplète.
Aperçu
Exécute le validateur d'Ikiform dans votre navigateur. Rien n'est envoyé.
use_case exige au moins 40 caractères, ce qui écarte les candidatures en un mot.| Champ | Ce qu'il permet de décider | Pourquoi ce type |
|---|---|---|
email | Qui inviter | Obligatoire. Un e-mail professionnel donne une idée de l'entreprise sans la demander |
role | S'ils ressentent le problème que vous résolvez | Un select avec vos propres catégories, pour pouvoir les pondérer |
team_size | Si votre première version convient à leur configuration | Un radio : une seule réponse, toujours comparable |
use_case | S'ils utiliseront la bêta ce mois-ci | Un textarea avec une longueur minimale. Le signal le plus sincère du formulaire |
current_tool | Qui vous remplacez, et à quoi ils vous compareront | Texte libre facultatif |
interview | Avec qui vous pouvez échanger | Une checkbox. Absente signifie false, elle ne bloque donc jamais une candidature |
422 et une erreur unknown_field. Si vous ajoutez un input masqué comme ref, ajoutez-le aussi à la définition.Le formulaire
Chaque version ci-dessous affiche le message du serveur sous le champ concerné, car un formulaire de sélection échoue pour plus de raisons qu'un e-mail invalide. Chacune conserve ce que la personne a saisi quand un envoi est refusé, pour que personne ne retape un paragraphe.
Les attributs natifs required et minlength détectent la plupart des oublis avant l'envoi. Le formulaire classique fonctionne sans script et arrive sur votre page de remerciement ; le script garde les gens sur la page et liste chaque erreur de champ.
<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 réinitialise un formulaire après l'exécution d'une server action, ce qui effacerait une longue réponse en cas de 422. L'action renvoie les réponses dans values, et chaque input les relit 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>
);
}Sans schéma input, une Astro Action reçoit le FormData brut et c'est Ikiform qui valide. Le handler renvoie les erreurs de champ comme des données au lieu de lever une exception, pour que la page puisse placer chaque message.
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>
)}La server function transmet chaque entrée texte. Le formulaire est envoyé via onSubmit, ses inputs gardent donc leurs valeurs quand le serveur répond par des erreurs.
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>
);
}L'action de la route transmet l'envoi et conserve le statut d'Ikiform avec data(). <Form> envoie sans rechargement, les réponses restent donc en place.
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() renvoie les erreurs de champ à form. use:enhance ne réinitialise le formulaire qu'après un succès : une candidature refusée garde donc ses réponses. Déclarez IKIFORM_SECRET_KEY dans src/env.ts avec defineEnvVars, comme dans la liste d'attente SvelteKit, pour que $app/env/private puisse la lire, et transmettez l'Origin du navigateur pour que les tests en local comptent comme des envois gratuits depuis 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}La route serveur lit le FormData avec readFormData et transmet le statut d'Ikiform. ignoreResponseError fait renvoyer à $fetch le corps de l'erreur au lieu de lever une exception.
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>Une application monopage envoie directement à Ikiform depuis le navigateur : il n'y a donc pas de clé secrète dans ce code. Gardez Allowed domains réglé sur votre 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>
);
}Le même envoi côté navigateur dans un composant Vue. Le formulaire garde son action, il envoie donc même si le script échoue.
<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 dans des variables d'environnement réservées au serveur.Choisir une cohorte
- Les candidatures arrivent complètes, car Ikiform refuse les envois qui sautent une question obligatoire.
- Un script les lit via l'API et note chacune selon le rôle, la taille de l'équipe, la longueur du cas d'usage et la case d'appel.
- Les 25 premiers forment la première cohorte et reçoivent une invitation depuis votre outil d'e-mailing.
- Deux semaines plus tard, relancez le script avec les e-mails déjà invités, pour qu'ils soient ignorés, et choisissez le groupe suivant.
- Tous ceux sous la barre restent sur la liste et auront de vos nouvelles quand viendra leur tour.
Le script lit chaque candidature reçue avec l'API REST, garde la plus récente par e-mail, la note et écrit le groupe de tête dans cohort.csv. Passez en arguments les e-mails déjà invités pour que le tour suivant les ignore. Il lui faut une clé secrète avec le scope 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}`);- Adaptez
ROLEetSIZEau public de votre première version. Un outil d'équipe pourrait mieux noter2-10; un outil individuel,1. - Le cas d'usage rapporte un point par tranche de 25 mots, jusqu'à 3. La longueur n'est pas la qualité, mais quelqu'un qui écrit trois phrases est généralement au rendez-vous.
- Mettre chaque valeur entre guillemets évite que des virgules dans les réponses ne cassent le CSV.
- Rangez la liste des invités dans un endroit où vous la retrouverez, comme un
invited.txtque vous passez avec$(cat invited.txt).
Envoyer l'invitation
Importez cohort.csv dans votre outil d'e-mailing et envoyez l'invitation depuis celui-ci. Ikiform n'envoie pas d'e-mail à la personne qui remplit le formulaire. Les e-mails de notification vont uniquement à vous et à votre équipe : il n'y a donc ni réponse automatique, ni e-mail de confirmation, ni double opt-in. Envoyez-les depuis votre outil d'e-mailing, déclenché par un webhook. Gardez la première invitation courte : ce qu'ils obtiennent, la chose que vous voulez qu'ils essaient, et comment vous joindre. Chaque e-mail de notification qu'Ikiform vous envoie a un bouton Reply qui écrit au candidat, ce qui convient à un petit mot personnel aux personnes proches de la barre.
Vérifiez que tout fonctionne
- Dans Allowed domains du formulaire, ajoutez le domaine de votre site, plus
localhostpendant le développement. Laissez vide pour accepter les envois de partout. - Collez la définition des champs dans l'onglet Fields du formulaire et enregistrez-la.
- Envoyez une candidature avec un cas d'usage d'une ligne. Le formulaire affiche le message de longueur minimale sous ce champ, et rien n'est stocké.
- Envoyez une candidature complète. Elle apparaît sous Submissions avec toutes les réponses, et
interviewvauttrueoufalse. - Vérifiez la boîte de réception de chaque destinataire des notifications. Chacun reçoit un e-mail avec les champs, et Reply répond à l'expéditeur quand le formulaire a un champ
email. - Envoyez avec le honeypot rempli,
_gotcha=bot. L'envoi reçoit quand même une réponse de succès, mais la soumission est stockée comme spam et n'envoie ni e-mail ni webhook. - Lancez le script de notation avec une clé secrète et vérifiez que
cohort.csvliste votre candidature de test avec sa note.
Pièges
| Problème | Que faire |
|---|---|
422 validation_failed | L'envoi ne correspond pas à la définition des champs du formulaire. error.fields nomme chaque champ et la raison. Faites correspondre le name de l'input à l'id du champ. |
422 avec Unknown field pour ref, utm_source ou un autre input masqué | La définition des champs ne le liste pas. Ajoutez-le comme champ text facultatif, ou renommez-le avec un _ initial pour qu'Ikiform l'ignore. |
| Une longue réponse disparaît après une erreur dans Next.js | React réinitialise le formulaire après une server action. Renvoyez les réponses depuis l'action et définissez defaultValue sur chaque input, comme le fait l'exemple Next.js. |
Le script de notation lève 401 unauthorized ou 403 forbidden | Un 401 signifie que la clé est absente ou révoquée ; un 403 qu'elle est publiable ou n'a pas le scope. Utilisez une clé secrète (ik_sk_) avec le scope submissions:read. |
| La soumission est dans le tableau de bord mais aucun e-mail ni webhook n'est arrivé | Elle a été stockée comme spam. Le spam n'envoie rien. Ouvrez-la sous le filtre Spam pour voir les raisons, et marquez-la comme non-spam pour envoyer son e-mail et ses webhooks. |
Questions
Trois à six. Chaque question doit changer qui vous invitez. Les choix fermés obligatoires, comme le rôle et la taille de l'équipe, se trient bien ; une question ouverte obligatoire avec une longueur minimale vous dit qui utilisera la bêta.
Oui. Définissez les champs dans l'onglet Fields du formulaire et marquez-les comme obligatoires. Un envoi qui en omet un est refusé avec 422 validation_failed, et l'erreur nomme chaque champ, pour que votre formulaire affiche le message au bon endroit.
Non. Ikiform n'a ni tags ni statuts personnalisés. Gardez la liste des invités là où vous envoyez les invitations, ou dans un fichier que vous passez au script de notation, comme montré ci-dessus.
Non. Chaque envoi accepté est stocké. Une répétition identique dans les 24 heures augmente le score de spam mais n'est pas du spam en soi : dédoublonnez donc par e-mail dans votre liste, votre base de données ou votre export CSV.
Non, il n'y a pas d'offre gratuite. Les tests sont gratuits : vous pouvez envoyer 50 soumissions de test par mois depuis localhost avant de payer. Pour passer en production, Pro commence à 6 $/mois pour 500 soumissions, ou vous pouvez payer une seule fois 10 000 soumissions pour 49 $ ou 50 000 soumissions pour 149 $, avec des crédits qui n'expirent jamais.
Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.