diff --git a/app/actions/submissions.ts b/app/actions/submissions.ts index 54393da..fb53cc9 100644 --- a/app/actions/submissions.ts +++ b/app/actions/submissions.ts @@ -8,10 +8,17 @@ import { getServerSession } from "next-auth"; import { authOptions } from "@/app/api/auth/[...nextauth]/route"; import { requireAdmin, requireDiscordId, requireFormAccess } from "@/lib/auth"; import { sse } from "@/lib/sse"; +import { + decodeCheckboxAnswer, + encodeAnswerValue, + formatStoredAnswer, + type FormAnswerValue, +} from "@/lib/form-answer-values"; +import { stripHtml } from "@/lib/utils"; export async function submitForm( formId: string, - answersList: { questionId: string; value: string }[] + answersList: { questionId: string; value: FormAnswerValue }[] ) { const session = await getServerSession(authOptions); if (!session?.user) throw new Error("Not authenticated"); @@ -21,6 +28,96 @@ export async function submitForm( const form = await requireFormAccess(formId, discordId); if (!form.isOpen) throw new Error("This form is disabled for now"); + const questionsList = await db.query.questions.findMany({ + where: (question, { eq }) => eq(question.formId, formId), + orderBy: (question, { asc }) => asc(question.displayOrder), + }); + if (questionsList.length === 0) { + throw new Error("แบบฟอร์มนี้ยังไม่มีคำถาม"); + } + + const questionsById = new Map(questionsList.map((question) => [question.id, question])); + const submittedQuestionIds = new Set(); + for (const answer of answersList) { + if (!questionsById.has(answer.questionId) || submittedQuestionIds.has(answer.questionId)) { + throw new Error("ข้อมูลคำตอบไม่ตรงกับแบบฟอร์ม"); + } + submittedQuestionIds.add(answer.questionId); + } + + const submittedAnswers = new Map( + answersList.map((answer) => [answer.questionId, answer.value]) + ); + const normalizedAnswers = questionsList.map((question) => { + const rawValue = submittedAnswers.get(question.id); + + if (question.type === "checkbox") { + if ( + rawValue !== undefined && + typeof rawValue !== "string" && + (!Array.isArray(rawValue) || + rawValue.some((value) => typeof value !== "string")) + ) { + throw new Error(`รูปแบบคำตอบของ “${stripHtml(question.label)}” ไม่ถูกต้อง`); + } + + const selectedValues = [...new Set(decodeCheckboxAnswer(rawValue))]; + if ( + selectedValues.some( + (value) => !value.trim() || value === "__OTHER__" + ) + ) { + throw new Error(`โปรดระบุคำตอบสำหรับ “${stripHtml(question.label)}”`); + } + if (question.required && selectedValues.length === 0) { + throw new Error(`จำเป็นต้องตอบ “${stripHtml(question.label)}”`); + } + + const standardOptions = question.options ?? []; + const customValues = selectedValues.filter( + (value) => !standardOptions.includes(value) + ); + if ( + (!question.allowOther && customValues.length > 0) || + customValues.length > 1 + ) { + throw new Error(`ตัวเลือกของ “${stripHtml(question.label)}” ไม่ถูกต้อง`); + } + + return { + questionId: question.id, + value: encodeAnswerValue(selectedValues), + displayValue: selectedValues.join(", "), + }; + } + + if (rawValue !== undefined && typeof rawValue !== "string") { + throw new Error(`รูปแบบคำตอบของ “${stripHtml(question.label)}” ไม่ถูกต้อง`); + } + + const value = rawValue ?? ""; + if (value === "__OTHER__") { + throw new Error(`โปรดระบุคำตอบสำหรับ “${stripHtml(question.label)}”`); + } + if (question.required && !value.trim()) { + throw new Error(`จำเป็นต้องตอบ “${stripHtml(question.label)}”`); + } + if ( + question.type === "radio" && + value && + !(question.options ?? []).includes(value) && + !question.allowOther + ) { + throw new Error(`ตัวเลือกของ “${stripHtml(question.label)}” ไม่ถูกต้อง`); + } + + return { + questionId: question.id, + value, + displayValue: value, + }; + }); + const result = await db.transaction(async (tx) => { const existingSubmission = await tx.query.submissions.findFirst({ where: (submission, { eq, and }) => @@ -69,9 +166,9 @@ export async function submitForm( submissionId = inserted.id; } - if (answersList.length > 0) { + if (normalizedAnswers.length > 0) { await tx.insert(answers).values( - answersList.map((answer) => ({ + normalizedAnswers.map((answer) => ({ submissionId, questionId: answer.questionId, value: answer.value, @@ -89,19 +186,15 @@ export async function submitForm( if (form.discordWebhookUrl && templateToUse) { try { - const questionsList = await db.query.questions.findMany({ - where: (q, { eq }) => eq(q.formId, formId), - orderBy: (q, { asc }) => asc(q.displayOrder) - }); - const questionMap = questionsList.reduce((acc, q, index) => { + const questionOrderMap = questionsList.reduce((acc, q, index) => { acc[q.id] = String(index + 1); return acc; }, {} as Record); - const answersDict = answersList.reduce((acc, a) => { - const idx = questionMap[a.questionId]; + const answersDict = normalizedAnswers.reduce((acc, answer) => { + const idx = questionOrderMap[answer.questionId]; if (idx) { - acc[idx] = a.value; + acc[idx] = answer.displayValue; } return acc; }, {} as Record); @@ -109,11 +202,17 @@ export async function submitForm( let updatesText = ""; if (result.isUpdate) { const changed: string[] = []; - for (const ans of answersList) { - const oldVal = result.oldAnswersDict[ans.questionId]; - if (oldVal !== ans.value) { - const qTitle = questionsList.find(q => q.id === ans.questionId)?.label || `Question`; - changed.push(`**${qTitle}**: \`${oldVal || "Empty"}\` ➡️ \`${ans.value || "Empty"}\``); + for (const answer of normalizedAnswers) { + const question = questionsById.get(answer.questionId); + const oldValue = result.oldAnswersDict[answer.questionId]; + const oldDisplayValue = question + ? formatStoredAnswer(oldValue, question.type) + : oldValue; + if (oldDisplayValue !== answer.displayValue) { + const questionTitle = stripHtml(question?.label || "Question"); + changed.push( + `**${questionTitle}**: \`${oldDisplayValue || "Empty"}\` ➡️ \`${answer.displayValue || "Empty"}\`` + ); } } updatesText = changed.length > 0 ? changed.join("\n") : "No changes made."; diff --git a/app/admin/form/[id]/result/[submissionId]/client.tsx b/app/admin/form/[id]/result/[submissionId]/client.tsx index 32c5b70..c2349a6 100644 --- a/app/admin/form/[id]/result/[submissionId]/client.tsx +++ b/app/admin/form/[id]/result/[submissionId]/client.tsx @@ -17,6 +17,7 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import Image from 'next/image'; import { stripHtml } from "@/lib/utils"; import { HtmlDisplay } from "@/components/html-display"; +import { formatStoredAnswer } from "@/lib/form-answer-values"; interface Question { id: string; @@ -94,15 +95,16 @@ export default function SubmissionDetailClient({ {/* Answers List */}
{questions.map((q) => { - const ansValue = answers[q.id] || ""; + const rawAnswerValue = answers[q.id] || ""; + const answerValue = formatStoredAnswer(rawAnswerValue, q.type); const wasEdited = editHistory.some( - (edit) => (edit.oldAnswers[q.id] ?? "") !== ansValue + (edit) => (edit.oldAnswers[q.id] ?? "") !== rawAnswerValue ); const qAnswerHistory = editHistory.map((edit) => ({ editedAt: edit.editedAt, - answer: edit.oldAnswers[q.id] ?? "", + answer: formatStoredAnswer(edit.oldAnswers[q.id], q.type), })); return ( @@ -135,7 +137,7 @@ export default function SubmissionDetailClient({ )} - {ansValue ? ( + {answerValue ? (
{wasEdited && ( @@ -144,14 +146,14 @@ export default function SubmissionDetailClient({ )} - {ansValue} + {answerValue}
); } @@ -78,11 +102,17 @@ interface FormFillerClientProps { isOpen: boolean; }; questions: Question[]; - initialAnswers?: Record; + initialAnswers?: Record; + hasSubmission?: boolean; } const LS_KEY = (formId: string) => `form-draft-${formId}`; type FormValues = Record; +type StoredDraft = { + version: 1; + answers: FormValues; + otherValues: Record; +}; const buildSchema = (questions: Question[]) => { const shape: Record = {}; @@ -93,113 +123,222 @@ const buildSchema = (questions: Question[]) => { : z.array(z.string()).optional(); } else { shape[q.id] = q.required - ? z.string().min(1, `จำเป็นต้องตอบข้อนี้`) + ? z.string().trim().min(1, `จำเป็นต้องตอบข้อนี้`) : z.string().optional(); } }); return z.object(shape); }; -export function FormFillerClient({ form, questions, initialAnswers = {} }: FormFillerClientProps) { +function prepareInitialState( + questions: Question[], + answers: Record, + savedOtherValues: Record = {} +) { + const values: FormValues = {}; + const otherValues = { ...savedOtherValues }; + + questions.forEach((question) => { + const answer = answers[question.id]; + + 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 }; +} + +function parseStoredDraft(raw: string): { + answers: FormValues; + otherValues: 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 ?? {}, + }; + } + + return { + answers: (parsed as FormValues) ?? {}, + otherValues: {}, + }; +} + +export function FormFillerClient({ + form, + questions, + initialAnswers = {}, + hasSubmission = false, +}: FormFillerClientProps) { const router = useRouter(); - const isUpdating = Object.keys(initialAnswers).length > 0; + const isUpdating = hasSubmission; const formSchema = useMemo(() => buildSchema(questions), [questions]); + const serverInitialState = useMemo( + () => prepareInitialState(questions, initialAnswers), + [initialAnswers, questions] + ); - const { defaultValues, initialOtherValues } = useMemo(() => { - const serverAnswers = { ...initialAnswers }; - let draft: FormValues = {}; - try { - const raw = localStorage.getItem(LS_KEY(form.id)); - if (raw) draft = JSON.parse(raw) as FormValues; - } catch {} - - const merged = { ...draft, ...serverAnswers }; - - const vals: FormValues = {}; - const others: Record = {}; - - questions.forEach(q => { - const ans = merged[q.id]; - if (q.type === "checkbox") { - let arr = ans ? (Array.isArray(ans) ? ans : ans.split(", ").filter(Boolean)) : []; - if (q.allowOther && q.options) { - const standardOptions = q.options; - const customAnswers = arr.filter((x: string) => !standardOptions.includes(x) && x !== "__OTHER__"); - if (customAnswers.length > 0) { - others[q.id] = customAnswers.join(", "); - } - // If the draft had __OTHER__ or if there's a custom answer from server, ensure __OTHER__ is selected - if (arr.includes("__OTHER__") || customAnswers.length > 0) { - arr = arr.filter((x: string) => standardOptions.includes(x)); - arr.push("__OTHER__"); - } - } - vals[q.id] = arr; - } else if (q.type === "radio") { - let val = typeof ans === "string" ? ans : ""; - if (q.allowOther && q.options && val !== "" && !q.options.includes(val)) { - if (val !== "__OTHER__") { - others[q.id] = val; - } - val = "__OTHER__"; - } - vals[q.id] = val; - } else { - vals[q.id] = typeof ans === "string" ? ans : ""; - } - }); - return { defaultValues: vals, initialOtherValues: others }; - }, [form.id, initialAnswers, questions]); - - const [otherValues, setOtherValues] = useState>(initialOtherValues); + const [otherValues, setOtherValues] = useState>( + serverInitialState.otherValues + ); + const [draftReady, setDraftReady] = useState(false); + const [draftSaved, setDraftSaved] = useState(false); const [showSuccessDialog, setShowSuccessDialog] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); const methods = useForm>({ resolver: zodResolver(formSchema), - defaultValues, + defaultValues: serverInitialState.values, mode: "onTouched", }); - const { handleSubmit, control, formState: { isSubmitting }, reset } = methods; + const { + clearErrors, + getValues, + handleSubmit, + control, + formState: { isSubmitting }, + reset, + setError, + } = methods; const watchedValues = useWatch({ control }); useEffect(() => { + 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, + draft.answers, + draft.otherValues + ); + reset(restored.values); + setOtherValues(restored.otherValues); + setDraftSaved(true); + } + } catch { + localStorage.removeItem(LS_KEY(form.id)); + } finally { + setDraftReady(true); + } + }); + + return () => cancelAnimationFrame(frame); + }, [form.id, isUpdating, questions, reset]); + + useEffect(() => { + if (!draftReady || !form.isOpen) { + return; + } + try { - localStorage.setItem(LS_KEY(form.id), JSON.stringify(watchedValues)); - } catch {} - }, [form.id, watchedValues]); - - + const draft: StoredDraft = { + version: 1, + answers: getValues() as FormValues, + otherValues, + }; + localStorage.setItem(LS_KEY(form.id), JSON.stringify(draft)); + queueMicrotask(() => setDraftSaved(true)); + } catch { + queueMicrotask(() => setDraftSaved(false)); + } + }, [draftReady, form.id, form.isOpen, getValues, otherValues, watchedValues]); const onSubmit = async (values: z.infer) => { if (!form.isOpen) 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; + } + } + try { const formattedAnswers = questions.map((q) => { - let val = values[q.id] as string | string[] | undefined; + let val = values[q.id] as FormAnswerValue | undefined; if (q.allowOther) { - const customText = otherValues[q.id] || ""; + const customText = otherValues[q.id]?.trim() || ""; if (Array.isArray(val)) { val = val.map(v => v === "__OTHER__" ? customText : v); } else if (val === "__OTHER__") { val = customText; } } - const finalString = Array.isArray(val) ? val.join(", ") : (val || ""); - if (q.required && !finalString.trim()) { - throw new Error(`จำเป็นต้องตอบ "${stripHtml(q.label)}" (สำหรับตัวเลือก 'อื่นๆ')`); - } return { questionId: q.id, - value: finalString, + value: val ?? (q.type === "checkbox" ? [] : ""), }; }); const res = await submitForm(form.id, formattedAnswers); if (res.ok) { if (isUpdating) { - toast.success("อัปเดตฟอร์มสำเร็จ!"); + toast.success("บันทึกการแก้ไขแล้ว"); } else { setShowSuccessDialog(true); router.refresh(); @@ -212,288 +351,497 @@ export function FormFillerClient({ form, questions, initialAnswers = {} }: FormF }; const handleDelete = async () => { + setIsDeleting(true); try { const res = await deleteOwnSubmission(form.id); if (res.ok) { - toast.success("ลบข้อมูลสำเร็จ!"); + toast.success("ลบคำตอบแล้ว"); localStorage.removeItem(LS_KEY(form.id)); reset({}); + setOtherValues({}); + setDraftSaved(false); + setDeleteDialogOpen(false); router.refresh(); } } catch (err: unknown) { console.error(err); toast.error(getErrorMessage(err, "ไม่สามารถลบข้อมูลได้")); + } finally { + setIsDeleting(false); } }; + if (!form.isOpen && !isUpdating) { + return ( + + + ปิดรับคำตอบแล้ว + + แบบฟอร์ม “{form.title}” ไม่รับคำตอบใหม่ในขณะนี้ + + + + ); + } + return ( -
- {!form.isOpen && ( -
- - - - ฟอร์มนี้ถูกปิดใช้งานชั่วคราว - - - -
- )} - - +
+ {form.title} - {form.description && ( -
- -
- )} + + + {form.isOpen + ? isUpdating + ? "กำลังแก้ไขคำตอบ" + : "คำตอบใหม่" + : "ปิดรับคำตอบแล้ว"} + + {questions.length} คำถาม +
+ {form.description && ( + + {stripHtml(form.description).length > 280 ? ( + + + ดูรายละเอียดและเงื่อนไขทั้งหมด + + + + + + ) : ( + + )} + + )} + + {questions.some((question) => question.required) && ( + + ช่องที่มีป้าย “จำเป็น” ต้องตอบให้ครบ + + )} + {!form.isOpen && ( + + คุณยังดูหรือลบคำตอบเดิมได้ แต่บันทึกการแก้ไขไม่ได้ + + )} +
- -
- {questions.map((q) => ( - - -
-
- -
-
- {q.imageUrl && ( - - -
- {q.label} -
-
- - Enlarged image for {q.label} -
- {q.label} -
-
-
- )} - {q.required && *} -
-
-
- + + {questions.length === 0 ? ( + + + ยังไม่มีคำถาม + + แบบฟอร์มนี้ยังไม่พร้อมรับคำตอบ + + + + ) : ( + + {questions.map((q, questionIndex) => { + const questionLabelId = `question-${q.id}`; + const questionErrorId = `question-error-${q.id}`; + const plainQuestionLabel = stripHtml(q.label); + + return ( ( - + render={({ field, fieldState }) => ( + + + + ข้อ {questionIndex + 1} จาก {questions.length} + +
+ + + +
+ {q.required && ( + จำเป็น + )} + {q.imageUrl && ( + + + + + + + รูปประกอบคำถาม {plainQuestionLabel} + +
+ {`รูปประกอบคำถาม +
+
+
+ )} +
+
+
+ + {q.type === "text" && ( - - - + + + + + + )} {q.type === "textarea" && ( - -