// The live form: multi-page (or focus-mode) rendering with per-screen // validation, field state via TanStack Form, submission to the same-origin // /api. Client checks are a courtesy; the backend re-validates everything and // its message wins the error box. import { useEffect, useRef, useState } from "react"; import { useForm } from "@tanstack/react-form"; import type { FormField, PublicForm } from "./api"; import { personalToken, submitForm, StalePageError, SubmitRejectedError } from "./api"; import type { ResolvedDesign } from "./design"; import { focusSteps, pageIndexOf, splitPages } from "./design"; import { postSubmitted, redirect } from "./embed"; import type { Tracker } from "./events"; import { visitorKey } from "./events"; import type { AnswerValue } from "./fields"; import { FieldControl } from "./fields"; import { Turnstile } from "./Turnstile"; import { resetTurnstile } from "./turnstile"; type Answers = Record; const LAYOUT_TYPES = new Set(["heading", "paragraph", "divider", "hidden", "page_break"]); function isInput(f: FormField): boolean { return !LAYOUT_TYPES.has(f.type); } function prefillValue(f: FormField, raw: string): AnswerValue | undefined { switch (f.type) { case "checkbox": return ["yes", "true", "1", "on"].includes(raw.toLowerCase()) ? true : undefined; case "checkboxes": { const opts = new Set(f.options ?? []); const picked = raw.split(", ").filter((v) => opts.has(v)); return picked.length > 0 ? picked : undefined; } case "select": case "radio": return (f.options ?? []).includes(raw) ? raw : undefined; default: return raw; } } function defaultsFor(fields: FormField[], prefill?: Record): Answers { const out: Answers = {}; for (const f of fields) { if (!isInput(f)) continue; out[f.id] = f.type === "checkboxes" ? [] : f.type === "checkbox" ? false : ""; const raw = prefill?.[f.id]; if (raw) { const v = prefillValue(f, raw); if (v !== undefined) out[f.id] = v; } } return out; } function validateField(f: FormField, v: AnswerValue): string | undefined { const empty = f.type === "checkboxes" ? !Array.isArray(v) || v.length === 0 : f.type === "checkbox" ? v !== true : typeof v !== "string" || v.trim() === ""; if (f.required && empty) return `${f.label || "This field"} is required.`; if (f.type === "email" && typeof v === "string" && v.trim() !== "" && !/^\S+@\S+\.\S+$/.test(v.trim())) { return "Enter a valid email address."; } return undefined; } function buildAnswers(fields: FormField[], value: Answers): Record { const out: Record = {}; for (const f of fields) { if (f.type === "hidden") { if (f.value) out[f.id] = [f.value]; continue; } if (!isInput(f)) continue; const v = value[f.id]; if (f.type === "checkboxes") { if (Array.isArray(v) && v.length > 0) out[f.id] = v; } else if (f.type === "checkbox") { if (v === true) out[f.id] = ["yes"]; } else if (typeof v === "string" && v.trim() !== "") { out[f.id] = [v]; } } return out; } function SuccessBlock({ message }: { message: string }) { return (

