Files
erika/app/form/client.tsx
T
2026-07-28 00:02:34 +07:00

1110 lines
47 KiB
TypeScript

"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/errors";
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 (
<DropdownMenuAvatar
userName={userName}
userImage={userImage}
profile={profile || null}
/>
);
}
export function LoginButton() {
return (
<Button variant="outline" size="sm" onClick={() => signIn("discord", { callbackUrl: window.location.href })}>
เข้าสู่ระบบ
</Button>
);
}
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;
allowMultipleSubmissions: boolean;
};
questions: Question[];
initialAnswers?: Record<string, AnswerDraft>;
hasSubmission?: boolean;
selectedSubmissionId?: string | null;
ownSubmissions?: {
id: string;
submittedAt: string;
answers: Record<string, AnswerDraft>;
}[];
totalAnswers: number;
queueNumber: number | null;
preview?: boolean;
}
const LS_KEY = (formId: string) => `form-draft-${formId}`;
type FormValues = Record<string, string | string[] | undefined>;
type StoredDraft = {
version: 1;
answers: FormValues;
otherValues: Record<string, string>;
imageAnswers?: Record<string, string | null>;
};
const buildSchema = (questions: Question[]) => {
const shape: Record<string, z.ZodTypeAny> = {};
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<string, AnswerDraft | undefined>,
savedOtherValues: Record<string, string> = {}
) {
const values: FormValues = {};
const imageAnswers: Record<string, string | null> = {};
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<string, string>;
imageAnswers: Record<string, string | null>;
} {
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<string | null>(
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<Record<string, string>>(
serverInitialState.otherValues
);
const [imageAnswers, setImageAnswers] = useState<Record<string, string | null>>(
serverInitialState.imageAnswers
);
const [imageErrors, setImageErrors] = useState<Record<string, string | null>>({});
const imageInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
const [uploadingAnswerId, setUploadingAnswerId] = useState<string | null>(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 methods = useForm<z.infer<typeof formSchema>>({
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 || !form.isOpen) {
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, form.isOpen, getValues, imageAnswers, 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<string, string | null> = {};
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<HTMLInputElement>
) => {
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<typeof formSchema>) => {
if (preview) return;
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;
}
}
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 (!form.isOpen && !isUpdating) {
return (
<Card className="mx-auto max-w-md">
<CardHeader>
<CardTitle className="text-xl">ปิดรับคำตอบแล้ว</CardTitle>
<CardDescription>
แบบฟอร์ม “{form.title}” ไม่รับคำตอบใหม่ในขณะนี้
</CardDescription>
</CardHeader>
</Card>
);
}
return (
<div className="flex flex-col gap-6 pb-12">
<Card className="border-t-8 border-t-primary shadow-md">
<CardHeader>
<CardTitle className="text-3xl font-bold">{form.title}</CardTitle>
<CardDescription className="flex flex-wrap items-center gap-2">
<span>{questions.length} คำถาม</span>
<span>{totalAnswers} คำตอบทั้งหมด</span>
{queueNumber !== null && (
<Badge variant="outline">ลำดับของคุณ #{queueNumber}</Badge>
)}
</CardDescription>
</CardHeader>
{form.description && (
<CardContent>
{stripHtml(form.description).length > 280 ? (
<Accordion type="single" collapsible>
<AccordionItem value="rules">
<AccordionTrigger>ดูรายละเอียดและเงื่อนไขทั้งหมด</AccordionTrigger>
<AccordionContent>
<HtmlDisplay html={form.description} />
</AccordionContent>
</AccordionItem>
</Accordion>
) : (
<HtmlDisplay html={form.description} />
)}
</CardContent>
)}
<CardFooter className="flex-wrap gap-2 border-t">
{questions.some((question) => question.required) && (
<span className="text-sm text-muted-foreground">
ช่องที่มีป้าย “จำเป็น” ต้องตอบให้ครบ
</span>
)}
{!form.isOpen && (
<span className="text-sm text-muted-foreground">
คุณยังดูหรือลบคำตอบเดิมได้ แต่บันทึกการแก้ไขไม่ได้
</span>
)}
</CardFooter>
</Card>
{form.allowMultipleSubmissions && ownSubmissions.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">คำตอบของคุณ</CardTitle>
<CardDescription>เลือกคำตอบเดิมเพื่อแก้ไข หรือเริ่มส่งคำตอบใหม่</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
<Button
type="button"
variant={selectedSubmissionId ? "outline" : "default"}
onClick={() => switchToSubmission(null)}
>
คำตอบใหม่
</Button>
{ownSubmissions.map((submission, index) => (
<Button
key={submission.id}
type="button"
variant={selectedSubmissionId === submission.id ? "default" : "outline"}
onClick={() => switchToSubmission(submission.id)}
>
#{ownSubmissions.length - index} · {new Date(submission.submittedAt).toLocaleString("th-TH", {
timeZone: "Asia/Bangkok",
hour12: false,
})}
</Button>
))}
</CardContent>
</Card>
)}
<Form {...methods}>
<form onSubmit={handleSubmit(onSubmit, onInvalidSubmit)} className="flex flex-col gap-6">
{questions.length === 0 ? (
<Card>
<CardHeader>
<CardTitle>ยังไม่มีคำถาม</CardTitle>
<CardDescription>
แบบฟอร์มนี้ยังไม่พร้อมรับคำตอบ
</CardDescription>
</CardHeader>
</Card>
) : (
<FieldGroup className="gap-4">
{questions.map((q, questionIndex) => {
const questionLabelId = `question-${q.id}`;
const questionErrorId = `question-error-${q.id}`;
const plainQuestionLabel = stripHtml(q.label);
return (
<FormField
key={q.id}
control={control}
name={q.id}
render={({ field, fieldState }) => (
<Card className="shadow-sm">
<CardHeader>
<CardDescription>
ข้อ {questionIndex + 1} จาก {questions.length}
</CardDescription>
<div className="flex items-start justify-between gap-4">
<CardTitle
id={questionLabelId}
className="min-w-0 flex-1 text-base leading-normal"
>
<HtmlDisplay html={q.label} />
</CardTitle>
<div className="flex shrink-0 items-start gap-2">
{q.required && (
<Badge variant="destructive">จำเป็น</Badge>
)}
{q.imageUrl && (
<Dialog>
<DialogTrigger asChild>
<Button
type="button"
variant="outline"
className="relative size-24 overflow-hidden p-0"
aria-label={`ขยายรูปประกอบคำถาม ${plainQuestionLabel}`}
>
<Image
width={96}
height={96}
src={q.imageUrl}
alt=""
className="size-full object-contain"
/>
</Button>
</DialogTrigger>
<DialogContent className="flex h-[80vh] w-[90vw] max-w-4xl items-center justify-center border-0 bg-transparent p-0 shadow-none">
<DialogTitle className="sr-only">
รูปประกอบคำถาม {plainQuestionLabel}
</DialogTitle>
<div className="relative size-full">
<Image
src={q.imageUrl}
alt={`รูปประกอบคำถาม ${plainQuestionLabel}`}
fill
unoptimized
className="rounded-md object-contain"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 75vw, 60vw"
/>
</div>
</DialogContent>
</Dialog>
)}
</div>
</div>
</CardHeader>
<CardContent>
<FormItem>
{q.type === "text" && !isImageOnlyQuestion(q) && (
<Field data-invalid={fieldState.invalid || undefined}>
<FormControl
aria-describedby={
fieldState.invalid ? questionErrorId : undefined
}
>
<Input
placeholder="พิมพ์คำตอบ"
{...field}
value={(field.value as string) || ""}
aria-labelledby={questionLabelId}
aria-required={q.required}
disabled={!form.isOpen || isSubmitting}
/>
</FormControl>
<FormMessage id={questionErrorId} />
</Field>
)}
{q.type === "textarea" && !isImageOnlyQuestion(q) && (
<Field data-invalid={fieldState.invalid || undefined}>
<FormControl
aria-describedby={
fieldState.invalid ? questionErrorId : undefined
}
>
<Textarea
placeholder="พิมพ์คำตอบ"
{...field}
value={(field.value as string) || ""}
aria-labelledby={questionLabelId}
aria-required={q.required}
className="min-h-32 resize-y"
disabled={!form.isOpen || isSubmitting}
/>
</FormControl>
<FormMessage id={questionErrorId} />
</Field>
)}
{q.type === "radio" && q.options && (
<FieldSet data-invalid={fieldState.invalid || undefined}>
<FieldLegend className="sr-only">
{plainQuestionLabel}
</FieldLegend>
<FormControl
aria-describedby={
fieldState.invalid ? questionErrorId : undefined
}
>
<RadioGroup
onValueChange={field.onChange}
value={(field.value as string) || ""}
aria-labelledby={questionLabelId}
aria-required={q.required}
className="flex flex-col gap-3"
disabled={!form.isOpen || isSubmitting}
>
{q.options.map((option, optionIndex) => (
<Field
key={`${q.id}-${optionIndex}`}
orientation="horizontal"
>
<RadioGroupItem
value={option}
id={`${q.id}-${optionIndex}`}
/>
<FieldLabel
htmlFor={`${q.id}-${optionIndex}`}
className="cursor-pointer font-normal"
>
<FormattedText text={option} />
</FieldLabel>
</Field>
))}
{q.allowOther && (
<FieldGroup className="gap-2">
<Field orientation="horizontal">
<RadioGroupItem
value="__OTHER__"
id={`${q.id}-other`}
/>
<FieldLabel
htmlFor={`${q.id}-other`}
className="cursor-pointer font-normal"
>
อื่นๆ
</FieldLabel>
</Field>
{field.value === "__OTHER__" && (
<Input
value={otherValues[q.id] || ""}
onChange={(event) => {
setOtherValues((current) => ({
...current,
[q.id]: event.target.value,
}));
if (event.target.value.trim()) {
clearErrors(q.id);
}
}}
aria-label={`ระบุคำตอบอื่นสำหรับ ${plainQuestionLabel}`}
aria-invalid={fieldState.invalid}
aria-describedby={
fieldState.invalid
? questionErrorId
: undefined
}
className="w-full max-w-xs sm:ml-6"
placeholder="โปรดระบุ"
disabled={!form.isOpen || isSubmitting}
/>
)}
</FieldGroup>
)}
</RadioGroup>
</FormControl>
<FormMessage id={questionErrorId} />
</FieldSet>
)}
{q.type === "checkbox" && q.options && (
<FieldSet data-invalid={fieldState.invalid || undefined}>
<FieldLegend className="sr-only">
{plainQuestionLabel}
</FieldLegend>
<FormControl
aria-describedby={
fieldState.invalid ? questionErrorId : undefined
}
>
<FieldGroup
data-slot="checkbox-group"
role="group"
aria-labelledby={questionLabelId}
>
{q.options.map((option, optionIndex) => {
const isChecked =
Array.isArray(field.value) &&
field.value.includes(option);
return (
<Field
key={`${q.id}-${optionIndex}`}
orientation="horizontal"
>
<Checkbox
id={`${q.id}-${optionIndex}`}
checked={isChecked}
disabled={!form.isOpen || isSubmitting}
onCheckedChange={(checked) => {
const current = Array.isArray(field.value)
? field.value
: [];
field.onChange(
checked
? [...current, option]
: current.filter(
(item) => item !== option
)
);
}}
/>
<FieldLabel
htmlFor={`${q.id}-${optionIndex}`}
className="cursor-pointer font-normal"
>
<FormattedText text={option} />
</FieldLabel>
</Field>
);
})}
{q.allowOther && (
<FieldGroup className="gap-2">
<Field orientation="horizontal">
<Checkbox
id={`${q.id}-other`}
checked={
Array.isArray(field.value) &&
field.value.includes("__OTHER__")
}
disabled={!form.isOpen || isSubmitting}
onCheckedChange={(checked) => {
const current = Array.isArray(field.value)
? field.value
: [];
field.onChange(
checked
? [...current, "__OTHER__"]
: current.filter(
(item) => item !== "__OTHER__"
)
);
}}
/>
<FieldLabel
htmlFor={`${q.id}-other`}
className="cursor-pointer font-normal"
>
อื่นๆ
</FieldLabel>
</Field>
{Array.isArray(field.value) &&
field.value.includes("__OTHER__") && (
<Input
value={otherValues[q.id] || ""}
onChange={(event) => {
setOtherValues((current) => ({
...current,
[q.id]: event.target.value,
}));
if (event.target.value.trim()) {
clearErrors(q.id);
}
}}
aria-label={`ระบุคำตอบอื่นสำหรับ ${plainQuestionLabel}`}
aria-invalid={fieldState.invalid}
aria-describedby={
fieldState.invalid
? questionErrorId
: undefined
}
className="w-full max-w-xs sm:ml-6"
placeholder="โปรดระบุ"
disabled={!form.isOpen || isSubmitting}
/>
)}
</FieldGroup>
)}
</FieldGroup>
</FormControl>
<FormMessage id={questionErrorId} />
</FieldSet>
)}
{q.imageAnswerMode !== "none" && (
<Field data-invalid={Boolean(imageErrors[q.id]) || undefined}>
<FieldLabel htmlFor={`answer-image-${q.id}`}>
รูปภาพคำตอบ
{q.imageAnswerMode === "required" && (
<Badge variant="destructive">จำเป็น</Badge>
)}
{q.imageAnswerMode === "optional" && (
<Badge variant="outline">ไม่บังคับ</Badge>
)}
</FieldLabel>
<div className="flex flex-wrap items-center gap-3">
<Input
id={`answer-image-${q.id}`}
ref={(input) => {
imageInputRefs.current[q.id] = input;
}}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
disabled={!form.isOpen || isSubmitting || uploadingAnswerId !== null}
onChange={(event) => handleAnswerImageUpload(q.id, event)}
className="max-w-sm"
aria-invalid={Boolean(imageErrors[q.id])}
aria-describedby={
imageErrors[q.id]
? `answer-image-error-${q.id}`
: undefined
}
/>
{uploadingAnswerId === q.id && (
<Badge variant="secondary">
<Spinner data-icon="inline-start" />
กำลังอัปโหลด
</Badge>
)}
{imageAnswers[q.id] && (
<Dialog>
<DialogTrigger asChild>
<Button type="button" variant="outline">
ดูรูป
</Button>
</DialogTrigger>
<DialogContent className="max-w-3xl">
<DialogTitle>รูปภาพคำตอบ</DialogTitle>
<Image
src={`/cdn/${imageAnswers[q.id]}`}
alt=""
width={1200}
height={800}
unoptimized
className="max-h-[70vh] w-full rounded-md object-contain"
onError={(event) => {
event.currentTarget.style.display = "none";
}}
/>
</DialogContent>
</Dialog>
)}
{imageAnswers[q.id] && (
<Button
type="button"
variant="ghost"
onClick={() => clearAnswerImage(q.id)}
disabled={!form.isOpen || isSubmitting}
>
ลบรูป
</Button>
)}
</div>
{imageErrors[q.id] && (
<p
id={`answer-image-error-${q.id}`}
className="text-sm text-destructive"
>
{imageErrors[q.id]}
</p>
)}
</Field>
)}
</FormItem>
</CardContent>
</Card>
)}
/>
);
})}
</FieldGroup>
)}
<Card size="sm" className="sticky bottom-3 shadow-md">
<CardContent className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground" aria-live="polite">
{preview
? "ตัวอย่างสำหรับผู้ดูแลระบบ — ไม่มีการส่งคำตอบ"
: !form.isOpen
? "ปิดรับคำตอบแล้ว"
: draftSaved
? "บันทึกฉบับร่างไว้ในอุปกรณ์นี้แล้ว"
: "กำลังเตรียมฉบับร่างในอุปกรณ์นี้"}
</p>
{!preview && <div className="flex flex-col-reverse gap-2 sm:flex-row">
{isUpdating && (
<AlertDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="destructive"
disabled={isSubmitting || isDeleting}
>
ลบคำตอบ
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>ลบคำตอบของคุณ?</AlertDialogTitle>
<AlertDialogDescription>
คำตอบและประวัติการแก้ไขจะถูกลบถาวร และไม่สามารถย้อนกลับได้
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>
เก็บคำตอบไว้
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={(event) => {
event.preventDefault();
void handleDelete();
}}
disabled={isDeleting}
>
{isDeleting && <Spinner data-icon="inline-start" />}
{isDeleting ? "กำลังลบ" : "ลบคำตอบถาวร"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
{form.isOpen && (
<Button
type="submit"
disabled={isSubmitting || questions.length === 0}
className="min-w-32"
>
{isSubmitting && <Spinner data-icon="inline-start" />}
{isSubmitting
? isUpdating
? "กำลังบันทึก"
: "กำลังส่ง"
: isUpdating
? "บันทึกการแก้ไข"
: "ส่งคำตอบ"}
</Button>
)}
</div>}
</CardContent>
</Card>
</form>
</Form>
<AlertDialog open={showSuccessDialog} onOpenChange={setShowSuccessDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="text-center text-2xl font-bold">
ส่งคำตอบแล้ว
</AlertDialogTitle>
<AlertDialogDescription>
บันทึกคำตอบของคุณเรียบร้อย คุณอยู่ในคิวที่ #{queueNumber} จากทั้งหมด{" "}
{totalAnswers} คำตอบ และกลับมาแก้ไขได้ตราบใดที่แบบฟอร์มยังเปิดอยู่
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction
onClick={() => setShowSuccessDialog(false)}
className="w-full"
>
ดูคำตอบที่ส่ง
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}