1"use client";
2
3
36import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
37import { apiFetch } from "@/lib/api";
38
39
40
41
42
43export const FORM_QUESTION_TYPES = [
44 "SHORT_TEXT",
45 "LONG_TEXT",
46 "NUMBER",
47 "EMAIL",
48 "DATE",
49 "CHECKBOX",
50 "SINGLE_SELECT",
51 "MULTI_SELECT",
52 "RATING",
53 "FILE"
54] as const;
55export type FormQuestionType = (typeof FORM_QUESTION_TYPES)[number];
56
57export const FORM_STATUSES = ["DRAFT", "PUBLISHED", "CLOSED"] as const;
58export type FormStatus = (typeof FORM_STATUSES)[number];
59
60export interface FormOption {
61
62 id?: string;
63 name: string;
64 color?: string;
65}
66
67
68export interface FormValidation extends Record<string, unknown> {
69
77 options?: (FormOption | string)[];
78 min?: number;
79 max?: number;
80 maxLength?: number;
81}
82
83
85export interface FormQuestionInput {
86 type: FormQuestionType;
87 label: string;
88 description?: string;
89 required?: boolean;
90 propertyId?: string | null;
91 validation?: FormValidation | null;
92 placeholder?: string;
93}
94
95
96export interface FormQuestion extends FormQuestionInput {
97 id: string;
98 position: string;
99 required: boolean;
100}
101
102export interface FormConfig extends Record<string, unknown> {
103
104 icon?: string | null;
105
106 intro?: string;
107
108 accent?: string;
109 successMessage?: string;
110 closedMessage?: string;
111
112 poweredBy?: boolean;
113 recaptcha?: { enabled?: boolean; siteKey?: string };
114}
115
116
119export interface FormDto {
120 id: string;
121 name: string;
122 slug: string;
123 description?: string | null;
124 status: FormStatus;
125 config: FormConfig;
126
127 databaseId?: string;
128 databaseName?: string;
129 questions: FormQuestion[];
130 createdAt?: string;
131 updatedAt?: string;
132}
133
134
135
136
137
138function unwrap<T>(body: unknown): T {
139 const b = body as { data?: T } | null;
140 if (b && typeof b === "object" && "data" in (b as Record<string, unknown>)) {
141 return (b as { data: T }).data;
142 }
143 return body as T;
144}
145
146
152function normalizeStatus(status: string | undefined | null): FormStatus {
153 const s = (status ?? "").toUpperCase();
154 return (FORM_STATUSES as readonly string[]).includes(s) ? (s as FormStatus) : "DRAFT";
155}
156
157
165export function questionOptions(question: Pick<FormQuestion, "validation">): FormOption[] {
166 const o = question.validation?.options;
167 if (!Array.isArray(o)) return [];
168 return o.map((entry) => (typeof entry === "string" ? { name: entry } : entry));
169}
170
171
172export function withOptions(question: FormQuestionInput, options: FormOption[]): FormQuestionInput {
173 const validation: FormValidation = { ...(question.validation ?? {}) };
174 if (options.length) validation.options = options;
175 else delete validation.options;
176 return { ...question, validation: validation && Object.keys(validation).length ? validation : null };
177}
178
179const formKey = (id: string) => ["form", id] as const;
180
181
182
183
184
185export function useForm(id: string | undefined) {
186 return useQuery({
187 queryKey: formKey(id ?? "none"),
188 enabled: !!id,
189 queryFn: async (): Promise<FormDto> => {
190 const f = await apiFetch<{
191 id: string;
192 databaseId: string;
193 name: string;
194 slug: string;
195 description?: string | null;
196 status: FormStatus;
197 config: FormConfig;
198 database: { id: string; title: string };
199 questions: FormQuestion[];
200 createdAt: string;
201 updatedAt: string;
202 }>(`/forms/${id}`);
203 return {
204 id: f.id,
205 name: f.name,
206 slug: f.slug,
207 description: f.description,
208 status: normalizeStatus(f.status),
209 config: f.config ?? {},
210 databaseId: f.databaseId,
211 databaseName: f.database?.title,
212 questions: f.questions ?? [],
213 createdAt: f.createdAt,
214 updatedAt: f.updatedAt
215 };
216 }
217 });
218}
219
220
222export function useFormBySlug(slug: string | undefined) {
223 return useQuery({
224 queryKey: ["form-by-slug", slug ?? "none"],
225 enabled: !!slug,
226 retry: false,
227 queryFn: async (): Promise<FormDto> => {
228
229
230
231 const res = await apiFetch<{
232 form: { name: string; slug: string; description?: string | null; status: string; config: FormConfig };
233 questions: FormQuestion[];
234 }>(`/public/forms/${encodeURIComponent(slug as string)}`, { noAuth: true });
235 return {
236 id: `public:${res.form.slug}`,
237 name: res.form.name,
238 slug: res.form.slug,
239 description: res.form.description,
240 status: normalizeStatus(res.form.status),
241 config: res.form.config ?? {},
242 questions: res.questions ?? []
243 };
244 }
245 });
246}
247
248export function useListForms(databaseId: string | undefined) {
249 return useQuery({
250 queryKey: ["forms", databaseId ?? "none"],
251 enabled: !!databaseId,
252 queryFn: async (): Promise<{ id: string; databaseId: string; name: string; slug: string; status: FormStatus; questionCount: number; submissionCount: number }[]> => {
253 const body = await apiFetch<{ data: { id: string; databaseId: string; name: string; slug: string; status: string; questionCount: number; submissionCount: number }[] }>(
254 `/databases/${databaseId}/forms`
255 );
256 return (body.data ?? []).map((f) => ({ ...f, status: normalizeStatus(f.status) }));
257 }
258 });
259}
260
261
262
263
264
265export function useCreateForm(databaseId: string | undefined) {
266 const qc = useQueryClient();
267 return useMutation({
268 mutationFn: async (payload: {
269 name: string;
270 slug?: string;
271 description?: string;
272 config?: FormConfig;
273 questions?: FormQuestionInput[];
274 }): Promise<FormDto> => {
275 const f = await apiFetch<{
276 id: string;
277 databaseId: string;
278 name: string;
279 slug: string;
280 description?: string | null;
281 status: FormStatus;
282 config: FormConfig;
283 database?: { id: string; title: string };
284 questions: FormQuestion[];
285 }>(`/databases/${databaseId}/forms`, { method: "POST", body: JSON.stringify(payload) });
286 return {
287 id: f.id,
288 name: f.name,
289 slug: f.slug,
290 description: f.description,
291 status: normalizeStatus(f.status),
292 config: f.config ?? {},
293 databaseId: f.databaseId,
294 databaseName: f.database?.title,
295 questions: f.questions ?? []
296 };
297 },
298 onSuccess: (created) => {
299 qc.setQueryData(formKey(created.id), created);
300 qc.invalidateQueries({ queryKey: ["forms"] });
301 }
302 });
303}
304
305export function useUpdateForm(databaseId: string | undefined) {
306 const qc = useQueryClient();
307 return useMutation({
308 mutationFn: async (payload: {
309 formId: string;
310 name?: string;
311 description?: string | null;
312 status?: FormStatus;
313 slug?: string;
314 config?: FormConfig;
315 questions?: FormQuestionInput[];
316 }): Promise<FormDto> => {
317 const { formId, ...body } = payload;
318 const f = await apiFetch<{
319 id: string;
320 databaseId: string;
321 name: string;
322 slug: string;
323 description?: string | null;
324 status: FormStatus;
325 config: FormConfig;
326 database?: { id: string; title: string };
327 questions: FormQuestion[];
328 }>(`/forms/${formId}`, { method: "PATCH", body: JSON.stringify(body) });
329 return {
330 id: f.id,
331 name: f.name,
332 slug: f.slug,
333 description: f.description,
334 status: normalizeStatus(f.status),
335 config: f.config ?? {},
336 databaseId: f.databaseId,
337 databaseName: f.database?.title,
338 questions: f.questions ?? []
339 };
340 },
341 onSuccess: (updated) => {
342 qc.setQueryData(formKey(updated.id), updated);
343 qc.invalidateQueries({ queryKey: ["forms"] });
344 if (databaseId) qc.invalidateQueries({ queryKey: ["forms", databaseId] });
345 }
346 });
347}
348
349export function useDeleteForm(databaseId: string | undefined) {
350 const qc = useQueryClient();
351 return useMutation({
352 mutationFn: async (formId: string): Promise<void> => {
353 await apiFetch(`/forms/${formId}`, { method: "DELETE" });
354 },
355 onSuccess: (_d, formId) => {
356 qc.removeQueries({ queryKey: formKey(formId) });
357 qc.invalidateQueries({ queryKey: ["forms"] });
358 if (databaseId) qc.invalidateQueries({ queryKey: ["forms", databaseId] });
359 }
360 });
361}
362
363
364
365
366
367
368export function useSubmitForm(slug: string) {
369 return useMutation({
370 mutationFn: async (answers: Record<string, unknown>): Promise<{ success: boolean; submissionId?: string; message?: string | null }> =>
371 unwrap(
372 await apiFetch<{ success: boolean; submissionId?: string; message?: string | null }>(
373 `/public/forms/${encodeURIComponent(slug)}/submit`,
374 {
375 method: "POST",
376 body: JSON.stringify({ answers }),
377 noAuth: true
378 }
379 )
380 ),
381 });
382}
383
384
385
386
387
388export function isAnswerEmpty(value: unknown): boolean {
389 if (value == null) return true;
390 if (typeof value === "string") return value.trim() === "";
391 if (Array.isArray(value)) return value.length === 0;
392 return false;
393}
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409export interface SubmissionDto {
410 id: string;
411 formId: string;
412 recordId: string | null;
413
414 answers: Record<string, unknown>;
415
416 questionLabels: Record<string, { label: string; type: string }>;
417 submittedBy: { id: string; name: string; avatarUrl: string | null; email: string | null } | null;
418
419 ip: string;
420 ipMasked: boolean;
421 createdAt: string;
422}
423
424export interface SubmissionsFilter {
425
426 q?: string;
427
428 from?: string;
429
430 to?: string;
431
432 anonymousOnly?: boolean;
433 limit?: number;
434 cursor?: string;
435}
436
437function submissionsPath(formId: string, f?: SubmissionsFilter): string {
438 const params = new URLSearchParams();
439 if (f?.q) params.set("q", f.q);
440 if (f?.from) params.set("from", f.from);
441 if (f?.to) params.set("to", f.to);
442 if (f?.anonymousOnly) params.set("anonymousOnly", "true");
443 if (f?.limit) params.set("limit", String(f.limit));
444 if (f?.cursor) params.set("cursor", f.cursor);
445 const qs = params.toString();
446 return `/forms/${formId}/submissions${qs ? `?${qs}` : ""}`;
447}
448
449export function useSubmissions(formId: string | undefined, filter?: SubmissionsFilter) {
450 return useQuery({
451 queryKey: ["submissions", formId, filter?.q, filter?.from, filter?.to, filter?.anonymousOnly, filter?.cursor],
452 enabled: !!formId,
453 queryFn: async (): Promise<{ items: SubmissionDto[]; nextCursor: string | null }> =>
454 apiFetch<{ items: SubmissionDto[]; nextCursor: string | null }>(submissionsPath(formId as string, filter)),
455 });
456}
457
458export function useDeleteSubmission(formId: string | undefined) {
459 const qc = useQueryClient();
460 return useMutation({
461 mutationFn: async (submissionId: string): Promise<{ deleted: boolean; recordDeleted: boolean; filesPurged: number }> =>
462 apiFetch(`/forms/${formId}/submissions/${submissionId}?confirm=true`, { method: "DELETE" }),
463 onSuccess: () => {
464 qc.invalidateQueries({ queryKey: ["submissions", formId] });
465
466 if (formId) qc.invalidateQueries({ queryKey: ["database"] });
467 }
468 });
469}
470