Files
erika/app/form/client.tsx
T

507 lines
21 KiB
TypeScript

"use client";
import React, { useState, useEffect, useMemo } from "react";
import { useRouter } from "next/navigation";
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } 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, LogOut } from "lucide-react";
import { signIn, signOut } from "next-auth/react";
import Image from "next/image";
import { z } from "zod";
import { useForm, Controller } 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";
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}`;
const buildSchema = (questions: Question[]) => {
const shape: Record<string, z.ZodTypeAny> = {};
questions.forEach(q => {
const plainLabel = stripHtml(q.label);
if (q.type === "checkbox") {
shape[q.id] = q.required
? z.array(z.string()).min(1, `จำเป็นต้องตอบ "${plainLabel}"`)
: z.array(z.string()).optional();
} else {
shape[q.id] = q.required
? z.string().min(1, `จำเป็นต้องตอบ "${plainLabel}"`)
: 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: Record<string, any> = {};
try {
const raw = localStorage.getItem(LS_KEY(form.id));
if (raw) draft = JSON.parse(raw);
} catch {}
const merged = { ...draft, ...serverAnswers };
const vals: Record<string, any> = {};
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 = 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] = 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, watch, formState: { isSubmitting }, trigger, reset } = methods;
useEffect(() => {
const subscription = watch((value) => {
try {
localStorage.setItem(LS_KEY(form.id), JSON.stringify(value));
} catch {}
});
return () => subscription.unsubscribe();
}, [watch, form.id]);
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: any) {
console.error(err);
toast.error(err.message || "ไม่สามารถส่งฟอร์มได้");
}
};
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: any) {
console.error(err);
toast.error(err.message || "ไม่สามารถลบข้อมูลได้");
}
};
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>
);
}