// --------------------------------------------------------------------------- // forms / FormPublic.tsx — ⭐ wave-23 item 8 (contract W23-C9). // // The page an ANONYMOUS person lands on at `#/form/`. E mounts it in the // Shell BEFORE the auth wall (wiring W23-W2); everything below assumes there is // no session, no workspace, no nav and no viewer. // // ⛔ IT IMPORTS NOTHING FROM `customer-grid/**`, AND THAT IS A REQUIREMENT, NOT // A PREFERENCE. The grid bundle drags glide-data-grid — hundreds of kilobytes // of canvas table — into whatever chunk touches it, and this page is the one // surface in the product that a stranger loads cold, once, probably on a phone, // probably on mobile data, to type four answers. One convenience import from // `types.ts` would put the whole spreadsheet engine on that wire. So the field // vocabulary below is DECLARED HERE as plain strings rather than imported: the // server sends `type` as a string and this page renders a control per string, // which is the same "no client union over a server vocabulary" rule the rest of // the wave follows, arriving here for a second reason. // // ⚠ Styling reuses `.lpf-*` rules in the D REGION of index.css — the shell's // stylesheet is one file and already loaded; a second stylesheet for one page // would be a second copy of the tokens. // --------------------------------------------------------------------------- import { useEffect, useState } from "react"; /** One field, exactly as `routes_forms._public_form` builds it. Nothing here is * optional-because-maybe: every key below is one the server always sends, and * the three that are conditional say so. */ interface FormField { key: string; label: string; /** The server's own word. Deliberately `string`, not a union — an unknown * type lands on the text input, which is the safe direction: a person can * always type an answer, and a control this page has never heard of would * otherwise render as nothing at all. */ type: string; required: boolean; options?: string[]; max?: number; } interface FormSpec { title: string; desc: string; submitLabel: string; fields: FormField[]; /** The honeypot's field name — planted, never shown. Server-named so the two * halves cannot drift into a trap nobody checks. */ honeypot: string; } type Phase = "loading" | "ready" | "sent" | "gone"; const LONG_TEXT_MIN = 0; // every text field gets a textarea when it is the only one export default function FormPublic({ token }: { token: string }) { const [spec, setSpec] = useState(null); const [phase, setPhase] = useState("loading"); const [values, setValues] = useState>({}); const [error, setError] = useState(""); const [busy, setBusy] = useState(false); useEffect(() => { let live = true; setPhase("loading"); fetch(`/api/v1/forms/${encodeURIComponent(token)}`) .then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status))))) .then((data: FormSpec) => { if (!live) return; setSpec(data); setPhase("ready"); }) // ⚠ ONE dead-end state for every failure, mirroring the server's uniform 403. Telling a // visitor "this form is disabled" versus "no such form" would hand an enumerator the // distinction the whole server-side refusal exists to withhold. .catch(() => live && setPhase("gone")); return () => { live = false; }; }, [token]); const set = (key: string, v: string) => setValues((prev) => ({ ...prev, [key]: v })); const submit = (event: React.FormEvent) => { event.preventDefault(); if (busy) return; setBusy(true); setError(""); fetch(`/api/v1/forms/${encodeURIComponent(token)}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ values }), }) .then(async (r) => { if (r.ok) { setPhase("sent"); return; } // The server's sentence, verbatim — it names the field by the label THIS PAGE showed, // which is the only name the person has. A generic "something went wrong" here would // discard the one useful thing the refusal carried. const body = await r.json().catch(() => null); setError( String(body?.error?.message || "That could not be sent — check your answers and try again.") ); }) .catch(() => setError("That could not be sent — check your connection and try again.") ) .finally(() => setBusy(false)); }; if (phase === "loading") return (
); if (phase === "gone") return (

This form is not available

{/* ONE line (DESIGN.md §4). There is nothing this person can do about it and no detail we may safely give, so the page does not pretend otherwise. */}

The link may have expired or been turned off.

); if (phase === "sent") return (

Thank you

Your response has been recorded.

); if (!spec) return null; return (

{spec.title || "Form"}

{spec.desc &&

{spec.desc}

} {spec.fields.map((f) => ( ))} {/* ⛔ THE HONEYPOT. Off-screen rather than `display:none` — several bot frameworks skip hidden inputs on purpose, and a trap they know to skip is not a trap. `tabIndex={-1}` and `aria-hidden` keep it away from a keyboard user and a screen reader alike, and `autoComplete="off"` stops a browser filling it for a real person, which would silently drop their submission. */} set(spec.honeypot, e.target.value)} tabIndex={-1} autoComplete="off" aria-hidden /> {error &&

{error}

}

Loopable

); } /** One control per server type string. An unrecognised type falls through to text — see the * `FormField.type` note: a stranger can always type an answer, and a blank where a control * should be is the one failure they cannot work around. */ function FieldControl({ field, value, onChange, }: { field: FormField; value: string; onChange: (key: string, v: string) => void; }) { const common = { className: "lpf-input", value, required: field.required, onChange: (e: { target: { value: string } }) => onChange(field.key, e.target.value), }; switch (field.type) { case "checkbox": return ( onChange(field.key, e.target.checked ? "1" : "")} /> ); case "select": case "status": return ( ` with no blank member has its first choice pre-selected, so an untouched optional field silently submits a value the person never picked. */} {(field.options ?? []).map((o) => ( ))} ); case "int": case "currency": case "pct": case "rating": return ; case "date": // ⚠ `type="date"` submits ISO (`YYYY-MM-DD`) whatever the browser DISPLAYS, which is // exactly what the server's refuse-never-coerce date check demands. A text input here // would send whatever the locale suggested and be refused on half the planet. return ; case "email": return ; case "phone": return ; case "url": return ; case "text": return (