425 lines
15 KiB
TypeScript
425 lines
15 KiB
TypeScript
"use client";
|
||
|
||
import { useState } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import PersianDateField from "@/components/PersianDateField";
|
||
import PersianTimeField from "@/components/PersianTimeField";
|
||
import {
|
||
dateValueToJalali,
|
||
formatPersianDateTime,
|
||
getDateFromJalaliDateTime,
|
||
getGregorianDateInputValue,
|
||
getTehranTimeInputValue,
|
||
gregorianDateAndTimeToUtcIso,
|
||
} from "@/lib/persianDate";
|
||
|
||
type Question = {
|
||
questionText: string;
|
||
options: string[];
|
||
correctAnswer: number;
|
||
};
|
||
|
||
type QuizFormProps = {
|
||
quizId?: string;
|
||
initial?: {
|
||
date: Date;
|
||
windowStart: Date;
|
||
windowEnd: Date;
|
||
goldWinnersCount: number;
|
||
silverWinnersCount: number;
|
||
bronzeWinnersCount: number;
|
||
goldMinCorrect: number | null;
|
||
silverMinCorrect: number | null;
|
||
bronzeMinCorrect: number | null;
|
||
questions: Question[];
|
||
};
|
||
submissionsCount?: number;
|
||
};
|
||
|
||
const emptyQuestion = (): Question => ({
|
||
questionText: "",
|
||
options: ["", "", "", ""],
|
||
correctAnswer: 0,
|
||
});
|
||
|
||
function getQuizDateTimeSummary(date: string, time: string) {
|
||
if (!date || !time) {
|
||
return "بعد از انتخاب تاریخ و ساعت، زمان نهایی به صورت شمسی اینجا نمایش داده میشود.";
|
||
}
|
||
|
||
const jalali = dateValueToJalali(date, "date");
|
||
if (!jalali) {
|
||
return "تاریخ انتخابی معتبر نیست.";
|
||
}
|
||
|
||
return formatPersianDateTime(getDateFromJalaliDateTime(jalali.year, jalali.month, jalali.day, time));
|
||
}
|
||
|
||
export default function QuizForm({ quizId, initial, submissionsCount = 0 }: QuizFormProps) {
|
||
const router = useRouter();
|
||
const [form, setForm] = useState({
|
||
date: initial ? getGregorianDateInputValue(new Date(initial.date)) : "",
|
||
windowStart: initial ? getTehranTimeInputValue(new Date(initial.windowStart)) : "12:00",
|
||
windowEnd: initial ? getTehranTimeInputValue(new Date(initial.windowEnd)) : "13:00",
|
||
goldWinnersCount: initial?.goldWinnersCount ?? 1,
|
||
silverWinnersCount: initial?.silverWinnersCount ?? 0,
|
||
bronzeWinnersCount: initial?.bronzeWinnersCount ?? 0,
|
||
goldMinCorrect: initial?.goldMinCorrect?.toString() ?? "",
|
||
silverMinCorrect: initial?.silverMinCorrect?.toString() ?? "",
|
||
bronzeMinCorrect: initial?.bronzeMinCorrect?.toString() ?? "",
|
||
});
|
||
const [questions, setQuestions] = useState<Question[]>(
|
||
initial?.questions?.length
|
||
? initial.questions.map((question) => ({
|
||
questionText: question.questionText,
|
||
options: [...question.options],
|
||
correctAnswer: question.correctAnswer,
|
||
}))
|
||
: [emptyQuestion()]
|
||
);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState("");
|
||
|
||
function updateQuestion(index: number, field: keyof Question, value: string | number | string[]) {
|
||
setQuestions((prev) => {
|
||
const updated = [...prev];
|
||
updated[index] = { ...updated[index], [field]: value };
|
||
return updated;
|
||
});
|
||
}
|
||
|
||
function updateOption(qIndex: number, oIndex: number, value: string) {
|
||
setQuestions((prev) => {
|
||
const updated = [...prev];
|
||
const opts = [...updated[qIndex].options];
|
||
opts[oIndex] = value;
|
||
updated[qIndex] = { ...updated[qIndex], options: opts };
|
||
return updated;
|
||
});
|
||
}
|
||
|
||
function addQuestion() {
|
||
setQuestions((prev) => [...prev, emptyQuestion()]);
|
||
}
|
||
|
||
function removeQuestion(index: number) {
|
||
setQuestions((prev) => prev.filter((_, i) => i !== index));
|
||
}
|
||
|
||
async function getErrorMessage(res: Response) {
|
||
const text = await res.text();
|
||
if (!text) {
|
||
return "خطا در ذخیره";
|
||
}
|
||
|
||
try {
|
||
const data = JSON.parse(text) as { error?: string };
|
||
return data.error ?? "خطا در ذخیره";
|
||
} catch {
|
||
return text;
|
||
}
|
||
}
|
||
|
||
async function handleSubmit(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
|
||
if (!form.date || !form.windowStart || !form.windowEnd) {
|
||
setError("تاریخ و بازه زمانی را کامل وارد کنید.");
|
||
return;
|
||
}
|
||
|
||
const startUtc = gregorianDateAndTimeToUtcIso(form.date, form.windowStart);
|
||
const endUtc = gregorianDateAndTimeToUtcIso(form.date, form.windowEnd);
|
||
|
||
if (!startUtc || !endUtc) {
|
||
setError("تبدیل تاریخ یا ساعت نامعتبر است.");
|
||
return;
|
||
}
|
||
|
||
if (new Date(startUtc) >= new Date(endUtc)) {
|
||
setError("ساعت پایان باید بعد از ساعت شروع باشد.");
|
||
return;
|
||
}
|
||
|
||
const normalizeThreshold = (value: string) => {
|
||
if (!value.trim()) return null;
|
||
const parsed = Number(value);
|
||
return Number.isInteger(parsed) && parsed > 0 ? parsed : NaN;
|
||
};
|
||
|
||
const normalizeWinnerCount = (value: number) =>
|
||
Number.isInteger(value) && value >= 0 ? value : NaN;
|
||
|
||
const goldMinCorrect = normalizeThreshold(form.goldMinCorrect);
|
||
const silverMinCorrect = normalizeThreshold(form.silverMinCorrect);
|
||
const bronzeMinCorrect = normalizeThreshold(form.bronzeMinCorrect);
|
||
const goldWinnersCount = normalizeWinnerCount(form.goldWinnersCount);
|
||
const silverWinnersCount = normalizeWinnerCount(form.silverWinnersCount);
|
||
const bronzeWinnersCount = normalizeWinnerCount(form.bronzeWinnersCount);
|
||
const questionCount = questions.length;
|
||
|
||
if ([goldMinCorrect, silverMinCorrect, bronzeMinCorrect].some((value) => Number.isNaN(value))) {
|
||
setError("حداقل پاسخ صحیح کارتها باید عدد صحیح بزرگتر از صفر باشد.");
|
||
return;
|
||
}
|
||
|
||
if ([goldWinnersCount, silverWinnersCount, bronzeWinnersCount].some((value) => Number.isNaN(value))) {
|
||
setError("تعداد برنده هر کارت باید عدد صحیح بزرگتر یا مساوی صفر باشد.");
|
||
return;
|
||
}
|
||
|
||
if (goldWinnersCount + silverWinnersCount + bronzeWinnersCount <= 0) {
|
||
setError("حداقل باید برای یک کارت، حداقل یک برنده تعریف کنید.");
|
||
return;
|
||
}
|
||
|
||
if (goldWinnersCount > 0 && goldMinCorrect == null) {
|
||
setError("برای کارت طلایی باید حداقل جواب صحیح را مشخص کنید.");
|
||
return;
|
||
}
|
||
|
||
if (silverWinnersCount > 0 && silverMinCorrect == null) {
|
||
setError("برای کارت نقرهای باید حداقل جواب صحیح را مشخص کنید.");
|
||
return;
|
||
}
|
||
|
||
if (bronzeWinnersCount > 0 && bronzeMinCorrect == null) {
|
||
setError("برای کارت برنزی باید حداقل جواب صحیح را مشخص کنید.");
|
||
return;
|
||
}
|
||
|
||
if ([goldMinCorrect, silverMinCorrect, bronzeMinCorrect].some((value) => value != null && value > questionCount)) {
|
||
setError("حداقل پاسخ صحیح هر کارت نمیتواند بیشتر از تعداد سوالات باشد.");
|
||
return;
|
||
}
|
||
|
||
if (goldMinCorrect != null && silverMinCorrect != null && silverMinCorrect >= goldMinCorrect) {
|
||
setError("آستانه کارت نقرهای باید کمتر از طلایی باشد.");
|
||
return;
|
||
}
|
||
|
||
if (silverMinCorrect != null && bronzeMinCorrect != null && bronzeMinCorrect >= silverMinCorrect) {
|
||
setError("آستانه کارت برنزی باید کمتر از نقرهای باشد.");
|
||
return;
|
||
}
|
||
|
||
setLoading(true);
|
||
setError("");
|
||
|
||
const payload = {
|
||
date: form.date,
|
||
windowStart: startUtc,
|
||
windowEnd: endUtc,
|
||
goldWinnersCount,
|
||
silverWinnersCount,
|
||
bronzeWinnersCount,
|
||
goldMinCorrect,
|
||
silverMinCorrect,
|
||
bronzeMinCorrect,
|
||
questions,
|
||
};
|
||
|
||
try {
|
||
const res = await fetch(quizId ? `/api/admin/quiz/${quizId}` : "/api/admin/quiz", {
|
||
method: quizId ? "PUT" : "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
|
||
if (res.ok) {
|
||
router.push("/admin/quiz");
|
||
router.refresh();
|
||
} else {
|
||
setError(await getErrorMessage(res));
|
||
}
|
||
} catch {
|
||
setError("خطا در ارتباط با سرور");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<form onSubmit={handleSubmit} className="bg-white rounded-2xl shadow p-6 flex flex-col gap-6">
|
||
{submissionsCount > 0 && (
|
||
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||
{submissionsCount} کاربر قبلاً به این کوییز پاسخ دادهاند. اگر سوالها را ویرایش کنید، امتیاز پاسخهای قبلی بر اساس نسخه جدید دوباره محاسبه میشود.
|
||
</div>
|
||
)}
|
||
|
||
{error && <p className="text-red-500 text-sm">{error}</p>}
|
||
|
||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||
<PersianDateField
|
||
label="تاریخ"
|
||
value={form.date}
|
||
onChange={(value) => setForm({ ...form, date: value })}
|
||
required
|
||
/>
|
||
<div>
|
||
<label className="mb-1 block text-sm font-medium">تعداد برنده کارت طلایی</label>
|
||
<input
|
||
type="number"
|
||
min={0}
|
||
value={form.goldWinnersCount}
|
||
onChange={(e) => setForm({ ...form, goldWinnersCount: Number(e.target.value) })}
|
||
className="w-full rounded-2xl border border-slate-200 px-4 py-3 focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="mb-1 block text-sm font-medium">تعداد برنده کارت نقرهای</label>
|
||
<input
|
||
type="number"
|
||
min={0}
|
||
value={form.silverWinnersCount}
|
||
onChange={(e) => setForm({ ...form, silverWinnersCount: Number(e.target.value) })}
|
||
className="w-full rounded-2xl border border-slate-200 px-4 py-3 focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="mb-1 block text-sm font-medium">تعداد برنده کارت برنزی</label>
|
||
<input
|
||
type="number"
|
||
min={0}
|
||
value={form.bronzeWinnersCount}
|
||
onChange={(e) => setForm({ ...form, bronzeWinnersCount: Number(e.target.value) })}
|
||
className="w-full rounded-2xl border border-slate-200 px-4 py-3 focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="mb-1 block text-sm font-medium">حداقل جواب صحیح کارت طلایی</label>
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={questions.length}
|
||
value={form.goldMinCorrect}
|
||
onChange={(e) => setForm({ ...form, goldMinCorrect: e.target.value })}
|
||
className="w-full rounded-2xl border border-slate-200 px-4 py-3 focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||
placeholder={`مثلاً ${questions.length}`}
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="mb-1 block text-sm font-medium">حداقل جواب صحیح کارت نقرهای</label>
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={questions.length}
|
||
value={form.silverMinCorrect}
|
||
onChange={(e) => setForm({ ...form, silverMinCorrect: e.target.value })}
|
||
className="w-full rounded-2xl border border-slate-200 px-4 py-3 focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||
placeholder="اختیاری"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="mb-1 block text-sm font-medium">حداقل جواب صحیح کارت برنزی</label>
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={questions.length}
|
||
value={form.bronzeMinCorrect}
|
||
onChange={(e) => setForm({ ...form, bronzeMinCorrect: e.target.value })}
|
||
className="w-full rounded-2xl border border-slate-200 px-4 py-3 focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||
placeholder="اختیاری"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<PersianTimeField
|
||
label="شروع بازه"
|
||
value={form.windowStart}
|
||
onChange={(value) => setForm({ ...form, windowStart: value })}
|
||
required
|
||
/>
|
||
<p className="mt-2 text-xs text-slate-500">{getQuizDateTimeSummary(form.date, form.windowStart)}</p>
|
||
</div>
|
||
|
||
<div>
|
||
<PersianTimeField
|
||
label="پایان بازه"
|
||
value={form.windowEnd}
|
||
onChange={(value) => setForm({ ...form, windowEnd: value })}
|
||
required
|
||
/>
|
||
<p className="mt-2 text-xs text-slate-500">{getQuizDateTimeSummary(form.date, form.windowEnd)}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-4">
|
||
<div className="flex justify-between items-center">
|
||
<h2 className="font-bold text-lg">سوالات</h2>
|
||
<button
|
||
type="button"
|
||
onClick={addQuestion}
|
||
className="text-sm text-green-700 border border-green-700 px-3 py-1 rounded-lg hover:bg-green-50 transition"
|
||
>
|
||
+ سوال جدید
|
||
</button>
|
||
</div>
|
||
|
||
{questions.map((q, qi) => (
|
||
<div key={qi} className="border rounded-xl p-4 flex flex-col gap-3 bg-gray-50">
|
||
<div className="flex justify-between items-center">
|
||
<span className="font-medium text-sm text-gray-600">سوال {qi + 1}</span>
|
||
{questions.length > 1 && (
|
||
<button
|
||
type="button"
|
||
onClick={() => removeQuestion(qi)}
|
||
className="text-red-500 text-xs hover:underline"
|
||
>
|
||
حذف
|
||
</button>
|
||
)}
|
||
</div>
|
||
<input
|
||
type="text"
|
||
placeholder="متن سوال"
|
||
value={q.questionText}
|
||
onChange={(e) => updateQuestion(qi, "questionText", e.target.value)}
|
||
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500 bg-white"
|
||
required
|
||
/>
|
||
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
|
||
{q.options.map((opt, oi) => (
|
||
<div key={oi} className="flex items-center gap-2">
|
||
<input
|
||
type="radio"
|
||
name={`correct-${qi}`}
|
||
checked={q.correctAnswer === oi}
|
||
onChange={() => updateQuestion(qi, "correctAnswer", oi)}
|
||
className="accent-green-600"
|
||
/>
|
||
<input
|
||
type="text"
|
||
placeholder={`گزینه ${oi + 1}`}
|
||
value={opt}
|
||
onChange={(e) => updateOption(qi, oi, e.target.value)}
|
||
className="flex-1 border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500 bg-white"
|
||
required
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<p className="text-xs text-gray-400">گزینه صحیح را با دایره انتخاب کنید</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<button
|
||
type="submit"
|
||
disabled={loading}
|
||
className="bg-green-700 text-white py-3 rounded-xl font-bold hover:bg-green-800 transition disabled:opacity-50"
|
||
>
|
||
{loading ? "در حال ذخیره..." : quizId ? "ذخیره تغییرات کوییز" : "ذخیره کوییز"}
|
||
</button>
|
||
</form>
|
||
);
|
||
}
|