Files
warmbly/forms/src/FormRenderer.tsx
T
Matthew Meszaros ae012dd13f Clear the live error-tracking issues, and the workspace rename that renamed the wrong workspace (#533)
* feat: stop a managed Kafka cluster refusing topic creation from failing the publish, by treating a topic- or cluster-authorization failure from CreateTopics as a topic the cluster owns rather than one that is missing, which on Confluent Cloud dropped every warmup event and filed one issue per message because the topic never became known

* feat: drop a report whose error is a cancelled context in errs rather than at ninety call sites, so a browser navigating away or a container draining on deploy stops filing one issue per query that happened to be in flight, while a deadline this process set and blew through still reports

* feat: stop renaming one workspace from renaming another, by keying the workspace settings editor on the workspace id so a switch re-seeds the name field instead of leaving the previous workspace's name against the new workspace's autosave baseline, and pinning every save on the workspace, sending and tracking pages to the workspace its draft was hydrated from

* feat: drop Script error. and the ResizeObserver notice on the marketing site and the hosted form page the way the dashboard and admin panel already do, since those two carry no stack and no bug and between them were the largest issues in error tracking, all of it from warmbly.com

* feat: rename the forms Turnstile script module to turnstileScript.ts so it no longer differs only in case from the Turnstile.tsx component, which resolved both imports to one file on a case-insensitive filesystem and failed forms' typecheck with TS1149

* feat: upload source maps from the static build:pages build as well as the image build, so the dashboards served from a static host stop reporting every stack frame as a minified name beside 'Invalid source map: bad json', which is PostHog falling back to fetching the .map from a host that answers with its SPA fallback

* feat: build every admin list in pg_admin.go with make rather than declaring it nil, so an empty page serializes as [] instead of null, and guard the audit table's own empty check, which is what crashed admin.warmbly.com/audit with 'null is not an object (evaluating d.data.length)' whenever a filter matched nothing

* feat: match the whole broker description rather than a substring when deciding a topic create was refused for permissions, since that answer remembers the topic as present, and clear the cached promise and dead tag when the forms Turnstile script fails to load so a blocked first attempt no longer leaves every later mount with the same rejection and the captcha permanently missing
2026-09-15 09:05:53 -07:00

362 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 { track } from "./observability";
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 "./turnstileScript";
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);
track("form_submitted", 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>
);
}