1"use client";
2
3
24import { useRef, useState } from "react";
25import { Upload, X } from "lucide-react";
26import { cn } from "@/lib/utils";
27import { FormOption, FormQuestion, questionOptions } from "@/hooks/use-forms";
28import { uploadFile } from "@/lib/api";
29
30export interface FormFieldProps {
31 question: FormQuestion;
32 value: unknown;
33 onChange: (value: unknown) => void;
34 error?: string;
35 disabled?: boolean;
36}
37
38const inputCls =
39 "w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50";
40
41export function FormField({ question: q, value, onChange, error, disabled }: FormFieldProps) {
42 const options: FormOption[] = questionOptions(q);
43 const validation = q.validation ?? {};
44 const placeholder = q.placeholder ?? "";
45
46 switch (q.type) {
47 case "SHORT_TEXT":
48 return (
49 <FieldShell question={q} error={error}>
50 <input
51 className={inputCls}
52 type="text"
53 placeholder={placeholder}
54 maxLength={typeof validation.maxLength === "number" ? (validation.maxLength as number) : undefined}
55 value={typeof value === "string" ? value : ""}
56 disabled={disabled}
57 onChange={(e) => onChange(e.target.value)}
58 aria-label={q.label}
59 />
60 </FieldShell>
61 );
62
63 case "LONG_TEXT":
64 return (
65 <FieldShell question={q} error={error}>
66 <textarea
67 className={cn(inputCls, "min-h-24 resize-y")}
68 placeholder={placeholder}
69 maxLength={typeof validation.maxLength === "number" ? (validation.maxLength as number) : undefined}
70 value={typeof value === "string" ? value : ""}
71 disabled={disabled}
72 onChange={(e) => onChange(e.target.value)}
73 aria-label={q.label}
74 />
75 </FieldShell>
76 );
77
78 case "NUMBER":
79 return (
80 <FieldShell question={q} error={error}>
81 <input
82 className={inputCls}
83 type="number"
84 placeholder={placeholder}
85 min={typeof validation.min === "number" ? (validation.min as number) : undefined}
86 max={typeof validation.max === "number" ? (validation.max as number) : undefined}
87 value={typeof value === "number" ? value : ""}
88 disabled={disabled}
89 onChange={(e) => {
90 const n = e.target.value;
91 onChange(n === "" ? null : Number(n));
92 }}
93 aria-label={q.label}
94 />
95 </FieldShell>
96 );
97
98 case "EMAIL":
99 return (
100 <FieldShell question={q} error={error}>
101 <input
102 className={inputCls}
103 type="email"
104 placeholder={placeholder || "name@example.com"}
105 value={typeof value === "string" ? value : ""}
106 disabled={disabled}
107 onChange={(e) => onChange(e.target.value)}
108 aria-label={q.label}
109 />
110 </FieldShell>
111 );
112
113 case "DATE":
114 return (
115 <FieldShell question={q} error={error}>
116 <input
117 className={inputCls}
118 type="date"
119 value={typeof value === "string" ? value : ""}
120 disabled={disabled}
121 onChange={(e) => onChange(e.target.value || null)}
122 aria-label={q.label}
123 />
124 </FieldShell>
125 );
126
127 case "CHECKBOX":
128 return (
129 <FieldShell question={q} error={error}>
130 <label className="flex cursor-pointer items-center gap-2 text-sm">
131 <input
132 type="checkbox"
133 className="size-4 accent-[oklch(var(--primary))]"
134 checked={value === true}
135 disabled={disabled}
136 onChange={(e) => onChange(e.target.checked)}
137 aria-label={q.label}
138 />
139 {q.placeholder || "Yes"}
140 </label>
141 </FieldShell>
142 );
143
144 case "SINGLE_SELECT":
145 return (
146 <FieldShell question={q} error={error}>
147 <select
148 className={inputCls}
149 value={typeof value === "string" ? value : ""}
150 disabled={disabled}
151 onChange={(e) => onChange(e.target.value || null)}
152 aria-label={q.label}
153 >
154 <option value="">Select an option…</option>
155 {options.map((o) => (
156 <option key={o.id ?? o.name} value={o.name}>
157 {o.name}
158 </option>
159 ))}
160 </select>
161 </FieldShell>
162 );
163
164 case "MULTI_SELECT":
165 return (
166 <FieldShell question={q} error={error}>
167 <div className="flex flex-wrap gap-1.5">
168 {options.map((o) => {
169 const selected = Array.isArray(value) && (value as string[]).includes(o.name);
170 return (
171 <button
172 key={o.id ?? o.name}
173 type="button"
174 disabled={disabled}
175 className={cn(
176 "rounded-full border px-2.5 py-1 text-xs transition-colors",
177 selected
178 ? "border-primary bg-primary/10 text-primary"
179 : "border-border text-muted-foreground hover:bg-muted"
180 )}
181 onClick={() => {
182 const cur = Array.isArray(value) ? (value as string[]) : [];
183 onChange(selected ? cur.filter((x) => x !== o.name) : [...cur, o.name]);
184 }}
185 aria-pressed={selected}
186 >
187 {o.name}
188 </button>
189 );
190 })}
191 {options.length === 0 && <span className="text-xs text-muted-foreground">No options configured.</span>}
192 </div>
193 </FieldShell>
194 );
195
196 case "RATING": {
197 const max = typeof validation.max === "number" ? (validation.max as number) : 5;
198 const current = typeof value === "number" ? value : 0;
199 return (
200 <FieldShell question={q} error={error}>
201 <div className="flex items-center gap-1" role="radiogroup" aria-label={q.label}>
202 {Array.from({ length: max }, (_, i) => i + 1).map((n) => (
203 <button
204 key={n}
205 type="button"
206 disabled={disabled}
207 className={cn("text-xl leading-none transition-opacity", n <= current ? "opacity-100" : "opacity-30", "hover:scale-110")}
208 onClick={() => onChange(n === current ? 0 : n)}
209 aria-label={`${n} of ${max}`}
210 aria-pressed={n === current}
211 >
212 ★
213 </button>
214 ))}
215 </div>
216 </FieldShell>
217 );
218 }
219
220 case "FILE":
221 return <FileField question={q} value={value} onChange={onChange} error={error} disabled={disabled} />;
222
223 default:
224 return (
225 <FieldShell question={q} error={error}>
226 <input className={inputCls} type="text" value="" disabled aria-label={q.label} />
227 </FieldShell>
228 );
229 }
230}
231
232
233
234
235
236function FileField({ question: q, value, onChange, error, disabled }: FormFieldProps) {
237 const [uploading, setUploading] = useState(false);
238 const [names, setNames] = useState<string[]>([]);
239 const inputRef = useRef<HTMLInputElement>(null);
240
241 const ids: string[] = Array.isArray(value) ? (value as string[]) : [];
242
243 const onFiles = async (files: FileList | null) => {
244 if (!files || files.length === 0) return;
245 setUploading(true);
246 try {
247 const next: string[] = [...ids];
248 const nextNames: string[] = [...names];
249 for (const f of Array.from(files)) {
250 const meta = await uploadFile(f);
251 next.push(meta.id);
252 nextNames.push(meta.name ?? f.name);
253 }
254 setNames(nextNames);
255 onChange(next);
256 } catch (e) {
257
258 onChange(ids);
259
260 console.error("File upload failed:", e);
261 } finally {
262 setUploading(false);
263 if (inputRef.current) inputRef.current.value = "";
264 }
265 };
266
267 return (
268 <FieldShell question={q} error={error}>
269 <div className="flex flex-wrap items-center gap-2">
270 <input ref={inputRef} type="file" multiple className="hidden" onChange={(e) => void onFiles(e.target.files)} disabled={disabled || uploading} aria-label={`Upload files for ${q.label}`} />
271 <button
272 type="button"
273 className="inline-flex items-center gap-1.5 rounded-md border border-dashed px-3 py-1.5 text-xs text-muted-foreground hover:bg-muted disabled:opacity-50"
274 onClick={() => inputRef.current?.click()}
275 disabled={disabled || uploading}
276 >
277 <Upload className="size-3.5" aria-hidden />
278 {uploading ? "Uploading…" : "Upload files"}
279 </button>
280 {names.map((n, i) => (
281 <span key={`${n}-${i}`} className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs">
282 {n}
283 <button
284 type="button"
285 className="text-muted-foreground hover:text-foreground"
286 onClick={() => {
287 const nextIds = ids.filter((_, j) => j !== i);
288 const nextNames = names.filter((_, j) => j !== i);
289 setNames(nextNames);
290 onChange(nextIds);
291 }}
292 aria-label={`Remove ${n}`}
293 >
294 <X className="size-3" />
295 </button>
296 </span>
297 ))}
298 </div>
299 </FieldShell>
300 );
301}
302
303
304
305
306
307function FieldShell({
308 question: q,
309 error,
310 children
311}: {
312 question: FormQuestion;
313 error?: string;
314 children: React.ReactNode;
315}) {
316 return (
317 <div>
318 <label className="mb-1 block text-sm font-medium">
319 {q.label}
320 {q.required && <span className="ml-0.5 text-destructive">*</span>}
321 </label>
322 {children}
323 {error && <p className="mt-1 text-xs text-destructive">{error}</p>}
324 </div>
325 );
326}
327