Files
warmbly/forms/src/FormRenderer.tsx
T

360 lines
15 KiB
TypeScript

// 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<string, AnswerValue>;
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<string, string>): 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<string, string[]> {
const out: Record<string, string[]> = {};
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 (
<div className="ok">
<svg
width="40"
height="40"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10"></circle>
<path d="m9 12 2 2 4-4"></path>
</svg>
<p>{message}</p>
</div>
);
}
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<HTMLInputElement>(null);
const stepRef = useRef<HTMLDivElement>(null);
const [captchaToken, setCaptchaToken] = useState("");
const [serverError, setServerError] = useState("");
const [done, setDone] = useState<string | null>(null);
const [screen, setScreen] = useState(0);
const [dir, setDir] = useState<"fwd" | "back">("fwd");
const [screenErrors, setScreenErrors] = useState<Record<string, string>>({});
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<HTMLElement>("input:not([type=hidden]), select, textarea");
el?.focus();
}, [screen, design.mode]);
if (done !== null) return <SuccessBlock message={done} />;
// 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<string, string> = {};
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 (
<form
noValidate
onFocusCapture={() => 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();
}}
>
<div className="hpwrap" aria-hidden="true">
<input ref={honeypot} type="text" name="website" tabIndex={-1} autoComplete="off" />
</div>
{showProgress && (
<div className="wf-progress" role="presentation">
<span style={{ width: `${((screen + 1) / screens.length) * 100}%` }} />
</div>
)}
<div key={screen} ref={stepRef} className="wf-step" data-dir={dir}>
<div className="grid">
{serverError && <div className="err">{serverError}</div>}
{!focusMode && current.title && <p className="wf-pagetitle">{current.title}</p>}
{current.fields.map((f) => {
switch (f.type) {
case "heading":
return (
<div key={f.id} className="fld">
<h2 className="h">{f.label}</h2>
</div>
);
case "paragraph":
return (
<div key={f.id} className="fld">
<p className="p">{f.value}</p>
</div>
);
case "divider":
return <hr key={f.id} className="d" />;
case "hidden":
case "page_break":
return null;
default:
return (
<form.Field key={f.id} name={f.id}>
{(field) => (
<div className={f.width === "half" ? "fld half" : "fld"}>
{f.type !== "checkbox" && (
<label className="l" htmlFor={`f-${f.id}`}>
{f.label}
{f.required && <span className="req"> *</span>}
</label>
)}
<FieldControl
field={f}
value={field.state.value}
onChange={(v) => {
field.handleChange(v);
if (screenErrors[f.id]) {
setScreenErrors((prev) => {
const rest = { ...prev };
delete rest[f.id];
return rest;
});
}
}}
onBlur={field.handleBlur}
/>
{screenErrors[f.id] && <p className="fielderr">{screenErrors[f.id]}</p>}
{f.help_text && <p className="help">{f.help_text}</p>}
</div>
)}
</form.Field>
);
}
})}
{isLast && def.captcha_site_key && (
<Turnstile siteKey={def.captcha_site_key} onToken={setCaptchaToken} />
)}
{isLast ? (
<div className={screen > 0 ? "wf-pagenav" : "btnrow"}>
{screen > 0 && (
<button type="button" className="wf-back" onClick={goBack}>
Back
</button>
)}
<form.Subscribe selector={(s) => s.isSubmitting}>
{(isSubmitting) => (
<button
className={design.btnFullWidth ? "submit full" : "submit"}
type="submit"
disabled={isSubmitting}
>
{design.btnLabel}
</button>
)}
</form.Subscribe>
</div>
) : (
<div className="wf-pagenav">
{screen > 0 ? (
<button type="button" className="wf-back" onClick={goBack}>
Back
</button>
) : (
<span />
)}
<button type="button" className="submit" onClick={goNext}>
Next
</button>
</div>
)}
{focusMode && <p className="wf-hint">Press Enter to continue</p>}
</div>
</div>
</form>
);
}