This commit is contained in:
2026-05-03 17:01:46 +03:30
parent b5ad5420b2
commit 9c30295b4b
76 changed files with 7891 additions and 461 deletions

View File

@@ -2,47 +2,76 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import PersianDateField from "@/components/PersianDateField";
export default function GameweekForm() {
const router = useRouter();
const [form, setForm] = useState({ number: "", name: "", deadline: "" });
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!form.deadline) {
setError("ددلاین را انتخاب کنید.");
return;
}
setLoading(true);
setError("");
const res = await fetch("/api/gameweeks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...form, number: parseInt(form.number) }),
body: JSON.stringify({ ...form, number: parseInt(form.number, 10) }),
});
if (res.ok) {
setForm({ number: "", name: "", deadline: "" });
router.refresh();
} else {
const d = await res.json();
setError(d.error ?? "خطا در ذخیره");
}
setLoading(false);
}
return (
<form onSubmit={handleSubmit} className="bg-white rounded-2xl shadow p-6 flex flex-col gap-4">
{error && <p className="text-red-500 text-sm bg-red-50 px-3 py-2 rounded-lg">{error}</p>}
<div>
<label className="block text-sm font-medium mb-1">شماره هفته</label>
<input type="number" min="1" value={form.number} onChange={(e) => setForm({ ...form, number: e.target.value })}
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500" required />
<input
type="number"
min="1"
value={form.number}
onChange={(e) => setForm({ ...form, number: e.target.value })}
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">نام</label>
<input type="text" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="مثلاً: مرحله گروهی - روز ۱"
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500" required />
<input
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="مثلاً: مرحله گروهی - روز اول"
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">deadline انتخاب تیم</label>
<input type="datetime-local" value={form.deadline} onChange={(e) => setForm({ ...form, deadline: e.target.value })}
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500" required />
</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">
<PersianDateField
label="ددلاین انتخاب تیم"
value={form.deadline}
onChange={(value) => setForm({ ...form, deadline: value })}
mode="datetime"
required
/>
<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 ? "در حال ذخیره..." : "افزودن هفته"}
</button>
</form>

View File

