| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { useEffect, useState } from "react"; |
|
|
| |
| |
| |
| interface FormField { |
| key: string; |
| label: string; |
| |
| |
| |
| |
| type: string; |
| required: boolean; |
| options?: string[]; |
| max?: number; |
| } |
|
|
| interface FormSpec { |
| title: string; |
| desc: string; |
| submitLabel: string; |
| fields: FormField[]; |
| |
| |
| honeypot: string; |
| } |
|
|
| type Phase = "loading" | "ready" | "sent" | "gone"; |
|
|
| const LONG_TEXT_MIN = 0; |
|
|
| 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"); |
| }) |
| |
| |
| |
| .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; |
| } |
| |
| |
| |
| 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> |
| ); |
| } |
|
|
| |
| |
| |
| 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" />; |
| } |
| } |
| |