1024 lines
44 KiB
TypeScript
1024 lines
44 KiB
TypeScript
"use client";
|
|
|
|
import React, { useRef, useState, useEffect, useMemo } from "react";
|
|
import { usePathname, useRouter } from "next/navigation";
|
|
import Link from "next/link";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardFooter,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { ShieldCheck } from "lucide-react";
|
|
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,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} 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/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 {
|
|
type FormAnswerValue,
|
|
} from "@/lib/form/form-answer-values";
|
|
import { isImageOnlyResponse } from "@/lib/form/question-response-type";
|
|
import {
|
|
buildFormSchema,
|
|
formatQueuePosition,
|
|
parseStoredDraft,
|
|
prepareInitialState,
|
|
type FormFillerClientProps,
|
|
type FormValues,
|
|
type StoredDraft,
|
|
} from "@/lib/form/form-client";
|
|
|
|
export function FormLayoutClient({
|
|
userName,
|
|
userImage,
|
|
profile,
|
|
isAdmin,
|
|
}: {
|
|
userName: string;
|
|
userImage: string;
|
|
profile?: DiscordMemberProfile | null;
|
|
isAdmin: boolean;
|
|
}) {
|
|
const pathname = usePathname();
|
|
const formId = pathname.match(/^\/form\/([^/]+)/)?.[1];
|
|
const adminHref = formId ? `/admin/form/${formId}` : "/admin";
|
|
|
|
return (
|
|
<>
|
|
{isAdmin && (
|
|
<Button variant="outline" size="sm" asChild>
|
|
<Link href={adminHref}>
|
|
<ShieldCheck data-icon="inline-start" />
|
|
Go to admin
|
|
</Link>
|
|
</Button>
|
|
)}
|
|
<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>
|
|
);
|
|
}
|
|
|
|
const LS_KEY = (formId: string) => `form-draft-${formId}`;
|
|
|
|
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(() => buildFormSchema(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 closesAtTime = form.closesAt
|
|
? new Date(form.closesAt).getTime()
|
|
: null;
|
|
const [reachedDeadline, setReachedDeadline] = useState<number | null>(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<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 || !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<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);
|
|
const uploadToastId = toast.loading("กำลังอัปโหลดรูป");
|
|
|
|
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("อัปโหลดรูปแล้ว", { id: uploadToastId });
|
|
} catch (error) {
|
|
toast.error(getErrorMessage(error, "ไม่สามารถอัปโหลดรูปได้"), {
|
|
id: uploadToastId,
|
|
});
|
|
} finally {
|
|
setUploadingAnswerId(null);
|
|
}
|
|
};
|
|
|
|
const onSubmit = async (values: z.infer<typeof formSchema>) => {
|
|
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 (
|
|
<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">
|
|
ลำดับของคุณ {formatQueuePosition(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 || question.imageAnswerMode === "required",
|
|
) && (
|
|
<span className="text-sm text-muted-foreground">
|
|
คำถามที่มี “จำเป็น” ต้องตอบให้ครบ
|
|
</span>
|
|
)}
|
|
{!isAcceptingSubmissions && (
|
|
<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>
|
|
<CardHeader>
|
|
<CardDescription>
|
|
ข้อที่ {questionIndex + 1}
|
|
</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 || q.imageAnswerMode === "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="object-contain"
|
|
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 75vw, 60vw"
|
|
/>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<FormItem>
|
|
{q.type === "text" && !isImageOnlyResponse(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={!isAcceptingSubmissions || isSubmitting}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage id={questionErrorId} />
|
|
</Field>
|
|
)}
|
|
|
|
{q.type === "textarea" && !isImageOnlyResponse(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={!isAcceptingSubmissions || isSubmitting}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage id={questionErrorId} />
|
|
</Field>
|
|
)}
|
|
|
|
{q.type === "radio" && q.options && !isImageOnlyResponse(q) && (
|
|
<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={!isAcceptingSubmissions || 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={!isAcceptingSubmissions || isSubmitting}
|
|
/>
|
|
)}
|
|
</FieldGroup>
|
|
)}
|
|
</RadioGroup>
|
|
</FormControl>
|
|
<FormMessage id={questionErrorId} />
|
|
</FieldSet>
|
|
)}
|
|
|
|
{q.type === "checkbox" && q.options && !isImageOnlyResponse(q) && (
|
|
<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={!isAcceptingSubmissions || 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={!isAcceptingSubmissions || 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={!isAcceptingSubmissions || 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={!isAcceptingSubmissions || 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
|
|
}
|
|
/>
|
|
{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 object-contain"
|
|
onError={(event) => {
|
|
event.currentTarget.style.display = "none";
|
|
}}
|
|
/>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)}
|
|
{imageAnswers[q.id] && (
|
|
<Button
|
|
type="button"
|
|
variant="destructive"
|
|
onClick={() => clearAnswerImage(q.id)}
|
|
disabled={!isAcceptingSubmissions || 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
|
|
? "ตัวอย่างสำหรับผู้ดูแลระบบ — ไม่มีการส่งคำตอบ"
|
|
: !isAcceptingSubmissions
|
|
? "ปิดรับคำตอบแล้ว"
|
|
: 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>
|
|
)}
|
|
{isAcceptingSubmissions && (
|
|
<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-2xl">
|
|
ส่งคำตอบแล้ว
|
|
</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
บันทึกคำตอบของคุณเรียบร้อย คำตอบของคุณอยู่ลำดับที่ {formatQueuePosition(queueNumber ?? 1)} จากทั้งหมด{" "}
|
|
{totalAnswers} คำตอบ คุณสามารถแก้ไขได้คำตอบตอนไหนก็ได้ที่แบบฟอร์มยังเปิดอยู่
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogAction
|
|
onClick={() => setShowSuccessDialog(false)}
|
|
className="w-full"
|
|
>
|
|
ดูคำตอบที่ส่ง
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
);
|
|
}
|