504 lines
21 KiB
TypeScript
504 lines
21 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useMemo } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { Card, CardContent, 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 { Label } from "@/components/ui/label";
|
|
import { cn, 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 { Loader2 } from "lucide-react";
|
|
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";
|
|
|
|
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 })}>
|
|
Login
|
|
</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;
|
|
}
|
|
|
|
interface FormFillerClientProps {
|
|
form: {
|
|
id: string;
|
|
title: string;
|
|
description: string | null;
|
|
isOpen: boolean;
|
|
};
|
|
questions: Question[];
|
|
initialAnswers?: Record<string, string>;
|
|
}
|
|
|
|
const LS_KEY = (formId: string) => `form-draft-${formId}`;
|
|
type FormValues = Record<string, string | string[] | undefined>;
|
|
|
|
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().min(1, `จำเป็นต้องตอบข้อนี้`)
|
|
: z.string().optional();
|
|
}
|
|
});
|
|
return z.object(shape);
|
|
};
|
|
|
|
export function FormFillerClient({ form, questions, initialAnswers = {} }: FormFillerClientProps) {
|
|
const router = useRouter();
|
|
const isUpdating = Object.keys(initialAnswers).length > 0;
|
|
|
|
const formSchema = useMemo(() => buildSchema(questions), [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<string, string> = {};
|
|
|
|
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<Record<string, string>>(initialOtherValues);
|
|
const [showSuccessDialog, setShowSuccessDialog] = useState(false);
|
|
|
|
const methods = useForm<z.infer<typeof formSchema>>({
|
|
resolver: zodResolver(formSchema),
|
|
defaultValues,
|
|
mode: "onTouched",
|
|
});
|
|
|
|
const { handleSubmit, control, formState: { isSubmitting }, reset } = methods;
|
|
const watchedValues = useWatch({ control });
|
|
|
|
useEffect(() => {
|
|
try {
|
|
localStorage.setItem(LS_KEY(form.id), JSON.stringify(watchedValues));
|
|
} catch {}
|
|
}, [form.id, watchedValues]);
|
|
|
|
|
|
|
|
const onSubmit = async (values: z.infer<typeof formSchema>) => {
|
|
if (!form.isOpen) return;
|
|
|
|
try {
|
|
const formattedAnswers = questions.map((q) => {
|
|
let val = values[q.id] as string | string[] | undefined;
|
|
if (q.allowOther) {
|
|
const customText = otherValues[q.id] || "";
|
|
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,
|
|
};
|
|
});
|
|
|
|
const res = await submitForm(form.id, formattedAnswers);
|
|
if (res.ok) {
|
|
if (isUpdating) {
|
|
toast.success("อัปเดตฟอร์มสำเร็จ!");
|
|
} else {
|
|
setShowSuccessDialog(true);
|
|
router.refresh();
|
|
}
|
|
}
|
|
} catch (err: unknown) {
|
|
console.error(err);
|
|
toast.error(getErrorMessage(err, "ไม่สามารถส่งฟอร์มได้"));
|
|
}
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
try {
|
|
const res = await deleteOwnSubmission(form.id);
|
|
if (res.ok) {
|
|
toast.success("ลบข้อมูลสำเร็จ!");
|
|
localStorage.removeItem(LS_KEY(form.id));
|
|
reset({});
|
|
router.refresh();
|
|
}
|
|
} catch (err: unknown) {
|
|
console.error(err);
|
|
toast.error(getErrorMessage(err, "ไม่สามารถลบข้อมูลได้"));
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="relative pb-16">
|
|
{!form.isOpen && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/50 backdrop-blur-sm p-4">
|
|
<Card className="w-full max-w-sm">
|
|
<CardHeader>
|
|
<CardTitle className="text-center text-xl">
|
|
ฟอร์มนี้ถูกปิดใช้งานชั่วคราว
|
|
</CardTitle>
|
|
</CardHeader>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
<Card className="border-t-8 border-t-primary shadow-md mb-6">
|
|
<CardHeader>
|
|
<CardTitle className="text-3xl font-bold">{form.title}</CardTitle>
|
|
{form.description && (
|
|
<div className="text-base mt-2 text-muted-foreground">
|
|
<HtmlDisplay html={form.description} />
|
|
</div>
|
|
)}
|
|
</CardHeader>
|
|
</Card>
|
|
|
|
<Form {...methods}>
|
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
|
<div className="space-y-4">
|
|
{questions.map((q) => (
|
|
<Card key={q.id} className="shadow-sm">
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="text-base leading-normal mt-1 flex-1">
|
|
<HtmlDisplay html={q.label} />
|
|
</div>
|
|
<div className="flex items-start gap-4 shrink-0">
|
|
{q.imageUrl && (
|
|
<Dialog>
|
|
<DialogTrigger asChild>
|
|
<div className="relative max-w-full h-auto overflow-hidden rounded-md border inline-block shrink-0 cursor-pointer hover:opacity-80 transition-opacity">
|
|
<Image
|
|
width={100}
|
|
height={100}
|
|
src={q.imageUrl}
|
|
alt={q.label}
|
|
className="object-contain w-auto"
|
|
/>
|
|
</div>
|
|
</DialogTrigger>
|
|
<DialogContent className="max-w-4xl w-[90vw] h-[80vh] border-0 bg-transparent p-0 shadow-none flex justify-center items-center">
|
|
<DialogTitle className="sr-only">Enlarged image for {q.label}</DialogTitle>
|
|
<div className="relative w-full h-full">
|
|
<Image
|
|
src={q.imageUrl}
|
|
alt={q.label}
|
|
fill
|
|
unoptimized
|
|
className="object-contain rounded-md"
|
|
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 75vw, 60vw"
|
|
/>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)}
|
|
{q.required && <span className="text-destructive mt-1">*</span>}
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<FormField
|
|
control={control}
|
|
name={q.id}
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
{q.type === "text" && (
|
|
<FormControl>
|
|
<Input
|
|
placeholder="คำตอบของคุณ"
|
|
{...field}
|
|
value={(field.value as string) || ""}
|
|
className="w-full"
|
|
disabled={!form.isOpen || isSubmitting}
|
|
/>
|
|
</FormControl>
|
|
)}
|
|
|
|
{q.type === "textarea" && (
|
|
<FormControl>
|
|
<Textarea
|
|
placeholder="คำตอบของคุณ"
|
|
{...field}
|
|
value={(field.value as string) || ""}
|
|
className="w-full min-h-24 resize-y"
|
|
disabled={!form.isOpen || isSubmitting}
|
|
/>
|
|
</FormControl>
|
|
)}
|
|
|
|
{q.type === "radio" && q.options && (
|
|
<FormControl>
|
|
<RadioGroup
|
|
onValueChange={field.onChange}
|
|
defaultValue={field.value as string}
|
|
value={field.value as string}
|
|
className="space-y-2"
|
|
disabled={!form.isOpen || isSubmitting}
|
|
>
|
|
{q.options.map((opt, idx) => (
|
|
<div key={idx} className="flex items-center space-x-2">
|
|
<RadioGroupItem value={opt} id={`${q.id}-${idx}`} />
|
|
<Label htmlFor={`${q.id}-${idx}`} className="font-normal cursor-pointer">
|
|
<FormattedText text={opt} />
|
|
</Label>
|
|
</div>
|
|
))}
|
|
{q.allowOther && (
|
|
<div className="flex flex-col space-y-2">
|
|
<div className="flex items-center space-x-2">
|
|
<RadioGroupItem value="__OTHER__" id={`${q.id}-other`} />
|
|
<Label htmlFor={`${q.id}-other`} className="font-normal cursor-pointer">
|
|
อื่นๆ
|
|
</Label>
|
|
</div>
|
|
{field.value === "__OTHER__" && (
|
|
<Input
|
|
className="ml-6 w-full max-w-xs"
|
|
value={otherValues[q.id] || ""}
|
|
onChange={(e) => setOtherValues(prev => ({...prev, [q.id]: e.target.value}))}
|
|
placeholder="โปรดระบุ"
|
|
disabled={!form.isOpen || isSubmitting}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
</RadioGroup>
|
|
</FormControl>
|
|
)}
|
|
|
|
{q.type === "checkbox" && q.options && (
|
|
<FormControl>
|
|
<div className="space-y-2">
|
|
{q.options.map((opt, idx) => {
|
|
const isChecked = Array.isArray(field.value) && field.value.includes(opt);
|
|
return (
|
|
<div key={idx} className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id={`${q.id}-${idx}`}
|
|
checked={isChecked}
|
|
disabled={!form.isOpen || isSubmitting}
|
|
onCheckedChange={(checked) => {
|
|
const current = Array.isArray(field.value) ? field.value : [];
|
|
const updated = checked
|
|
? [...current, opt]
|
|
: current.filter((item) => item !== opt);
|
|
field.onChange(updated);
|
|
}}
|
|
/>
|
|
<Label htmlFor={`${q.id}-${idx}`} className="font-normal cursor-pointer">
|
|
<FormattedText text={opt} />
|
|
</Label>
|
|
</div>
|
|
);
|
|
})}
|
|
{q.allowOther && (
|
|
<div className="flex flex-col space-y-2">
|
|
<div className="flex items-center space-x-2">
|
|
<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 : [];
|
|
const updated = checked
|
|
? [...current, "__OTHER__"]
|
|
: current.filter(x => x !== "__OTHER__");
|
|
field.onChange(updated);
|
|
}}
|
|
/>
|
|
<Label htmlFor={`${q.id}-other`} className="font-normal cursor-pointer">
|
|
อื่นๆ
|
|
</Label>
|
|
</div>
|
|
{Array.isArray(field.value) && field.value.includes("__OTHER__") && (
|
|
<Input
|
|
className="ml-6 w-full max-w-xs"
|
|
value={otherValues[q.id] || ""}
|
|
onChange={(e) => setOtherValues(prev => ({...prev, [q.id]: e.target.value}))}
|
|
placeholder="โปรดระบุ"
|
|
disabled={!form.isOpen || isSubmitting}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</FormControl>
|
|
)}
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-3 pt-4">
|
|
{isUpdating && (
|
|
<AlertDialog>
|
|
<AlertDialogTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="destructive"
|
|
disabled={isSubmitting || !form.isOpen}
|
|
>
|
|
ลบ
|
|
</Button>
|
|
</AlertDialogTrigger>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>ยืนยันการลบข้อมูล?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
คุณแน่ใจหรือไม่ว่าต้องการลบข้อมูลที่ส่งไปแล้ว? การกระทำนี้ไม่สามารถย้อนกลับได้
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel disabled={isSubmitting}>ยกเลิก</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
variant="destructive"
|
|
onClick={handleDelete}
|
|
disabled={isSubmitting}
|
|
>
|
|
ยืนยันการลบ
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
)}
|
|
<Button
|
|
type="submit"
|
|
disabled={isSubmitting || !form.isOpen}
|
|
className={cn("min-w-28", isUpdating && "bg-green-600 hover:bg-green-700")}
|
|
>
|
|
{isSubmitting ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
กำลังส่ง
|
|
</>
|
|
) : isUpdating ? (
|
|
"อัปเดตข้อมูล"
|
|
) : (
|
|
"ส่ง"
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</Form>
|
|
|
|
<AlertDialog open={showSuccessDialog} onOpenChange={setShowSuccessDialog}>
|
|
<AlertDialogContent >
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle className="text-2xl text-center font-bold text-primary">ส่งฟอร์มสำเร็จ!</AlertDialogTitle>
|
|
<AlertDialogDescription >
|
|
กดอีกครั้งเพื่ออัพเดทคำตอบของคุณ!
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter >
|
|
<AlertDialogAction onClick={() => setShowSuccessDialog(false)} className="w-full font-bold">
|
|
ตกลง
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
);
|
|
}
|