"use client"; /** * FormRenderer — a public-facing form (used by BOTH the in-app "form" view * and the top-level /f/[slug] page). * * Loads the form by id (in-app, auth) or by slug (public, no auth), validates * required questions client-side (the server re-validates + coerces), collects * RAW answers keyed by question id, and submits via POST /f/:slug/submit * (no auth) which creates a record in the linked database. * * Status is UPPERCASE (DRAFT/PUBLISHED/CLOSED). On the public path only * PUBLISHED forms resolve (publicGet 404s otherwise), so the DRAFT notice is * really an in-app-only affordance. */ import { useMemo, useState } from "react"; import { CheckCircle2, Loader2, Send } from "lucide-react"; import { cn } from "@/lib/utils"; import { FormDto, isAnswerEmpty, useForm, useFormBySlug, useSubmitForm } from "@/hooks/use-forms"; import { FormField } from "@/components/form/form-field"; export interface FormRendererProps { /** In-app: load by form id (auth). */ formId?: string; /** Public: load by slug (no auth). */ slug?: string; className?: string; } /** * Map a submit 422's per-field details (ApiError.details = [{ questionId, * issue, message }]) to "Label: message" strings keyed to the question's * label. Non-422 errors / unknown shapes → [] (the caller falls back to the * raw message). */ function submitFieldErrors( questions: { id: string; label: string }[], error: unknown ): string[] { if (!error || !("details" in (error as object))) return []; const details = (error as { details?: unknown }).details; if (!Array.isArray(details)) return []; const label = new Map(questions.map((q) => [q.id, q.label])); const out: string[] = []; for (const d of details) { const item = d as { questionId?: string; message?: string }; if (item?.questionId && item?.message) { out.push(`${label.get(item.questionId) ?? "Question"}: ${item.message}`); } } return out; } export function FormRenderer({ formId, slug, className }: FormRendererProps) { const byId = useForm(formId); const bySlug = useFormBySlug(slug); const form: FormDto | undefined = byId.data ?? bySlug.data; const isLoading = (formId ? byId.isLoading : bySlug.isLoading) || (formId ? byId.isFetching : bySlug.isFetching); const error = (formId ? byId.error : bySlug.error) as Error | null; const submit = useSubmitForm(slug ?? form?.slug ?? ""); const [answers, setAnswers] = useState>({}); const [errors, setErrors] = useState>({}); const [done, setDone] = useState(false); const questions = useMemo(() => form?.questions ?? [], [form]); const setAnswer = (qid: string, v: unknown) => { setAnswers((a) => ({ ...a, [qid]: v })); if (errors[qid]) setErrors((e) => ({ ...e, [qid]: "" })); }; const validate = () => { const next: Record = {}; for (const q of questions) { if (!q.required) continue; if (isAnswerEmpty(answers[q.id])) next[q.id] = "This field is required."; } setErrors(next); return Object.keys(next).length === 0; }; const onSubmit = (e?: React.FormEvent) => { e?.preventDefault(); if (!form || !form.slug) return; if (!validate()) return; submit.mutate(answers, { onSuccess: () => { setDone(true); setAnswers({}); } }); }; // ---- states ----------------------------------------------------------------- if (isLoading) { return (
{Array.from({ length: 5 }).map((_, i) => (
))}
); } if (error || !form) { return (
This form is not available.
); } if (form.status === "CLOSED") { return (

Form closed

{form.config.closedMessage || "This form is no longer accepting submissions."}

); } const isDraft = form.status === "DRAFT"; // The submit's 422 carries per-field details: [{ questionId, issue, message }]. // Map them to the offending question's label so the error reads as a field // error, not an opaque server message. const fieldErrors = submitFieldErrors(questions, submit.error); return (
{isDraft && (

This form is a draft — only you can see it. Publish it to share the link.

)} {form.config.intro &&

{form.config.intro}

}
{questions.map((q) => ( setAnswer(q.id, v)} error={errors[q.id]} disabled={done} /> ))}
{done ? (
{form.config.successMessage || "Thanks! Your response was recorded."}
) : (
{submit.error && (
{fieldErrors.length > 0 ? (
    {fieldErrors.map((e, i) => (
  • {e}
  • ))}
) : (

{(submit.error as Error).message}

)}
)}
)}
); }