"use client"; import React, { useRef, useState, useEffect, useMemo } from "react"; import { useRouter } from "next/navigation"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Checkbox } from "@/components/ui/checkbox"; import { stripHtml } from "@/lib/utils"; import { HtmlDisplay } from "@/components/html-display"; import { FormattedText } from "@/components/formatted-text"; import { Dialog, DialogContent, DialogTrigger, DialogTitle } from "@/components/ui/dialog"; 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 { signIn } from "next-auth/react"; import Image from "next/image"; import { z } from "zod"; import { useForm, useWatch } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { Form, FormControl, FormField, FormItem, FormMessage, } from "@/components/ui/form"; import { DropdownMenuAvatar, DiscordMemberProfile } from "@/components/dropdown-menu-avatar"; import { getErrorMessage } from "@/lib/utils"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; import { Badge } from "@/components/ui/badge"; import { Field, FieldGroup, FieldLabel, FieldLegend, FieldSet, } from "@/components/ui/field"; import { Spinner } from "@/components/ui/spinner"; import { decodeCheckboxAnswer, type FormAnswerValue, } from "@/lib/form-answer-values"; export function FormLayoutClient({ userName, userImage, profile, }: { userName: string; userImage: string; profile?: DiscordMemberProfile | null; }) { return ( ); } export function LoginButton() { return ( ); } interface Question { id: string; formId: string; type: "text" | "textarea" | "radio" | "checkbox"; label: string; imageUrl: string | null; displayOrder: number; required: boolean; options: string[] | null; allowOther: boolean; imageAnswerMode: "none" | "optional" | "required"; } type AnswerDraft = { value: FormAnswerValue; imageCdnId: string | null; }; interface FormFillerClientProps { form: { id: string; title: string; description: string | null; isOpen: boolean; closesAt: string | null; allowMultipleSubmissions: boolean; }; questions: Question[]; initialAnswers?: Record; hasSubmission?: boolean; selectedSubmissionId?: string | null; ownSubmissions?: { id: string; submittedAt: string; answers: Record; }[]; totalAnswers: number; queueNumber: number | null; preview?: boolean; } const LS_KEY = (formId: string) => `form-draft-${formId}`; type FormValues = Record; type StoredDraft = { version: 1; answers: FormValues; otherValues: Record; imageAnswers?: Record; }; 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, `จำเป็นต้องตอบข้อนี้`) : z.array(z.string()).optional(); } else { shape[q.id] = q.required ? z.string().trim().min(1, `จำเป็นต้องตอบข้อนี้`) : z.string().optional(); } }); return z.object(shape); }; function isImageOnlyQuestion(question: Question) { return ( question.imageAnswerMode === "required" && !question.required && (question.type === "text" || question.type === "textarea") ); } function prepareInitialState( questions: Question[], answers: Record, savedOtherValues: Record = {} ) { const values: FormValues = {}; const imageAnswers: Record = {}; const otherValues = { ...savedOtherValues }; questions.forEach((question) => { const answer = answers[question.id]?.value; imageAnswers[question.id] = answers[question.id]?.imageCdnId ?? null; if (question.type === "checkbox") { let selected = decodeCheckboxAnswer(answer); if (question.allowOther && question.options) { const customAnswers = selected.filter( (value) => !question.options?.includes(value) && value !== "__OTHER__" ); if (customAnswers.length > 0) { otherValues[question.id] = customAnswers.join(", "); } if (selected.includes("__OTHER__") || customAnswers.length > 0) { selected = selected.filter((value) => question.options?.includes(value)); selected.push("__OTHER__"); } } values[question.id] = selected; return; } if (question.type === "radio") { let selected = typeof answer === "string" ? answer : ""; if ( question.allowOther && question.options && selected && !question.options.includes(selected) ) { if (selected !== "__OTHER__") { otherValues[question.id] = selected; } selected = "__OTHER__"; } values[question.id] = selected; return; } values[question.id] = typeof answer === "string" ? answer : ""; }); return { values, otherValues, imageAnswers }; } function parseStoredDraft(raw: string): { answers: FormValues; otherValues: Record; imageAnswers: Record; } { const parsed: unknown = JSON.parse(raw); if ( parsed && typeof parsed === "object" && "version" in parsed && parsed.version === 1 && "answers" in parsed ) { const draft = parsed as StoredDraft; return { answers: draft.answers ?? {}, otherValues: draft.otherValues ?? {}, imageAnswers: draft.imageAnswers ?? {}, }; } return { answers: (parsed as FormValues) ?? {}, otherValues: {}, imageAnswers: {}, }; } export function FormFillerClient({ form, questions, initialAnswers = {}, hasSubmission = false, selectedSubmissionId: initialSelectedSubmissionId = null, ownSubmissions = [], totalAnswers: initialTotalAnswers, queueNumber: initialQueueNumber, preview = false, }: FormFillerClientProps) { const router = useRouter(); const [selectedSubmissionId, setSelectedSubmissionId] = useState( initialSelectedSubmissionId ); const isUpdating = Boolean(selectedSubmissionId) || (!form.allowMultipleSubmissions && hasSubmission); const formSchema = useMemo(() => buildSchema(questions), [questions]); const serverInitialState = useMemo( () => prepareInitialState(questions, initialAnswers), [initialAnswers, questions] ); const [otherValues, setOtherValues] = useState>( serverInitialState.otherValues ); const [imageAnswers, setImageAnswers] = useState>( serverInitialState.imageAnswers ); const [imageErrors, setImageErrors] = useState>({}); const imageInputRefs = useRef>({}); const [uploadingAnswerId, setUploadingAnswerId] = useState(null); const [draftReady, setDraftReady] = useState(preview); const [draftSaved, setDraftSaved] = useState(false); const [showSuccessDialog, setShowSuccessDialog] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const [totalAnswers, setTotalAnswers] = useState(initialTotalAnswers); const [queueNumber, setQueueNumber] = useState(initialQueueNumber); const closesAtTime = form.closesAt ? new Date(form.closesAt).getTime() : null; const [reachedDeadline, setReachedDeadline] = useState(null); const deadlineReached = closesAtTime !== null && reachedDeadline === closesAtTime; const isAcceptingSubmissions = preview || (form.isOpen && !deadlineReached); useEffect(() => { if (closesAtTime === null) return; let timeoutId: number | undefined; const checkDeadline = () => { const remaining = closesAtTime - Date.now(); if (remaining <= 0) { setReachedDeadline(closesAtTime); return; } timeoutId = window.setTimeout( checkDeadline, Math.min(remaining, 2_147_483_647) ); }; const remaining = closesAtTime - Date.now(); timeoutId = window.setTimeout( checkDeadline, Math.max(0, Math.min(remaining, 2_147_483_647)) ); return () => { if (timeoutId !== undefined) window.clearTimeout(timeoutId); }; }, [closesAtTime]); const methods = useForm>({ resolver: zodResolver(formSchema), defaultValues: serverInitialState.values, mode: "onTouched", }); const { clearErrors, getValues, handleSubmit, control, formState: { isSubmitting }, reset, setError, } = methods; const watchedValues = useWatch({ control }); useEffect(() => { if (preview) return; const frame = requestAnimationFrame(() => { if (isUpdating) { setDraftReady(true); return; } try { const raw = localStorage.getItem(LS_KEY(form.id)); if (raw) { const draft = parseStoredDraft(raw); const restored = prepareInitialState( questions, Object.fromEntries( Object.entries(draft.answers).map(([questionId, value]) => [ questionId, { value: value as FormAnswerValue, imageCdnId: draft.imageAnswers[questionId] ?? null }, ]) ), draft.otherValues ); reset(restored.values); setOtherValues(restored.otherValues); setImageAnswers(restored.imageAnswers); setDraftSaved(true); } } catch { localStorage.removeItem(LS_KEY(form.id)); } finally { setDraftReady(true); } }); return () => cancelAnimationFrame(frame); }, [form.id, isUpdating, preview, questions, reset]); useEffect(() => { if (preview || !draftReady || !isAcceptingSubmissions) { return; } try { const draft: StoredDraft = { version: 1, answers: getValues() as FormValues, otherValues, imageAnswers, }; localStorage.setItem(LS_KEY(form.id), JSON.stringify(draft)); queueMicrotask(() => setDraftSaved(true)); } catch { queueMicrotask(() => setDraftSaved(false)); } }, [draftReady, form.id, getValues, imageAnswers, isAcceptingSubmissions, otherValues, preview, watchedValues]); const switchToSubmission = (submissionId: string | null) => { const submission = submissionId ? ownSubmissions.find((item) => item.id === submissionId) : null; const nextState = prepareInitialState(questions, submission?.answers ?? {}); reset(nextState.values); setOtherValues(nextState.otherValues); setImageAnswers(nextState.imageAnswers); setImageErrors({}); Object.values(imageInputRefs.current).forEach((input) => { if (input) input.value = ""; }); setSelectedSubmissionId(submissionId); setDraftSaved(false); }; const clearAnswerImage = (questionId: string) => { setImageAnswers((current) => ({ ...current, [questionId]: null, })); setImageErrors((current) => ({ ...current, [questionId]: null, })); const input = imageInputRefs.current[questionId]; if (input) input.value = ""; }; const validateRequiredImages = () => { const nextErrors: Record = {}; let firstInvalidId: string | null = null; for (const question of questions) { if (question.imageAnswerMode === "required" && !imageAnswers[question.id]) { nextErrors[question.id] = "จำเป็นต้องอัปโหลดรูปสำหรับข้อนี้"; firstInvalidId ??= question.id; } else { nextErrors[question.id] = null; } } setImageErrors((current) => ({ ...current, ...nextErrors })); if (firstInvalidId) { document.getElementById(`answer-image-${firstInvalidId}`)?.focus(); return false; } return true; }; const handleAnswerImageUpload = async ( questionId: string, event: React.ChangeEvent ) => { const file = event.target.files?.[0]; if (!file) return; const formData = new FormData(); formData.append("file", file); setUploadingAnswerId(questionId); try { const response = await fetch("/api/upload", { method: "POST", body: formData, }); const data = await response.json(); if (!response.ok || !data.id) { throw new Error(data.error || "ไม่สามารถอัปโหลดรูปได้"); } setImageAnswers((current) => ({ ...current, [questionId]: data.id })); setImageErrors((current) => ({ ...current, [questionId]: null })); toast.success("อัปโหลดรูปแล้ว"); } catch (error) { toast.error(getErrorMessage(error, "ไม่สามารถอัปโหลดรูปได้")); } finally { setUploadingAnswerId(null); } }; const onSubmit = async (values: z.infer) => { if (preview) return; if (!isAcceptingSubmissions) return; for (const question of questions) { const value = values[question.id] as FormAnswerValue | undefined; const usesOther = Array.isArray(value) ? value.includes("__OTHER__") : value === "__OTHER__"; if (usesOther && !otherValues[question.id]?.trim()) { setError( question.id, { type: "required", message: "โปรดระบุคำตอบสำหรับตัวเลือก “อื่นๆ”", }, { shouldFocus: true } ); return; } } if (!validateRequiredImages()) { return; } try { const formattedAnswers = questions.map((q) => { let val = values[q.id] as FormAnswerValue | undefined; if (q.allowOther) { const customText = otherValues[q.id]?.trim() || ""; if (Array.isArray(val)) { val = val.map(v => v === "__OTHER__" ? customText : v); } else if (val === "__OTHER__") { val = customText; } } return { questionId: q.id, value: val ?? (q.type === "checkbox" ? [] : ""), imageCdnId: q.imageAnswerMode === "none" ? null : imageAnswers[q.id] ?? null, }; }); const res = await submitForm(form.id, formattedAnswers, selectedSubmissionId); if (res.ok) { setTotalAnswers(res.totalAnswers); setQueueNumber(res.queueNumber); if (isUpdating) { toast.success("บันทึกการแก้ไขแล้ว"); } else { setShowSuccessDialog(true); router.refresh(); } } } catch (err: unknown) { console.error(err); toast.error(getErrorMessage(err, "ไม่สามารถส่งฟอร์มได้")); } }; const onInvalidSubmit = () => { validateRequiredImages(); }; const handleDelete = async () => { setIsDeleting(true); try { const res = await deleteOwnSubmission(form.id, selectedSubmissionId); if (res.ok) { toast.success("ลบคำตอบแล้ว"); localStorage.removeItem(LS_KEY(form.id)); reset({}); setOtherValues({}); setImageAnswers({}); setSelectedSubmissionId(null); setDraftSaved(false); setDeleteDialogOpen(false); router.refresh(); } } catch (err: unknown) { console.error(err); toast.error(getErrorMessage(err, "ไม่สามารถลบข้อมูลได้")); } finally { setIsDeleting(false); } }; if (!isAcceptingSubmissions && !isUpdating) { return ( ปิดรับคำตอบแล้ว แบบฟอร์ม “{form.title}” ไม่รับคำตอบใหม่ในขณะนี้ ); } return (
{form.title} {questions.length} คำถาม {totalAnswers} คำตอบทั้งหมด {queueNumber !== null && ( ลำดับของคุณ #{queueNumber} )} {form.description && ( {stripHtml(form.description).length > 280 ? ( ดูรายละเอียดและเงื่อนไขทั้งหมด ) : ( )} )} {questions.some((question) => question.required) && ( ช่องที่มีป้าย “จำเป็น” ต้องตอบให้ครบ )} {!isAcceptingSubmissions && ( คุณยังดูหรือลบคำตอบเดิมได้ แต่บันทึกการแก้ไขไม่ได้ )} {form.allowMultipleSubmissions && ownSubmissions.length > 0 && ( คำตอบของคุณ เลือกคำตอบเดิมเพื่อแก้ไข หรือเริ่มส่งคำตอบใหม่ {ownSubmissions.map((submission, index) => ( ))} )}
{questions.length === 0 ? ( ยังไม่มีคำถาม แบบฟอร์มนี้ยังไม่พร้อมรับคำตอบ ) : ( {questions.map((q, questionIndex) => { const questionLabelId = `question-${q.id}`; const questionErrorId = `question-error-${q.id}`; const plainQuestionLabel = stripHtml(q.label); return ( ( ข้อ {questionIndex + 1} จาก {questions.length}
{q.required && ( จำเป็น )} {q.imageUrl && ( รูปประกอบคำถาม {plainQuestionLabel}
{`รูปประกอบคำถาม
)}
{q.type === "text" && !isImageOnlyQuestion(q) && ( )} {q.type === "textarea" && !isImageOnlyQuestion(q) && (