@@ -7,6 +7,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
const links = [
{ href: "/admin", label: "داشبورد", icon: "📊" },
{ href: "/admin/rounds", label: "دورهای بازی", icon: "🏆" },
{ href: "/admin/quiz", label: "کوییز روزانه", icon: "📋" },
{ href: "/admin/players", label: "بازیکنان", icon: "⚽" },
{ href: "/admin/matches", label: "بازی‌ها", icon: "🏟️" },
{ href: "/admin/scoring", label: "قوانین امتیازدهی", icon: "⚙️" },

View File

@@ -2,6 +2,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import PersianDateField from "@/components/PersianDateField";
type Country = { id: string; name: string };
type Round = { id: string; name: string; number: number };
@@ -23,7 +24,7 @@ export default function MatchForm({
awayTeamId: initial?.awayTeamId ?? "",
stage: initial?.stage ?? "GROUP",
status: initial?.status ?? "SCHEDULED",
matchDate: initial?.matchDate ? new Date(initial.matchDate).toISOString().slice(0, 16) : "",
matchDate: initial?.matchDate ?? "",
homeScore: initial?.homeScore ?? "",
awayScore: initial?.awayScore ?? "",
roundId: initial?.roundId ?? "",
@@ -33,11 +34,17 @@ export default function MatchForm({
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!form.matchDate) {
setError("تاریخ و ساعت بازی را انتخاب کنید.");
return;
}
setLoading(true);
const payload = {
...form,
homeScore: form.homeScore !== "" ? parseInt(String(form.homeScore)) : null,
awayScore: form.awayScore !== "" ? parseInt(String(form.awayScore)) : null,
homeScore: form.homeScore !== "" ? parseInt(String(form.homeScore), 10) : null,
awayScore: form.awayScore !== "" ? parseInt(String(form.awayScore), 10) : null,
roundId: form.roundId || null,
};
const res = await fetch(matchId ? `/api/matches/${matchId}` : "/api/matches", {
@@ -67,70 +74,115 @@ export default function MatchForm({
return (
<form onSubmit={handleSubmit} className="bg-white rounded-2xl shadow p-6 flex flex-col gap-4">
{error && <p className="text-red-500 text-sm bg-red-50 px-3 py-2 rounded-lg">{error}</p>}
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className="block text-sm font-medium mb-1">تیم میزبان</label>
<select value={form.homeTeamId} onChange={(e) => setForm({ ...form, homeTeamId: e.target.value })}
className="w-full border rounded-xl px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500" required>
<select
value={form.homeTeamId}
onChange={(e) => setForm({ ...form, homeTeamId: e.target.value })}
className="w-full border rounded-xl px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
required
>
<option value="">انتخاب کنید</option>
{countries.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
{countries.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">تیم مهمان</label>
<select value={form.awayTeamId} onChange={(e) => setForm({ ...form, awayTeamId: e.target.value })}
className="w-full border rounded-xl px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500" required>
<select
value={form.awayTeamId}
onChange={(e) => setForm({ ...form, awayTeamId: e.target.value })}
className="w-full border rounded-xl px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
required
>
<option value="">انتخاب کنید</option>
{countries.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
{countries.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className="block text-sm font-medium mb-1">گل میزبان</label>
<input type="number" min="0" value={form.homeScore}
<input
type="number"
min="0"
value={form.homeScore}
onChange={(e) => setForm({ ...form, homeScore: e.target.value })}
className="w-full border rounded-xl px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500" />
className="w-full border rounded-xl px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">گل مهمان</label>
<input type="number" min="0" value={form.awayScore}
<input
type="number"
min="0"
value={form.awayScore}
onChange={(e) => setForm({ ...form, awayScore: e.target.value })}
className="w-full border rounded-xl px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500" />
className="w-full border rounded-xl px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1">مرحله</label>
<select value={form.stage} onChange={(e) => setForm({ ...form, stage: e.target.value })}
className="w-full border rounded-xl px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500">
{stages.map((s) => <option key={s.value} value={s.value}>{s.label}</option>)}
<select
value={form.stage}
onChange={(e) => setForm({ ...form, stage: e.target.value })}
className="w-full border rounded-xl px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
>
{stages.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">وضعیت</label>
<select value={form.status} onChange={(e) => setForm({ ...form, status: e.target.value })}
className="w-full border rounded-xl px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500">
<select
value={form.status}
onChange={(e) => setForm({ ...form, status: e.target.value })}
className="w-full border rounded-xl px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
>
<option value="SCHEDULED">برنامهریزی شده</option>
<option value="LIVE">زنده</option>
<option value="FINISHED">پایان یافته</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">تاریخ و ساعت</label>
<input type="datetime-local" value={form.matchDate}
onChange={(e) => setForm({ ...form, matchDate: e.target.value })}
className="w-full border rounded-xl px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500" required />
</div>
<PersianDateField
label="تاریخ و ساعت"
value={form.matchDate}
onChange={(value) => setForm({ ...form, matchDate: value })}
mode="datetime"
required
/>
<div>
<label className="block text-sm font-medium mb-1">دور بازی</label>
<select value={form.roundId} onChange={(e) => setForm({ ...form, roundId: e.target.value })}
className="w-full border rounded-xl px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500">
<select
value={form.roundId}
onChange={(e) => setForm({ ...form, roundId: e.target.value })}
className="w-full border rounded-xl px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
>
<option value="">بدون دور</option>
{rounds.map((r) => <option key={r.id} value={r.id}>دور {r.number} - {r.name}</option>)}
{rounds.map((r) => (
<option key={r.id} value={r.id}>
دور {r.number} - {r.name}
</option>
))}
</select>
</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">
<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 ? "در حال ذخیره..." : matchId ? "ذخیره تغییرات" : "افزودن بازی"}
</button>
</form>

View File

@@ -0,0 +1,56 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
type CardTier = "GOLD" | "SILVER" | "BRONZE";
const labels: Record<CardTier, string> = {
GOLD: "طلایی",
SILVER: "نقره ای",
BRONZE: "برنزی",
};
export default function CardTierSelect({
playerId,
initial,
}: {
playerId: string;
initial: CardTier;
}) {
const [value, setValue] = useState<CardTier>(initial);
const [loading, setLoading] = useState(false);
const router = useRouter();
async function handleChange(next: CardTier) {
setValue(next);
setLoading(true);
const res = await fetch(`/api/admin/players/${playerId}/card-tier`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ cardTier: next }),
});
if (!res.ok) {
setValue(initial);
} else {
router.refresh();
}
setLoading(false);
}
return (
<select
value={value}
onChange={(e) => handleChange(e.target.value as CardTier)}
disabled={loading}
className="rounded-lg border border-slate-200 bg-white px-3 py-2 text-xs focus:outline-none focus:ring-2 focus:ring-emerald-500 disabled:opacity-50"
>
<option value="GOLD">{labels.GOLD}</option>
<option value="SILVER">{labels.SILVER}</option>
<option value="BRONZE">{labels.BRONZE}</option>
</select>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function GoldenToggle({ playerId, initial }: { playerId: string; initial: boolean }) {
const [enabled, setEnabled] = useState(initial);
const [loading, setLoading] = useState(false);
const router = useRouter();
async function toggle() {
setLoading(true);
const res = await fetch(`/api/admin/players/${playerId}/golden-toggle`, { method: "PATCH" });
if (res.ok) {
const data = await res.json();
setEnabled(data.isGoldenCardEligible);
router.refresh();
}
setLoading(false);
}
return (
<button
onClick={toggle}
disabled={loading}
title="Golden Card Eligible"
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
enabled ? "bg-yellow-400" : "bg-gray-300"
} disabled:opacity-50`}
>
<span
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${
enabled ? "-translate-x-1" : "translate-x-1"
}`}
/>
</button>
);
}

View File

@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import Image from "next/image";
type Country = { id: string; name: string };
type CardTier = "GOLD" | "SILVER" | "BRONZE";
export default function PlayerForm({
countries,
@@ -12,7 +13,7 @@ export default function PlayerForm({
playerId,
}: {
countries: Country[];
initial?: { name: string; position: string; countryId: string; price: number; image?: string | null };
initial?: { name: string; position: string; countryId: string; price: number; image?: string | null; cardTier: CardTier };
playerId?: string;
}) {
const router = useRouter();
@@ -22,6 +23,7 @@ export default function PlayerForm({
countryId: initial?.countryId ?? "",
price: initial?.price ?? 5.0,
image: initial?.image ?? "",
cardTier: initial?.cardTier ?? "BRONZE",
});
const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false);
@@ -154,6 +156,18 @@ export default function PlayerForm({
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">نوع کارت</label>
<select
value={form.cardTier}
onChange={(e) => setForm({ ...form, cardTier: e.target.value as CardTier })}
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
>
<option value="GOLD">طلایی</option>
<option value="SILVER">نقره ای</option>
<option value="BRONZE">برنزی</option>
</select>
</div>
<button
type="submit"
disabled={loading}

View File

@@ -27,6 +27,7 @@ export default async function EditPlayerPage({ params }: { params: Promise<{ id:
countryId: player.countryId,
price: player.price,
image: player.image,
cardTier: player.cardTier,
}}
/>
</div>

View File

@@ -1,6 +1,8 @@
import { db } from "@/lib/db";
import Link from "next/link";
import PositionBadge from "@/components/PositionBadge";
import CardTierSelect from "./CardTierSelect";
import { CARD_TIER_LABELS, getCardTierBadgeClass } from "@/lib/cardTier";
export default async function AdminPlayersPage() {
const players = await db.player.findMany({
@@ -25,6 +27,8 @@ export default async function AdminPlayersPage() {
<th className="text-right px-5 py-4">تیم ملی</th>
<th className="text-right px-5 py-4">قیمت</th>
<th className="text-right px-5 py-4">امتیاز</th>
<th className="text-right px-5 py-4">کارت</th>
<th className="text-right px-5 py-4">ویرایش کارت</th>
<th className="px-5 py-4"></th>
</tr>
</thead>
@@ -36,6 +40,14 @@ export default async function AdminPlayersPage() {
<td className="px-5 py-3 text-gray-600">{p.country.name}</td>
<td className="px-5 py-3 text-green-700 font-bold">{p.price}M</td>
<td className="px-5 py-3 text-blue-700 font-bold">{p.totalPoints}</td>
<td className="px-5 py-3">
<span className={`rounded-full px-2 py-1 text-xs font-bold ${getCardTierBadgeClass(p.cardTier)}`}>
{CARD_TIER_LABELS[p.cardTier]}
</span>
</td>
<td className="px-5 py-3">
<CardTierSelect playerId={p.id} initial={p.cardTier} />
</td>
<td className="px-5 py-3">
<Link href={`/admin/players/${p.id}/edit`} className="text-blue-600 hover:underline text-xs">
ویرایش

View File

@@ -0,0 +1,123 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
type LotteryButtonProps = {
quizId: string;
goldWinnersCount: number;
silverWinnersCount: number;
bronzeWinnersCount: number;
totalParticipants: number;
perfectParticipants: number;
};
export default function LotteryButton({
quizId,
goldWinnersCount,
silverWinnersCount,
bronzeWinnersCount,
totalParticipants,
perfectParticipants,
}: LotteryButtonProps) {
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const router = useRouter();
const correctPercentage = totalParticipants > 0
? Math.round((perfectParticipants / totalParticipants) * 100)
: 0;
const incorrectParticipants = Math.max(totalParticipants - perfectParticipants, 0);
const totalWinnersCount = goldWinnersCount + silverWinnersCount + bronzeWinnersCount;
async function run() {
setLoading(true);
const res = await fetch(`/api/admin/quiz/${quizId}/lottery`, { method: "POST" });
const data = await res.json();
if (res.ok) {
setResult(`${data.winners.length} برنده انتخاب شد`);
setOpen(false);
router.refresh();
} else {
setResult(data.error ?? "خطا");
}
setLoading(false);
}
return (
<>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setOpen(true)}
disabled={loading}
className="bg-yellow-500 text-black text-xs px-3 py-1 rounded-lg hover:bg-yellow-400 transition disabled:opacity-50"
>
{loading ? "..." : "قرعه‌کشی"}
</button>
{result && <span className="text-xs text-gray-500">{result}</span>}
</div>
{open && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl">
<h3 className="text-lg font-bold text-gray-900">تایید قرعهکشی</h3>
<p className="mt-2 text-sm leading-6 text-gray-600">
با اجرای قرعهکشی، پاسخ دادن به این کوییز بسته میشود و دیگر امکان ویرایش سوالها وجود ندارد.
</p>
<div className="mt-4 grid grid-cols-2 gap-3 text-sm">
<div className="rounded-xl bg-slate-50 p-3">
<div className="text-slate-500">کل شرکتکنندگان</div>
<div className="mt-1 text-xl font-bold text-slate-900">{totalParticipants}</div>
</div>
<div className="rounded-xl bg-emerald-50 p-3">
<div className="text-emerald-700">واجد دریافت کارت</div>
<div className="mt-1 text-xl font-bold text-emerald-800">{perfectParticipants} نفر</div>
<div className="text-xs text-emerald-700">{correctPercentage}%</div>
</div>
<div className="rounded-xl bg-rose-50 p-3">
<div className="text-rose-700">سایر شرکتکنندگان</div>
<div className="mt-1 text-xl font-bold text-rose-800">{incorrectParticipants} نفر</div>
<div className="text-xs text-rose-700">{Math.max(100 - correctPercentage, 0)}%</div>
</div>
<div className="rounded-xl bg-amber-50 p-3">
<div className="text-amber-700">تعداد برنده</div>
<div className="mt-1 text-xl font-bold text-amber-800">{totalWinnersCount} نفر</div>
<div className="text-xs text-amber-700">
G:{goldWinnersCount} | S:{silverWinnersCount} | B:{bronzeWinnersCount}
</div>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<button
type="button"
onClick={() => setOpen(false)}
disabled={loading}
className="rounded-xl border border-slate-200 px-4 py-2 text-sm text-slate-700 transition hover:bg-slate-50 disabled:opacity-50"
>
انصراف
</button>
<button
type="button"
onClick={run}
disabled={loading}
className="rounded-xl bg-yellow-500 px-4 py-2 text-sm font-bold text-black transition hover:bg-yellow-400 disabled:opacity-50"
>
{loading ? "در حال اجرا..." : "اعمال قرعه‌کشی"}
</button>
</div>
</div>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,86 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
type QuizDeleteButtonProps = {
quizId: string;
submissionsCount: number;
};
export default function QuizDeleteButton({ quizId, submissionsCount }: QuizDeleteButtonProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleDelete() {
setLoading(true);
setError(null);
const res = await fetch(`/api/admin/quiz/${quizId}`, { method: "DELETE" });
const data = await res.json();
if (res.ok) {
setOpen(false);
router.refresh();
return;
}
setError(data.error ?? "خطا در حذف کوئیز");
setLoading(false);
}
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="text-red-600 hover:underline text-xs"
>
حذف
</button>
{open && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl">
<h3 className="text-lg font-bold text-gray-900">حذف کوئیز</h3>
<p className="mt-2 text-sm leading-6 text-gray-600">
با حذف کوئیز، تمام سوالها و تمام پاسخهای ثبتشدهی این کوئیز هم پاک میشوند.
</p>
{submissionsCount > 0 && (
<div className="mt-4 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
{submissionsCount} کاربر به این کوئیز پاسخ دادهاند. آیا از حذف آن مطمئن هستید؟
</div>
)}
{error && <p className="mt-4 text-sm text-red-600">{error}</p>}
<div className="mt-6 flex justify-end gap-3">
<button
type="button"
onClick={() => {
if (loading) return;
setOpen(false);
setError(null);
}}
className="rounded-xl border border-slate-200 px-4 py-2 text-sm text-slate-700 transition hover:bg-slate-50"
>
انصراف
</button>
<button
type="button"
onClick={handleDelete}
disabled={loading}
className="rounded-xl bg-red-600 px-4 py-2 text-sm font-bold text-white transition hover:bg-red-500 disabled:opacity-50"
>
{loading ? "در حال حذف..." : "حذف نهایی"}
</button>
</div>
</div>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,424 @@
"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>
);
}

View File

@@ -0,0 +1,50 @@
import { db } from "@/lib/db";
import { requireAdmin } from "@/lib/session";
import { notFound } from "next/navigation";
import QuizForm from "../../QuizForm";
export default async function EditQuizPage({ params }: { params: Promise<{ id: string }> }) {
await requireAdmin();
const { id } = await params;
const quiz = await db.dailyQuiz.findUnique({
where: { id },
include: {
questions: {
orderBy: { order: "asc" },
},
_count: {
select: { submissions: true },
},
},
});
if (!quiz) notFound();
if (quiz.isProcessed) notFound();
return (
<div className="max-w-2xl mx-auto">
<h1 className="text-2xl font-bold mb-6">ویرایش کوییز</h1>
<QuizForm
quizId={quiz.id}
submissionsCount={quiz._count.submissions}
initial={{
date: quiz.date,
windowStart: quiz.windowStart,
windowEnd: quiz.windowEnd,
goldWinnersCount: quiz.goldWinnersCount,
silverWinnersCount: quiz.silverWinnersCount,
bronzeWinnersCount: quiz.bronzeWinnersCount,
goldMinCorrect: quiz.goldMinCorrect,
silverMinCorrect: quiz.silverMinCorrect,
bronzeMinCorrect: quiz.bronzeMinCorrect,
questions: quiz.questions.map((question) => ({
questionText: question.questionText,
options: question.options,
correctAnswer: question.correctAnswer,
})),
}}
/>
</div>
);
}

View File

@@ -0,0 +1,125 @@
import { db } from "@/lib/db";
import { requireAdmin } from "@/lib/session";
import { notFound } from "next/navigation";
import Image from "next/image";
import { formatPersianDate, formatPersianDateTime } from "@/lib/persianDate";
import { CARD_TIER_LABELS, getCardTierBadgeClass, resolveQuizRewardTier } from "@/lib/cardTier";
export default async function QuizResultsPage({ params }: { params: Promise<{ id: string }> }) {
await requireAdmin();
const { id } = await params;
const quiz = await db.dailyQuiz.findUnique({
where: { id },
include: {
questions: { orderBy: { order: "asc" } },
submissions: {
include: { user: { select: { id: true, name: true, email: true } } },
orderBy: [{ score: "desc" }, { submittedAt: "asc" }],
},
},
});
if (!quiz) notFound();
const awardedCards = await db.goldenCard.findMany({
where: { quizId: id },
include: {
user: { select: { id: true, name: true, email: true } },
player: { include: { country: true } },
},
orderBy: { acquiredDate: "desc" },
});
return (
<div className="flex flex-col gap-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold">نتایج کوییز - {formatPersianDate(new Date(quiz.date))}</h1>
<span
className={`text-sm px-3 py-1 rounded-full ${
quiz.isProcessed ? "bg-green-100 text-green-700" : "bg-yellow-100 text-yellow-700"
}`}
>
{quiz.isProcessed ? "تخصیص کارت انجام شده" : "در انتظار تخصیص کارت"}
</span>
</div>
{awardedCards.length > 0 && (
<div className="bg-white rounded-2xl shadow p-6">
<h2 className="font-bold text-lg mb-4">کارت های تخصیص داده شده</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{awardedCards.map((card) => (
<div key={card.id} className="border rounded-xl p-4 flex items-center gap-3 bg-slate-50">
{card.player.image ? (
<div className="relative w-12 h-12 rounded-full overflow-hidden border-2 border-slate-200">
<Image src={`/uploads/players/${card.player.image}`} alt={card.player.name} fill className="object-cover" />
</div>
) : (
<div className="w-12 h-12 rounded-full bg-slate-200 flex items-center justify-center text-xl">*</div>
)}
<div>
<p className="font-bold text-sm">{card.user.name ?? card.user.email}</p>
<p className="text-xs text-gray-500">{card.player.name} - {card.player.country.name}</p>
<div className="mt-1 flex items-center gap-2">
<span className={`rounded-full px-2 py-0.5 text-xs font-bold ${getCardTierBadgeClass(card.cardTier)}`}>
{CARD_TIER_LABELS[card.cardTier]}
</span>
<span
className={`text-xs px-2 py-0.5 rounded-full ${
card.status === "OPENED" ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-600"
}`}
>
{card.status === "OPENED" ? "باز شده" : "مهر شده"}
</span>
</div>
</div>
</div>
))}
</div>
</div>
)}
<div className="bg-white rounded-2xl shadow overflow-hidden">
<div className="px-5 py-4 border-b">
<h2 className="font-bold">همه شرکت کنندگان ({quiz.submissions.length})</h2>
</div>
<table className="w-full text-sm">
<thead className="bg-gray-50 text-gray-600">
<tr>
<th className="text-right px-5 py-3">کاربر</th>
<th className="text-right px-5 py-3">نتیجه</th>
<th className="text-right px-5 py-3">زمان ارسال</th>
</tr>
</thead>
<tbody>
{quiz.submissions.map((submission) => {
const rewardTier = resolveQuizRewardTier(quiz, submission.correctAnswers);
return (
<tr key={submission.id} className={`border-t ${rewardTier ? "bg-green-50" : ""}`}>
<td className="px-5 py-3">{submission.user.name ?? submission.user.email}</td>
<td className="px-5 py-3">
<div className="flex flex-col gap-1">
<span className={`font-bold ${rewardTier ? "text-green-600" : "text-gray-700"}`}>
{submission.score}%
</span>
<span className="text-xs text-gray-500">{submission.correctAnswers} جواب صحیح</span>
{rewardTier && (
<span className={`inline-flex w-fit rounded-full px-2 py-0.5 text-xs font-bold ${getCardTierBadgeClass(rewardTier)}`}>
{CARD_TIER_LABELS[rewardTier]}
</span>
)}
</div>
</td>
<td className="px-5 py-3 text-gray-500 text-xs">
{formatPersianDateTime(new Date(submission.submittedAt))}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,12 @@
import { requireAdmin } from "@/lib/session";
import QuizForm from "../QuizForm";
export default async function NewQuizPage() {
await requireAdmin();
return (
<div className="max-w-2xl mx-auto">
<h1 className="text-2xl font-bold mb-6">کوییز جدید</h1>
<QuizForm />
</div>
);
}

View File

@@ -0,0 +1,129 @@
import { db } from "@/lib/db";
import { requireAdmin } from "@/lib/session";
import Link from "next/link";
import LotteryButton from "./LotteryButton";
import QuizDeleteButton from "./QuizDeleteButton";
import { formatPersianDate, formatPersianTime } from "@/lib/persianDate";
import { resolveQuizRewardTier } from "@/lib/cardTier";
function getTotalWinnersCount(quiz: {
goldWinnersCount: number;
silverWinnersCount: number;
bronzeWinnersCount: number;
}) {
return quiz.goldWinnersCount + quiz.silverWinnersCount + quiz.bronzeWinnersCount;
}
export default async function AdminQuizPage() {
await requireAdmin();
const quizzes = await db.dailyQuiz.findMany({
orderBy: { date: "desc" },
include: {
questions: true,
submissions: {
select: { score: true, correctAnswers: true },
},
_count: { select: { submissions: true } },
},
});
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">کوییز روزانه</h1>
<Link
href="/admin/quiz/new"
className="bg-green-700 text-white px-5 py-2 rounded-xl hover:bg-green-800 transition font-medium"
>
+ کوییز جدید
</Link>
</div>
<div className="bg-white rounded-2xl shadow overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-100 text-gray-600">
<tr>
<th className="text-right px-5 py-4">تاریخ</th>
<th className="text-right px-5 py-4">بازه زمانی</th>
<th className="text-right px-5 py-4">سوالات</th>
<th className="text-right px-5 py-4">شرکتکنندگان</th>
<th className="text-right px-5 py-4">برندگان</th>
<th className="text-right px-5 py-4">وضعیت</th>
<th className="px-5 py-4"></th>
</tr>
</thead>
<tbody>
{quizzes.map((q) => {
const eligibleParticipants =
q.goldMinCorrect != null || q.silverMinCorrect != null || q.bronzeMinCorrect != null
? q.submissions.filter((submission) => resolveQuizRewardTier(q, submission.correctAnswers) !== null).length
: q.submissions.filter((submission) => submission.score === 100).length;
return (
<tr key={q.id} className="border-t hover:bg-gray-50 transition">
<td className="px-5 py-3 font-medium">
{formatPersianDate(new Date(q.date))}
</td>
<td className="px-5 py-3 text-gray-600 text-xs">
{formatPersianTime(new Date(q.windowStart))}
{" - "}
{formatPersianTime(new Date(q.windowEnd))}
</td>
<td className="px-5 py-3">{q.questions.length}</td>
<td className="px-5 py-3">{q._count.submissions}</td>
<td className="px-5 py-3 text-green-700 font-bold">
{getTotalWinnersCount(q)}
<div className="text-[11px] font-normal text-gray-500">
G:{q.goldWinnersCount} | S:{q.silverWinnersCount} | B:{q.bronzeWinnersCount}
</div>
</td>
<td className="px-5 py-3">
{q.isProcessed ? (
<span className="bg-green-100 text-green-700 text-xs px-2 py-1 rounded-full">انجام شده</span>
) : (
<span className="bg-yellow-100 text-yellow-700 text-xs px-2 py-1 rounded-full">در انتظار</span>
)}
</td>
<td className="px-5 py-3 flex gap-2">
<Link href={`/admin/quiz/${q.id}/results`} className="text-blue-600 hover:underline text-xs">
نتایج
</Link>
{!q.isProcessed && (
<Link href={`/admin/quiz/${q.id}/edit`} className="text-emerald-700 hover:underline text-xs">
ویرایش
</Link>
)}
{!q.isProcessed && (
<LotteryButton
quizId={q.id}
goldWinnersCount={q.goldWinnersCount}
silverWinnersCount={q.silverWinnersCount}
bronzeWinnersCount={q.bronzeWinnersCount}
totalParticipants={q._count.submissions}
perfectParticipants={eligibleParticipants}
/>
)}
{!q.isProcessed && (
<QuizDeleteButton
quizId={q.id}
submissionsCount={q._count.submissions}
/>
)}
</td>
</tr>
);
})}
{quizzes.length === 0 && (
<tr>
<td colSpan={7} className="text-center py-10 text-gray-400">
هیچ کوییزی ثبت نشده
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -2,6 +2,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import PersianDateField from "@/components/PersianDateField";
type Round = {
id: string;
@@ -15,27 +16,33 @@ export default function RoundForm({ editRound }: { editRound?: Round }) {
const [form, setForm] = useState({
number: editRound?.number.toString() ?? "",
name: editRound?.name ?? "",
deadline: editRound ? new Date(editRound.deadline).toISOString().slice(0, 16) : "",
deadline: editRound ? String(editRound.deadline) : "",
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!form.deadline) {
setError("مهلت انتخاب تیم را مشخص کنید.");
return;
}
setLoading(true);
setError("");
const method = editRound ? "PUT" : "POST";
const body = editRound
? { id: editRound.id, ...form, number: parseInt(form.number) }
: { ...form, number: parseInt(form.number) };
const body = editRound
? { id: editRound.id, ...form, number: parseInt(form.number, 10) }
: { ...form, number: parseInt(form.number, 10) };
const res = await fetch("/api/rounds", {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (res.ok) {
setForm({ number: "", name: "", deadline: "" });
router.refresh();
@@ -52,28 +59,38 @@ export default function RoundForm({ editRound }: { editRound?: Round }) {
{error && <p className="text-red-500 text-sm bg-red-50 px-3 py-2 rounded-lg">{error}</p>}
<div>
<label className="block text-sm font-medium mb-1">شماره دور</label>
<input type="number" min="1" value={form.number}
<input
type="number"
min="1"
value={form.number}
onChange={(e) => setForm({ ...form, number: e.target.value })}
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
required />
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">نام دور</label>
<input type="text" value={form.name}
<input
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="مثلاً: دور اول - مرحله گروهی"
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
required />
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">آخرین مهلت انتخاب تیم</label>
<input type="datetime-local" value={form.deadline}
onChange={(e) => setForm({ ...form, deadline: e.target.value })}
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
required />
</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">
<PersianDateField
label="آخرین مهلت انتخاب تیم"
value={form.deadline}
onChange={(value) => setForm({ ...form, deadline: value })}
mode="datetime"
required
/>
<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 ? "در حال ذخیره..." : editRound ? "ویرایش دور" : "افزودن دور"}
</button>
</form>

View File

@@ -0,0 +1,290 @@
"use client";
import { useState } from "react";
import Image from "next/image";
type Player = {
id: string;
name: string;
image: string | null;
position: "GK" | "DEF" | "MID" | "FWD";
price: number;
country: { name: string; flagUrl: string | null };
};
type Card = {
id: string;
status: "SEALED" | "OPENED";
state: "IN_INVENTORY" | "IN_TEAM" | "SOLD";
acquiredDate: string;
openedAt: string | null;
player: Player;
};
const POSITION_LABELS: Record<Player["position"], string> = {
GK: "دروازه‌بان",
DEF: "مدافع",
MID: "هافبک",
FWD: "مهاجم",
};
function saleValue(price: number) {
return Math.round(price * 0.7);
}
function SealedCard({ card, onReveal }: { card: Card; onReveal: (id: string) => void }) {
const [opening, setOpening] = useState(false);
async function handleOpen() {
setOpening(true);
await new Promise((resolve) => setTimeout(resolve, 600));
onReveal(card.id);
}
return (
<div
className={`relative rounded-2xl border border-yellow-400/30 bg-gradient-to-br from-yellow-900/30 to-amber-900/20 backdrop-blur p-6 flex flex-col items-center gap-4 cursor-pointer hover:border-yellow-400/60 transition-all duration-300 ${opening ? "scale-95 opacity-70" : "hover:scale-105"}`}
onClick={!opening ? handleOpen : undefined}
>
<div className="absolute inset-0 rounded-2xl bg-yellow-400/5 blur-xl" />
<div className="relative z-10 flex flex-col items-center gap-3">
<div className={`w-20 h-20 rounded-2xl bg-gradient-to-br from-yellow-400 to-amber-600 flex items-center justify-center text-4xl shadow-lg shadow-yellow-500/30 ${opening ? "animate-spin" : "animate-pulse"}`}>
🎴
</div>
<p className="font-bold text-yellow-300">کارت ویژه مهر شده</p>
<p className="text-xs text-gray-400">دریافت: {new Date(card.acquiredDate).toLocaleDateString("fa-IR")}</p>
<button className="mt-1 px-5 py-2 rounded-xl bg-gradient-to-r from-yellow-500 to-amber-500 text-black font-bold text-sm hover:opacity-90 transition shadow-lg shadow-yellow-500/20">
{opening ? "در حال باز شدن..." : "باز کردن کارت"}
</button>
</div>
</div>
);
}
function OpenedCard({
card,
loading,
onAdd,
onSell,
}: {
card: Card;
loading: boolean;
onAdd?: () => void;
onSell?: () => void;
}) {
return (
<div className="relative rounded-2xl border border-white/10 bg-white/5 backdrop-blur overflow-hidden">
<div className="relative z-10 p-5 flex flex-col items-center gap-3">
<div className="relative w-20 h-20 rounded-full overflow-hidden bg-gray-900 border-2 border-amber-400/50">
{card.player.image ? (
<Image
src={`/uploads/players/${card.player.image}`}
alt={card.player.name}
width={80}
height={80}
className="object-cover w-full h-full"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-3xl"></div>
)}
</div>
<div className="text-center">
<p className="font-bold text-white">{card.player.name}</p>
<p className="text-xs text-gray-400">{card.player.country.name}</p>
</div>
<span className="text-xs font-bold px-3 py-1 rounded-full bg-amber-500 text-white">
{POSITION_LABELS[card.player.position]}
</span>
<div className="text-xs text-amber-300">
{card.state === "IN_TEAM" ? "در تیم" : `فروش: ${saleValue(card.player.price)}M`}
</div>
<div className="w-full flex gap-2 pt-2">
{card.state === "IN_INVENTORY" && onAdd && (
<button
onClick={onAdd}
disabled={loading}
className="flex-1 rounded-xl bg-green-700 py-2 text-sm font-bold text-white hover:bg-green-800 disabled:opacity-50 transition"
>
افزودن به تیم
</button>
)}
{card.state !== "SOLD" && onSell && (
<button
onClick={onSell}
disabled={loading}
className="flex-1 rounded-xl bg-amber-500 py-2 text-sm font-bold text-white hover:bg-amber-600 disabled:opacity-50 transition"
>
فروش
</button>
)}
</div>
</div>
</div>
);
}
export default function GoldenCardsClient({ initialCards }: { initialCards: Card[] }) {
const [cards, setCards] = useState<Card[]>(initialCards);
const [revealedCard, setRevealedCard] = useState<Card | null>(null);
const [loadingId, setLoadingId] = useState<string | null>(null);
const [replacementDialog, setReplacementDialog] = useState<{
card: Card;
candidates: Array<{ playerId: string; name: string; isBench: boolean; isSpecial: boolean }>;
} | null>(null);
async function handleReveal(cardId: string) {
const res = await fetch(`/api/golden-cards/${cardId}/reveal`, { method: "POST" });
if (res.ok) {
const updated: Card = await res.json();
setCards((prev) => prev.map((card) => (card.id === cardId ? updated : card)));
setRevealedCard(updated);
}
}
async function handleSell(cardId: string) {
setLoadingId(cardId);
const res = await fetch(`/api/golden-cards/${cardId}/sell`, { method: "POST" });
if (res.ok) {
setCards((prev) => prev.map((card) => (card.id === cardId ? { ...card, state: "SOLD" } : card)));
}
setLoadingId(null);
}
async function handleAdd(cardId: string, replacePlayerId?: string) {
setLoadingId(cardId);
const res = await fetch(`/api/golden-cards/${cardId}/add-to-team`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(replacePlayerId ? { replacePlayerId } : {}),
});
const data = await res.json().catch(() => null);
if (res.ok) {
setCards((prev) =>
prev.map((card) => {
if (card.id === cardId) return { ...card, state: "IN_TEAM" };
if (data?.replacedGoldenCardId && card.id === data.replacedGoldenCardId) return { ...card, state: "IN_INVENTORY" };
return card;
})
);
setReplacementDialog(null);
} else if (res.status === 409 && data?.needsReplacement) {
const card = cards.find((item) => item.id === cardId);
if (card) {
setReplacementDialog({ card, candidates: data.candidates });
}
}
setLoadingId(null);
}
const sealed = cards.filter((card) => card.status === "SEALED");
const opened = cards.filter((card) => card.status === "OPENED" && card.state !== "SOLD");
const sold = cards.filter((card) => card.state === "SOLD");
return (
<div className="max-w-4xl mx-auto">
{replacementDialog && (
<div className="fixed inset-0 z-50 bg-black/70 p-4 flex items-center justify-center">
<div className="w-full max-w-md rounded-3xl bg-white p-6 shadow-2xl">
<h3 className="text-xl font-bold mb-2">پست بازیکن پر است</h3>
<p className="text-sm text-gray-600 mb-4">
برای اضافه کردن {replacementDialog.card.player.name} یکی از بازیکنان این پست را انتخاب کنید.
</p>
<div className="space-y-2">
{replacementDialog.candidates.map((candidate) => (
<button
key={candidate.playerId}
onClick={() => handleAdd(replacementDialog.card.id, candidate.playerId)}
className="w-full rounded-2xl border border-gray-200 px-4 py-3 text-right hover:border-green-500 hover:bg-green-50 transition"
>
<div className="font-bold text-black">{candidate.name}</div>
<div className="text-xs text-gray-500">
{candidate.isBench ? "ذخیره" : "فیکس"}
{candidate.isSpecial ? " | کارت ویژه" : ""}
</div>
</button>
))}
</div>
<button onClick={() => setReplacementDialog(null)} className="w-full mt-4 rounded-2xl bg-gray-100 py-3 text-sm font-bold text-gray-700">
بستن
</button>
</div>
</div>
)}
<div className="text-center mb-10">
<h1 className="text-3xl font-black mb-2 text-transparent bg-clip-text bg-gradient-to-r from-yellow-400 to-amber-500">
کارت ویژه
</h1>
<p className="text-gray-400">بازیکنان ویژه را به تیم اضافه کنید یا با فروش آنها بودجه بگیرید.</p>
</div>
{revealedCard && (
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4" onClick={() => setRevealedCard(null)}>
<div className="bg-gray-900 border border-yellow-400/30 rounded-3xl p-8 max-w-sm w-full text-center" onClick={(e) => e.stopPropagation()}>
<p className="text-yellow-400 font-bold mb-4 text-lg">کارت شما باز شد</p>
<OpenedCard
card={revealedCard}
loading={loadingId === revealedCard.id}
onAdd={revealedCard.state === "IN_INVENTORY" ? () => handleAdd(revealedCard.id) : undefined}
onSell={() => handleSell(revealedCard.id)}
/>
<button onClick={() => setRevealedCard(null)} className="mt-6 px-6 py-2 rounded-xl bg-white/10 hover:bg-white/20 transition text-sm">
بستن
</button>
</div>
</div>
)}
{cards.length === 0 && (
<div className="text-center py-20 text-gray-500">
<div className="text-5xl mb-4">🎴</div>
<p>هنوز کارت ویژه ندارید</p>
<p className="text-sm mt-1">در کوییز روزانه شرکت کنید تا شانس دریافت کارت ویژه داشته باشید.</p>
</div>
)}
{sealed.length > 0 && (
<div className="mb-8">
<h2 className="font-bold text-lg mb-4 text-yellow-400">کارتهای مهر شده ({sealed.length})</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{sealed.map((card) => (
<SealedCard key={card.id} card={card} onReveal={handleReveal} />
))}
</div>
</div>
)}
{opened.length > 0 && (
<div className="mb-8">
<h2 className="font-bold text-lg mb-4 text-gray-300">کارتهای آماده استفاده ({opened.length})</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{opened.map((card) => (
<OpenedCard
key={card.id}
card={card}
loading={loadingId === card.id}
onAdd={card.state === "IN_INVENTORY" ? () => handleAdd(card.id) : undefined}
onSell={() => handleSell(card.id)}
/>
))}
</div>
</div>
)}
{sold.length > 0 && (
<div>
<h2 className="font-bold text-lg mb-4 text-gray-500">فروختهشدهها ({sold.length})</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 opacity-70">
{sold.map((card) => (
<OpenedCard key={card.id} card={card} loading={false} />
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,20 @@
import { requireAuth } from "@/lib/session";
import { db } from "@/lib/db";
import GoldenCardsClient from "./GoldenCardsClient";
export default async function GoldenCardsPage() {
const session = await requireAuth();
const userId = (session.user as any).id;
const cards = await db.goldenCard.findMany({
where: { userId },
include: { player: { include: { country: true } } },
orderBy: { acquiredDate: "desc" },
});
return (
<div className="min-h-screen bg-gray-950 text-white py-10 px-4">
<GoldenCardsClient initialCards={cards as any} />
</div>
);
}

View File

@@ -0,0 +1,254 @@
"use client";
import { useState, useEffect, useCallback } from "react";
type Question = { id: string; questionText: string; options: string[]; order: number };
type Quiz = {
id: string;
windowStart: string | Date;
windowEnd: string | Date;
isProcessed: boolean;
questions: Question[];
};
function useCountdown(target: Date) {
const calc = useCallback(() => Math.max(0, target.getTime() - Date.now()), [target]);
const [ms, setMs] = useState(calc);
useEffect(() => {
const t = setInterval(() => setMs(calc()), 1000);
return () => clearInterval(t);
}, [calc]);
const s = Math.floor(ms / 1000);
return { hours: Math.floor(s / 3600), minutes: Math.floor((s % 3600) / 60), seconds: s % 60, done: ms === 0 };
}
function CountdownUnit({ value, label }: { value: number; label: string }) {
return (
<div className="flex flex-col items-center">
<div className="bg-white/10 backdrop-blur border border-white/20 rounded-xl w-16 h-16 flex items-center justify-center text-2xl font-bold tabular-nums">
{String(value).padStart(2, "0")}
</div>
<span className="text-xs text-gray-400 mt-1">{label}</span>
</div>
);
}
export default function DailyQuizClient({
quiz,
alreadySubmitted,
}: {
quiz: Quiz | null;
alreadySubmitted: boolean;
}) {
const [answers, setAnswers] = useState<(number | null)[]>(
quiz ? Array(quiz.questions.length).fill(null) : []
);
const [step, setStep] = useState(0);
const [submitted, setSubmitted] = useState(alreadySubmitted);
const [result, setResult] = useState<{
score: number;
correct: number;
total: number;
rewardTier: "GOLD" | "SILVER" | "BRONZE" | null;
rewardTierLabel: string | null;
} | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const windowEnd = quiz ? new Date(quiz.windowEnd) : new Date();
const windowStart = quiz ? new Date(quiz.windowStart) : new Date();
const countdown = useCountdown(windowEnd);
const startCountdown = useCountdown(windowStart);
const now = new Date();
const isProcessed = quiz?.isProcessed ?? false;
const isActive = quiz ? !isProcessed && now >= windowStart && now <= windowEnd : false;
const notStarted = quiz ? !isProcessed && now < windowStart : false;
async function handleSubmit() {
if (!quiz) return;
if (answers.some((a) => a === null)) {
setError("لطفاً به همه سوالات پاسخ دهید");
return;
}
setLoading(true);
setError("");
const res = await fetch("/api/quiz/submit", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ quizId: quiz.id, answers }),
});
const data = await res.json();
if (res.ok) {
setResult(data);
setSubmitted(true);
} else {
setError(data.error ?? "خطا");
}
setLoading(false);
}
if (!quiz) {
return (
<div className="max-w-lg mx-auto text-center py-20">
<div className="text-6xl mb-4">*</div>
<h1 className="text-2xl font-bold mb-2">کوییزی برای امروز وجود ندارد</h1>
<p className="text-gray-400">فردا دوباره بیا</p>
</div>
);
}
if (submitted) {
return (
<div className="max-w-lg mx-auto text-center py-20">
<div className="bg-white/5 backdrop-blur border border-white/10 rounded-2xl p-8">
{result ? (
<>
<div className="text-6xl mb-4">{result.score === 100 ? "*" : result.score >= 50 ? "+" : "-"}</div>
<h2 className="text-2xl font-bold mb-2">نتیجه شما</h2>
<div className="text-5xl font-black text-transparent bg-clip-text bg-gradient-to-r from-yellow-400 to-amber-500 my-4">
{result.score}%
</div>
<p className="text-gray-300">{result.correct} از {result.total} سوال صحیح</p>
{result.score === 100 && (
<p className="mt-4 text-green-400 font-medium">شما در قرعهکشی Golden Card شرکت دارید</p>
)}
</>
) : (
<>
<div className="text-5xl mb-4">OK</div>
<h2 className="text-xl font-bold">پاسخهای شما ثبت شد</h2>
</>
)}
</div>
</div>
);
}
if (notStarted) {
return (
<div className="max-w-lg mx-auto text-center py-20">
<div className="bg-white/5 backdrop-blur border border-white/10 rounded-2xl p-8">
<p className="text-gray-400 mb-4">کوییز هنوز شروع نشده و در این زمان باز میشود:</p>
<div className="flex justify-center gap-3">
<CountdownUnit value={startCountdown.hours} label="ساعت" />
<CountdownUnit value={startCountdown.minutes} label="دقیقه" />
<CountdownUnit value={startCountdown.seconds} label="ثانیه" />
</div>
</div>
</div>
);
}
if (!isActive) {
return (
<div className="max-w-lg mx-auto text-center py-20">
<div className="text-5xl mb-4">!</div>
<h2 className="text-xl font-bold">
{isProcessed ? "این کوییز بعد از قرعه‌کشی بسته شده است" : "بازه زمانی کوییز به پایان رسیده"}
</h2>
</div>
);
}
const q = quiz.questions[step];
const progress = ((step + 1) / quiz.questions.length) * 100;
return (
<div className="max-w-xl mx-auto">
<div className="text-center mb-8">
<h1 className="text-2xl font-bold mb-1">کوییز روزانه</h1>
<p className="text-gray-400 text-sm">پاسخ صحیح = شانس برنده شدن Golden Card</p>
</div>
<div className="flex justify-center gap-3 mb-8">
<CountdownUnit value={countdown.hours} label="ساعت" />
<CountdownUnit value={countdown.minutes} label="دقیقه" />
<CountdownUnit value={countdown.seconds} label="ثانیه" />
</div>
<div className="mb-6">
<div className="flex justify-between text-xs text-gray-400 mb-1">
<span>سوال {step + 1} از {quiz.questions.length}</span>
<span>{Math.round(progress)}%</span>
</div>
<div className="h-1.5 bg-white/10 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-yellow-400 to-amber-500 rounded-full transition-all duration-500"
style={{ width: `${progress}%` }}
/>
</div>
</div>
<div className="bg-white/5 backdrop-blur border border-white/10 rounded-2xl p-6 mb-4">
<p className="text-lg font-medium mb-6 leading-relaxed">{q.questionText}</p>
<div className="flex flex-col gap-3">
{q.options.map((opt, oi) => (
<button
key={oi}
type="button"
onClick={() => {
const updated = [...answers];
updated[step] = oi;
setAnswers(updated);
}}
className={`text-right px-4 py-3 rounded-xl border transition-all text-sm ${
answers[step] === oi
? "border-yellow-400 bg-yellow-400/10 text-yellow-300"
: "border-white/10 hover:border-white/30 hover:bg-white/5"
}`}
>
<span className="font-bold ml-2 text-gray-400">{["الف", "ب", "ج", "د"][oi]})</span>
{opt}
</button>
))}
</div>
</div>
{error && <p className="text-red-400 text-sm text-center mb-3">{error}</p>}
<div className="flex gap-3">
{step > 0 && (
<button
type="button"
onClick={() => setStep(step - 1)}
className="flex-1 py-3 rounded-xl border border-white/20 hover:bg-white/5 transition text-sm"
>
قبلی
</button>
)}
{step < quiz.questions.length - 1 ? (
<button
type="button"
onClick={() => {
if (answers[step] === null) {
setError("لطفاً یک گزینه انتخاب کنید");
return;
}
setError("");
setStep(step + 1);
}}
className="flex-1 py-3 rounded-xl bg-gradient-to-r from-yellow-500 to-amber-500 text-black font-bold hover:opacity-90 transition text-sm"
>
بعدی
</button>
) : (
<button
type="button"
onClick={handleSubmit}
disabled={loading}
className="flex-1 py-3 rounded-xl bg-gradient-to-r from-green-500 to-emerald-500 text-white font-bold hover:opacity-90 transition text-sm disabled:opacity-50"
>
{loading ? "در حال ارسال..." : "ثبت پاسخ‌ها"}
</button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,115 @@
import { requireAuth } from "@/lib/session";
import { db } from "@/lib/db";
import { formatPersianDate, formatPersianDateTime } from "@/lib/persianDate";
export default async function QuizHistoryPage() {
const session = await requireAuth();
const userId = (session.user as any).id;
const submissions = await db.quizSubmission.findMany({
where: { userId },
include: {
quiz: {
include: { questions: { orderBy: { order: "asc" } } },
},
},
orderBy: { submittedAt: "desc" },
});
return (
<div className="min-h-screen bg-gray-950 text-white py-10 px-4">
<div className="max-w-3xl mx-auto">
<h1 className="text-2xl font-bold mb-6">تاریخچه کوییزها</h1>
{submissions.length === 0 && (
<div className="text-center py-20 text-gray-500">
<div className="text-5xl mb-4">📋</div>
<p>هنوز در هیچ کوییزی شرکت نکردهاید</p>
</div>
)}
<div className="flex flex-col gap-4">
{submissions.map((sub) => {
const correct = sub.answers.filter((ans, i) => ans === sub.quiz.questions[i]?.correctAnswer).length;
const total = sub.quiz.questions.length;
return (
<div
key={sub.id}
className={`bg-white/5 backdrop-blur border rounded-2xl p-5 ${
sub.score === 100 ? "border-yellow-400/30" : "border-white/10"
}`}
>
<div className="flex justify-between items-start mb-3">
<div>
<p className="font-bold text-lg">
{formatPersianDate(new Date(sub.quiz.date))}
</p>
<p className="text-xs text-gray-400">
{formatPersianDateTime(new Date(sub.submittedAt))}
</p>
</div>
<div className="text-left">
<div
className={`text-2xl font-black ${
sub.score === 100
? "text-yellow-400"
: sub.score >= 50
? "text-green-400"
: "text-red-400"
}`}
>
{sub.score}%
</div>
<p className="text-xs text-gray-400">
{correct} از {total}
</p>
</div>
</div>
{sub.score === 100 && (
<div className="bg-yellow-400/10 border border-yellow-400/30 rounded-lg px-3 py-2 text-xs text-yellow-300 flex items-center gap-2">
<span>🏆</span>
<span>واجد شرایط قرعهکشی Golden Card</span>
</div>
)}
{/* Show answers */}
<details className="mt-4">
<summary className="cursor-pointer text-sm text-gray-400 hover:text-white transition">
مشاهده جزئیات
</summary>
<div className="mt-3 flex flex-col gap-3">
{sub.quiz.questions.map((q, i) => {
const userAnswer = sub.answers[i];
const isCorrect = userAnswer === q.correctAnswer;
return (
<div
key={q.id}
className={`border rounded-lg p-3 text-sm ${
isCorrect ? "border-green-500/30 bg-green-500/5" : "border-red-500/30 bg-red-500/5"
}`}
>
<p className="font-medium mb-2">{q.questionText}</p>
<div className="flex flex-col gap-1 text-xs">
<p className={isCorrect ? "text-green-400" : "text-red-400"}>
پاسخ شما: {q.options[userAnswer ?? 0]} {isCorrect ? "✓" : "✗"}
</p>
{!isCorrect && (
<p className="text-green-400">پاسخ صحیح: {q.options[q.correctAnswer]}</p>
)}
</div>
</div>
);
})}
</div>
</details>
</div>
);
})}
</div>
</div>
</div>
);
}

36
app/(user)/quiz/page.tsx Normal file
View File

@@ -0,0 +1,36 @@
import { requireAuth } from "@/lib/session";
import { db } from "@/lib/db";
import DailyQuizClient from "./DailyQuizClient";
export default async function QuizPage() {
const session = await requireAuth();
const userId = (session.user as any).id;
const now = new Date();
const todayStart = new Date(now);
todayStart.setHours(0, 0, 0, 0);
const todayEnd = new Date(now);
todayEnd.setHours(23, 59, 59, 999);
const quiz = await db.dailyQuiz.findFirst({
where: { date: { gte: todayStart, lte: todayEnd } },
include: {
questions: {
orderBy: { order: "asc" },
select: { id: true, questionText: true, options: true, order: true },
},
},
});
const alreadySubmitted = quiz
? !!(await db.quizSubmission.findUnique({
where: { userId_quizId: { userId, quizId: quiz.id } },
}))
: false;
return (
<div className="min-h-screen bg-gray-950 text-white py-10 px-4">
<DailyQuizClient quiz={quiz} alreadySubmitted={alreadySubmitted} />
</div>
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useRef } from "react";
import { useMemo, useState } from "react";
import PositionBadge from "@/components/PositionBadge";
import Image from "next/image";
@@ -8,14 +8,15 @@ type Player = {
id: string;
name: string;
image: string | null;
position: string;
position: "GK" | "DEF" | "MID" | "FWD";
price: number;
totalPoints: number;
country: { name: string; code: string; flagUrl?: string | null };
country: { name: string; code: string; flagUrl?: string | null; isEliminated?: boolean };
};
type TeamPlayer = {
playerId: string;
goldenCardId: string | null;
isCaptain: boolean;
isViceCaptain: boolean;
isBench: boolean;
@@ -33,53 +34,103 @@ type Team = {
players: TeamPlayer[];
} | null;
const FORMATIONS: Record<string, { label: string; def: number; mid: number; fwd: number }> = {
"4-3-3": { label: "۴-۳-۳", def: 4, mid: 3, fwd: 3 },
"4-4-2": { label: "۴-۴-۲", def: 4, mid: 4, fwd: 2 },
"4-5-1": { label: "۴-۵-۱", def: 4, mid: 5, fwd: 1 },
"3-5-2": { label: "۳-۵-۲", def: 3, mid: 5, fwd: 2 },
"3-4-3": { label: "۳-۴-۳", def: 3, mid: 4, fwd: 3 },
"5-3-2": { label: "۵-۳-۲", def: 5, mid: 3, fwd: 2 },
"5-4-1": { label: "۵-۴-۱", def: 5, mid: 4, fwd: 1 },
type SpecialCard = {
id: string;
status: "SEALED" | "OPENED";
state: "IN_INVENTORY" | "IN_TEAM" | "SOLD";
acquiredDate: string;
openedAt: string | null;
player: Player;
teamPlayer?: { playerId: string; teamId: string } | null;
};
const POS_COLORS: Record<string, string> = {
GK: "bg-yellow-400 text-yellow-900 border-yellow-500",
DEF: "bg-blue-500 text-white border-blue-600",
MID: "bg-green-500 text-white border-green-600",
FWD: "bg-red-500 text-white border-red-600",
type ReplacementCandidate = {
playerId: string;
name: string;
isBench: boolean;
isSpecial: boolean;
};
const FORMATIONS: Record<string, { label: string; def: number; mid: number; fwd: number }> = {
"4-3-3": { label: "4-3-3", def: 4, mid: 3, fwd: 3 },
"4-4-2": { label: "4-4-2", def: 4, mid: 4, fwd: 2 },
"4-5-1": { label: "4-5-1", def: 4, mid: 5, fwd: 1 },
"3-5-2": { label: "3-5-2", def: 3, mid: 5, fwd: 2 },
"3-4-3": { label: "3-4-3", def: 3, mid: 4, fwd: 3 },
"5-3-2": { label: "5-3-2", def: 5, mid: 3, fwd: 2 },
"5-4-1": { label: "5-4-1", def: 5, mid: 4, fwd: 1 },
};
const POSITION_LABELS: Record<Player["position"], string> = {
GK: "دروازه‌بان",
DEF: "مدافع",
MID: "هافبک",
FWD: "مهاجم",
};
function formatSaleValue(price: number) {
return Math.round(price * 0.7);
}
function isSpecialTeamPlayer(tp: TeamPlayer) {
return Boolean(tp.goldenCardId);
}
export default function TeamBuilder({
team: initialTeam,
allPlayers,
initialSpecialCards,
}: {
team: Team;
allPlayers: Player[];
initialSpecialCards: SpecialCard[];
}) {
const [team, setTeam] = useState<Team>(initialTeam);
const [specialCards, setSpecialCards] = useState<SpecialCard[]>(initialSpecialCards);
const [teamName, setTeamName] = useState("");
const [formation, setFormation] = useState(initialTeam?.formation ?? "4-3-3");
const [filter, setFilter] = useState("");
const [posFilter, setPosFilter] = useState("");
const [loading, setLoading] = useState(false);
const [msg, setMsg] = useState<{ text: string; type: "error" | "success" } | null>(null);
const [draggedId, setDraggedId] = useState<string | null>(null);
const [submitLoading, setSubmitLoading] = useState(false);
const [replacementDialog, setReplacementDialog] = useState<{
card: SpecialCard;
candidates: ReplacementCandidate[];
} | null>(null);
const spent = team?.players.filter((tp) => !tp.isBench).reduce((s, tp) => s + tp.player.price, 0) ?? 0;
const benchSpent = team?.players.filter((tp) => tp.isBench).reduce((s, tp) => s + tp.player.price, 0) ?? 0;
const remaining = (team?.budget ?? 100) - spent - benchSpent;
const specialPlayerIds = useMemo(
() => new Set(specialCards.filter((card) => card.state !== "SOLD").map((card) => card.player.id)),
[specialCards]
);
const spent = team?.players
.filter((tp) => !isSpecialTeamPlayer(tp))
.reduce((sum, tp) => sum + tp.player.price, 0) ?? 0;
const remaining = (team?.budget ?? 100) - spent;
const fmt = FORMATIONS[formation] ?? FORMATIONS["4-3-3"];
const starters = team?.players.filter((tp) => !tp.isBench) ?? [];
const bench = team?.players.filter((tp) => tp.isBench) ?? [];
const specialSlotsUsed = team?.players.filter(isSpecialTeamPlayer).length ?? 0;
const gkSlots = starters.filter((tp) => tp.player.position === "GK");
const defSlots = starters.filter((tp) => tp.player.position === "DEF");
const midSlots = starters.filter((tp) => tp.player.position === "MID");
const fwdSlots = starters.filter((tp) => tp.player.position === "FWD");
const myPlayerIds = new Set(team?.players.map((tp) => tp.playerId) ?? []);
const filtered = allPlayers.filter(
(p) =>
!myPlayerIds.has(p.id) &&
!specialPlayerIds.has(p.id) &&
(posFilter ? p.position === posFilter : true) &&
(filter ? p.name.includes(filter) || p.country.name.includes(filter) : true)
);
const inventoryCards = specialCards.filter((card) => card.state === "IN_INVENTORY");
const inTeamCards = specialCards.filter((card) => card.state === "IN_TEAM");
const sealedCount = specialCards.filter((card) => card.status === "SEALED").length;
async function createTeam() {
if (!teamName.trim()) return;
setLoading(true);
@@ -89,8 +140,12 @@ export default function TeamBuilder({
body: JSON.stringify({ name: teamName, formation }),
});
const data = await res.json();
if (res.ok) setTeam({ ...data, players: [] });
else setMsg({ text: data.error, type: "error" });
if (res.ok) {
setTeam({ ...data, players: [] });
setMsg(null);
} else {
setMsg({ text: data.error, type: "error" });
}
setLoading(false);
}
@@ -104,7 +159,9 @@ export default function TeamBuilder({
const data = await res.json();
if (res.ok) {
const player = allPlayers.find((p) => p.id === playerId)!;
setTeam((t) => t ? { ...t, players: [...t.players, { ...data, player }] } : t);
setTeam((current) =>
current ? { ...current, players: [...current.players, { ...data, goldenCardId: null, player }] } : current
);
setMsg(null);
} else {
setMsg({ text: data.error, type: "error" });
@@ -114,12 +171,125 @@ export default function TeamBuilder({
async function removePlayer(playerId: string) {
setLoading(true);
await fetch("/api/team/players", {
const teamPlayer = team?.players.find((tp) => tp.playerId === playerId) ?? null;
const res = await fetch("/api/team/players", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ playerId }),
});
setTeam((t) => t ? { ...t, players: t.players.filter((tp) => tp.playerId !== playerId) } : t);
const data = await res.json().catch(() => null);
if (res.ok) {
setTeam((current) =>
current ? { ...current, players: current.players.filter((tp) => tp.playerId !== playerId) } : current
);
if (teamPlayer?.goldenCardId) {
setSpecialCards((current) =>
current.map((card) => (card.id === teamPlayer.goldenCardId ? { ...card, state: "IN_INVENTORY" } : card))
);
setMsg({ text: "بازیکن ویژه از تیم خارج شد و به کارت ویژه برگشت", type: "success" });
} else {
setMsg(null);
}
} else if (data?.error) {
setMsg({ text: data.error, type: "error" });
}
setLoading(false);
}
async function sellSpecialCard(cardId: string) {
setLoading(true);
const res = await fetch(`/api/golden-cards/${cardId}/sell`, { method: "POST" });
const data = await res.json();
if (res.ok) {
setSpecialCards((current) => current.map((card) => (card.id === cardId ? { ...card, state: "SOLD" } : card)));
setTeam((current) => {
if (!current) return current;
const soldCard = specialCards.find((card) => card.id === cardId);
return {
...current,
budget: current.budget + data.addedBudget,
players: soldCard ? current.players.filter((tp) => tp.goldenCardId !== cardId) : current.players,
};
});
setMsg({ text: `${data.addedBudget} میلیون به بودجه تیم اضافه شد`, type: "success" });
} else {
setMsg({ text: data.error, type: "error" });
}
setLoading(false);
}
function mergeSpecialPlayer(card: SpecialCard, teamPlayer: { playerId: string; isBench: boolean; goldenCardId: string }) {
setTeam((current) => {
if (!current) return current;
const existing = current.players.find((tp) => tp.playerId === teamPlayer.playerId);
if (existing) {
return {
...current,
players: current.players.map((tp) =>
tp.playerId === teamPlayer.playerId ? { ...tp, goldenCardId: card.id, isBench: teamPlayer.isBench } : tp
),
};
}
return {
...current,
players: [
...current.players,
{
playerId: card.player.id,
goldenCardId: card.id,
isCaptain: false,
isViceCaptain: false,
isBench: teamPlayer.isBench,
positionIndex: 0,
player: card.player,
},
],
};
});
}
async function addSpecialCardToTeam(card: SpecialCard, replacePlayerId?: string) {
setLoading(true);
const res = await fetch(`/api/golden-cards/${card.id}/add-to-team`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(replacePlayerId ? { replacePlayerId } : {}),
});
const data = await res.json();
if (res.ok) {
setReplacementDialog(null);
setSpecialCards((current) => current.map((item) => (item.id === card.id ? { ...item, state: "IN_TEAM" } : item)));
if (data.replacedGoldenCardId) {
setSpecialCards((current) =>
current.map((item) => (item.id === data.replacedGoldenCardId ? { ...item, state: "IN_INVENTORY" } : item))
);
}
if (data.replacedPlayerId) {
setTeam((current) =>
current
? { ...current, players: current.players.filter((tp) => tp.playerId !== data.replacedPlayerId) }
: current
);
}
mergeSpecialPlayer(card, {
playerId: card.player.id,
isBench: data.teamPlayer.isBench,
goldenCardId: card.id,
});
setMsg({ text: data.message ?? `بازیکن ویژه در ${data.placement} قرار گرفت`, type: "success" });
} else if (res.status === 409 && data.needsReplacement) {
setReplacementDialog({ card, candidates: data.candidates });
} else {
setMsg({ text: data.error, type: "error" });
}
setLoading(false);
}
@@ -129,11 +299,11 @@ export default function TeamBuilder({
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ playerId, type }),
});
setTeam((t) => {
if (!t) return t;
setTeam((current) => {
if (!current) return current;
return {
...t,
players: t.players.map((tp) => ({
...current,
players: current.players.map((tp) => ({
...tp,
isCaptain: type === "captain" ? tp.playerId === playerId : tp.isCaptain,
isViceCaptain: type === "vice" ? tp.playerId === playerId : tp.isViceCaptain,
@@ -147,69 +317,52 @@ export default function TeamBuilder({
const res = await fetch("/api/team/submit", { method: "POST" });
const data = await res.json();
if (res.ok) {
setTeam((t) => t ? { ...t, status: "PENDING" } : t);
setMsg({ text: "تیم برای تایید ارسال شد", type: "success" });
setTeam((current) => (current ? { ...current, status: "ACTIVE" } : current));
setMsg({ text: "تیم ثبت شد و وارد رقابت شد", type: "success" });
} else {
setMsg({ text: data.error, type: "error" });
}
setSubmitLoading(false);
}
// drag & drop swap
function onDragStart(playerId: string) { setDraggedId(playerId); }
function onDrop(targetId: string) {
if (!draggedId || draggedId === targetId) return;
setTeam((t) => {
if (!t) return t;
const a = t.players.find((p) => p.playerId === draggedId);
const b = t.players.find((p) => p.playerId === targetId);
if (!a || !b) return t;
// swap bench status
return {
...t,
players: t.players.map((p) => {
if (p.playerId === draggedId) return { ...p, isBench: b.isBench };
if (p.playerId === targetId) return { ...p, isBench: a.isBench };
return p;
}),
};
});
setDraggedId(null);
}
const myPlayerIds = new Set(team?.players.map((tp) => tp.playerId) ?? []);
const filtered = allPlayers.filter(
(p) =>
!myPlayerIds.has(p.id) &&
(posFilter ? p.position === posFilter : true) &&
(filter ? p.name.includes(filter) || p.country.name.includes(filter) : true)
);
const isComplete = starters.length === 11 && bench.length >= 4;
const canSubmit = isComplete && team?.status === "INACTIVE";
const isComplete = starters.length === 11 && bench.length === 4;
const canSubmit = isComplete && team?.status !== "ACTIVE";
if (!team) {
return (
<div className="max-w-md mx-auto py-20 px-6 text-center">
<div className="text-6xl mb-6"></div>
<h1 className="text-2xl font-bold mb-2">تیمت رو بساز</h1>
<p className="text-gray-500 mb-8 text-sm">با بودجه ۱۰۰ میلیون، ۱۵ بازیکن انتخاب کن</p>
<input type="text" placeholder="نام تیم" value={teamName}
<h1 className="text-2xl font-bold mb-2">تیمت را بساز</h1>
<p className="text-gray-500 mb-8 text-sm">با بودجه 100 میلیون، 15 بازیکن برای تیمت انتخاب کن.</p>
<input
type="text"
placeholder="نام تیم"
value={teamName}
onChange={(e) => setTeamName(e.target.value)}
className="w-full border-2 rounded-xl px-4 py-3 mb-4 focus:outline-none focus:border-green-500 text-center text-lg" />
className="w-full border-2 rounded-xl px-4 py-3 mb-4 focus:outline-none focus:border-green-500 text-center text-lg"
/>
<div className="mb-4">
<label className="block text-sm font-medium mb-2 text-right">ترکیب</label>
<div className="grid grid-cols-4 gap-2">
{Object.entries(FORMATIONS).map(([key, val]) => (
<button key={key} type="button" onClick={() => setFormation(key)}
className={`py-2 rounded-xl text-sm font-bold border-2 transition ${formation === key ? "bg-green-700 text-white border-green-700" : "bg-white border-gray-200 hover:border-green-400"}`}>
{Object.entries(FORMATIONS).map(([key]) => (
<button
key={key}
type="button"
onClick={() => setFormation(key)}
className={`py-2 rounded-xl text-sm font-bold border-2 transition ${
formation === key ? "bg-green-700 text-white border-green-700" : "bg-white border-gray-200 hover:border-green-400"
}`}
>
{key}
</button>
))}
</div>
</div>
<button onClick={createTeam} disabled={loading || !teamName.trim()}
className="w-full bg-green-700 text-white py-3 rounded-xl font-bold text-lg hover:bg-green-800 transition disabled:opacity-50">
<button
onClick={createTeam}
disabled={loading || !teamName.trim()}
className="w-full bg-green-700 text-white py-3 rounded-xl font-bold text-lg hover:bg-green-800 transition disabled:opacity-50"
>
ساخت تیم
</button>
</div>
@@ -218,35 +371,56 @@ export default function TeamBuilder({
return (
<div className="max-w-7xl mx-auto py-6 px-4">
{/* هدر */}
{replacementDialog && (
<div className="fixed inset-0 z-50 bg-black/70 p-4 flex items-center justify-center">
<div className="w-full max-w-md rounded-3xl bg-white p-6 shadow-2xl">
<h3 className="text-xl font-bold mb-2">پست {POSITION_LABELS[replacementDialog.card.player.position]} پر است</h3>
<p className="text-sm text-gray-600 mb-4">
برای اضافه کردن {replacementDialog.card.player.name} یکی از بازیکنان این پست را جایگزین کنید.
</p>
<div className="space-y-2">
{replacementDialog.candidates.map((candidate) => (
<button
key={candidate.playerId}
onClick={() => addSpecialCardToTeam(replacementDialog.card, candidate.playerId)}
className="w-full rounded-2xl border border-gray-200 px-4 py-3 text-right hover:border-green-500 hover:bg-green-50 transition"
>
<div className="font-bold">{candidate.name}</div>
<div className="text-xs text-gray-500">
{candidate.isBench ? "ذخیره" : "فیکس"}
{candidate.isSpecial ? " | کارت ویژه" : ""}
</div>
</button>
))}
</div>
<button
onClick={() => setReplacementDialog(null)}
className="w-full mt-4 rounded-2xl bg-gray-100 py-3 text-sm font-bold text-gray-700 hover:bg-gray-200 transition"
>
بستن
</button>
</div>
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-4 mb-6">
<div>
<h1 className="text-2xl font-bold">{team.name}</h1>
<div className="flex items-center gap-3 mt-1">
<span className={`text-xs px-2 py-1 rounded-full font-medium ${
team.status === "ACTIVE" ? "bg-green-100 text-green-700" :
"bg-gray-100 text-gray-600"
}`}>
{team.status === "ACTIVE" ? "✓ فعال - در رقابت" : "در حال تکمیل"}
<span
className={`text-xs px-2 py-1 rounded-full font-medium ${
team.status === "ACTIVE" ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-600"
}`}
>
{team.status === "ACTIVE" ? "فعال" : "در حال تکمیل"}
</span>
<span className="text-sm text-gray-500">ترکیب: {formation}</span>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-blue-700">{team.totalPoints}</div>
<div className="text-xs text-gray-500">امتیاز</div>
</div>
<div className="text-center">
<div className={`text-2xl font-bold ${remaining < 0 ? "text-red-600" : "text-green-700"}`}>
{remaining.toFixed(1)}M
</div>
<div className="text-xs text-gray-500">بودجه</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-gray-700">{starters.length}/11</div>
<div className="text-xs text-gray-500">بازیکن</div>
</div>
<Metric label="امتیاز" value={team.totalPoints} tone="text-blue-700" />
<Metric label="بودجه" value={`${remaining.toFixed(1)}M`} tone={remaining < 0 ? "text-red-600" : "text-green-700"} />
<Metric label="ویژه" value={`${specialSlotsUsed}/3`} tone="text-amber-600" />
</div>
</div>
@@ -257,24 +431,11 @@ export default function TeamBuilder({
)}
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
{/* زمین - ۳ ستون */}
<div className="lg:col-span-3">
{/* انتخاب ترکیب */}
{team.status === "DRAFT" && (
<div className="flex gap-2 mb-3 flex-wrap">
{Object.keys(FORMATIONS).map((f) => (
<button key={f} onClick={() => setFormation(f)}
className={`px-3 py-1 rounded-lg text-xs font-bold border transition ${formation === f ? "bg-green-700 text-white border-green-700" : "bg-white border-gray-200 hover:border-green-400"}`}>
{f}
</button>
))}
</div>
)}
{/* زمین فوتبال */}
<div className="relative rounded-2xl overflow-hidden shadow-xl"
style={{ background: "linear-gradient(180deg,#1a5c35 0%,#2d8653 20%,#3a9e63 40%,#2d8653 60%,#1a5c35 100%)", minHeight: 500 }}>
{/* خطوط */}
<div
className="relative rounded-2xl overflow-hidden shadow-xl"
style={{ background: "linear-gradient(180deg,#1a5c35 0%,#2d8653 20%,#3a9e63 40%,#2d8653 60%,#1a5c35 100%)", minHeight: 500 }}
>
<svg className="absolute inset-0 w-full h-full opacity-20" viewBox="0 0 400 500" preserveAspectRatio="none">
<line x1="200" y1="0" x2="200" y2="500" stroke="white" strokeWidth="1" />
<circle cx="200" cy="250" r="50" stroke="white" strokeWidth="1" fill="none" />
@@ -285,209 +446,218 @@ export default function TeamBuilder({
</svg>
<div className="relative z-10 p-4 flex flex-col gap-3 h-full">
{/* مهاجمان */}
<PitchRow players={fwdSlots} slots={fmt.fwd} position="FWD"
onRemove={removePlayer} onDragStart={onDragStart} onDrop={onDrop}
onCaptain={setCaptain} draggedId={draggedId} />
{/* هافبک‌ها */}
<PitchRow players={midSlots} slots={fmt.mid} position="MID"
onRemove={removePlayer} onDragStart={onDragStart} onDrop={onDrop}
onCaptain={setCaptain} draggedId={draggedId} />
{/* مدافعان */}
<PitchRow players={defSlots} slots={fmt.def} position="DEF"
onRemove={removePlayer} onDragStart={onDragStart} onDrop={onDrop}
onCaptain={setCaptain} draggedId={draggedId} />
{/* دروازه‌بان */}
<PitchRow players={gkSlots} slots={1} position="GK"
onRemove={removePlayer} onDragStart={onDragStart} onDrop={onDrop}
onCaptain={setCaptain} draggedId={draggedId} />
<PitchRow players={fwdSlots} slots={FORMATIONS[formation]?.fwd ?? 3} onRemove={removePlayer} onCaptain={setCaptain} onSell={sellSpecialCard} />
<PitchRow players={midSlots} slots={FORMATIONS[formation]?.mid ?? 3} onRemove={removePlayer} onCaptain={setCaptain} onSell={sellSpecialCard} />
<PitchRow players={defSlots} slots={FORMATIONS[formation]?.def ?? 4} onRemove={removePlayer} onCaptain={setCaptain} onSell={sellSpecialCard} />
<PitchRow players={gkSlots} slots={1} onRemove={removePlayer} onCaptain={setCaptain} onSell={sellSpecialCard} />
</div>
</div>
{/* ذخیره‌ها */}
<div className="mt-3 bg-gray-800 rounded-2xl p-4">
<p className="text-gray-400 text-xs mb-3 font-medium">ذخیرهها (حداکثر ۴ نفر)</p>
<p className="text-gray-400 text-xs mb-3 font-medium">نیمکت</p>
<div className="flex gap-3 justify-center flex-wrap">
{bench.map((tp) => (
<PitchCard key={tp.playerId} tp={tp} onRemove={removePlayer}
onDragStart={onDragStart} onDrop={onDrop} onCaptain={setCaptain}
draggedId={draggedId} small />
<PitchCard key={tp.playerId} tp={tp} onRemove={removePlayer} onCaptain={setCaptain} onSell={sellSpecialCard} small />
))}
{Array.from({ length: Math.max(0, 4 - bench.length) }).map((_, i) => (
<EmptySlot key={i} label="ذخیره" />
{Array.from({ length: Math.max(0, 4 - bench.length) }).map((_, index) => (
<EmptySlot key={index} label="ذخیره" />
))}
</div>
</div>
{/* دکمه ارسال */}
{canSubmit && (
<button onClick={submitTeam} disabled={submitLoading}
className="w-full mt-4 bg-green-700 text-white py-3 rounded-xl font-bold text-lg hover:bg-green-800 transition disabled:opacity-50">
{submitLoading ? "در حال ثبت..." : "تیم کامله! وارد رقابت شو ✓"}
<button
onClick={submitTeam}
disabled={submitLoading}
className="w-full mt-4 bg-green-700 text-white py-3 rounded-xl font-bold text-lg hover:bg-green-800 transition disabled:opacity-50"
>
{submitLoading ? "در حال ثبت..." : "ثبت تیم"}
</button>
)}
{!isComplete && team.status === "DRAFT" && (
<p className="text-center text-sm text-gray-400 mt-3">
برای ورود به رقابت باید ۱۱ بازیکن اصلی + ۴ ذخیره (هر پست ۱ ذخیره) داشته باشی
</p>
)}
</div>
{/* لیست بازیکنان - ۲ ستون */}
<div className="lg:col-span-2">
<h2 className="text-lg font-bold mb-3">انتخاب بازیکن</h2>
<div className="flex gap-2 mb-3 flex-wrap">
{["", "GK", "DEF", "MID", "FWD"].map((pos) => (
<button key={pos} onClick={() => setPosFilter(pos)}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition ${posFilter === pos ? "bg-green-700 text-white" : "bg-white shadow text-gray-700 hover:bg-gray-50"}`}>
{pos === "" ? "همه" : pos}
</button>
))}
</div>
<input type="text" placeholder="🔍 جستجو..." value={filter}
onChange={(e) => setFilter(e.target.value)}
className="w-full border rounded-xl px-4 py-2 text-sm mb-3 focus:outline-none focus:ring-2 focus:ring-green-400" />
<div className="bg-white rounded-2xl shadow p-4" style={{ maxHeight: 520, overflowY: "auto" }}>
<div className="flex gap-3 flex-wrap">
{filtered.map((p) => (
<div
key={p.id}
draggable
onDragStart={() => setDraggedId(p.id)}
className="flex-shrink-0 bg-gray-50 rounded-xl p-2 cursor-move hover:bg-gray-100 transition border-2 border-transparent hover:border-green-500"
style={{ width: "90px" }}
>
<div className="relative w-20 h-20 rounded-lg overflow-hidden bg-gray-200 mb-1 mx-auto">
{p.image ? (
<Image
src={`/uploads/players/${p.image}`}
alt={p.name}
fill
className="object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400 text-3xl">
👤
</div>
)}
</div>
<div className="text-[10px] font-bold text-gray-800 text-center leading-tight mb-1">
{p.name.split(" ").slice(-1)[0]}
</div>
<div className="flex items-center justify-center gap-1 mb-1">
<span className="text-xs">{p.country.flagUrl}</span>
<PositionBadge position={p.position} />
</div>
<div className="text-[9px] text-center text-gray-600 mb-2">
<span className="text-green-700 font-bold">{p.price}M</span>
<span className="mx-1">·</span>
<span className="text-blue-700 font-bold">{p.totalPoints}pts</span>
</div>
<button
onClick={() => addPlayer(p.id)}
onPointerDown={(e) => e.stopPropagation()}
disabled={loading || p.price > remaining + 0.01}
className="w-full bg-green-600 text-white text-xs py-1 rounded-lg hover:bg-green-700 transition disabled:opacity-30 font-bold"
>
+ افزودن
</button>
</div>
<div className="lg:col-span-2 space-y-6">
<section className="bg-white rounded-2xl shadow p-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-bold">کارتهای ویژه</h2>
<span className="text-xs rounded-full bg-amber-100 px-3 py-1 font-bold text-amber-700">
ظرفیت تیم: {specialSlotsUsed}/3
</span>
</div>
{sealedCount > 0 && <p className="text-xs text-gray-500 mb-3">{sealedCount} کارت ویژه هنوز باز نشده است.</p>}
<div className="space-y-3">
{inventoryCards.map((card) => (
<SpecialCardRow
key={card.id}
card={card}
loading={loading}
onAdd={() => addSpecialCardToTeam(card)}
onSell={() => sellSpecialCard(card.id)}
/>
))}
{filtered.length === 0 && (
<div className="w-full text-center text-gray-400 py-8">بازیکنی پیدا نشد</div>
{inTeamCards.map((card) => (
<SpecialCardRow
key={card.id}
card={card}
loading={loading}
onSell={() => sellSpecialCard(card.id)}
/>
))}
{inventoryCards.length === 0 && inTeamCards.length === 0 && (
<div className="rounded-2xl border border-dashed border-gray-200 px-4 py-6 text-center text-sm text-gray-500">
کارت ویژه آماده استفاده ندارید.
</div>
)}
</div>
</div>
</section>
<section>
<h2 className="text-lg font-bold mb-3">خرید بازیکن عادی</h2>
<div className="flex gap-2 mb-3 flex-wrap">
{["", "GK", "DEF", "MID", "FWD"].map((pos) => (
<button
key={pos}
onClick={() => setPosFilter(pos)}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition ${
posFilter === pos ? "bg-green-700 text-white" : "bg-white shadow text-gray-700 hover:bg-gray-50"
}`}
>
{pos === "" ? "همه" : pos}
</button>
))}
</div>
<input
type="text"
placeholder="جست‌وجو"
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="w-full border rounded-xl px-4 py-2 text-sm mb-3 focus:outline-none focus:ring-2 focus:ring-green-400"
/>
<div className="bg-white rounded-2xl shadow p-4 max-h-[520px] overflow-y-auto">
<div className="flex gap-3 flex-wrap">
{filtered.map((p) => (
<div
key={p.id}
className="flex-shrink-0 bg-gray-50 rounded-xl p-2 border-2 border-transparent hover:border-green-500 transition"
style={{ width: "90px" }}
>
<div className="relative w-20 h-20 rounded-lg overflow-hidden bg-gray-200 mb-1 mx-auto">
{p.image ? (
<Image src={`/uploads/players/${p.image}`} alt={p.name} fill className="object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400 text-3xl">👤</div>
)}
</div>
<div className="text-[10px] font-bold text-gray-800 text-center leading-tight mb-1">
{p.name.split(" ").slice(-1)[0]}
</div>
<div className="flex items-center justify-center gap-1 mb-1">
<PositionBadge position={p.position} />
</div>
<div className="text-[9px] text-center text-gray-600 mb-2">
<span className="text-green-700 font-bold">{p.price}M</span>
<span className="mx-1">|</span>
<span className="text-blue-700 font-bold">{p.totalPoints}pts</span>
</div>
<button
onClick={() => addPlayer(p.id)}
disabled={loading || p.price > remaining + 0.01}
className="w-full bg-green-600 text-white text-xs py-1 rounded-lg hover:bg-green-700 transition disabled:opacity-30 font-bold"
>
+ افزودن
</button>
</div>
))}
{filtered.length === 0 && <div className="w-full text-center text-gray-400 py-8">بازیکنی پیدا نشد</div>}
</div>
</div>
</section>
</div>
</div>
</div>
);
}
function PitchRow({ players, slots, position, onRemove, onDragStart, onDrop, onCaptain, draggedId, }: {
players: TeamPlayer[]; slots: number; position: string;
onRemove: (id: string) => void; onDragStart: (id: string) => void;
onDrop: (id: string) => void; onCaptain: (id: string, t: "captain" | "vice") => void;
draggedId: string | null;
function Metric({ label, value, tone }: { label: string; value: string | number; tone: string }) {
return (
<div className="text-center">
<div className={`text-2xl font-bold ${tone}`}>{value}</div>
<div className="text-xs text-gray-500">{label}</div>
</div>
);
}
function PitchRow({
players,
slots,
onRemove,
onCaptain,
onSell,
}: {
players: TeamPlayer[];
slots: number;
onRemove: (id: string) => void;
onCaptain: (id: string, t: "captain" | "vice") => void;
onSell: (cardId: string) => void;
}) {
return (
<div className="flex justify-center gap-2 flex-wrap py-1">
{Array.from({ length: slots }).map((_, i) => {
const tp = players[i];
{Array.from({ length: slots }).map((_, index) => {
const tp = players[index];
return tp ? (
<PitchCard key={tp.playerId} tp={tp} onRemove={onRemove}
onDragStart={onDragStart} onDrop={onDrop} onCaptain={onCaptain} draggedId={draggedId} />
<PitchCard key={tp.playerId} tp={tp} onRemove={onRemove} onCaptain={onCaptain} onSell={onSell} />
) : (
<EmptySlot key={i} label={position} />
<EmptySlot key={index} label="خالی" />
);
})}
</div>
);
}
function PitchCard({ tp, onRemove, onDragStart, onDrop, onCaptain, draggedId, small }: {
tp: TeamPlayer; onRemove: (id: string) => void; onDragStart: (id: string) => void;
onDrop: (id: string) => void; onCaptain: (id: string, t: "captain" | "vice") => void;
draggedId: string | null; small?: boolean;
function PitchCard({
tp,
onRemove,
onCaptain,
onSell,
small,
}: {
tp: TeamPlayer;
onRemove: (id: string) => void;
onCaptain: (id: string, t: "captain" | "vice") => void;
onSell: (cardId: string) => void;
small?: boolean;
}) {
const [showMenu, setShowMenu] = useState(false);
const isDragging = draggedId === tp.playerId;
const isEliminated = (tp.player as any).country?.isEliminated;
const isEliminated = Boolean(tp.player.country?.isEliminated);
const shortName = tp.player.name.split(" ").slice(-1)[0];
const special = isSpecialTeamPlayer(tp);
return (
<div
className={`relative group ${isDragging ? "opacity-50" : ""}`}
draggable
onDragStart={() => onDragStart(tp.playerId)}
onDragOver={(e) => e.preventDefault()}
onDrop={() => onDrop(tp.playerId)}
>
<div className={`bg-white/95 rounded-xl p-2 cursor-move hover:bg-white transition shadow-lg ${small ? "w-16" : "w-20"}`}>
<div className="relative group">
<div className={`rounded-xl p-2 shadow-lg ${special ? "bg-amber-50 border border-amber-300" : "bg-white/95"} ${small ? "w-16" : "w-20"}`}>
<div className={`relative ${small ? "w-12 h-12" : "w-16 h-16"} rounded-lg overflow-hidden bg-gray-200 mb-1 mx-auto`}>
{tp.player.image ? (
<Image
src={`/uploads/players/${tp.player.image}`}
alt={tp.player.name}
fill
className="object-cover"
/>
<Image src={`/uploads/players/${tp.player.image}`} alt={tp.player.name} fill className="object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400 text-2xl">
👤
</div>
<div className="w-full h-full flex items-center justify-center text-gray-400 text-2xl">👤</div>
)}
{isEliminated && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
<span className="text-white text-xs font-bold"></span>
<span className="text-white text-xs font-bold">×</span>
</div>
)}
</div>
<div className={`text-[10px] font-bold text-gray-800 text-center leading-tight ${isEliminated ? "opacity-50" : ""}`}>
{shortName}
</div>
<div className={`text-[10px] font-bold text-center leading-tight ${isEliminated ? "opacity-50" : "text-gray-800"}`}>{shortName}</div>
{special && <div className="mt-1 text-center text-[8px] font-bold text-amber-700">کارت ویژه</div>}
<div className="flex items-center justify-center gap-1 mt-1">
{tp.isCaptain && (
<div className="bg-yellow-400 text-yellow-900 rounded-full w-4 h-4 flex items-center justify-center text-[8px] font-bold">
C
</div>
)}
{tp.isViceCaptain && (
<div className="bg-gray-400 text-white rounded-full w-4 h-4 flex items-center justify-center text-[8px] font-bold">
V
</div>
)}
</div>
<div className="text-[8px] text-center text-gray-600 mt-1">
{tp.player.totalPoints}pts
{tp.isCaptain && <div className="bg-yellow-400 text-yellow-900 rounded-full w-4 h-4 flex items-center justify-center text-[8px] font-bold">C</div>}
{tp.isViceCaptain && <div className="bg-gray-400 text-white rounded-full w-4 h-4 flex items-center justify-center text-[8px] font-bold">V</div>}
</div>
</div>
<div className="absolute -bottom-8 left-1/2 -translate-x-1/2 opacity-0 group-hover:opacity-100 transition flex gap-1 z-20">
<div className="absolute -bottom-10 left-1/2 -translate-x-1/2 opacity-0 group-hover:opacity-100 transition flex gap-1 z-20">
<button
onClick={(e) => {
e.stopPropagation();
@@ -497,6 +667,17 @@ function PitchCard({ tp, onRemove, onDragStart, onDrop, onCaptain, draggedId, sm
>
{tp.isCaptain ? "VC" : "C"}
</button>
{special && tp.goldenCardId ? (
<button
onClick={(e) => {
e.stopPropagation();
onSell(tp.goldenCardId!);
}}
className="bg-amber-500 text-white text-[8px] px-2 py-0.5 rounded-full font-bold shadow"
>
فروش
</button>
) : null}
<button
onClick={(e) => {
e.stopPropagation();
@@ -504,7 +685,62 @@ function PitchCard({ tp, onRemove, onDragStart, onDrop, onCaptain, draggedId, sm
}}
className="bg-red-500 text-white text-[8px] px-2 py-0.5 rounded-full font-bold shadow"
>
حذف
{special ? "برداشتن" : "حذف"}
</button>
</div>
</div>
);
}
function SpecialCardRow({
card,
loading,
onAdd,
onSell,
}: {
card: SpecialCard;
loading: boolean;
onAdd?: () => void;
onSell: () => void;
}) {
const canAdd = card.state === "IN_INVENTORY";
return (
<div className="rounded-2xl border border-amber-200 bg-amber-50/70 p-3">
<div className="flex gap-3">
<div className="relative w-16 h-16 rounded-xl overflow-hidden bg-white shrink-0">
{card.player.image ? (
<Image src={`/uploads/players/${card.player.image}`} alt={card.player.name} fill className="object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400 text-2xl">👤</div>
)}
</div>
<div className="min-w-0 flex-1">
<div className="font-bold truncate">{card.player.name}</div>
<div className="text-xs text-gray-600 mt-1">
{POSITION_LABELS[card.player.position]} | {card.player.price}M
</div>
<div className="text-xs font-bold text-amber-700 mt-1">
{card.state === "IN_TEAM" ? "در تیم" : `فروش: ${formatSaleValue(card.player.price)}M`}
</div>
</div>
</div>
<div className="flex gap-2 mt-3">
{canAdd && (
<button
onClick={onAdd}
disabled={loading}
className="flex-1 rounded-xl bg-green-700 py-2 text-sm font-bold text-white hover:bg-green-800 disabled:opacity-50 transition"
>
افزودن به تیم
</button>
)}
<button
onClick={onSell}
disabled={loading}
className="flex-1 rounded-xl bg-amber-500 py-2 text-sm font-bold text-white hover:bg-amber-600 disabled:opacity-50 transition"
>
فروش
</button>
</div>
</div>

View File

@@ -20,5 +20,18 @@ export default async function TeamPage() {
orderBy: { totalPoints: "desc" },
});
return <TeamBuilder team={team} allPlayers={allPlayers} />;
const specialCards = await db.goldenCard.findMany({
where: {
userId,
status: "OPENED",
state: { not: "SOLD" },
},
include: {
player: { include: { country: true } },
teamPlayer: true,
},
orderBy: { acquiredDate: "desc" },
});
return <TeamBuilder team={team} allPlayers={allPlayers} initialSpecialCards={specialCards as any} />;
}

View File

@@ -52,9 +52,9 @@ export async function POST(_: NextRequest, { params }: { params: Promise<{ id: s
});
const stat = await db.playerMatchStat.upsert({
where: { playerId_matchId: { playerId, matchId: params.id } },
where: { playerId_matchId: { playerId, matchId: id } },
update: { goals, assists, yellowCards, redCards, minutesPlayed, cleanSheet, penaltySaved, penaltyMissed, ownGoals, isMotm, extraTimeBonus, points },
create: { playerId, matchId: params.id, goals, assists, yellowCards, redCards, minutesPlayed, cleanSheet, penaltySaved, penaltyMissed, ownGoals, isMotm, extraTimeBonus, points },
create: { playerId, matchId: id, goals, assists, yellowCards, redCards, minutesPlayed, cleanSheet, penaltySaved, penaltyMissed, ownGoals, isMotm, extraTimeBonus, points },
});
// آپدیت totalPoints بازیکن

View File

@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
const validTiers = new Set(["GOLD", "SILVER", "BRONZE"]);
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getServerSession(authOptions);
if (!session || (session.user as any).role !== "ADMIN") {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const { cardTier } = await req.json();
if (!validTiers.has(cardTier)) {
return NextResponse.json({ error: "Invalid card tier" }, { status: 400 });
}
const updated = await db.player.update({
where: { id },
data: {
cardTier,
isGoldenCardEligible: cardTier === "GOLD",
},
});
return NextResponse.json(updated);
}

View File

@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
// PATCH /api/admin/players/[id]/golden-toggle
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getServerSession(authOptions);
if (!session || (session.user as any).role !== "ADMIN") {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const player = await db.player.findUnique({ where: { id } });
if (!player) return NextResponse.json({ error: "Player not found" }, { status: 404 });
const updated = await db.player.update({
where: { id },
data: { isGoldenCardEligible: !player.isGoldenCardEligible },
});
return NextResponse.json({ isGoldenCardEligible: updated.isGoldenCardEligible });
}

View File

@@ -0,0 +1,105 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { CARD_TIER_LABELS, resolveQuizRewardTier } from "@/lib/cardTier";
function shuffleArray<T>(items: T[]) {
return [...items].sort(() => Math.random() - 0.5);
}
// POST /api/admin/quiz/[id]/lottery - run reward distribution for a quiz
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getServerSession(authOptions);
if (!session || (session.user as any).role !== "ADMIN") {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const quiz = await db.dailyQuiz.findUnique({
where: { id },
include: { questions: true },
});
if (!quiz) return NextResponse.json({ error: "Quiz not found" }, { status: 404 });
if (quiz.isProcessed) {
return NextResponse.json({ error: "قرعه کشی قبلا انجام شده" }, { status: 400 });
}
const submissions = await db.quizSubmission.findMany({
where: { quizId: id },
include: { user: true },
});
const tierLimits = {
GOLD: quiz.goldWinnersCount,
SILVER: quiz.silverWinnersCount,
BRONZE: quiz.bronzeWinnersCount,
} as const;
const candidatesByTier = {
GOLD: submissions.filter((submission) => resolveQuizRewardTier(quiz, submission.correctAnswers) === "GOLD"),
SILVER: submissions.filter((submission) => resolveQuizRewardTier(quiz, submission.correctAnswers) === "SILVER"),
BRONZE: submissions.filter((submission) => resolveQuizRewardTier(quiz, submission.correctAnswers) === "BRONZE"),
};
const rewardQueue = (["GOLD", "SILVER", "BRONZE"] as const).flatMap((cardTier) =>
shuffleArray(candidatesByTier[cardTier])
.slice(0, Math.max(tierLimits[cardTier], 0))
.map((submission) => ({ submission, cardTier }))
);
if (rewardQueue.length === 0) {
await db.dailyQuiz.update({ where: { id }, data: { isProcessed: true } });
return NextResponse.json({ winners: [], message: "هیچ شرکت کننده ای واجد دریافت کارت نبود" });
}
const players = await db.player.findMany({
where: {
isActive: true,
cardTier: { in: ["GOLD", "SILVER", "BRONZE"] },
},
include: { country: true },
});
const playersByTier = {
GOLD: players.filter((player) => player.cardTier === "GOLD"),
SILVER: players.filter((player) => player.cardTier === "SILVER"),
BRONZE: players.filter((player) => player.cardTier === "BRONZE"),
};
for (const tier of ["GOLD", "SILVER", "BRONZE"] as const) {
if (rewardQueue.some((item) => item.cardTier === tier) && playersByTier[tier].length === 0) {
return NextResponse.json(
{ error: `برای کارت ${CARD_TIER_LABELS[tier]} هیچ بازیکن فعالی تعریف نشده است` },
{ status: 400 }
);
}
}
const createdCards = await db.$transaction(
rewardQueue.map(({ submission, cardTier }) => {
const tierPlayers = playersByTier[cardTier];
const randomPlayer = tierPlayers[Math.floor(Math.random() * tierPlayers.length)];
return db.goldenCard.create({
data: {
userId: submission.userId,
quizId: id,
playerId: randomPlayer.id,
cardTier,
status: "SEALED",
},
include: {
user: { select: { id: true, name: true, email: true } },
player: { include: { country: true } },
},
});
})
);
await db.dailyQuiz.update({ where: { id }, data: { isProcessed: true } });
return NextResponse.json({ winners: createdCards });
}

View File

@@ -0,0 +1,191 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { Prisma } from "@prisma/client";
async function requireAdmin() {
const session = await getServerSession(authOptions);
if (!session || (session.user as any).role !== "ADMIN") {
return null;
}
return session;
}
function calculateResult(answers: number[], questions: Array<{ correctAnswer: number }>) {
let correct = 0;
questions.forEach((question, index) => {
if (answers[index] === question.correctAnswer) {
correct += 1;
}
});
return {
correct,
score: questions.length > 0 ? Math.round((correct / questions.length) * 100) : 0,
};
}
function validateTierConfig(input: {
goldWinnersCount: number;
silverWinnersCount: number;
bronzeWinnersCount: number;
goldMinCorrect: number | null;
silverMinCorrect: number | null;
bronzeMinCorrect: number | null;
}) {
if (input.goldWinnersCount < 0 || input.silverWinnersCount < 0 || input.bronzeWinnersCount < 0) {
return "Winner counts cannot be negative";
}
if (input.goldWinnersCount + input.silverWinnersCount + input.bronzeWinnersCount <= 0) {
return "At least one winner must be configured";
}
if (input.goldWinnersCount > 0 && input.goldMinCorrect == null) {
return "Gold minimum correct answers is required";
}
if (input.silverWinnersCount > 0 && input.silverMinCorrect == null) {
return "Silver minimum correct answers is required";
}
if (input.bronzeWinnersCount > 0 && input.bronzeMinCorrect == null) {
return "Bronze minimum correct answers is required";
}
return null;
}
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const session = await requireAdmin();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params;
const {
date,
windowStart,
windowEnd,
goldWinnersCount,
silverWinnersCount,
bronzeWinnersCount,
goldMinCorrect,
silverMinCorrect,
bronzeMinCorrect,
questions,
} = await req.json();
const parsedInput = {
goldWinnersCount: Number(goldWinnersCount),
silverWinnersCount: Number(silverWinnersCount),
bronzeWinnersCount: Number(bronzeWinnersCount),
goldMinCorrect: goldMinCorrect == null ? null : Number(goldMinCorrect),
silverMinCorrect: silverMinCorrect == null ? null : Number(silverMinCorrect),
bronzeMinCorrect: bronzeMinCorrect == null ? null : Number(bronzeMinCorrect),
};
const validationError = validateTierConfig(parsedInput);
if (validationError) {
return NextResponse.json({ error: validationError }, { status: 400 });
}
if (!Array.isArray(questions) || questions.length === 0) {
return NextResponse.json({ error: "At least one question is required" }, { status: 400 });
}
const quiz = await db.dailyQuiz.findUnique({
where: { id },
include: {
submissions: {
select: { id: true, answers: true },
},
},
});
if (!quiz) return NextResponse.json({ error: "Quiz not found" }, { status: 404 });
if (quiz.isProcessed) {
return NextResponse.json({ error: "Quiz can no longer be edited after lottery processing" }, { status: 400 });
}
const normalizedQuestions = questions.map((q: any, index: number) => ({
questionText: q.questionText,
options: q.options,
correctAnswer: Number(q.correctAnswer),
order: index,
}));
const updatedQuiz = await db.$transaction(async (tx) => {
await tx.quizQuestion.deleteMany({ where: { quizId: id } });
const updated = await tx.dailyQuiz.update({
where: { id },
data: {
date: new Date(`${date}T00:00:00.000Z`),
windowStart: new Date(windowStart),
windowEnd: new Date(windowEnd),
goldWinnersCount: parsedInput.goldWinnersCount,
silverWinnersCount: parsedInput.silverWinnersCount,
bronzeWinnersCount: parsedInput.bronzeWinnersCount,
goldMinCorrect: parsedInput.goldMinCorrect,
silverMinCorrect: parsedInput.silverMinCorrect,
bronzeMinCorrect: parsedInput.bronzeMinCorrect,
questions: {
create: normalizedQuestions,
},
},
include: {
questions: { orderBy: { order: "asc" } },
},
});
for (const submission of quiz.submissions) {
const result = calculateResult(submission.answers, normalizedQuestions);
await tx.quizSubmission.update({
where: { id: submission.id },
data: {
score: result.score,
correctAnswers: result.correct,
},
});
}
return updated;
});
return NextResponse.json(updatedQuiz);
} catch (error) {
console.error("Failed to update quiz", error);
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === "P2002") {
return NextResponse.json({ error: "Quiz date already exists" }, { status: 409 });
}
}
return NextResponse.json({ error: "Failed to update quiz" }, { status: 500 });
}
}
export async function DELETE(_: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await requireAdmin();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params;
const quiz = await db.dailyQuiz.findUnique({
where: { id },
select: {
id: true,
isProcessed: true,
},
});
if (!quiz) return NextResponse.json({ error: "Quiz not found" }, { status: 404 });
if (quiz.isProcessed) {
return NextResponse.json({ error: "Quiz can no longer be deleted after lottery processing" }, { status: 400 });
}
await db.dailyQuiz.delete({ where: { id } });
return NextResponse.json({ success: true });
}

132
app/api/admin/quiz/route.ts Normal file
View File

@@ -0,0 +1,132 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { Prisma } from "@prisma/client";
async function adminOnly(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session || (session.user as any).role !== "ADMIN") return null;
return session;
}
function validateTierConfig(input: {
goldWinnersCount: number;
silverWinnersCount: number;
bronzeWinnersCount: number;
goldMinCorrect: number | null;
silverMinCorrect: number | null;
bronzeMinCorrect: number | null;
}) {
if (input.goldWinnersCount < 0 || input.silverWinnersCount < 0 || input.bronzeWinnersCount < 0) {
return "Winner counts cannot be negative";
}
if (input.goldWinnersCount + input.silverWinnersCount + input.bronzeWinnersCount <= 0) {
return "At least one winner must be configured";
}
if (input.goldWinnersCount > 0 && input.goldMinCorrect == null) {
return "Gold minimum correct answers is required";
}
if (input.silverWinnersCount > 0 && input.silverMinCorrect == null) {
return "Silver minimum correct answers is required";
}
if (input.bronzeWinnersCount > 0 && input.bronzeMinCorrect == null) {
return "Bronze minimum correct answers is required";
}
return null;
}
// GET /api/admin/quiz - list all quizzes
export async function GET(req: NextRequest) {
const session = await adminOnly(req);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const quizzes = await db.dailyQuiz.findMany({
orderBy: { date: "desc" },
include: {
questions: { orderBy: { order: "asc" } },
_count: { select: { submissions: true } },
},
});
return NextResponse.json(quizzes);
}
// POST /api/admin/quiz - create quiz
export async function POST(req: NextRequest) {
try {
const session = await adminOnly(req);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const {
date,
windowStart,
windowEnd,
goldWinnersCount,
silverWinnersCount,
bronzeWinnersCount,
goldMinCorrect,
silverMinCorrect,
bronzeMinCorrect,
questions,
} = await req.json();
const parsedInput = {
goldWinnersCount: Number(goldWinnersCount),
silverWinnersCount: Number(silverWinnersCount),
bronzeWinnersCount: Number(bronzeWinnersCount),
goldMinCorrect: goldMinCorrect == null ? null : Number(goldMinCorrect),
silverMinCorrect: silverMinCorrect == null ? null : Number(silverMinCorrect),
bronzeMinCorrect: bronzeMinCorrect == null ? null : Number(bronzeMinCorrect),
};
const validationError = validateTierConfig(parsedInput);
if (validationError) {
return NextResponse.json({ error: validationError }, { status: 400 });
}
if (!Array.isArray(questions) || questions.length === 0) {
return NextResponse.json({ error: "At least one question is required" }, { status: 400 });
}
const quiz = await db.dailyQuiz.create({
data: {
date: new Date(`${date}T00:00:00.000Z`),
windowStart: new Date(windowStart),
windowEnd: new Date(windowEnd),
goldWinnersCount: parsedInput.goldWinnersCount,
silverWinnersCount: parsedInput.silverWinnersCount,
bronzeWinnersCount: parsedInput.bronzeWinnersCount,
goldMinCorrect: parsedInput.goldMinCorrect,
silverMinCorrect: parsedInput.silverMinCorrect,
bronzeMinCorrect: parsedInput.bronzeMinCorrect,
questions: {
create: questions.map((q: any, i: number) => ({
questionText: q.questionText,
options: q.options,
correctAnswer: Number(q.correctAnswer),
order: i,
})),
},
},
include: { questions: true },
});
return NextResponse.json(quiz, { status: 201 });
} catch (error) {
console.error("Failed to create quiz", error);
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === "P2002") {
return NextResponse.json({ error: "Quiz date already exists" }, { status: 409 });
}
}
return NextResponse.json({ error: "Failed to create quiz" }, { status: 500 });
}
}

View File

@@ -3,14 +3,15 @@ import { db } from "@/lib/db";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
export async function PUT(req: NextRequest, { params }: { params: { id: string } }) {
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const session = await getServerSession(authOptions);
if (!session || (session.user as any).role !== "ADMIN")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { status } = await req.json();
const team = await db.team.update({
where: { id: params.id },
where: { id },
data: { status },
});
return NextResponse.json(team);

View File

@@ -3,7 +3,8 @@ import { db } from "@/lib/db";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
export async function POST(_: NextRequest, { params }: { params: { id: string } }) {
export async function POST(_: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const session = await getServerSession(authOptions);
if (!session || (session.user as any).role !== "ADMIN")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
@@ -11,6 +12,6 @@ export async function POST(_: NextRequest, { params }: { params: { id: string }
// غیرفعال کردن همه
await db.gameweek.updateMany({ data: { isActive: false } });
// فعال کردن این هفته
const gw = await db.gameweek.update({ where: { id: params.id }, data: { isActive: true } });
const gw = await db.gameweek.update({ where: { id }, data: { isActive: true } });
return NextResponse.json(gw);
}

View File

@@ -14,6 +14,11 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const body = await req.json();
const gw = await db.gameweek.create({ data: body });
const gw = await db.gameweek.create({
data: {
...body,
deadline: new Date(body.deadline),
},
});
return NextResponse.json(gw, { status: 201 });
}

View File

@@ -0,0 +1,173 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import {
getAutoPlacement,
getPositionLabel,
SPECIAL_CARD_TEAM_LIMIT,
} from "@/lib/specialCards";
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const userId = (session.user as any).id;
const { id } = await params;
const { replacePlayerId } = await req.json().catch(() => ({}));
const team = await db.team.findUnique({
where: { userId },
include: {
players: {
include: {
player: true,
goldenCard: true,
},
},
},
});
if (!team) return NextResponse.json({ error: "ابتدا تیم بساز" }, { status: 400 });
const card = await db.goldenCard.findUnique({
where: { id },
include: {
player: { include: { country: true } },
teamPlayer: true,
},
});
if (!card) return NextResponse.json({ error: "کارت ویژه پیدا نشد" }, { status: 404 });
if (card.userId !== userId) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
if (card.status !== "OPENED") return NextResponse.json({ error: "ابتدا کارت را باز کنید" }, { status: 400 });
if (card.state === "SOLD") return NextResponse.json({ error: "این کارت فروخته شده است" }, { status: 400 });
if (card.state === "IN_TEAM") return NextResponse.json({ error: "این کارت همین حالا در تیم است" }, { status: 400 });
const existingSpecialCount = team.players.filter((item) => item.goldenCardId).length;
const sameCountry = team.players.filter((item) => item.player.countryId === card.player.countryId).length;
const existingPlayer = team.players.find((item) => item.playerId === card.playerId);
if (!existingPlayer && sameCountry >= 3) {
return NextResponse.json({ error: "حداکثر 3 بازیکن از یک تیم ملی" }, { status: 400 });
}
if (existingPlayer) {
if (existingPlayer.goldenCardId) {
return NextResponse.json({ error: "نسخه ویژه این بازیکن در تیم شما وجود دارد" }, { status: 400 });
}
if (existingSpecialCount >= SPECIAL_CARD_TEAM_LIMIT) {
return NextResponse.json({ error: "ظرفیت 3 کارت ویژه تیم پر است" }, { status: 400 });
}
const updatedTeamPlayer = await db.$transaction(async (tx) => {
const updatedPlayer = await tx.teamPlayer.update({
where: { teamId_playerId: { teamId: team.id, playerId: existingPlayer.playerId } },
data: { goldenCardId: card.id },
});
await tx.goldenCard.update({
where: { id: card.id },
data: { state: "IN_TEAM" },
});
return updatedPlayer;
});
return NextResponse.json({
success: true,
action: "converted_existing",
placement: existingPlayer.isBench ? "ذخیره" : "فیکس",
teamPlayer: updatedTeamPlayer,
card: { ...card, state: "IN_TEAM" },
message: "بازیکن موجود تیم شما به نسخه ویژه تبدیل شد",
});
}
const autoPlacement = getAutoPlacement(team.formation, team.players as any, card.player.position);
if (!replacePlayerId && !autoPlacement) {
const candidates = team.players
.filter((item) => item.player.position === card.player.position)
.map((item) => ({
playerId: item.playerId,
name: item.player.name,
isBench: item.isBench,
isSpecial: Boolean(item.goldenCardId),
}));
return NextResponse.json(
{
error: `پست ${getPositionLabel(card.player.position)} در ترکیب اصلی و ذخیره پر است`,
needsReplacement: true,
candidates,
},
{ status: 409 }
);
}
const replacingPlayer = replacePlayerId
? team.players.find((item) => item.playerId === replacePlayerId)
: null;
if (replacePlayerId && (!replacingPlayer || replacingPlayer.player.position !== card.player.position)) {
return NextResponse.json({ error: "بازیکن انتخاب‌شده برای تعویض معتبر نیست" }, { status: 400 });
}
const nextSpecialCount = existingSpecialCount + 1 - (replacingPlayer?.goldenCardId ? 1 : 0);
if (nextSpecialCount > SPECIAL_CARD_TEAM_LIMIT) {
return NextResponse.json({ error: "ظرفیت 3 کارت ویژه تیم پر است" }, { status: 400 });
}
if (!replacingPlayer && team.players.length >= 15) {
return NextResponse.json({ error: "تیم پر است" }, { status: 400 });
}
const result = await db.$transaction(async (tx) => {
if (replacingPlayer) {
await tx.teamPlayer.delete({
where: { teamId_playerId: { teamId: team.id, playerId: replacingPlayer.playerId } },
});
if (replacingPlayer.goldenCardId) {
await tx.goldenCard.update({
where: { id: replacingPlayer.goldenCardId },
data: { state: "IN_INVENTORY" },
});
}
}
const teamPlayer = await tx.teamPlayer.create({
data: {
teamId: team.id,
playerId: card.playerId,
goldenCardId: card.id,
isBench: replacingPlayer ? replacingPlayer.isBench : autoPlacement!.isBench,
},
});
await tx.goldenCard.update({
where: { id: card.id },
data: { state: "IN_TEAM" },
});
return teamPlayer;
});
const placement = replacingPlayer
? replacingPlayer.isBench
? "ذخیره"
: "فیکس"
: autoPlacement!.placementLabel;
return NextResponse.json({
success: true,
action: replacingPlayer ? "replaced" : "added",
placement,
replacedPlayerId: replacingPlayer?.playerId ?? null,
replacedGoldenCardId: replacingPlayer?.goldenCardId ?? null,
card: { ...card, state: "IN_TEAM" },
teamPlayer: result,
message: replacingPlayer
? "بازیکن ویژه جایگزین بازیکن انتخاب‌شده شد"
: `بازیکن ویژه به صورت خودکار در ${placement} قرار گرفت`,
});
}

View File

@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
// POST /api/golden-cards/[id]/reveal
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const userId = (session.user as any).id;
const { id } = await params;
const card = await db.goldenCard.findUnique({ where: { id } });
if (!card) return NextResponse.json({ error: "Card not found" }, { status: 404 });
if (card.userId !== userId) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
if (card.status === "OPENED") return NextResponse.json({ error: "کارت قبلاً باز شده" }, { status: 400 });
const updated = await db.goldenCard.update({
where: { id },
data: { status: "OPENED", openedAt: new Date() },
include: { player: { include: { country: true } } },
});
return NextResponse.json(updated);
}

View File

@@ -0,0 +1,52 @@
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { getSpecialCardSalePrice } from "@/lib/specialCards";
export async function POST(_: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const userId = (session.user as any).id;
const { id } = await params;
const card = await db.goldenCard.findUnique({
where: { id },
include: { player: true, teamPlayer: true },
});
if (!card) return NextResponse.json({ error: "کارت ویژه پیدا نشد" }, { status: 404 });
if (card.userId !== userId) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
if (card.status !== "OPENED") return NextResponse.json({ error: "ابتدا کارت را باز کنید" }, { status: 400 });
if (card.state === "SOLD") return NextResponse.json({ error: "این کارت قبلاً فروخته شده" }, { status: 400 });
const team = await db.team.findUnique({ where: { userId } });
if (!team) return NextResponse.json({ error: "تیم پیدا نشد" }, { status: 404 });
const addedBudget = getSpecialCardSalePrice(card.player.price);
await db.$transaction(async (tx) => {
if (card.teamPlayer) {
await tx.teamPlayer.delete({
where: { teamId_playerId: { teamId: card.teamPlayer.teamId, playerId: card.teamPlayer.playerId } },
});
}
await tx.goldenCard.update({
where: { id },
data: { state: "SOLD" },
});
await tx.team.update({
where: { id: team.id },
data: { budget: { increment: addedBudget } },
});
});
return NextResponse.json({
success: true,
addedBudget,
cardId: id,
});
}

View File

@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
// GET /api/golden-cards - get current user's golden cards
export async function GET() {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const userId = (session.user as any).id;
const cards = await db.goldenCard.findMany({
where: { userId },
include: {
player: { include: { country: true } },
},
orderBy: { acquiredDate: "desc" },
});
return NextResponse.json(cards);
}

View File

@@ -20,7 +20,13 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const body = await req.json();
const match = await db.match.update({ where: { id }, data: body });
const match = await db.match.update({
where: { id },
data: {
...body,
matchDate: new Date(body.matchDate),
},
});
return NextResponse.json(match);
}

View File

@@ -2,9 +2,10 @@ import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { calculatePoints } from "@/lib/points";
import { calculateMatchPoints } from "@/lib/points";
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const session = await getServerSession(authOptions);
if (!session || (session.user as any).role !== "ADMIN")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
@@ -25,12 +26,25 @@ export async function POST(req: NextRequest, { params }: { params: { id: string
const player = await db.player.findUnique({ where: { id: stat.playerId } });
if (!player) continue;
const points = calculatePoints({ position: player.position, ...stat });
const points = await calculateMatchPoints({
position: player.position,
goals: stat.goals,
assists: stat.assists,
yellowCards: stat.yellowCards,
redCards: stat.redCards,
minutesPlayed: stat.minutesPlayed,
cleanSheet: stat.cleanSheet,
penaltySaved: 0,
penaltyMissed: 0,
ownGoals: 0,
isMotm: false,
extraTimeBonus: 0,
});
const record = await db.playerMatchStat.upsert({
where: { playerId_matchId: { playerId: stat.playerId, matchId: params.id } },
where: { playerId_matchId: { playerId: stat.playerId, matchId: id } },
update: { ...stat, points },
create: { ...stat, matchId: params.id, points },
create: { ...stat, matchId: id, points },
});
// آپدیت امتیاز کل بازیکن

View File

@@ -22,7 +22,10 @@ export async function POST(req: NextRequest) {
const body = await req.json();
const match = await db.match.create({
data: body,
data: {
...body,
matchDate: new Date(body.matchDate),
},
include: { homeTeam: true, awayTeam: true },
});
return NextResponse.json(match, { status: 201 });

11
app/api/openapi/route.ts Normal file
View File

@@ -0,0 +1,11 @@
import { NextResponse } from "next/server";
import { openApiSpec } from "@/lib/openapi";
export async function GET() {
return NextResponse.json(openApiSpec, {
headers: {
"Cache-Control": "no-store",
},
});
}

View File

@@ -13,7 +13,11 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
const body = await req.json();
const player = await db.player.update({
where: { id },
data: body,
data: {
...body,
cardTier: body.cardTier ?? undefined,
isGoldenCardEligible: body.cardTier ? body.cardTier === "GOLD" : undefined,
},
});
return NextResponse.json(player);
}

View File

@@ -27,6 +27,12 @@ export async function POST(req: NextRequest) {
}
const body = await req.json();
const player = await db.player.create({ data: body });
const player = await db.player.create({
data: {
...body,
cardTier: body.cardTier ?? "BRONZE",
isGoldenCardEligible: (body.cardTier ?? "BRONZE") === "GOLD",
},
});
return NextResponse.json(player, { status: 201 });
}

View File

@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
// GET /api/quiz/my-results
export async function GET() {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const userId = (session.user as any).id;
const submissions = await db.quizSubmission.findMany({
where: { userId },
include: {
quiz: {
include: { questions: { orderBy: { order: "asc" } } },
},
},
orderBy: { submittedAt: "desc" },
});
return NextResponse.json(submissions);
}

31
app/api/quiz/route.ts Normal file
View File

@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
// GET /api/quiz - get today's active quiz
export async function GET() {
const now = new Date();
const todayStart = new Date(now);
todayStart.setHours(0, 0, 0, 0);
const todayEnd = new Date(now);
todayEnd.setHours(23, 59, 59, 999);
const quiz = await db.dailyQuiz.findFirst({
where: { date: { gte: todayStart, lte: todayEnd } },
include: {
questions: {
orderBy: { order: "asc" },
select: {
id: true,
questionText: true,
options: true,
order: true,
},
},
},
});
if (!quiz) return NextResponse.json(null);
const isActive = !quiz.isProcessed && now >= quiz.windowStart && now <= quiz.windowEnd;
return NextResponse.json({ ...quiz, isActive });
}

View File

@@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { CARD_TIER_LABELS, resolveQuizRewardTier } from "@/lib/cardTier";
// POST /api/quiz/submit
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const userId = (session.user as any).id;
const { quizId, answers } = await req.json();
if (!quizId || !Array.isArray(answers)) {
return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
}
const quiz = await db.dailyQuiz.findUnique({
where: { id: quizId },
include: { questions: { orderBy: { order: "asc" } } },
});
if (!quiz) return NextResponse.json({ error: "Quiz not found" }, { status: 404 });
if (quiz.isProcessed) {
return NextResponse.json({ error: "این کوییز بعد از قرعه‌کشی بسته شده است" }, { status: 400 });
}
const now = new Date();
if (now < quiz.windowStart || now > quiz.windowEnd) {
return NextResponse.json({ error: "خارج از بازه زمانی مجاز" }, { status: 400 });
}
const existing = await db.quizSubmission.findUnique({
where: { userId_quizId: { userId, quizId } },
});
if (existing) return NextResponse.json({ error: "قبلاً شرکت کرده‌اید" }, { status: 400 });
let correct = 0;
quiz.questions.forEach((q, i) => {
if (answers[i] === q.correctAnswer) correct++;
});
const score = quiz.questions.length > 0
? Math.round((correct / quiz.questions.length) * 100)
: 0;
const rewardTier = resolveQuizRewardTier(quiz, correct);
const submission = await db.quizSubmission.create({
data: { userId, quizId, answers, correctAnswers: correct, score },
});
return NextResponse.json({
score,
correct,
total: quiz.questions.length,
rewardTier,
rewardTierLabel: rewardTier ? CARD_TIER_LABELS[rewardTier] : null,
submission,
});
}

View File

@@ -3,7 +3,6 @@ import { db } from "@/lib/db";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
// اضافه کردن بازیکن به تیم
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
@@ -13,7 +12,7 @@ export async function POST(req: NextRequest) {
const team = await db.team.findUnique({
where: { userId },
include: { players: { include: { player: true } } },
include: { players: { include: { player: true, goldenCard: true } } },
});
if (!team) return NextResponse.json({ error: "ابتدا تیم بساز" }, { status: 400 });
@@ -21,32 +20,34 @@ export async function POST(req: NextRequest) {
const player = await db.player.findUnique({ where: { id: playerId } });
if (!player) return NextResponse.json({ error: "بازیکن پیدا نشد" }, { status: 404 });
// چک بودجه
const spent = team.players.reduce((s, tp) => s + tp.player.price, 0);
if (spent + player.price > team.budget)
const spent = team.players
.filter((item) => !item.goldenCardId)
.reduce((sum, item) => sum + item.player.price, 0);
if (spent + player.price > team.budget) {
return NextResponse.json({ error: "بودجه کافی نیست" }, { status: 400 });
}
// چک تعداد (۱۵ نفر: ۱۱ اصلی + ۴ ذخیره)
if (team.players.length >= 15)
return NextResponse.json({ error: "تیم پر است (حداکثر ۱۵ بازیکن)" }, { status: 400 });
if (team.players.length >= 15) {
return NextResponse.json({ error: "تیم پر است (حداکثر 15 بازیکن)" }, { status: 400 });
}
// چک تکراری
const exists = team.players.find((tp) => tp.playerId === playerId);
if (exists) return NextResponse.json({ error: "این بازیکن قبلاً انتخاب شده" }, { status: 400 });
const exists = team.players.find((item) => item.playerId === playerId);
if (exists) {
return NextResponse.json({ error: "این بازیکن قبلاً انتخاب شده" }, { status: 400 });
}
// چک حداکثر ۳ بازیکن از یک تیم ملی
const sameCountry = team.players.filter((tp) => tp.player.countryId === player.countryId).length;
if (sameCountry >= 3)
return NextResponse.json({ error: "حداکثر ۳ بازیکن از یک تیم ملی" }, { status: 400 });
const sameCountry = team.players.filter((item) => item.player.countryId === player.countryId).length;
if (sameCountry >= 3) {
return NextResponse.json({ error: "حداکثر 3 بازیکن از یک تیم ملی" }, { status: 400 });
}
const tp = await db.teamPlayer.create({
const teamPlayer = await db.teamPlayer.create({
data: { teamId: team.id, playerId, isBench: isBench ?? false },
});
return NextResponse.json(tp, { status: 201 });
return NextResponse.json(teamPlayer, { status: 201 });
}
// حذف بازیکن از تیم
export async function DELETE(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
@@ -54,11 +55,26 @@ export async function DELETE(req: NextRequest) {
const { playerId } = await req.json();
const userId = (session.user as any).id;
const team = await db.team.findUnique({ where: { userId } });
const team = await db.team.findUnique({
where: { userId },
include: { players: true },
});
if (!team) return NextResponse.json({ error: "تیم پیدا نشد" }, { status: 404 });
await db.teamPlayer.delete({
where: { teamId_playerId: { teamId: team.id, playerId } },
const teamPlayer = team.players.find((item) => item.playerId === playerId);
if (!teamPlayer) return NextResponse.json({ error: "بازیکن در تیم نیست" }, { status: 404 });
await db.$transaction(async (tx) => {
await tx.teamPlayer.delete({
where: { teamId_playerId: { teamId: team.id, playerId } },
});
if (teamPlayer.goldenCardId) {
await tx.goldenCard.update({
where: { id: teamPlayer.goldenCardId },
data: { state: "IN_INVENTORY" },
});
}
});
return NextResponse.json({ success: true });

71
app/swagger/route.ts Normal file
View File

@@ -0,0 +1,71 @@
const html = `<!DOCTYPE html>
<html lang="fa" dir="rtl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Swagger UI - Football Next</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
<style>
body {
margin: 0;
background: #f5f7fb;
font-family: Tahoma, sans-serif;
}
.topbar {
padding: 18px 24px;
background: linear-gradient(135deg, #0f172a, #1d4ed8);
color: #fff;
}
.topbar h1 {
margin: 0 0 6px;
font-size: 22px;
}
.topbar p {
margin: 0;
opacity: 0.9;
line-height: 1.8;
}
#swagger-ui {
max-width: 1400px;
margin: 0 auto;
}
.swagger-ui .topbar {
display: none;
}
</style>
</head>
<body>
<div class="topbar">
<h1>مستندات Swagger پروژه Football Next</h1>
<p>خروجی OpenAPI از مسیر <code>/api/openapi</code> خوانده می‌شود. اگر قبلاً در همین مرورگر لاگین کرده باشید، تست Endpointهای Session-based هم قابل انجام است.</p>
</div>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-standalone-preset.js"></script>
<script>
window.onload = function () {
window.ui = SwaggerUIBundle({
url: "/api/openapi",
dom_id: "#swagger-ui",
deepLinking: true,
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
layout: "StandaloneLayout",
persistAuthorization: true,
docExpansion: "list",
defaultModelsExpandDepth: 2,
displayRequestDuration: true,
});
};
</script>
</body>
</html>`;
export async function GET() {
return new Response(html, {
headers: {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-store",
},
});
}