Files
erika/app/form/client.tsx
T

437 lines
17 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 { 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, ChevronRight, ChevronLeft } from "lucide-react";
import { signOut } from "next-auth/react";
import Image from "next/image";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
export function FormLayoutClient({
userName,
userImage,
}: {
userName: string;
userImage: string;
}) {
return (
<div className="flex items-center gap-3">
{userImage && (
<Image
src={userImage}
alt={userName}
width={28}
height={28}
className="rounded-full "
/>
)}
<span className="text-sm text-muted-foreground">{userName}</span>
<Button
variant="ghost"
size="sm"
onClick={() => signOut()}
>
<LogOut className="w-4 h-4" />
</Button>
</div>
);
}
interface Question {
id: string;
formId: string;
type: "text" | "textarea" | "radio" | "checkbox";
label: string;
imageUrl: string | null;
displayOrder: number;
required: boolean;
options: string[] | null;
}
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 QUESTIONS_PER_STEP = 3;
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, `จำเป็นต้องตอบ "${q.label}"`)
: z.array(z.string()).optional();
} else {
shape[q.id] = q.required
? z.string().min(1, `จำเป็นต้องตอบ "${q.label}"`)
: 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 = 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> = {};
questions.forEach(q => {
if (q.type === "checkbox") {
vals[q.id] = merged[q.id] ? (Array.isArray(merged[q.id]) ? merged[q.id] : merged[q.id].split(", ").filter(Boolean)) : [];
} else {
vals[q.id] = merged[q.id] || "";
}
});
return vals;
}, [form.id, initialAnswers, questions]);
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 [currentStep, setCurrentStep] = useState(0);
const totalSteps = Math.ceil(questions.length / QUESTIONS_PER_STEP);
const currentQuestions = questions.slice(
currentStep * QUESTIONS_PER_STEP,
(currentStep + 1) * QUESTIONS_PER_STEP
);
const nextStep = async () => {
const fieldsToValidate = currentQuestions.map(q => q.id);
const isStepValid = await trigger(fieldsToValidate);
if (isStepValid) {
setCurrentStep(prev => Math.min(prev + 1, totalSteps - 1));
window.scrollTo(0, 0);
}
};
const prevStep = () => {
setCurrentStep(prev => Math.max(prev - 1, 0));
window.scrollTo(0, 0);
};
const onSubmit = async (values: z.infer<typeof formSchema>) => {
if (!form.isOpen) return;
try {
const formattedAnswers = questions.map((q) => {
const val = values[q.id] as string | string[] | undefined;
return {
questionId: q.id,
value: Array.isArray(val) ? val.join(", ") : (val || ""),
};
});
const res = await submitForm(form.id, formattedAnswers);
if (res.ok) {
toast.success(isUpdating ? "อัปเดตฟอร์มสำเร็จ!" : "ส่งฟอร์มสำเร็จ!");
}
} 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 && (
<CardDescription className="text-base mt-2 whitespace-pre-wrap">
{form.description}
</CardDescription>
)}
</CardHeader>
</Card>
<Form {...methods}>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div className="space-y-4">
{currentQuestions.map((q) => (
<Card key={q.id} className="shadow-sm">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-4">
<Label className="text-base font-semibold leading-normal mt-1">
{q.label}
{q.required && <span className="text-destructive ml-1">*</span>}
</Label>
{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
className="object-contain rounded-md"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 75vw, 60vw"
/>
</div>
</DialogContent>
</Dialog>
)}
</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">
{opt}
</Label>
</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">
{opt}
</Label>
</div>
);
})}
</div>
</FormControl>
)}
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
))}
</div>
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 pt-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground font-medium w-full sm:w-auto justify-center sm:justify-start">
หน้า {currentStep + 1} จาก {totalSteps || 1}
</div>
<div className="flex justify-between sm:justify-end items-center gap-3 w-full sm:w-auto">
{currentStep > 0 && (
<Button
type="button"
variant="outline"
onClick={prevStep}
disabled={isSubmitting || !form.isOpen}
>
<ChevronLeft className="w-4 h-4 mr-1" /> ย้อนกลับ
</Button>
)}
{currentStep < totalSteps - 1 ? (
<Button
type="button"
onClick={nextStep}
disabled={isSubmitting || !form.isOpen}
>
ถัดไป <ChevronRight className="w-4 h-4 ml-1" />
</Button>
) : (
<div className="flex gap-3">
{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="min-w-28">
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
กำลังส่ง
</>
) : isUpdating ? (
"อัปเดตข้อมูล"
) : (
"ส่ง"
)}
</Button>
</div>
)}
</div>
</div>
</form>
</Form>
</div>
);
}