Files
football-next/app/(admin)/admin/quiz/QuizForm.tsx
2026-05-03 17:01:46 +03:30

425 lines
15 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}