1"use client";
2
3
16import { useMemo, useState } from "react";
17import { CheckCircle2, Loader2, Send } from "lucide-react";
18import { cn } from "@/lib/utils";
19import {
20 FormDto,
21 isAnswerEmpty,
22 useForm,
23 useFormBySlug,
24 useSubmitForm
25} from "@/hooks/use-forms";
26import { FormField } from "@/components/form/form-field";
27
28export interface FormRendererProps {
29
30 formId?: string;
31
32 slug?: string;
33 className?: string;
34}
35
36
42function submitFieldErrors(
43 questions: { id: string; label: string }[],
44 error: unknown
45): string[] {
46 if (!error || !("details" in (error as object))) return [];
47 const details = (error as { details?: unknown }).details;
48 if (!Array.isArray(details)) return [];
49 const label = new Map(questions.map((q) => [q.id, q.label]));
50 const out: string[] = [];
51 for (const d of details) {
52 const item = d as { questionId?: string; message?: string };
53 if (item?.questionId && item?.message) {
54 out.push(`${label.get(item.questionId) ?? "Question"}: ${item.message}`);
55 }
56 }
57 return out;
58}
59
60export function FormRenderer({ formId, slug, className }: FormRendererProps) {
61 const byId = useForm(formId);
62 const bySlug = useFormBySlug(slug);
63 const form: FormDto | undefined = byId.data ?? bySlug.data;
64 const isLoading = (formId ? byId.isLoading : bySlug.isLoading) || (formId ? byId.isFetching : bySlug.isFetching);
65 const error = (formId ? byId.error : bySlug.error) as Error | null;
66 const submit = useSubmitForm(slug ?? form?.slug ?? "");
67
68 const [answers, setAnswers] = useState<Record<string, unknown>>({});
69 const [errors, setErrors] = useState<Record<string, string>>({});
70 const [done, setDone] = useState(false);
71
72 const questions = useMemo(() => form?.questions ?? [], [form]);
73
74 const setAnswer = (qid: string, v: unknown) => {
75 setAnswers((a) => ({ ...a, [qid]: v }));
76 if (errors[qid]) setErrors((e) => ({ ...e, [qid]: "" }));
77 };
78
79 const validate = () => {
80 const next: Record<string, string> = {};
81 for (const q of questions) {
82 if (!q.required) continue;
83 if (isAnswerEmpty(answers[q.id])) next[q.id] = "This field is required.";
84 }
85 setErrors(next);
86 return Object.keys(next).length === 0;
87 };
88
89 const onSubmit = (e?: React.FormEvent) => {
90 e?.preventDefault();
91 if (!form || !form.slug) return;
92 if (!validate()) return;
93 submit.mutate(answers, {
94 onSuccess: () => {
95 setDone(true);
96 setAnswers({});
97 }
98 });
99 };
100
101
102 if (isLoading) {
103 return (
104 <div className={cn("space-y-3 p-6", className)} aria-busy="true">
105 {Array.from({ length: 5 }).map((_, i) => (
106 <div key={i} className="h-10 animate-pulse rounded bg-muted" />
107 ))}
108 </div>
109 );
110 }
111
112 if (error || !form) {
113 return (
114 <div className={cn("rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground", className)}>
115 This form is not available.
116 </div>
117 );
118 }
119
120 if (form.status === "CLOSED") {
121 return (
122 <div className={cn("rounded-lg border border-border p-8 text-center", className)}>
123 <h2 className="text-lg font-medium">Form closed</h2>
124 <p className="mt-1 text-sm text-muted-foreground">
125 {form.config.closedMessage || "This form is no longer accepting submissions."}
126 </p>
127 </div>
128 );
129 }
130
131 const isDraft = form.status === "DRAFT";
132
133
134
135
136 const fieldErrors = submitFieldErrors(questions, submit.error);
137
138 return (
139 <form className={cn("mx-auto max-w-xl", className)} onSubmit={onSubmit} noValidate>
140 {isDraft && (
141 <p className="mb-3 rounded-md border border-yellow-500/30 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-700">
142 This form is a draft — only you can see it. Publish it to share the link.
143 </p>
144 )}
145
146 {form.config.intro && <p className="mb-4 text-sm text-muted-foreground">{form.config.intro}</p>}
147
148 <div className="space-y-4">
149 {questions.map((q) => (
150 <FormField key={q.id} question={q} value={answers[q.id]} onChange={(v) => setAnswer(q.id, v)} error={errors[q.id]} disabled={done} />
151 ))}
152 </div>
153
154 {done ? (
155 <div className="mt-6 flex items-center gap-2 rounded-md border border-emerald-500/30 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-700">
156 <CheckCircle2 className="size-5" aria-hidden />
157 <span>{form.config.successMessage || "Thanks! Your response was recorded."}</span>
158 </div>
159 ) : (
160 <div className="mt-6">
161 <button
162 type="submit"
163 disabled={submit.isPending || isDraft}
164 className="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
165 >
166 {submit.isPending && <Loader2 className="size-4 animate-spin" aria-hidden />}
167 <Send className="size-4" aria-hidden />
168 Submit
169 </button>
170 {submit.error && (
171 <div className="mt-2 space-y-1 text-xs text-destructive">
172 {fieldErrors.length > 0 ? (
173 <ul data-testid="form-submit-field-errors" className="list-disc space-y-0.5 pl-4">
174 {fieldErrors.map((e, i) => (
175 <li key={i}>{e}</li>
176 ))}
177 </ul>
178 ) : (
179 <p>{(submit.error as Error).message}</p>
180 )}
181 </div>
182 )}
183 </div>
184 )}
185 </form>
186 );
187}
188