{message}

); } interface Screen { fields: FormField[]; title: string; /** The classic page this screen belongs to, for funnel reporting. */ pageIndex: number; } // buildScreens flattens the two modes into one shape: classic mode shows a // page of fields per screen, focus mode one question per screen. function buildScreens(fields: FormField[], mode: string): Screen[] { const pageOf = pageIndexOf(fields); if (mode === "focus") { return focusSteps(fields).map((step) => { const anchor = step[step.length - 1]; return { fields: step, title: "", pageIndex: pageOf[anchor.id] ?? 0 }; }); } return splitPages(fields).map((p, i) => ({ fields: p.fields, title: p.title, pageIndex: i })); } export function FormRenderer({ form: def, design, tracker, }: { form: PublicForm; design: ResolvedDesign; tracker: Tracker; }) { // Bots submit near-instantly after render; the backend discards those. const renderedAt = useRef(Math.floor(Date.now() / 1000)); const honeypot = useRef(null); const stepRef = useRef(null); const [captchaToken, setCaptchaToken] = useState(""); const [serverError, setServerError] = useState(""); const [done, setDone] = useState(null); const [screen, setScreen] = useState(0); const [dir, setDir] = useState<"fwd" | "back">("fwd"); const [screenErrors, setScreenErrors] = useState>({}); const screens = buildScreens(def.fields, design.mode); const current = screens[Math.min(screen, screens.length - 1)]; const isLast = screen >= screens.length - 1; const form = useForm({ defaultValues: defaultsFor(def.fields, def.prefill), onSubmit: async ({ value }) => { setServerError(""); try { const res = await submitForm(def.public_id, { answers: buildAnswers(def.fields, value), website: honeypot.current?.value ?? "", _wt: renderedAt.current, captcha_token: captchaToken || undefined, source_url: document.referrer || window.location.href, link_token: personalToken() ?? undefined, visitor_key: visitorKey(), }); postSubmitted(def.public_id); if (res.redirect_url) { redirect(res.redirect_url); return; } setDone(res.message || "Thanks!"); } catch (e) { if (e instanceof StalePageError) { setServerError("This page has been open for a while. Refresh it and try again."); } else { setServerError(e instanceof SubmitRejectedError ? e.message : "Something went wrong. Try again."); } resetTurnstile(); setCaptchaToken(""); } }, }); // Focus mode puts the visitor straight into the question. useEffect(() => { if (design.mode !== "focus") return; const el = stepRef.current?.querySelector("input:not([type=hidden]), select, textarea"); el?.focus(); }, [screen, design.mode]); if (done !== null) return ; // validateScreen is the only client validation: every screen is checked on // the way forward, so the final submit only ever re-checks the last one. const validateScreen = (s: Screen): boolean => { const errs: Record = {}; for (const f of s.fields) { if (!isInput(f)) continue; const msg = validateField(f, form.state.values[f.id]); if (msg) errs[f.id] = msg; } setScreenErrors(errs); return Object.keys(errs).length === 0; }; const goNext = () => { if (!validateScreen(current) || isLast) return; setDir("fwd"); const next = screen + 1; setScreen(next); tracker.page(screens[next].pageIndex); stepRef.current?.closest(".wf-main")?.scrollTo?.(0, 0); window.scrollTo(0, 0); }; const goBack = () => { if (screen === 0) return; setScreenErrors({}); setDir("back"); setScreen(screen - 1); }; const submitCurrent = () => { if (!validateScreen(current)) return; void form.handleSubmit(); }; const showProgress = design.showProgress && screens.length > 1; const focusMode = design.mode === "focus"; return (
tracker.start()} onChangeCapture={() => tracker.start()} onKeyDown={(e) => { if (!focusMode || e.key !== "Enter" || e.shiftKey) return; const target = e.target as HTMLElement; if (target instanceof HTMLTextAreaElement) return; e.preventDefault(); if (isLast) submitCurrent(); else goNext(); }} onSubmit={(e) => { e.preventDefault(); e.stopPropagation(); submitCurrent(); }} > {showProgress && (
)}
{serverError &&
{serverError}
} {!focusMode && current.title &&

{current.title}

} {current.fields.map((f) => { switch (f.type) { case "heading": return (

{f.label}

); case "paragraph": return (

{f.value}

); case "divider": return
; case "hidden": case "page_break": return null; default: return ( {(field) => (
{f.type !== "checkbox" && ( )} { field.handleChange(v); if (screenErrors[f.id]) { setScreenErrors((prev) => { const rest = { ...prev }; delete rest[f.id]; return rest; }); } }} onBlur={field.handleBlur} /> {screenErrors[f.id] &&

{screenErrors[f.id]}

} {f.help_text &&

{f.help_text}

}
)}
); } })} {isLast && def.captcha_site_key && ( )} {isLast ? (
0 ? "wf-pagenav" : "btnrow"}> {screen > 0 && ( )} s.isSubmitting}> {(isSubmitting) => ( )}
) : (
{screen > 0 ? ( ) : ( )}
)} {focusMode &&

Press Enter to continue

}
); }