mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-09 00:03:10 +00:00
360 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|