loopable / web /src /forms /FormPublic.tsx
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
c3e4cb4 verified
Raw
History Blame Contribute Delete
9.8 kB
// ---------------------------------------------------------------------------
// forms / FormPublic.tsx — ⭐ wave-23 item 8 (contract W23-C9).
//
// The page an ANONYMOUS person lands on at `#/form/<token>`. 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<FormSpec | null>(null);
const [phase, setPhase] = useState<Phase>("loading");
const [values, setValues] = useState<Record<string, string>>({});
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 (
<div className="lpf-page">
<div className="lpf-card lpf-card--quiet" />
</div>
);
if (phase === "gone")
return (
<div className="lpf-page">
<div className="lpf-card">
<h1 className="lpf-title">This form is not available</h1>
{/* 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. */}
<p className="lpf-desc">The link may have expired or been turned off.</p>
</div>
</div>
);
if (phase === "sent")
return (
<div className="lpf-page">
<div className="lpf-card">
<h1 className="lpf-title">Thank you</h1>
<p className="lpf-desc">Your response has been recorded.</p>
<button
type="button"
className="lpf-btn"
onClick={() => {
setValues({});
setPhase("ready");
}}
>
Submit another
</button>
</div>
</div>
);
if (!spec) return null;
return (
<div className="lpf-page">
<form className="lpf-card" onSubmit={submit}>
<h1 className="lpf-title">{spec.title || "Form"}</h1>
{spec.desc && <p className="lpf-desc">{spec.desc}</p>}
{spec.fields.map((f) => (
<label key={f.key} className="lpf-field">
<span className="lpf-label">
{f.label}
{f.required && <span className="lpf-req" aria-hidden> *</span>}
</span>
<FieldControl field={f} value={values[f.key] ?? ""} onChange={set} />
</label>
))}
{/* ⛔ 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. */}
<input
className="lpf-hp"
type="text"
name={spec.honeypot}
value={values[spec.honeypot] ?? ""}
onChange={(e) => set(spec.honeypot, e.target.value)}
tabIndex={-1}
autoComplete="off"
aria-hidden
/>
{error && <p className="lpf-err">{error}</p>}
<button type="submit" className="lpf-btn lpf-btn--primary" disabled={busy}>
{busy ? "Sending…" : spec.submitLabel || "Submit"}
</button>
<p className="lpf-brand">Loopable</p>
</form>
</div>
);
}
/** 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 (
<input
type="checkbox"
className="lpf-check"
checked={value === "1"}
onChange={(e) => onChange(field.key, e.target.checked ? "1" : "")}
/>
);
case "select":
case "status":
return (
<select {...common} className="lpf-input lpf-select">
{/* An explicit empty option, always. A `<select>` with no blank member has its first
choice pre-selected, so an untouched optional field silently submits a value the
person never picked. */}
<option value="">{field.required ? "Choose…" : "—"}</option>
{(field.options ?? []).map((o) => (
<option key={o} value={o}>{o}</option>
))}
</select>
);
case "int":
case "currency":
case "pct":
case "rating":
return <input {...common} type="number" inputMode="decimal" />;
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 <input {...common} type="date" />;
case "email":
return <input {...common} type="email" inputMode="email" />;
case "phone":
return <input {...common} type="tel" inputMode="tel" />;
case "url":
return <input {...common} type="url" inputMode="url" />;
case "text":
return (
<textarea
{...common}
className="lpf-input lpf-textarea"
rows={value.length > 60 ? 4 : 2}
minLength={LONG_TEXT_MIN}
/>
);
default:
return <input {...common} type="text" />;
}
}