From bf215bb9cf72f646193765892106504e8c384b29 Mon Sep 17 00:00:00 2001 From: Gunshiz Date: Sat, 30 May 2026 00:50:36 +0700 Subject: [PATCH] feat: add react-hook-form and zod for form management and validation --- app/form/client.tsx | 554 ++++++++++++++++++++---------------- bun.lock | 185 +++++++++++- components/login-dialog.tsx | 6 +- components/ui/form.tsx | 177 ++++++++++++ package.json | 7 +- 5 files changed, 667 insertions(+), 262 deletions(-) create mode 100644 components/ui/form.tsx diff --git a/app/form/client.tsx b/app/form/client.tsx index 05d4cf6..ff6ef38 100644 --- a/app/form/client.tsx +++ b/app/form/client.tsx @@ -1,8 +1,8 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useMemo } from "react"; import { useRouter } from "next/navigation"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; @@ -13,9 +13,20 @@ import { Dialog, DialogContent, DialogTrigger, DialogTitle } from "@/components/ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; import { toast } from "sonner"; import { submitForm, deleteOwnSubmission } from "@/app/actions/submissions"; -import { Loader2, LogOut } from "lucide-react"; +import { Loader2, LogOut, ChevronRight, ChevronLeft } from "lucide-react"; import { signOut } from "next-auth/react"; import Image from "next/image"; +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; export function FormLayoutClient({ userName, @@ -70,140 +81,129 @@ interface FormFillerClientProps { } const LS_KEY = (formId: string) => `form-draft-${formId}`; +const QUESTIONS_PER_STEP = 3; + +const buildSchema = (questions: Question[]) => { + const shape: Record = {}; + questions.forEach(q => { + if (q.type === "checkbox") { + shape[q.id] = q.required + ? z.array(z.string()).min(1, `จำเป็นต้องตอบ "${q.label}"`) + : z.array(z.string()).optional(); + } else { + shape[q.id] = q.required + ? z.string().min(1, `จำเป็นต้องตอบ "${q.label}"`) + : z.string().optional(); + } + }); + return z.object(shape); +}; export function FormFillerClient({ form, questions, initialAnswers = {} }: FormFillerClientProps) { const router = useRouter(); + const isUpdating = Object.keys(initialAnswers).length > 0; - // Merge: server answers take precedence over localStorage draft - const getInitialState = () => { + const formSchema = useMemo(() => buildSchema(questions), [questions]); + + const defaultValues = useMemo(() => { const serverAnswers = { ...initialAnswers }; - let draft: Record = {}; + let draft: Record = {}; try { const raw = localStorage.getItem(LS_KEY(form.id)); if (raw) draft = JSON.parse(raw); } catch {} - // Merge draft under server values - return { ...draft, ...serverAnswers }; - }; - - const merged = getInitialState(); - - // Pre-process checkbox answers - const initialCheckboxes: Record = {}; - for (const q of questions) { - if (q.type === "checkbox" && merged[q.id]) { - initialCheckboxes[q.id] = merged[q.id].split(", "); - } - } - - const [answers, setAnswers] = useState>(merged); - const [checkboxAnswers, setCheckboxAnswers] = useState>(initialCheckboxes); - const [submitting, setSubmitting] = useState(false); - - const isUpdating = Object.keys(initialAnswers).length > 0; - - // Persist to localStorage whenever answers change - useEffect(() => { - try { - const combined: Record = { ...answers }; - for (const [qId, opts] of Object.entries(checkboxAnswers)) { - combined[qId] = opts.join(", "); + + const merged = { ...draft, ...serverAnswers }; + + const vals: Record = {}; + questions.forEach(q => { + if (q.type === "checkbox") { + vals[q.id] = merged[q.id] ? (Array.isArray(merged[q.id]) ? merged[q.id] : merged[q.id].split(", ").filter(Boolean)) : []; + } else { + vals[q.id] = merged[q.id] || ""; } - localStorage.setItem(LS_KEY(form.id), JSON.stringify(combined)); - } catch {} - }, [answers, checkboxAnswers, form.id]); - - const handleTextChange = (questionId: string, value: string) => { - setAnswers((prev) => ({ ...prev, [questionId]: value })); - }; - - const handleRadioChange = (questionId: string, value: string) => { - setAnswers((prev) => ({ ...prev, [questionId]: value })); - }; - - const handleCheckboxChange = (questionId: string, option: string, checked: boolean) => { - setCheckboxAnswers((prev) => { - const current = prev[questionId] || []; - const updated = checked - ? [...current, option] - : current.filter((item) => item !== option); - return { ...prev, [questionId]: updated }; }); + return vals; + }, [form.id, initialAnswers, questions]); + + const methods = useForm>({ + resolver: zodResolver(formSchema), + defaultValues, + mode: "onTouched", + }); + + const { handleSubmit, control, watch, formState: { isSubmitting }, trigger, reset } = methods; + + useEffect(() => { + const subscription = watch((value) => { + try { + localStorage.setItem(LS_KEY(form.id), JSON.stringify(value)); + } catch {} + }); + return () => subscription.unsubscribe(); + }, [watch, form.id]); + + const [currentStep, setCurrentStep] = useState(0); + const totalSteps = Math.ceil(questions.length / QUESTIONS_PER_STEP); + + const currentQuestions = questions.slice( + currentStep * QUESTIONS_PER_STEP, + (currentStep + 1) * QUESTIONS_PER_STEP + ); + + const nextStep = async () => { + const fieldsToValidate = currentQuestions.map(q => q.id); + const isStepValid = await trigger(fieldsToValidate); + if (isStepValid) { + setCurrentStep(prev => Math.min(prev + 1, totalSteps - 1)); + window.scrollTo(0, 0); + } }; - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); + const prevStep = () => { + setCurrentStep(prev => Math.max(prev - 1, 0)); + window.scrollTo(0, 0); + }; + const onSubmit = async (values: z.infer) => { if (!form.isOpen) return; - // Validate required questions - for (const q of questions) { - if (q.required) { - if (q.type === "checkbox") { - const selected = checkboxAnswers[q.id] || []; - if (selected.length === 0) { - toast.error(`จำเป็นต้องตอบ "${q.label}"`); - return; - } - } else { - const ans = answers[q.id] || ""; - if (!ans.trim()) { - toast.error(`จำเป็นต้องตอบ "${q.label}"`); - return; - } - } - } - } - - setSubmitting(true); try { const formattedAnswers = questions.map((q) => { - let value = ""; - if (q.type === "checkbox") { - value = (checkboxAnswers[q.id] || []).join(", "); - } else { - value = answers[q.id] || ""; - } + const val = values[q.id] as string | string[] | undefined; return { questionId: q.id, - value, + value: Array.isArray(val) ? val.join(", ") : (val || ""), }; }); const res = await submitForm(form.id, formattedAnswers); if (res.ok) { toast.success(isUpdating ? "อัปเดตฟอร์มสำเร็จ!" : "ส่งฟอร์มสำเร็จ!"); - // Fields are kept as-is; localStorage already reflects current state } } catch (err: any) { console.error(err); toast.error(err.message || "ไม่สามารถส่งฟอร์มได้"); - } finally { - setSubmitting(false); } }; const handleDelete = async () => { - setSubmitting(true); try { const res = await deleteOwnSubmission(form.id); if (res.ok) { toast.success("ลบข้อมูลสำเร็จ!"); localStorage.removeItem(LS_KEY(form.id)); - setAnswers({}); - setCheckboxAnswers({}); + reset({}); router.refresh(); } } catch (err: any) { console.error(err); toast.error(err.message || "ไม่สามารถลบข้อมูลได้"); - } finally { - setSubmitting(false); } }; return ( -
+
{!form.isOpen && (
@@ -215,168 +215,222 @@ export function FormFillerClient({ form, questions, initialAnswers = {} }: FormF
)} -
- - - {form.title} - {form.description && ( - - {form.description} - - )} - - - -
- {questions.map((q) => ( - - -
- - {q.imageUrl && ( - - -
- {q.label} -
-
- - Enlarged image for {q.label} - {q.label} - -
- )} -
-
- - {q.type === "text" && ( - handleTextChange(q.id, e.target.value)} - className="w-full" - disabled={!form.isOpen} - /> - )} - - {q.type === "textarea" && ( -