first commit

This commit is contained in:
a.alinaghipour
2026-04-05 15:53:20 +03:30
commit aa9ed69dd2
96 changed files with 7721 additions and 0 deletions

View File

@@ -0,0 +1,72 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
type Group = { id: string; name: string };
export default function CountryForm({
groups,
initial,
countryId,
}: {
groups: Group[];
initial?: { name: string; code: string; flagUrl?: string | null; groupId?: string | null };
countryId?: string;
}) {
const router = useRouter();
const [form, setForm] = useState({
name: initial?.name ?? "",
code: initial?.code ?? "",
flagUrl: initial?.flagUrl ?? "",
groupId: initial?.groupId ?? "",
});
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
const payload = { ...form, groupId: form.groupId || null, flagUrl: form.flagUrl || null };
const res = await fetch(countryId ? `/api/countries/${countryId}` : "/api/countries", {
method: countryId ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (res.ok) { router.push("/admin/countries"); router.refresh(); }
setLoading(false);
}
return (
<form onSubmit={handleSubmit} className="bg-white rounded-2xl shadow p-6 flex flex-col gap-4">
<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 })}
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">کد (مثلاً IRN)</label>
<input type="text" value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value.toUpperCase() })}
maxLength={3}
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.flagUrl} onChange={(e) => setForm({ ...form, flagUrl: 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" />
</div>
<div>
<label className="block text-sm font-medium mb-1">گروه</label>
<select value={form.groupId} onChange={(e) => setForm({ ...form, groupId: e.target.value })}
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500">
<option value="">بدون گروه</option>
{groups.map((g) => <option key={g.id} value={g.id}>گروه {g.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">
{loading ? "در حال ذخیره..." : countryId ? "ذخیره تغییرات" : "افزودن تیم"}
</button>
</form>
);
}

View File

@@ -0,0 +1,18 @@
import { db } from "@/lib/db";
import { notFound } from "next/navigation";
import CountryForm from "../../CountryForm";
export default async function EditCountryPage({ params }: { params: { id: string } }) {
const [country, groups] = await Promise.all([
db.country.findUnique({ where: { id: params.id } }),
db.group.findMany({ orderBy: { name: "asc" } }),
]);
if (!country) notFound();
return (
<div className="max-w-md">
<h1 className="text-2xl font-bold mb-6">ویرایش تیم ملی</h1>
<CountryForm groups={groups} countryId={country.id} initial={country} />
</div>
);
}

View File

@@ -0,0 +1,12 @@
import { db } from "@/lib/db";
import CountryForm from "../CountryForm";
export default async function NewCountryPage() {
const groups = await db.group.findMany({ orderBy: { name: "asc" } });
return (
<div className="max-w-md">
<h1 className="text-2xl font-bold mb-6">تیم ملی جدید</h1>
<CountryForm groups={groups} />
</div>
);
}

View File

@@ -0,0 +1,43 @@
import { db } from "@/lib/db";
import Link from "next/link";
export default async function AdminCountriesPage() {
const countries = await db.country.findMany({
include: {
group: true,
_count: { select: { players: true } },
},
orderBy: { name: "asc" },
});
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">تیمهای ملی</h1>
<Link href="/admin/countries/new" className="bg-green-700 text-white px-5 py-2 rounded-xl hover:bg-green-800 transition font-medium">
+ تیم جدید
</Link>
</div>
<div className="grid grid-cols-2 gap-4">
{countries.map((c) => (
<div key={c.id} className="bg-white rounded-2xl shadow p-5 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center text-2xl">
{c.flagUrl ?? "🏳️"}
</div>
<div>
<div className="font-bold">{c.name}</div>
<div className="text-sm text-gray-500">
{c.code} · گروه {c.group?.name ?? "-"} · {c._count.players} بازیکن
</div>
</div>
</div>
<Link href={`/admin/countries/${c.id}/edit`} className="text-blue-600 hover:underline text-sm">
ویرایش
</Link>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,50 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function GameweekForm() {
const router = useRouter();
const [form, setForm] = useState({ number: "", name: "", deadline: "" });
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
const res = await fetch("/api/gameweeks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...form, number: parseInt(form.number) }),
});
if (res.ok) {
setForm({ number: "", name: "", deadline: "" });
router.refresh();
}
setLoading(false);
}
return (
<form onSubmit={handleSubmit} className="bg-white rounded-2xl shadow p-6 flex flex-col gap-4">
<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 />
</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 />
</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">
{loading ? "در حال ذخیره..." : "افزودن هفته"}
</button>
</form>
);
}

View File

@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
// هفته‌های بازی به دورهای بازی تغییر نام داد
export default function GameweeksPage() {
redirect("/admin/rounds");
}

View File

@@ -0,0 +1,46 @@
import Link from "next/link";
import { requireAdmin } from "@/lib/session";
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
await requireAdmin();
const links = [
{ href: "/admin", label: "داشبورد", icon: "📊" },
{ href: "/admin/rounds", label: "دورهای بازی", icon: "🏆" },
{ href: "/admin/players", label: "بازیکنان", icon: "⚽" },
{ href: "/admin/matches", label: "بازی‌ها", icon: "🏟️" },
{ href: "/admin/scoring", label: "قوانین امتیازدهی", icon: "⚙️" },
{ href: "/admin/teams", label: "تیم‌های فانتزی", icon: "👥" },
{ href: "/admin/countries", label: "تیم‌های ملی", icon: "🌍" },
{ href: "/admin/users", label: "کاربران", icon: "👤" },
];
return (
<div className="flex min-h-screen">
<aside className="w-60 bg-gray-900 text-white flex flex-col">
<div className="p-5 border-b border-gray-700">
<h2 className="font-bold text-lg">پنل ادمین</h2>
<p className="text-gray-400 text-xs mt-1">فانتزی جام جهانی</p>
</div>
<nav className="flex-1 p-3 flex flex-col gap-1">
{links.map((l) => (
<Link
key={l.href}
href={l.href}
className="flex items-center gap-3 px-4 py-2.5 rounded-xl hover:bg-gray-700 transition text-sm font-medium"
>
<span>{l.icon}</span>
{l.label}
</Link>
))}
</nav>
<div className="p-4 border-t border-gray-700">
<Link href="/" className="text-gray-400 text-xs hover:text-white transition">
بازگشت به سایت
</Link>
</div>
</aside>
<main className="flex-1 bg-gray-50 p-8 overflow-auto">{children}</main>
</div>
);
}

View File

@@ -0,0 +1,138 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
type Country = { id: string; name: string };
type Round = { id: string; name: string; number: number };
export default function MatchForm({
countries,
rounds,
initial,
matchId,
}: {
countries: Country[];
rounds: Round[];
initial?: any;
matchId?: string;
}) {
const router = useRouter();
const [form, setForm] = useState({
homeTeamId: initial?.homeTeamId ?? "",
awayTeamId: initial?.awayTeamId ?? "",
stage: initial?.stage ?? "GROUP",
status: initial?.status ?? "SCHEDULED",
matchDate: initial?.matchDate ? new Date(initial.matchDate).toISOString().slice(0, 16) : "",
homeScore: initial?.homeScore ?? "",
awayScore: initial?.awayScore ?? "",
roundId: initial?.roundId ?? "",
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
const payload = {
...form,
homeScore: form.homeScore !== "" ? parseInt(String(form.homeScore)) : null,
awayScore: form.awayScore !== "" ? parseInt(String(form.awayScore)) : null,
roundId: form.roundId || null,
};
const res = await fetch(matchId ? `/api/matches/${matchId}` : "/api/matches", {
method: matchId ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (res.ok) {
router.push("/admin/matches");
router.refresh();
} else {
const d = await res.json();
setError(d.error ?? "خطا");
}
setLoading(false);
}
const stages = [
{ value: "GROUP", label: "مرحله گروهی" },
{ value: "ROUND_OF_16", label: "یک‌هشتم نهایی" },
{ value: "QUARTER_FINAL", label: "یک‌چهارم نهایی" },
{ value: "SEMI_FINAL", label: "نیمه‌نهایی" },
{ value: "THIRD_PLACE", label: "رده‌بندی" },
{ value: "FINAL", label: "فینال" },
];
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>
<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>
<option value="">انتخاب کنید</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>
<option value="">انتخاب کنید</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>
<label className="block text-sm font-medium mb-1">گل میزبان</label>
<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" />
</div>
<div>
<label className="block text-sm font-medium mb-1">گل مهمان</label>
<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" />
</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>
</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">
<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>
<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">
<option value="">بدون دور</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">
{loading ? "در حال ذخیره..." : matchId ? "ذخیره تغییرات" : "افزودن بازی"}
</button>
</form>
);
}

View File

@@ -0,0 +1,20 @@
import { db } from "@/lib/db";
import { notFound } from "next/navigation";
import MatchForm from "../../MatchForm";
export default async function EditMatchPage({ params }: { params: { id: string } }) {
const [match, countries, rounds] = await Promise.all([
db.match.findUnique({ where: { id: params.id } }),
db.country.findMany({ orderBy: { name: "asc" } }),
db.round.findMany({ orderBy: { number: "asc" } }),
]);
if (!match) notFound();
return (
<div className="max-w-xl">
<h1 className="text-2xl font-bold mb-6">ویرایش بازی</h1>
<MatchForm countries={countries} rounds={rounds} matchId={match.id} initial={match} />
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { db } from "@/lib/db";
import MatchForm from "../MatchForm";
export default async function NewMatchPage() {
const [countries, rounds] = await Promise.all([
db.country.findMany({ orderBy: { name: "asc" } }),
db.round.findMany({ orderBy: { number: "asc" } }),
]);
return (
<div className="max-w-xl">
<h1 className="text-2xl font-bold mb-6">بازی جدید</h1>
<MatchForm countries={countries} rounds={rounds} />
</div>
);
}

View File

@@ -0,0 +1,67 @@
import { db } from "@/lib/db";
import Link from "next/link";
const stageLabel: Record<string, string> = {
GROUP: "گروهی", ROUND_OF_16: "یک‌هشتم", QUARTER_FINAL: "یک‌چهارم",
SEMI_FINAL: "نیمه‌نهایی", THIRD_PLACE: "رده‌بندی", FINAL: "فینال",
};
export default async function AdminMatchesPage() {
const matches = await db.match.findMany({
include: { homeTeam: true, awayTeam: true, round: true },
orderBy: { matchDate: "asc" },
});
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">بازیها</h1>
<Link href="/admin/matches/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="text-right px-5 py-4">وضعیت</th>
<th className="px-5 py-4"></th>
</tr>
</thead>
<tbody>
{matches.map((m) => (
<tr key={m.id} className="border-t hover:bg-gray-50 transition">
<td className="px-5 py-3 font-medium">{m.homeTeam.name}</td>
<td className="px-5 py-3 font-bold text-center">
{m.status !== "SCHEDULED" ? `${m.homeScore ?? 0} - ${m.awayScore ?? 0}` : "-"}
</td>
<td className="px-5 py-3 font-medium">{m.awayTeam.name}</td>
<td className="px-5 py-3 text-gray-500">{stageLabel[m.stage]}</td>
<td className="px-5 py-3 text-gray-500">{m.round?.name ?? "-"}</td>
<td className="px-5 py-3 text-gray-500">{new Date(m.matchDate).toLocaleDateString("fa-IR")}</td>
<td className="px-5 py-3">
<span className={`text-xs px-2 py-1 rounded-full font-medium ${
m.status === "LIVE" ? "bg-red-100 text-red-600" :
m.status === "FINISHED" ? "bg-green-100 text-green-700" :
"bg-gray-100 text-gray-600"
}`}>
{m.status === "LIVE" ? "زنده" : m.status === "FINISHED" ? "پایان" : "برنامه"}
</span>
</td>
<td className="px-5 py-3">
<Link href={`/admin/matches/${m.id}/edit`} className="text-blue-600 hover:underline text-xs">ویرایش</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,84 @@
import { db } from "@/lib/db";
import Link from "next/link";
export default async function AdminDashboard() {
const [playerCount, userCount, teamCount, matchCount] = await Promise.all([
db.player.count(),
db.user.count(),
db.team.count(),
db.match.count(),
]);
const topTeams = await db.team.findMany({
orderBy: { totalPoints: "desc" },
take: 5,
include: { user: { select: { name: true } } },
});
const recentMatches = await db.match.findMany({
orderBy: { matchDate: "desc" },
take: 5,
include: { homeTeam: true, awayTeam: true },
});
return (
<div>
<h1 className="text-3xl font-bold mb-8">داشبورد</h1>
<div className="grid grid-cols-4 gap-5 mb-10">
{[
{ label: "بازیکنان", value: playerCount, icon: "⚽", color: "bg-green-500" },
{ label: "کاربران", value: userCount, icon: "👥", color: "bg-blue-500" },
{ label: "تیم‌های فانتزی", value: teamCount, icon: "🏆", color: "bg-yellow-500" },
{ label: "بازی‌ها", value: matchCount, icon: "🏟️", color: "bg-purple-500" },
].map((s) => (
<div key={s.label} className="bg-white rounded-2xl shadow p-6 flex items-center gap-4">
<div className={`${s.color} text-white text-2xl w-14 h-14 rounded-xl flex items-center justify-center`}>
{s.icon}
</div>
<div>
<div className="text-3xl font-bold">{s.value}</div>
<div className="text-gray-500 text-sm">{s.label}</div>
</div>
</div>
))}
</div>
<div className="grid grid-cols-2 gap-6">
<div className="bg-white rounded-2xl shadow p-6">
<h2 className="font-bold text-lg mb-4">برترین تیمها</h2>
<div className="flex flex-col gap-3">
{topTeams.map((t, i) => (
<div key={t.id} className="flex justify-between items-center">
<div className="flex items-center gap-3">
<span className="text-gray-400 w-5">{i + 1}</span>
<div>
<div className="font-medium text-sm">{t.name}</div>
<div className="text-xs text-gray-400">{t.user.name}</div>
</div>
</div>
<span className="font-bold text-blue-700">{t.totalPoints} pts</span>
</div>
))}
</div>
</div>
<div className="bg-white rounded-2xl shadow p-6">
<h2 className="font-bold text-lg mb-4">آخرین بازیها</h2>
<div className="flex flex-col gap-3">
{recentMatches.map((m) => (
<div key={m.id} className="flex justify-between items-center text-sm">
<span>{m.homeTeam.name}</span>
<span className="font-bold text-gray-600">
{m.status === "FINISHED" ? `${m.homeScore} - ${m.awayScore}` : "vs"}
</span>
<span>{m.awayTeam.name}</span>
</div>
))}
{recentMatches.length === 0 && <p className="text-gray-400 text-sm">بازیای ثبت نشده</p>}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,106 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
type Country = { id: string; name: string };
export default function PlayerForm({
countries,
initial,
playerId,
}: {
countries: Country[];
initial?: { name: string; position: string; countryId: string; price: number };
playerId?: string;
}) {
const router = useRouter();
const [form, setForm] = useState({
name: initial?.name ?? "",
position: initial?.position ?? "FWD",
countryId: initial?.countryId ?? "",
price: initial?.price ?? 5.0,
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
const res = await fetch(playerId ? `/api/players/${playerId}` : "/api/players", {
method: playerId ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
if (res.ok) {
router.push("/admin/players");
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">{error}</p>}
<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 })}
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>
<select
value={form.position}
onChange={(e) => setForm({ ...form, position: e.target.value })}
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
>
<option value="GK">دروازهبان</option>
<option value="DEF">مدافع</option>
<option value="MID">هافبک</option>
<option value="FWD">مهاجم</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">تیم ملی</label>
<select
value={form.countryId}
onChange={(e) => setForm({ ...form, countryId: 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
>
<option value="">انتخاب کنید</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>
<input
type="number"
step="0.5"
min="4"
max="15"
value={form.price}
onChange={(e) => setForm({ ...form, price: parseFloat(e.target.value) })}
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
/>
</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 ? "در حال ذخیره..." : playerId ? "ذخیره تغییرات" : "افزودن بازیکن"}
</button>
</form>
);
}

View File

@@ -0,0 +1,34 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
export default function DeleteButton({ playerId }: { playerId: string }) {
const router = useRouter();
const [confirm, setConfirm] = useState(false);
async function handleDelete() {
await fetch(`/api/players/${playerId}`, { method: "DELETE" });
router.push("/admin/players");
router.refresh();
}
if (confirm) {
return (
<div className="flex gap-2">
<button onClick={handleDelete} className="bg-red-600 text-white px-4 py-2 rounded-xl text-sm hover:bg-red-700">
تأیید حذف
</button>
<button onClick={() => setConfirm(false)} className="bg-gray-200 px-4 py-2 rounded-xl text-sm hover:bg-gray-300">
انصراف
</button>
</div>
);
}
return (
<button onClick={() => setConfirm(true)} className="text-red-500 hover:text-red-700 text-sm font-medium">
حذف بازیکن
</button>
);
}

View File

@@ -0,0 +1,32 @@
import { db } from "@/lib/db";
import { notFound } from "next/navigation";
import PlayerForm from "../../PlayerForm";
import DeleteButton from "./DeleteButton";
export default async function EditPlayerPage({ params }: { params: { id: string } }) {
const [player, countries] = await Promise.all([
db.player.findUnique({ where: { id: params.id } }),
db.country.findMany({ orderBy: { name: "asc" } }),
]);
if (!player) notFound();
return (
<div className="max-w-xl">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">ویرایش بازیکن</h1>
<DeleteButton playerId={player.id} />
</div>
<PlayerForm
countries={countries}
playerId={player.id}
initial={{
name: player.name,
position: player.position,
countryId: player.countryId,
price: player.price,
}}
/>
</div>
);
}

View File

@@ -0,0 +1,12 @@
import { db } from "@/lib/db";
import PlayerForm from "../PlayerForm";
export default async function NewPlayerPage() {
const countries = await db.country.findMany({ orderBy: { name: "asc" } });
return (
<div className="max-w-xl">
<h1 className="text-2xl font-bold mb-6">بازیکن جدید</h1>
<PlayerForm countries={countries} />
</div>
);
}

View File

@@ -0,0 +1,51 @@
import { db } from "@/lib/db";
import Link from "next/link";
import PositionBadge from "@/components/PositionBadge";
export default async function AdminPlayersPage() {
const players = await db.player.findMany({
include: { country: true },
orderBy: { name: "asc" },
});
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">بازیکنان</h1>
<Link href="/admin/players/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="px-5 py-4"></th>
</tr>
</thead>
<tbody>
{players.map((p) => (
<tr key={p.id} className="border-t hover:bg-gray-50 transition">
<td className="px-5 py-3 font-medium">{p.name}</td>
<td className="px-5 py-3"><PositionBadge position={p.position} /></td>
<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">
<Link href={`/admin/players/${p.id}/edit`} className="text-blue-600 hover:underline text-xs">
ویرایش
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,25 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function ActivateRoundButton({ roundId, isActive }: { roundId: string; isActive: boolean }) {
const router = useRouter();
const [loading, setLoading] = useState(false);
async function activate() {
setLoading(true);
await fetch(`/api/rounds/${roundId}/activate`, { method: "POST" });
router.refresh();
setLoading(false);
}
if (isActive) return <span className="text-xs text-green-600 font-medium px-3 py-1.5"> فعال</span>;
return (
<button onClick={activate} disabled={loading}
className="bg-blue-600 text-white px-3 py-1.5 rounded-lg text-sm hover:bg-blue-700 transition disabled:opacity-50">
{loading ? "..." : "فعال‌سازی"}
</button>
);
}

View File

@@ -0,0 +1,62 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function RoundForm() {
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();
setLoading(true);
setError("");
const res = await fetch("/api/rounds", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...form, number: parseInt(form.number) }),
});
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 />
</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 />
</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">
{loading ? "در حال ذخیره..." : "افزودن دور"}
</button>
</form>
);
}

View File

@@ -0,0 +1,302 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
const EVENT_LABELS: Record<string, { label: string; icon: string; color: string }> = {
GOAL: { label: "گل", icon: "⚽", color: "bg-green-100 text-green-700" },
ASSIST: { label: "پاس گل", icon: "🎯", color: "bg-blue-100 text-blue-700" },
YELLOW_CARD: { label: "کارت زرد", icon: "🟨", color: "bg-yellow-100 text-yellow-700" },
RED_CARD: { label: "کارت قرمز", icon: "🟥", color: "bg-red-100 text-red-700" },
SECOND_YELLOW: { label: "کارت زرد دوم", icon: "🟨🟥", color: "bg-orange-100 text-orange-700" },
SUBSTITUTION_IN: { label: "ورود تعویضی", icon: "🔄↑", color: "bg-teal-100 text-teal-700" },
SUBSTITUTION_OUT: { label: "خروج تعویضی", icon: "🔄↓", color: "bg-gray-100 text-gray-600" },
INJURY_NO_SUB: { label: "مصدومیت بدون تعویض", icon: "🤕", color: "bg-red-50 text-red-500" },
CLEAN_SHEET: { label: "کلین‌شیت", icon: "🧤", color: "bg-green-100 text-green-700" },
PENALTY_SAVED: { label: "پنالتی گرفته", icon: "🛡️", color: "bg-purple-100 text-purple-700" },
PENALTY_MISSED: { label: "پنالتی از دست داده", icon: "❌", color: "bg-red-100 text-red-600" },
OWN_GOAL: { label: "گل به خودی", icon: "😬", color: "bg-orange-100 text-orange-700" },
EXTRA_TIME_BONUS: { label: "وقت اضافه", icon: "⏱️", color: "bg-indigo-100 text-indigo-700" },
MOTM: { label: "بازیکن برتر", icon: "🌟", color: "bg-yellow-100 text-yellow-700" },
};
export default function MatchEventManager({ match, roundId }: { match: any; roundId: string }) {
const router = useRouter();
const [tab, setTab] = useState<"events" | "lineup" | "score">("events");
const [eventForm, setEventForm] = useState({ playerId: "", type: "GOAL", minute: "", extraInfo: "" });
const [score, setScore] = useState({ homeScore: match.homeScore ?? 0, awayScore: match.awayScore ?? 0, status: match.status });
const [loading, setLoading] = useState(false);
const [msg, setMsg] = useState("");
const allPlayers = [...match.homeTeam.players, ...match.awayTeam.players];
async function addEvent() {
if (!eventForm.playerId || !eventForm.type) return;
setLoading(true);
const res = await fetch(`/api/admin/matches/${match.id}/events`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...eventForm, minute: eventForm.minute ? parseInt(eventForm.minute) : null }),
});
if (res.ok) { router.refresh(); setMsg("رویداد ثبت شد"); setEventForm({ playerId: "", type: "GOAL", minute: "", extraInfo: "" }); }
else { const d = await res.json(); setMsg(d.error); }
setLoading(false);
}
async function deleteEvent(eventId: string) {
await fetch(`/api/admin/matches/${match.id}/events/${eventId}`, { method: "DELETE" });
router.refresh();
}
async function updateScore() {
setLoading(true);
await fetch(`/api/matches/${match.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(score),
});
router.refresh();
setLoading(false);
setMsg("نتیجه ذخیره شد");
}
async function calcPoints() {
setLoading(true);
const res = await fetch(`/api/admin/matches/${match.id}/calc-points`, { method: "POST" });
const d = await res.json();
setMsg(res.ok ? "امتیازات محاسبه شد" : d.error);
setLoading(false);
router.refresh();
}
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* پنل چپ - رویدادها */}
<div className="lg:col-span-2">
{/* تب‌ها */}
<div className="flex gap-2 mb-4">
{[["events", "رویدادها"], ["lineup", "ترکیب تیم‌ها"], ["score", "نتیجه"]].map(([key, label]) => (
<button key={key} onClick={() => setTab(key as any)}
className={`px-4 py-2 rounded-xl text-sm font-medium transition ${tab === key ? "bg-green-700 text-white" : "bg-white shadow text-gray-700 hover:bg-gray-50"}`}>
{label}
</button>
))}
<button onClick={calcPoints} disabled={loading}
className="mr-auto bg-blue-600 text-white px-4 py-2 rounded-xl text-sm font-medium hover:bg-blue-700 disabled:opacity-50">
محاسبه امتیازات
</button>
</div>
{msg && <div className="bg-green-50 text-green-700 px-4 py-2 rounded-xl text-sm mb-4">{msg}</div>}
{tab === "events" && (
<div>
{/* فرم ثبت رویداد */}
<div className="bg-white rounded-2xl shadow p-5 mb-4">
<h3 className="font-bold mb-4">ثبت رویداد جدید</h3>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs font-medium text-gray-600 mb-1 block">بازیکن</label>
<select value={eventForm.playerId} onChange={(e) => setEventForm({ ...eventForm, playerId: e.target.value })}
className="w-full border rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-400">
<option value="">انتخاب بازیکن...</option>
<optgroup label={match.homeTeam.name}>
{match.homeTeam.players.map((p: any) => (
<option key={p.id} value={p.id}>{p.name} ({p.position})</option>
))}
</optgroup>
<optgroup label={match.awayTeam.name}>
{match.awayTeam.players.map((p: any) => (
<option key={p.id} value={p.id}>{p.name} ({p.position})</option>
))}
</optgroup>
</select>
</div>
<div>
<label className="text-xs font-medium text-gray-600 mb-1 block">نوع رویداد</label>
<select value={eventForm.type} onChange={(e) => setEventForm({ ...eventForm, type: e.target.value })}
className="w-full border rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-400">
{Object.entries(EVENT_LABELS).map(([key, val]) => (
<option key={key} value={key}>{val.icon} {val.label}</option>
))}
</select>
</div>
<div>
<label className="text-xs font-medium text-gray-600 mb-1 block">دقیقه (اختیاری)</label>
<input type="number" min="1" max="120" value={eventForm.minute}
onChange={(e) => setEventForm({ ...eventForm, minute: e.target.value })}
placeholder="مثلاً 45"
className="w-full border rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-400" />
</div>
<div>
<label className="text-xs font-medium text-gray-600 mb-1 block">توضیح (اختیاری)</label>
<input type="text" value={eventForm.extraInfo}
onChange={(e) => setEventForm({ ...eventForm, extraInfo: e.target.value })}
placeholder="مثلاً نام بازیکن تعویضی"
className="w-full border rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-400" />
</div>
</div>
<button onClick={addEvent} disabled={loading || !eventForm.playerId}
className="mt-4 bg-green-700 text-white px-6 py-2 rounded-xl font-medium hover:bg-green-800 disabled:opacity-50 transition">
ثبت رویداد
</button>
</div>
{/* لیست رویدادها */}
<div className="bg-white rounded-2xl shadow overflow-hidden">
<div className="px-5 py-3 bg-gray-50 font-medium text-sm text-gray-600">رویدادهای ثبتشده ({match.events.length})</div>
{match.events.length === 0 ? (
<div className="text-center text-gray-400 py-8">رویدادی ثبت نشده</div>
) : (
<div className="divide-y">
{match.events.map((ev: any) => {
const evInfo = EVENT_LABELS[ev.type];
return (
<div key={ev.id} className="flex items-center justify-between px-5 py-3">
<div className="flex items-center gap-3">
<span className={`text-xs px-2 py-1 rounded-full font-medium ${evInfo?.color}`}>
{evInfo?.icon} {evInfo?.label}
</span>
<span className="font-medium text-sm">{ev.player.name}</span>
{ev.minute && <span className="text-gray-400 text-xs">{ev.minute}'</span>}
{ev.extraInfo && <span className="text-gray-400 text-xs">({ev.extraInfo})</span>}
</div>
<button onClick={() => deleteEvent(ev.id)} className="text-red-400 hover:text-red-600 text-xs">حذف</button>
</div>
);
})}
</div>
)}
</div>
</div>
)}
{tab === "lineup" && (
<LineupTab match={match} />
)}
{tab === "score" && (
<div className="bg-white rounded-2xl shadow p-6">
<h3 className="font-bold mb-4">نتیجه بازی</h3>
<div className="grid grid-cols-3 gap-4 items-center mb-4">
<div className="text-center">
<div className="font-bold mb-2">{match.homeTeam.name}</div>
<input type="number" min="0" value={score.homeScore}
onChange={(e) => setScore({ ...score, homeScore: parseInt(e.target.value) || 0 })}
className="w-20 border-2 rounded-xl px-3 py-3 text-center text-2xl font-bold focus:outline-none focus:border-green-500" />
</div>
<div className="text-center text-gray-400 text-xl font-bold">-</div>
<div className="text-center">
<div className="font-bold mb-2">{match.awayTeam.name}</div>
<input type="number" min="0" value={score.awayScore}
onChange={(e) => setScore({ ...score, awayScore: parseInt(e.target.value) || 0 })}
className="w-20 border-2 rounded-xl px-3 py-3 text-center text-2xl font-bold focus:outline-none focus:border-green-500" />
</div>
</div>
<div className="mb-4">
<label className="text-sm font-medium mb-1 block">وضعیت</label>
<select value={score.status} onChange={(e) => setScore({ ...score, status: e.target.value })}
className="border rounded-xl px-4 py-2 focus:outline-none focus:ring-2 focus:ring-green-400">
<option value="SCHEDULED">برنامه‌ریزی شده</option>
<option value="LIVE">زنده</option>
<option value="FINISHED">پایان یافته</option>
</select>
</div>
<button onClick={updateScore} disabled={loading}
className="bg-green-700 text-white px-6 py-2 rounded-xl font-medium hover:bg-green-800 disabled:opacity-50">
ذخیره نتیجه
</button>
</div>
)}
</div>
{/* پنل راست - آمار بازیکنان */}
<div>
<div className="bg-white rounded-2xl shadow overflow-hidden">
<div className="px-4 py-3 bg-gray-50 font-medium text-sm">آمار بازیکنان</div>
<div className="divide-y max-h-[600px] overflow-y-auto">
{match.playerStats.map((s: any) => (
<div key={s.id} className="px-4 py-3">
<div className="flex justify-between items-center">
<span className="font-medium text-sm">{s.player.name}</span>
<span className="font-bold text-blue-700">{s.points} pts</span>
</div>
<div className="flex gap-2 mt-1 flex-wrap">
{s.goals > 0 && <span className="text-xs bg-green-50 text-green-700 px-1.5 py-0.5 rounded">⚽ {s.goals}</span>}
{s.assists > 0 && <span className="text-xs bg-blue-50 text-blue-700 px-1.5 py-0.5 rounded">🎯 {s.assists}</span>}
{s.yellowCards > 0 && <span className="text-xs bg-yellow-50 text-yellow-700 px-1.5 py-0.5 rounded">🟨 {s.yellowCards}</span>}
{s.redCards > 0 && <span className="text-xs bg-red-50 text-red-700 px-1.5 py-0.5 rounded">🟥</span>}
{s.cleanSheet && <span className="text-xs bg-purple-50 text-purple-700 px-1.5 py-0.5 rounded">🧤</span>}
<span className="text-xs text-gray-400">{s.minutesPlayed}'</span>
</div>
</div>
))}
{match.playerStats.length === 0 && <div className="text-center text-gray-400 py-6 text-sm">امتیازی محاسبه نشده</div>}
</div>
</div>
</div>
</div>
);
}
function LineupTab({ match }: { match: any }) {
const [homeLineup, setHomeLineup] = useState<string[]>([]);
const [awayLineup, setAwayLineup] = useState<string[]>([]);
const [homeFormation, setHomeFormation] = useState(match.homeTeam.defaultFormation ?? "4-3-3");
const [awayFormation, setAwayFormation] = useState(match.awayTeam.defaultFormation ?? "4-3-3");
const [loading, setLoading] = useState(false);
const [msg, setMsg] = useState("");
async function saveLineup() {
setLoading(true);
await fetch(`/api/admin/matches/${match.id}/lineup`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify([
{ countryId: match.homeTeamId, formation: homeFormation, playerIds: homeLineup },
{ countryId: match.awayTeamId, formation: awayFormation, playerIds: awayLineup },
]),
});
setMsg("ترکیب ذخیره شد");
setLoading(false);
}
const togglePlayer = (id: string, team: "home" | "away") => {
if (team === "home") setHomeLineup((prev) => prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]);
else setAwayLineup((prev) => prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]);
};
return (
<div className="bg-white rounded-2xl shadow p-5">
<h3 className="font-bold mb-4">ثبت ترکیب رسمی</h3>
{msg && <div className="bg-green-50 text-green-700 px-3 py-2 rounded-lg text-sm mb-3">{msg}</div>}
<div className="grid grid-cols-2 gap-6">
{[{ team: match.homeTeam, side: "home" as const, lineup: homeLineup, formation: homeFormation, setFormation: setHomeFormation },
{ team: match.awayTeam, side: "away" as const, lineup: awayLineup, formation: awayFormation, setFormation: setAwayFormation }
].map(({ team, side, lineup, formation, setFormation }) => (
<div key={team.id}>
<div className="font-medium mb-2">{team.flagUrl} {team.name}</div>
<input type="text" value={formation} onChange={(e) => setFormation(e.target.value)}
placeholder="ترکیب مثلاً 4-3-3"
className="w-full border rounded-lg px-3 py-1.5 text-sm mb-2 focus:outline-none focus:ring-2 focus:ring-green-400" />
<div className="max-h-48 overflow-y-auto flex flex-col gap-1">
{team.players.map((p: any) => (
<label key={p.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-gray-50 px-2 py-1 rounded">
<input type="checkbox" checked={lineup.includes(p.id)} onChange={() => togglePlayer(p.id, side)}
className="accent-green-600" />
<span>{p.name}</span>
<span className="text-xs text-gray-400">{p.position}</span>
</label>
))}
</div>
<div className="text-xs text-gray-400 mt-1">{lineup.length} بازیکن انتخاب شده</div>
</div>
))}
</div>
<button onClick={saveLineup} disabled={loading}
className="mt-4 bg-green-700 text-white px-6 py-2 rounded-xl font-medium hover:bg-green-800 disabled:opacity-50">
ذخیره ترکیب
</button>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { db } from "@/lib/db";
import { notFound } from "next/navigation";
import Link from "next/link";
import MatchEventManager from "./MatchEventManager";
export default async function MatchDetailPage({ params }: { params: { id: string; matchId: string } }) {
const match = await db.match.findUnique({
where: { id: params.matchId },
include: {
homeTeam: { include: { players: { orderBy: { position: "asc" } } } },
awayTeam: { include: { players: { orderBy: { position: "asc" } } } },
events: { include: { player: true }, orderBy: { minute: "asc" } },
lineups: true,
playerStats: { include: { player: true } },
},
});
if (!match) notFound();
return (
<div>
<div className="flex items-center gap-3 mb-6">
<Link href={`/admin/rounds/${params.id}`} className="text-gray-400 hover:text-gray-600"> دور</Link>
<h1 className="text-xl font-bold">
{match.homeTeam.name} {match.homeScore ?? "-"} - {match.awayScore ?? "-"} {match.awayTeam.name}
</h1>
</div>
<MatchEventManager match={match} roundId={params.id} />
</div>
);
}

View File

@@ -0,0 +1,74 @@
import { db } from "@/lib/db";
import { notFound } from "next/navigation";
import Link from "next/link";
const statusStyle: Record<string, string> = {
SCHEDULED: "bg-gray-100 text-gray-600",
LIVE: "bg-red-100 text-red-600",
FINISHED: "bg-green-100 text-green-700",
};
const statusLabel: Record<string, string> = { SCHEDULED: "برنامه", LIVE: "🔴 زنده", FINISHED: "پایان" };
export default async function RoundDetailPage({ params }: { params: { id: string } }) {
const round = await db.round.findUnique({
where: { id: params.id },
include: {
matches: {
include: {
homeTeam: true, awayTeam: true,
_count: { select: { events: true, lineups: true } },
},
orderBy: { matchDate: "asc" },
},
},
});
if (!round) notFound();
return (
<div>
<div className="flex items-center gap-3 mb-6">
<Link href="/admin/rounds" className="text-gray-400 hover:text-gray-600"> دورها</Link>
<h1 className="text-2xl font-bold">{round.name}</h1>
{round.isActive && <span className="bg-green-100 text-green-700 text-xs px-2 py-1 rounded-full">فعال</span>}
</div>
<div className="flex flex-col gap-3">
{round.matches.map((m) => (
<div key={m.id} className="bg-white rounded-2xl shadow p-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 flex-1 justify-end">
<span className="font-bold">{m.homeTeam.name}</span>
<span>{m.homeTeam.flagUrl}</span>
</div>
<div className="mx-6 text-center min-w-[120px]">
{m.status !== "SCHEDULED" ? (
<div className="text-2xl font-bold">{m.homeScore} - {m.awayScore}</div>
) : (
<div className="text-sm text-gray-500">{new Date(m.matchDate).toLocaleDateString("fa-IR")}</div>
)}
<span className={`text-xs px-2 py-0.5 rounded-full mt-1 inline-block ${statusStyle[m.status]}`}>
{statusLabel[m.status]}
</span>
</div>
<div className="flex items-center gap-4 flex-1 justify-start">
<span>{m.awayTeam.flagUrl}</span>
<span className="font-bold">{m.awayTeam.name}</span>
</div>
<div className="flex gap-2 mr-4">
<div className="text-xs text-gray-400 text-center">
<div>{m._count.events} رویداد</div>
<div>{m._count.lineups > 0 ? "✓ ترکیب" : "بدون ترکیب"}</div>
</div>
<Link href={`/admin/rounds/${round.id}/match/${m.id}`}
className="bg-green-700 text-white px-4 py-2 rounded-xl text-sm font-medium hover:bg-green-800 transition">
جزئیات
</Link>
</div>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,49 @@
import { db } from "@/lib/db";
import Link from "next/link";
import RoundForm from "./RoundForm";
import ActivateRoundButton from "./ActivateRoundButton";
export default async function AdminRoundsPage() {
const rounds = await db.round.findMany({
orderBy: { number: "asc" },
include: { _count: { select: { matches: true } } },
});
return (
<div className="grid grid-cols-2 gap-8">
<div>
<h1 className="text-2xl font-bold mb-6">دورهای بازی</h1>
<div className="flex flex-col gap-3">
{rounds.map((r) => (
<div key={r.id} className={`bg-white rounded-2xl shadow p-5 border-2 ${r.isActive ? "border-green-500" : "border-transparent"}`}>
<div className="flex items-center justify-between">
<div>
<div className="font-bold flex items-center gap-2">
دور {r.number} - {r.name}
{r.isActive && <span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full">فعال</span>}
</div>
<div className="text-sm text-gray-500 mt-1">
{r._count.matches} بازی · deadline: {new Date(r.deadline).toLocaleDateString("fa-IR")}
</div>
</div>
<div className="flex items-center gap-2">
<ActivateRoundButton roundId={r.id} isActive={r.isActive} />
<Link href={`/admin/rounds/${r.id}`}
className="bg-green-700 text-white px-3 py-1.5 rounded-lg text-sm hover:bg-green-800 transition">
بازیها
</Link>
</div>
</div>
</div>
))}
{rounds.length === 0 && <p className="text-gray-400 text-sm">هنوز دوری ثبت نشده</p>}
</div>
</div>
<div>
<h2 className="text-xl font-bold mb-6">افزودن دور جدید</h2>
<RoundForm />
</div>
</div>
);
}

View File

@@ -0,0 +1,101 @@
"use client";
import { useState } from "react";
const POSITIONS = ["GK", "DEF", "MID", "FWD"] as const;
const POS_LABELS: Record<string, string> = { GK: "دروازه‌بان", DEF: "مدافع", MID: "هافبک", FWD: "مهاجم" };
const EVENT_LABELS: Record<string, string> = {
GOAL: "گل", ASSIST: "پاس گل", YELLOW_CARD: "کارت زرد", RED_CARD: "کارت قرمز",
SECOND_YELLOW: "کارت زرد دوم", CLEAN_SHEET: "کلین‌شیت", PENALTY_SAVED: "پنالتی گرفته",
PENALTY_MISSED: "پنالتی از دست داده", OWN_GOAL: "گل به خودی", MOTM: "بازیکن برتر",
EXTRA_TIME_BONUS: "وقت اضافه", INJURY_NO_SUB: "مصدومیت بدون تعویض",
};
type Rule = { id: string; position: string; eventType: string; points: number };
export default function ScoringRulesEditor({ rules, defaultRules }: { rules: Rule[]; defaultRules: any }) {
const [values, setValues] = useState<Record<string, number>>(() => {
const map: Record<string, number> = {};
for (const r of rules) map[`${r.position}_${r.eventType}`] = r.points;
return map;
});
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
function getValue(pos: string, evt: string): number {
const key = `${pos}_${evt}`;
if (key in values) return values[key];
return defaultRules[pos]?.[evt] ?? 0;
}
function setValue(pos: string, evt: string, val: number) {
setValues((v) => ({ ...v, [`${pos}_${evt}`]: val }));
}
async function handleSave() {
setSaving(true);
const payload = Object.entries(values).map(([key, points]) => {
const [position, eventType] = key.split("_");
return { position, eventType, points };
});
await fetch("/api/admin/scoring", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
setSaved(true);
setSaving(false);
setTimeout(() => setSaved(false), 3000);
}
const events = Object.keys(EVENT_LABELS);
return (
<div>
<div className="overflow-x-auto bg-white rounded-2xl shadow">
<table className="w-full text-sm">
<thead className="bg-green-800 text-white">
<tr>
<th className="text-right px-5 py-4 sticky right-0 bg-green-800">رویداد</th>
{POSITIONS.map((pos) => (
<th key={pos} className="px-5 py-4 text-center">{POS_LABELS[pos]}</th>
))}
</tr>
</thead>
<tbody>
{events.map((evt) => (
<tr key={evt} className="border-t hover:bg-gray-50">
<td className="px-5 py-3 font-medium sticky right-0 bg-white">{EVENT_LABELS[evt]}</td>
{POSITIONS.map((pos) => {
const val = getValue(pos, evt);
return (
<td key={pos} className="px-3 py-2 text-center">
<input
type="number"
value={val}
onChange={(e) => setValue(pos, evt, parseInt(e.target.value) || 0)}
className={`w-16 border rounded-lg px-2 py-1 text-center focus:outline-none focus:ring-2 focus:ring-green-400 font-bold ${
val > 0 ? "text-green-700 border-green-200" :
val < 0 ? "text-red-600 border-red-200" :
"text-gray-400"
}`}
/>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
<div className="flex items-center gap-4 mt-6">
<button onClick={handleSave} disabled={saving}
className="bg-green-700 text-white px-8 py-3 rounded-xl font-bold hover:bg-green-800 transition disabled:opacity-50">
{saving ? "در حال ذخیره..." : "ذخیره همه قوانین"}
</button>
{saved && <span className="text-green-600 font-medium"> ذخیره شد</span>}
</div>
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { db } from "@/lib/db";
import ScoringRulesEditor from "./ScoringRulesEditor";
import { DEFAULT_RULES } from "@/lib/points";
export default async function AdminScoringPage() {
const rules = await db.scoringRule.findMany({ orderBy: [{ position: "asc" }, { eventType: "asc" }] });
return (
<div>
<h1 className="text-2xl font-bold mb-2">مدیریت امتیازدهی</h1>
<p className="text-gray-500 text-sm mb-6">امتیاز هر رویداد را به ازای هر پست جداگانه تنظیم کنید</p>
<ScoringRulesEditor rules={rules} defaultRules={DEFAULT_RULES} />
</div>
);
}

View File

@@ -0,0 +1,138 @@
"use client";
import { useState } from "react";
type Player = { id: string; name: string; position: string };
type StatRow = {
playerId: string;
goals: number;
assists: number;
yellowCards: number;
redCards: number;
minutesPlayed: number;
cleanSheet: boolean;
};
export default function StatsForm({ match }: { match: any }) {
const allPlayers: Player[] = [
...match.homeTeam.players,
...match.awayTeam.players,
];
const initStats = (): Record<string, StatRow> => {
const map: Record<string, StatRow> = {};
for (const p of allPlayers) {
const existing = match.playerStats.find((s: any) => s.playerId === p.id);
map[p.id] = existing ?? {
playerId: p.id,
goals: 0,
assists: 0,
yellowCards: 0,
redCards: 0,
minutesPlayed: 0,
cleanSheet: false,
};
}
return map;
};
const [stats, setStats] = useState<Record<string, StatRow>>(initStats);
const [loading, setLoading] = useState(false);
const [saved, setSaved] = useState(false);
function update(playerId: string, field: keyof StatRow, value: any) {
setStats((prev) => ({ ...prev, [playerId]: { ...prev[playerId], [field]: value } }));
}
async function handleSave() {
setLoading(true);
const payload = Object.values(stats).filter((s) => s.minutesPlayed > 0);
await fetch(`/api/matches/${match.id}/stats`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
setSaved(true);
setLoading(false);
setTimeout(() => setSaved(false), 3000);
}
const renderTeam = (players: Player[], teamName: string) => (
<div className="mb-8">
<h3 className="font-bold text-lg mb-3 text-green-800">{teamName}</h3>
<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-4 py-3">بازیکن</th>
<th className="px-3 py-3">دقیقه</th>
<th className="px-3 py-3">گل</th>
<th className="px-3 py-3">پاس گل</th>
<th className="px-3 py-3">زرد</th>
<th className="px-3 py-3">قرمز</th>
<th className="px-3 py-3">کلینشیت</th>
</tr>
</thead>
<tbody>
{players.map((p) => {
const s = stats[p.id];
if (!s) return null;
return (
<tr key={p.id} className="border-t hover:bg-gray-50">
<td className="px-4 py-2">
<div className="font-medium">{p.name}</div>
<div className="text-xs text-gray-400">{p.position}</div>
</td>
{(["minutesPlayed", "goals", "assists", "yellowCards", "redCards"] as const).map((field) => (
<td key={field} className="px-2 py-2 text-center">
<input
type="number"
min="0"
max={field === "minutesPlayed" ? 120 : 10}
value={s[field] as number}
onChange={(e) => update(p.id, field, parseInt(e.target.value) || 0)}
className="w-14 border rounded-lg px-2 py-1 text-center focus:outline-none focus:ring-2 focus:ring-green-400"
/>
</td>
))}
<td className="px-2 py-2 text-center">
<input
type="checkbox"
checked={s.cleanSheet}
onChange={(e) => update(p.id, "cleanSheet", e.target.checked)}
className="w-4 h-4 accent-green-600"
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
return (
<div>
<div className="bg-green-50 border border-green-200 rounded-2xl p-4 mb-6 text-center">
<span className="font-bold text-lg">{match.homeTeam.name}</span>
<span className="mx-4 text-gray-400">vs</span>
<span className="font-bold text-lg">{match.awayTeam.name}</span>
</div>
{renderTeam(match.homeTeam.players, match.homeTeam.name)}
{renderTeam(match.awayTeam.players, match.awayTeam.name)}
<div className="flex items-center gap-4">
<button
onClick={handleSave}
disabled={loading}
className="bg-green-700 text-white px-8 py-3 rounded-xl font-bold hover:bg-green-800 transition disabled:opacity-50"
>
{loading ? "در حال ذخیره..." : "ذخیره آمار و محاسبه امتیازات"}
</button>
{saved && <span className="text-green-600 font-medium"> ذخیره شد</span>}
</div>
</div>
);
}

View File

@@ -0,0 +1,55 @@
import { db } from "@/lib/db";
import StatsForm from "./StatsForm";
export default async function AdminStatsPage({
searchParams,
}: {
searchParams: { matchId?: string };
}) {
const matches = await db.match.findMany({
include: { homeTeam: true, awayTeam: true },
orderBy: { matchDate: "desc" },
});
const selectedMatch = searchParams.matchId
? await db.match.findUnique({
where: { id: searchParams.matchId },
include: {
homeTeam: { include: { players: true } },
awayTeam: { include: { players: true } },
playerStats: { include: { player: true } },
},
})
: null;
return (
<div>
<h1 className="text-2xl font-bold mb-6">ثبت آمار بازیکنان</h1>
<div className="mb-6">
<label className="block text-sm font-medium mb-2">انتخاب بازی</label>
<form method="GET">
<select
name="matchId"
defaultValue={searchParams.matchId ?? ""}
onChange={(e) => {
if (typeof window !== "undefined") {
window.location.href = `/admin/stats?matchId=${(e.target as HTMLSelectElement).value}`;
}
}}
className="border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500 w-full max-w-md"
>
<option value="">انتخاب بازی...</option>
{matches.map((m) => (
<option key={m.id} value={m.id}>
{m.homeTeam.name} vs {m.awayTeam.name} - {new Date(m.matchDate).toLocaleDateString("fa-IR")}
</option>
))}
</select>
</form>
</div>
{selectedMatch && <StatsForm match={selectedMatch} />}
</div>
);
}

View File

@@ -0,0 +1,82 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import PositionBadge from "@/components/PositionBadge";
export default function TeamApprovalRow({ team }: { team: any }) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
async function updateStatus(status: "APPROVED" | "REJECTED") {
setLoading(true);
await fetch(`/api/admin/teams/${team.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status }),
});
router.refresh();
setLoading(false);
}
const starters = team.players.filter((tp: any) => !tp.isBench);
const bench = team.players.filter((tp: any) => tp.isBench);
return (
<div className="bg-white rounded-2xl shadow border-2 border-yellow-200">
<div className="p-5 flex items-center justify-between">
<div>
<div className="font-bold text-lg">{team.name}</div>
<div className="text-sm text-gray-500">{team.user.name ?? team.user.email} · ترکیب {team.formation} · {team.players.length} بازیکن</div>
</div>
<div className="flex items-center gap-3">
<button onClick={() => setOpen((v) => !v)} className="text-sm text-blue-600 hover:underline">
{open ? "بستن" : "مشاهده تیم"}
</button>
<button onClick={() => updateStatus("REJECTED")} disabled={loading}
className="bg-red-100 text-red-600 px-4 py-2 rounded-xl text-sm font-medium hover:bg-red-200 transition disabled:opacity-50">
رد
</button>
<button onClick={() => updateStatus("APPROVED")} disabled={loading}
className="bg-green-700 text-white px-4 py-2 rounded-xl text-sm font-medium hover:bg-green-800 transition disabled:opacity-50">
تایید
</button>
</div>
</div>
{open && (
<div className="border-t px-5 pb-5">
<div className="grid grid-cols-2 gap-4 mt-4">
<div>
<p className="text-sm font-medium text-gray-600 mb-2">ترکیب اصلی ({starters.length})</p>
<div className="flex flex-col gap-1">
{starters.map((tp: any) => (
<div key={tp.playerId} className="flex items-center justify-between text-sm bg-gray-50 rounded-lg px-3 py-1.5">
<span className="font-medium">{tp.player.name}</span>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-400">{tp.player.country.flagUrl}</span>
<PositionBadge position={tp.player.position} />
{tp.isCaptain && <span className="text-yellow-600 text-xs font-bold">©</span>}
</div>
</div>
))}
</div>
</div>
<div>
<p className="text-sm font-medium text-gray-600 mb-2">ذخیرهها ({bench.length})</p>
<div className="flex flex-col gap-1">
{bench.map((tp: any) => (
<div key={tp.playerId} className="flex items-center justify-between text-sm bg-gray-50 rounded-lg px-3 py-1.5">
<span className="font-medium">{tp.player.name}</span>
<PositionBadge position={tp.player.position} />
</div>
))}
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,71 @@
import { db } from "@/lib/db";
import TeamApprovalRow from "./TeamApprovalRow";
export default async function AdminTeamsPage() {
const teams = await db.team.findMany({
include: {
user: { select: { name: true, email: true } },
players: { include: { player: { include: { country: true } } } },
},
orderBy: { createdAt: "desc" },
});
const pending = teams.filter((t) => t.status === "PENDING");
const others = teams.filter((t) => t.status !== "PENDING");
return (
<div>
<h1 className="text-2xl font-bold mb-6">مدیریت تیمها</h1>
{pending.length > 0 && (
<div className="mb-8">
<h2 className="text-lg font-bold text-yellow-700 mb-3 flex items-center gap-2">
<span className="w-2 h-2 bg-yellow-500 rounded-full inline-block"></span>
در انتظار تایید ({pending.length})
</h2>
<div className="flex flex-col gap-4">
{pending.map((t) => <TeamApprovalRow key={t.id} team={t} />)}
</div>
</div>
)}
<div>
<h2 className="text-lg font-bold text-gray-700 mb-3">سایر تیمها</h2>
<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-3">تیم</th>
<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>
<th className="text-right px-5 py-3">امتیاز</th>
<th className="text-right px-5 py-3">وضعیت</th>
</tr>
</thead>
<tbody>
{others.map((t) => (
<tr key={t.id} className="border-t hover:bg-gray-50">
<td className="px-5 py-3 font-medium">{t.name}</td>
<td className="px-5 py-3 text-gray-500">{t.user.name ?? t.user.email}</td>
<td className="px-5 py-3">{t.formation}</td>
<td className="px-5 py-3">{t.players.length}</td>
<td className="px-5 py-3 font-bold text-blue-700">{t.totalPoints}</td>
<td className="px-5 py-3">
<span className={`text-xs px-2 py-1 rounded-full font-medium ${
t.status === "APPROVED" ? "bg-green-100 text-green-700" :
t.status === "REJECTED" ? "bg-red-100 text-red-600" :
"bg-gray-100 text-gray-600"
}`}>
{t.status === "APPROVED" ? "تایید" : t.status === "REJECTED" ? "رد" : "پیش‌نویس"}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,44 @@
import { db } from "@/lib/db";
export default async function AdminUsersPage() {
const users = await db.user.findMany({
include: { team: true },
orderBy: { createdAt: "desc" },
});
return (
<div>
<h1 className="text-2xl font-bold mb-6">کاربران</h1>
<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>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} className="border-t hover:bg-gray-50 transition">
<td className="px-5 py-3 font-medium">{u.name ?? "-"}</td>
<td className="px-5 py-3 text-gray-600">{u.email}</td>
<td className="px-5 py-3">
<span className={`text-xs px-2 py-1 rounded-full font-medium ${
u.role === "ADMIN" ? "bg-yellow-100 text-yellow-700" : "bg-gray-100 text-gray-600"
}`}>
{u.role === "ADMIN" ? "ادمین" : "کاربر"}
</span>
</td>
<td className="px-5 py-3 text-gray-600">{u.team?.name ?? "-"}</td>
<td className="px-5 py-3 text-gray-400">{new Date(u.createdAt).toLocaleDateString("fa-IR")}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
"use client";
import { useState } from "react";
export default function ProfileForm({ user }: { user: { id: string; name: string; email: string } }) {
const [name, setName] = useState(user.name);
const [saved, setSaved] = useState(false);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
await fetch("/api/user/profile", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
setSaved(true);
setLoading(false);
setTimeout(() => setSaved(false), 3000);
}
return (
<form onSubmit={handleSubmit} className="bg-white rounded-2xl shadow p-6 flex flex-col gap-4">
<h2 className="font-bold text-lg">اطلاعات حساب</h2>
<div>
<label className="block text-sm font-medium mb-1">نام</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
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>
<input
type="email"
value={user.email}
disabled
className="w-full border rounded-xl px-4 py-2.5 bg-gray-50 text-gray-400"
/>
</div>
<button
type="submit"
disabled={loading}
className="bg-green-700 text-white py-2.5 rounded-xl font-bold hover:bg-green-800 transition disabled:opacity-50"
>
{loading ? "در حال ذخیره..." : "ذخیره"}
</button>
{saved && <p className="text-green-600 text-sm text-center"> ذخیره شد</p>}
</form>
);
}

View File

@@ -0,0 +1,56 @@
import { requireAuth } from "@/lib/session";
import { db } from "@/lib/db";
import ProfileForm from "./ProfileForm";
export default async function ProfilePage() {
const session = await requireAuth();
const userId = (session.user as any).id;
const user = await db.user.findUnique({
where: { id: userId },
include: {
team: {
include: {
players: { include: { player: { include: { country: true } } } },
},
},
},
});
if (!user) return null;
return (
<div className="max-w-3xl mx-auto py-10 px-6">
<h1 className="text-3xl font-bold mb-8">پروفایل</h1>
<div className="grid grid-cols-2 gap-6">
<ProfileForm user={{ id: user.id, name: user.name ?? "", email: user.email }} />
<div className="bg-white rounded-2xl shadow p-6">
<h2 className="font-bold text-lg mb-4">آمار من</h2>
<div className="flex flex-col gap-3">
<div className="flex justify-between">
<span className="text-gray-500">نام تیم</span>
<span className="font-medium">{user.team?.name ?? "-"}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">امتیاز کل</span>
<span className="font-bold text-blue-700">{user.team?.totalPoints ?? 0}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">بودجه باقیمانده</span>
<span className="font-medium text-green-700">
{user.team
? (user.team.budget - user.team.players.reduce((s, tp) => s + tp.player.price, 0)).toFixed(1)
: 100}M
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">تعداد بازیکنان</span>
<span className="font-medium">{user.team?.players.length ?? 0} / 15</span>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,467 @@
"use client";
import { useState, useRef } from "react";
import PositionBadge from "@/components/PositionBadge";
type Player = {
id: string;
name: string;
position: string;
price: number;
totalPoints: number;
country: { name: string; code: string; flagUrl?: string | null };
};
type TeamPlayer = {
playerId: string;
isCaptain: boolean;
isViceCaptain: boolean;
isBench: boolean;
positionIndex: number;
player: Player;
};
type Team = {
id: string;
name: string;
budget: number;
totalPoints: number;
formation: string;
status: string;
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 },
};
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",
};
export default function TeamBuilder({
team: initialTeam,
allPlayers,
}: {
team: Team;
allPlayers: Player[];
}) {
const [team, setTeam] = useState<Team>(initialTeam);
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 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 fmt = FORMATIONS[formation] ?? FORMATIONS["4-3-3"];
const starters = team?.players.filter((tp) => !tp.isBench) ?? [];
const bench = team?.players.filter((tp) => tp.isBench) ?? [];
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");
async function createTeam() {
if (!teamName.trim()) return;
setLoading(true);
const res = await fetch("/api/team", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: teamName, formation }),
});
const data = await res.json();
if (res.ok) setTeam({ ...data, players: [] });
else setMsg({ text: data.error, type: "error" });
setLoading(false);
}
async function addPlayer(playerId: string, isBench = false) {
setLoading(true);
const res = await fetch("/api/team/players", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ playerId, isBench }),
});
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);
setMsg(null);
} else {
setMsg({ text: data.error, type: "error" });
}
setLoading(false);
}
async function removePlayer(playerId: string) {
setLoading(true);
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);
setLoading(false);
}
async function setCaptain(playerId: string, type: "captain" | "vice") {
await fetch("/api/team/captain", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ playerId, type }),
});
setTeam((t) => {
if (!t) return t;
return {
...t,
players: t.players.map((tp) => ({
...tp,
isCaptain: type === "captain" ? tp.playerId === playerId : tp.isCaptain,
isViceCaptain: type === "vice" ? tp.playerId === playerId : tp.isViceCaptain,
})),
};
});
}
async function submitTeam() {
setSubmitLoading(true);
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" });
} 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";
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}
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" />
<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"}`}>
{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>
</div>
);
}
return (
<div className="max-w-7xl mx-auto py-6 px-4">
{/* هدر */}
<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>
<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>
</div>
</div>
{msg && (
<div className={`mb-4 px-4 py-3 rounded-xl text-sm ${msg.type === "error" ? "bg-red-50 text-red-600" : "bg-green-50 text-green-700"}`}>
{msg.text}
</div>
)}
<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 }}>
{/* خطوط */}
<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" />
<rect x="120" y="0" width="160" height="60" stroke="white" strokeWidth="1" fill="none" />
<rect x="120" y="440" width="160" height="60" stroke="white" strokeWidth="1" fill="none" />
<rect x="160" y="0" width="80" height="25" stroke="white" strokeWidth="1" fill="none" />
<rect x="160" y="475" width="80" height="25" stroke="white" strokeWidth="1" fill="none" />
</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} />
</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>
<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 />
))}
{Array.from({ length: Math.max(0, 4 - bench.length) }).map((_, i) => (
<EmptySlot key={i} 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>
)}
{!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 overflow-hidden" style={{ maxHeight: 520, overflowY: "auto" }}>
<table className="w-full text-sm">
<thead className="bg-green-800 text-white sticky top-0">
<tr>
<th className="text-right px-3 py-3">بازیکن</th>
<th className="px-2 py-3">قیمت</th>
<th className="px-2 py-3">pts</th>
<th className="px-2 py-3"></th>
</tr>
</thead>
<tbody>
{filtered.map((p) => (
<tr key={p.id} className="border-t hover:bg-green-50 transition">
<td className="px-3 py-2">
<div className="font-medium text-sm">{p.name}</div>
<div className="flex items-center gap-1 mt-0.5">
<span className="text-xs text-gray-400">{p.country.flagUrl} {p.country.name}</span>
<PositionBadge position={p.position} />
</div>
</td>
<td className="px-2 py-2 text-center text-green-700 font-bold text-xs">{p.price}M</td>
<td className="px-2 py-2 text-center text-blue-700 font-bold text-xs">{p.totalPoints}</td>
<td className="px-2 py-2">
<button onClick={() => addPlayer(p.id)}
disabled={loading || p.price > remaining + 0.01}
className="bg-green-600 text-white w-7 h-7 rounded-lg text-lg font-bold hover:bg-green-700 disabled:opacity-30 transition flex items-center justify-center">
+
</button>
</td>
</tr>
))}
{filtered.length === 0 && (
<tr><td colSpan={4} className="text-center text-gray-400 py-8">بازیکنی پیدا نشد</td></tr>
)}
</tbody>
</table>
</div>
</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;
}) {
return (
<div className="flex justify-center gap-2 flex-wrap py-1">
{Array.from({ length: slots }).map((_, i) => {
const tp = players[i];
return tp ? (
<PitchCard key={tp.playerId} tp={tp} onRemove={onRemove}
onDragStart={onDragStart} onDrop={onDrop} onCaptain={onCaptain} draggedId={draggedId} />
) : (
<EmptySlot key={i} label={position} />
);
})}
</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;
}) {
const [showMenu, setShowMenu] = useState(false);
const isDragging = draggedId === tp.playerId;
const isEliminated = (tp.player as any).country?.isEliminated;
const color = isEliminated
? "bg-gray-500 text-gray-300 border-gray-600"
: POS_COLORS[tp.player.position] ?? "bg-gray-400 text-white border-gray-500";
const shortName = tp.player.name.split(" ").slice(-1)[0];
return (
<div className={`relative flex flex-col items-center gap-1 cursor-grab select-none transition-opacity ${isDragging ? "opacity-40" : ""} ${small ? "w-14" : "w-16"} group`}
draggable
onDragStart={() => onDragStart(tp.playerId)}
onDragOver={(e) => e.preventDefault()}
onDrop={() => onDrop(tp.playerId)}
onClick={() => setShowMenu((v) => !v)}>
<div className={`relative ${small ? "w-11 h-11 text-lg" : "w-14 h-14 text-xl"} rounded-full border-2 flex items-center justify-center font-bold shadow-lg ${color} ${isEliminated ? "grayscale opacity-60" : ""}`}>
{tp.player.position === "GK" ? "🧤" : tp.player.position === "DEF" ? "🛡️" : tp.player.position === "MID" ? "⚙️" : "⚡"}
{isEliminated && <div className="absolute -top-1 -right-1 w-4 h-4 bg-red-500 rounded-full flex items-center justify-center text-white text-[8px] font-bold"></div>}
</div>
<div className={`text-center font-medium leading-tight truncate w-full ${small ? "text-[9px]" : "text-[10px]"} ${isEliminated ? "text-gray-400" : "text-white"}`}>
{shortName}
</div>
<div className="flex items-center gap-1">
{tp.isCaptain && <span className="text-yellow-300 text-xs font-bold">©</span>}
{tp.isViceCaptain && <span className="text-gray-300 text-xs font-bold">VC</span>}
<span className={`text-[9px] ${isEliminated ? "text-gray-500" : "text-white/60"}`}>{tp.player.totalPoints}pts</span>
</div>
{isEliminated && (
<div className="absolute -top-8 left-1/2 -translate-x-1/2 bg-gray-900 text-white text-[9px] px-2 py-1 rounded whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none z-50 transition-opacity">
تیم ملی حذف شده
</div>
)}
{showMenu && (
<div className="absolute top-full mt-1 bg-white rounded-xl shadow-xl z-50 text-xs w-36 overflow-hidden border" onClick={(e) => e.stopPropagation()}>
{isEliminated && <div className="px-3 py-2 bg-red-50 text-red-600 text-[10px] border-b"> تیم ملی حذف شده</div>}
<button onClick={() => { onCaptain(tp.playerId, "captain"); setShowMenu(false); }} className="w-full text-right px-3 py-2 hover:bg-gray-50 border-b">کاپیتان ©</button>
<button onClick={() => { onCaptain(tp.playerId, "vice"); setShowMenu(false); }} className="w-full text-right px-3 py-2 hover:bg-gray-50 border-b">نایب کاپیتان VC</button>
<button onClick={() => { onRemove(tp.playerId); setShowMenu(false); }} className="w-full text-right px-3 py-2 hover:bg-red-50 text-red-600">حذف از تیم</button>
</div>
)}
</div>
);
}
function EmptySlot({ label }: { label: string }) {
return (
<div className="w-14 h-14 rounded-full border-2 border-dashed border-white/30 flex items-center justify-center">
<span className="text-white/30 text-[9px]">{label}</span>
</div>
);
}

24
app/(user)/team/page.tsx Normal file
View File

@@ -0,0 +1,24 @@
import { requireAuth } from "@/lib/session";
import { db } from "@/lib/db";
import TeamBuilder from "./TeamBuilder";
export default async function TeamPage() {
const session = await requireAuth();
const userId = (session.user as any).id;
const team = await db.team.findUnique({
where: { userId },
include: {
players: {
include: { player: { include: { country: true } } },
},
},
});
const allPlayers = await db.player.findMany({
include: { country: true },
orderBy: { totalPoints: "desc" },
});
return <TeamBuilder team={team} allPlayers={allPlayers} />;
}

View File

@@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { calculateMatchPoints } from "@/lib/points";
export async function POST(_: NextRequest, { params }: { params: { id: string } }) {
const session = await getServerSession(authOptions);
if (!session || (session.user as any).role !== "ADMIN")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const events = await db.matchEvent.findMany({
where: { matchId: params.id },
include: { player: true },
});
// گروه‌بندی رویدادها بر اساس بازیکن
const playerEvents: Record<string, { player: any; events: any[] }> = {};
for (const ev of events) {
if (!playerEvents[ev.playerId]) playerEvents[ev.playerId] = { player: ev.player, events: [] };
playerEvents[ev.playerId].events.push(ev);
}
const results = [];
for (const [playerId, { player, events: evs }] of Object.entries(playerEvents)) {
const goals = evs.filter((e) => e.type === "GOAL").length;
const assists = evs.filter((e) => e.type === "ASSIST").length;
const yellowCards = evs.filter((e) => e.type === "YELLOW_CARD" || e.type === "SECOND_YELLOW").length;
const redCards = evs.filter((e) => e.type === "RED_CARD" || e.type === "SECOND_YELLOW").length;
const cleanSheet = evs.some((e) => e.type === "CLEAN_SHEET");
const penaltySaved = evs.filter((e) => e.type === "PENALTY_SAVED").length;
const penaltyMissed = evs.filter((e) => e.type === "PENALTY_MISSED").length;
const ownGoals = evs.filter((e) => e.type === "OWN_GOAL").length;
const isMotm = evs.some((e) => e.type === "MOTM");
const extraTimeBonus = evs.filter((e) => e.type === "EXTRA_TIME_BONUS").length;
// دقیقه بازی: اگه تعویض شده کمتر از ۹۰، وگرنه ۹۰
const subOut = evs.find((e) => e.type === "SUBSTITUTION_OUT");
const subIn = evs.find((e) => e.type === "SUBSTITUTION_IN");
const injury = evs.find((e) => e.type === "INJURY_NO_SUB");
let minutesPlayed = 90;
if (subOut?.minute) minutesPlayed = subOut.minute;
else if (injury?.minute) minutesPlayed = injury.minute;
if (subIn?.minute) minutesPlayed = 90 - subIn.minute;
const points = await calculateMatchPoints({
position: player.position,
goals, assists, yellowCards, redCards, minutesPlayed,
cleanSheet, penaltySaved, penaltyMissed, ownGoals, isMotm, extraTimeBonus,
});
const stat = await db.playerMatchStat.upsert({
where: { playerId_matchId: { playerId, matchId: params.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 },
});
// آپدیت totalPoints بازیکن
const agg = await db.playerMatchStat.aggregate({ where: { playerId }, _sum: { points: true } });
await db.player.update({ where: { id: playerId }, data: { totalPoints: agg._sum.points ?? 0 } });
results.push(stat);
}
// آپدیت امتیاز تیم‌های فانتزی
const teams = await db.team.findMany({ include: { players: { include: { player: true } } } });
for (const team of teams) {
let total = 0;
for (const tp of team.players) {
const mult = tp.isCaptain ? 2 : tp.isViceCaptain ? 1.5 : 1;
total += tp.player.totalPoints * mult;
}
await db.team.update({ where: { id: team.id }, data: { totalPoints: Math.round(total) } });
}
return NextResponse.json({ calculated: results.length });
}

View File

@@ -0,0 +1,13 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
export async function DELETE(_: NextRequest, { params }: { params: { id: string; eventId: string } }) {
const session = await getServerSession(authOptions);
if (!session || (session.user as any).role !== "ADMIN")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
await db.matchEvent.delete({ where: { id: params.eventId } });
return NextResponse.json({ success: true });
}

View File

@@ -0,0 +1,18 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
const session = await getServerSession(authOptions);
if (!session || (session.user as any).role !== "ADMIN")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { playerId, type, minute, extraInfo } = await req.json();
const event = await db.matchEvent.create({
data: { matchId: params.id, playerId, type, minute: minute ?? null, extraInfo: extraInfo || null },
});
return NextResponse.json(event, { status: 201 });
}

View File

@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
const session = await getServerSession(authOptions);
if (!session || (session.user as any).role !== "ADMIN")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const lineups: Array<{ countryId: string; formation: string; playerIds: string[] }> = await req.json();
// حذف ترکیب‌های قبلی
await db.matchLineup.deleteMany({ where: { matchId: params.id } });
for (const l of lineups) {
await db.matchLineup.create({
data: { matchId: params.id, countryId: l.countryId, formation: l.formation, playerIds: l.playerIds },
});
}
return NextResponse.json({ success: true });
}

View File

@@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
export async function PUT(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session || (session.user as any).role !== "ADMIN")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const rules: Array<{ position: string; eventType: string; points: number }> = await req.json();
for (const rule of rules) {
await db.scoringRule.upsert({
where: { position_eventType: { position: rule.position as any, eventType: rule.eventType as any } },
update: { points: rule.points, updatedBy: (session.user as any).id },
create: { position: rule.position as any, eventType: rule.eventType as any, points: rule.points, updatedBy: (session.user as any).id },
});
}
return NextResponse.json({ success: true });
}

View File

@@ -0,0 +1,17 @@
import { NextRequest, NextResponse } from "next/server";
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 } }) {
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 },
data: { status },
});
return NextResponse.json(team);
}

View File

@@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
export async function GET() {
const session = await getServerSession(authOptions);
if (!session || (session.user as any).role !== "ADMIN")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const teams = await db.team.findMany({
include: {
user: { select: { name: true, email: true } },
_count: { select: { players: true } },
},
orderBy: { createdAt: "desc" },
});
return NextResponse.json(teams);
}

View File

@@ -0,0 +1,5 @@
import NextAuth from "next-auth";
import { authOptions } from "@/lib/auth";
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

View File

@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import bcrypt from "bcryptjs";
export async function POST(req: NextRequest) {
const { name, email, password } = await req.json();
if (!email || !password) {
return NextResponse.json({ error: "ایمیل و رمز عبور الزامی است" }, { status: 400 });
}
const existing = await db.user.findUnique({ where: { email } });
if (existing) {
return NextResponse.json({ error: "این ایمیل قبلاً ثبت شده" }, { status: 400 });
}
const hashed = await bcrypt.hash(password, 10);
const user = await db.user.create({
data: { name, email, password: hashed },
});
return NextResponse.json({ id: user.id }, { status: 201 });
}

View File

@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from "next/server";
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 } }) {
const session = await getServerSession(authOptions);
if (!session || (session.user as any).role !== "ADMIN")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const body = await req.json();
const country = await db.country.update({ where: { id: params.id }, data: body });
return NextResponse.json(country);
}
export async function DELETE(_: NextRequest, { params }: { params: { id: string } }) {
const session = await getServerSession(authOptions);
if (!session || (session.user as any).role !== "ADMIN")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
await db.country.delete({ where: { id: params.id } });
return NextResponse.json({ success: true });
}

View File

@@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
export async function GET() {
const countries = await db.country.findMany({
include: { group: true },
orderBy: { name: "asc" },
});
return NextResponse.json(countries);
}
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session || (session.user as any).role !== "ADMIN")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const body = await req.json();
const country = await db.country.create({ data: body });
return NextResponse.json(country, { status: 201 });
}

View File

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

View File

@@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
export async function GET() {
const gameweeks = await db.gameweek.findMany({ orderBy: { number: "asc" } });
return NextResponse.json(gameweeks);
}
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session || (session.user as any).role !== "ADMIN")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const body = await req.json();
const gw = await db.gameweek.create({ data: body });
return NextResponse.json(gw, { status: 201 });
}

View File

@@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
export async function GET() {
const teams = await db.team.findMany({
orderBy: { totalPoints: "desc" },
include: { user: { select: { name: true, email: true } } },
take: 50,
});
return NextResponse.json(
teams.map((t, i) => ({
rank: i + 1,
teamName: t.name,
userName: t.user.name ?? t.user.email,
totalPoints: t.totalPoints,
budget: t.budget,
}))
);
}

View File

@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
export async function GET(_: NextRequest, { params }: { params: { id: string } }) {
const match = await db.match.findUnique({
where: { id: params.id },
include: { homeTeam: true, awayTeam: true, playerStats: { include: { player: true } } },
});
if (!match) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json(match);
}
export async function PUT(req: NextRequest, { params }: { params: { id: string } }) {
const session = await getServerSession(authOptions);
if (!session || (session.user as any).role !== "ADMIN")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const body = await req.json();
const match = await db.match.update({ where: { id: params.id }, data: body });
return NextResponse.json(match);
}
export async function DELETE(_: NextRequest, { params }: { params: { id: string } }) {
const session = await getServerSession(authOptions);
if (!session || (session.user as any).role !== "ADMIN")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
await db.match.delete({ where: { id: params.id } });
return NextResponse.json({ success: true });
}

View File

@@ -0,0 +1,65 @@
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";
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
const session = await getServerSession(authOptions);
if (!session || (session.user as any).role !== "ADMIN")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const stats: Array<{
playerId: string;
goals: number;
assists: number;
yellowCards: number;
redCards: number;
minutesPlayed: number;
cleanSheet: boolean;
}> = await req.json();
const results = [];
for (const stat of stats) {
const player = await db.player.findUnique({ where: { id: stat.playerId } });
if (!player) continue;
const points = calculatePoints({ position: player.position, ...stat });
const record = await db.playerMatchStat.upsert({
where: { playerId_matchId: { playerId: stat.playerId, matchId: params.id } },
update: { ...stat, points },
create: { ...stat, matchId: params.id, points },
});
// آپدیت امتیاز کل بازیکن
const totalPoints = await db.playerMatchStat.aggregate({
where: { playerId: stat.playerId },
_sum: { points: true },
});
await db.player.update({
where: { id: stat.playerId },
data: { totalPoints: totalPoints._sum.points ?? 0 },
});
results.push(record);
}
// آپدیت امتیاز تیم‌های فانتزی
await recalcTeamPoints();
return NextResponse.json(results);
}
async function recalcTeamPoints() {
const teams = await db.team.findMany({ include: { players: { include: { player: true } } } });
for (const team of teams) {
let total = 0;
for (const tp of team.players) {
const multiplier = tp.isCaptain ? 2 : 1;
total += tp.player.totalPoints * multiplier;
}
await db.team.update({ where: { id: team.id }, data: { totalPoints: total } });
}
}

29
app/api/matches/route.ts Normal file
View File

@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
export async function GET() {
const matches = await db.match.findMany({
include: {
homeTeam: true,
awayTeam: true,
gameweek: true,
},
orderBy: { matchDate: "asc" },
});
return NextResponse.json(matches);
}
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session || (session.user as any).role !== "ADMIN")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const body = await req.json();
const match = await db.match.create({
data: body,
include: { homeTeam: true, awayTeam: true },
});
return NextResponse.json(match, { status: 201 });
}

View File

@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { requestPayment } from "@/lib/zarinpal";
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { packageId } = await req.json();
const userId = (session.user as any).id;
const pkg = await db.package.findUnique({ where: { id: packageId } });
if (!pkg || !pkg.isActive) return NextResponse.json({ error: "پکیج پیدا نشد" }, { status: 404 });
const callbackUrl = `${process.env.NEXTAUTH_URL}/api/payment/verify`;
const result = await requestPayment(pkg.price, `خرید ${pkg.name} - فانتزی جام جهانی`, callbackUrl);
if (!result.success) return NextResponse.json({ error: result.error }, { status: 400 });
// ذخیره پرداخت در دیتابیس
await db.payment.create({
data: {
userId,
packageId,
amount: pkg.price,
authority: result.authority,
status: "PENDING",
},
});
return NextResponse.json({ paymentUrl: result.paymentUrl });
}

View File

@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { verifyPayment } from "@/lib/zarinpal";
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const authority = searchParams.get("Authority");
const status = searchParams.get("Status");
if (!authority) return NextResponse.redirect(new URL("/shop?status=error", req.url));
const payment = await db.payment.findUnique({
where: { authority },
include: { package: true, user: true },
});
if (!payment) return NextResponse.redirect(new URL("/shop?status=error", req.url));
if (status !== "OK") {
await db.payment.update({ where: { id: payment.id }, data: { status: "FAILED" } });
return NextResponse.redirect(new URL("/shop?status=cancelled", req.url));
}
const result = await verifyPayment(authority, payment.amount);
if (!result.success) {
await db.payment.update({ where: { id: payment.id }, data: { status: "FAILED" } });
return NextResponse.redirect(new URL("/shop?status=failed", req.url));
}
// پرداخت موفق - آپدیت بودجه تیم
await db.payment.update({
where: { id: payment.id },
data: { status: "SUCCESS", refId: result.refId },
});
await db.team.updateMany({
where: { userId: payment.userId },
data: { budget: { increment: payment.package.budgetBonus } },
});
return NextResponse.redirect(new URL(`/shop?status=success&refId=${result.refId}`, req.url));
}

View File

@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from "next/server";
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 } }) {
const session = await getServerSession(authOptions);
if (!session || (session.user as any).role !== "ADMIN") {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await req.json();
const player = await db.player.update({
where: { id: params.id },
data: body,
});
return NextResponse.json(player);
}
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
const session = await getServerSession(authOptions);
if (!session || (session.user as any).role !== "ADMIN") {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
await db.player.delete({ where: { id: params.id } });
return NextResponse.json({ success: true });
}

32
app/api/players/route.ts Normal file
View File

@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const position = searchParams.get("position");
const countryId = searchParams.get("countryId");
const players = await db.player.findMany({
where: {
...(position ? { position: position as any } : {}),
...(countryId ? { countryId } : {}),
},
include: { country: true },
orderBy: { totalPoints: "desc" },
});
return NextResponse.json(players);
}
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session || (session.user as any).role !== "ADMIN") {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await req.json();
const player = await db.player.create({ data: body });
return NextResponse.json(player, { status: 201 });
}

View File

@@ -0,0 +1,14 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
export async function POST(_: NextRequest, { params }: { params: { id: string } }) {
const session = await getServerSession(authOptions);
if (!session || (session.user as any).role !== "ADMIN")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
await db.round.updateMany({ data: { isActive: false } });
const round = await db.round.update({ where: { id: params.id }, data: { isActive: true } });
return NextResponse.json(round);
}

25
app/api/rounds/route.ts Normal file
View File

@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
export async function GET() {
const rounds = await db.round.findMany({ orderBy: { number: "asc" } });
return NextResponse.json(rounds);
}
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session || (session.user as any).role !== "ADMIN")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { number, name, deadline } = await req.json();
const existing = await db.round.findUnique({ where: { number } });
if (existing) return NextResponse.json({ error: "این شماره دور قبلاً ثبت شده" }, { status: 400 });
const round = await db.round.create({
data: { number, name, deadline: new Date(deadline) },
});
return NextResponse.json(round, { status: 201 });
}

View File

@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
export async function PUT(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { playerId, type } = await req.json();
const team = await db.team.findUnique({ where: { userId: (session.user as any).id } });
if (!team) return NextResponse.json({ error: "تیم پیدا نشد" }, { status: 404 });
if (type === "captain") {
await db.teamPlayer.updateMany({ where: { teamId: team.id }, data: { isCaptain: false } });
await db.teamPlayer.update({ where: { teamId_playerId: { teamId: team.id, playerId } }, data: { isCaptain: true } });
} else {
await db.teamPlayer.updateMany({ where: { teamId: team.id }, data: { isViceCaptain: false } });
await db.teamPlayer.update({ where: { teamId_playerId: { teamId: team.id, playerId } }, data: { isViceCaptain: true } });
}
return NextResponse.json({ success: true });
}

View File

@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { getFormationChangeIssues, FORMATIONS } from "@/lib/teamValidation";
export async function PUT(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { formation } = await req.json();
if (!FORMATIONS[formation]) return NextResponse.json({ error: "ترکیب نامعتبر" }, { status: 400 });
const team = await db.team.findUnique({
where: { userId: (session.user as any).id },
include: { players: { include: { player: true } } },
});
if (!team) return NextResponse.json({ error: "تیم پیدا نشد" }, { status: 404 });
const playerList = team.players.map((tp) => ({ position: tp.player.position, isBench: tp.isBench }));
const issues = getFormationChangeIssues(playerList, team.formation, formation);
if (issues.length > 0) {
return NextResponse.json({ error: issues.join(" | "), issues }, { status: 400 });
}
const updated = await db.team.update({ where: { id: team.id }, data: { formation } });
return NextResponse.json(updated);
}

View File

@@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from "next/server";
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 });
const { playerId, isBench } = await req.json();
const userId = (session.user as any).id;
const team = await db.team.findUnique({
where: { userId },
include: { players: { include: { player: true } } },
});
if (!team) return NextResponse.json({ error: "ابتدا تیم بساز" }, { status: 400 });
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)
return NextResponse.json({ error: "بودجه کافی نیست" }, { status: 400 });
// چک تعداد (۱۵ نفر: ۱۱ اصلی + ۴ ذخیره)
if (team.players.length >= 15)
return NextResponse.json({ error: "تیم پر است (حداکثر ۱۵ بازیکن)" }, { status: 400 });
// چک تکراری
const exists = team.players.find((tp) => tp.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 tp = await db.teamPlayer.create({
data: { teamId: team.id, playerId, isBench: isBench ?? false },
});
return NextResponse.json(tp, { status: 201 });
}
// حذف بازیکن از تیم
export async function DELETE(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { playerId } = await req.json();
const userId = (session.user as any).id;
const team = await db.team.findUnique({ where: { userId } });
if (!team) return NextResponse.json({ error: "تیم پیدا نشد" }, { status: 404 });
await db.teamPlayer.delete({
where: { teamId_playerId: { teamId: team.id, playerId } },
});
return NextResponse.json({ success: true });
}

34
app/api/team/route.ts Normal file
View File

@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
export async function GET() {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const team = await db.team.findUnique({
where: { userId: (session.user as any).id },
include: {
players: {
include: { player: true },
},
},
});
return NextResponse.json(team);
}
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { name } = await req.json();
const userId = (session.user as any).id;
const existing = await db.team.findUnique({ where: { userId } });
if (existing) return NextResponse.json({ error: "Team already exists" }, { status: 400 });
const team = await db.team.create({ data: { name, userId } });
return NextResponse.json(team, { status: 201 });
}

View File

@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { validateTeamComposition } from "@/lib/teamValidation";
export async function POST() {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const team = await db.team.findUnique({
where: { userId: (session.user as any).id },
include: { players: { include: { player: true } } },
});
if (!team) return NextResponse.json({ error: "تیم پیدا نشد" }, { status: 404 });
const playerList = team.players.map((tp) => ({
position: tp.player.position,
isBench: tp.isBench,
}));
const { valid, errors } = validateTeamComposition(playerList, team.formation);
if (!valid) return NextResponse.json({ error: errors.join(" | ") }, { status: 400 });
// مستقیم ACTIVE میشه - نیازی به تایید ادمین نیست
const updated = await db.team.update({
where: { id: team.id },
data: { status: "ACTIVE" },
});
return NextResponse.json(updated);
}

View File

@@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
export async function PUT(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { name } = await req.json();
const user = await db.user.update({
where: { id: (session.user as any).id },
data: { name },
});
return NextResponse.json({ name: user.name });
}

14
app/error.tsx Normal file
View File

@@ -0,0 +1,14 @@
"use client";
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return (
<div className="min-h-[70vh] flex flex-col items-center justify-center text-center px-6">
<div className="text-6xl mb-6">😬</div>
<h1 className="text-2xl font-bold mb-3">یه مشکلی پیش اومد</h1>
<p className="text-gray-500 mb-8 text-sm">{error.message}</p>
<button onClick={reset} className="bg-green-700 text-white px-6 py-3 rounded-xl font-bold hover:bg-green-800 transition">
دوباره امتحان کن
</button>
</div>
);
}

15
app/globals.css Normal file
View File

@@ -0,0 +1,15 @@
@import "tailwindcss";
@font-face {
font-family: "Lahze";
src: url("/fonts/lahze.woff2") format("woff2"),
url("/fonts/lahze.woff") format("woff");
font-weight: normal;
font-style: normal;
font-display: swap;
}
* {
direction: rtl;
font-family: "Lahze", sans-serif;
}

22
app/layout.tsx Normal file
View File

@@ -0,0 +1,22 @@
import type { Metadata } from "next";
import "./globals.css";
import SessionProvider from "@/components/SessionProvider";
import Navbar from "@/components/Navbar";
export const metadata: Metadata = {
title: "فانتزی جام جهانی",
description: "بازی فوتبال فانتزی جام جهانی",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="fa" dir="rtl">
<body className="bg-gray-50 text-gray-900 font-sans min-h-screen">
<SessionProvider>
<Navbar />
{children}
</SessionProvider>
</body>
</html>
);
}

48
app/leaderboard/page.tsx Normal file
View File

@@ -0,0 +1,48 @@
import { db } from "@/lib/db";
export default async function LeaderboardPage() {
const teams = await db.team.findMany({
where: { status: "ACTIVE" },
orderBy: { totalPoints: "desc" },
include: { user: { select: { name: true, email: true } } },
take: 100,
});
return (
<div className="max-w-3xl mx-auto py-10 px-6">
<h1 className="text-3xl font-bold mb-8">🏆 جدول امتیازات</h1>
<div className="bg-white rounded-2xl shadow overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-green-800 text-white">
<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>
</tr>
</thead>
<tbody>
{teams.map((t, i) => (
<tr
key={t.id}
className={`border-t transition ${
i === 0 ? "bg-yellow-50" : i === 1 ? "bg-gray-50" : i === 2 ? "bg-orange-50" : "hover:bg-green-50"
}`}
>
<td className="px-5 py-4 font-bold text-lg">
{i === 0 ? "🥇" : i === 1 ? "🥈" : i === 2 ? "🥉" : i + 1}
</td>
<td className="px-5 py-4 font-semibold">{t.name}</td>
<td className="px-5 py-4 text-gray-500">{t.user.name ?? t.user.email}</td>
<td className="px-5 py-4 font-bold text-blue-700 text-lg">{t.totalPoints}</td>
</tr>
))}
</tbody>
</table>
{teams.length === 0 && (
<div className="text-center text-gray-400 py-16">هنوز تیمی ثبت نشده</div>
)}
</div>
</div>
);
}

77
app/login/page.tsx Normal file
View File

@@ -0,0 +1,77 @@
"use client";
import { signIn } from "next-auth/react";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
const res = await signIn("credentials", { email, password, redirect: false });
if (res?.error) {
setError("ایمیل یا رمز عبور اشتباه است");
setLoading(false);
} else {
router.push("/team");
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<div className="text-5xl mb-3"></div>
<h1 className="text-2xl font-bold">ورود به فانتزی جام جهانی</h1>
</div>
<form onSubmit={handleSubmit} className="bg-white p-8 rounded-2xl shadow-lg">
{error && (
<div className="bg-red-50 text-red-600 text-sm px-4 py-3 rounded-xl mb-4">
{error}
</div>
)}
<div className="mb-4">
<label className="block text-sm font-medium mb-1">ایمیل</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full border-2 rounded-xl px-4 py-3 focus:outline-none focus:border-green-500 transition"
required
/>
</div>
<div className="mb-6">
<label className="block text-sm font-medium mb-1">رمز عبور</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full border-2 rounded-xl px-4 py-3 focus:outline-none focus:border-green-500 transition"
required
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-green-700 text-white py-3 rounded-xl font-bold text-lg hover:bg-green-800 transition disabled:opacity-50"
>
{loading ? "در حال ورود..." : "ورود"}
</button>
<p className="text-center text-sm mt-4 text-gray-500">
حساب نداری؟{" "}
<Link href="/register" className="text-green-600 hover:underline font-medium">
ثبتنام
</Link>
</p>
</form>
</div>
</div>
);
}

82
app/matches/page.tsx Normal file
View File

@@ -0,0 +1,82 @@
import { db } from "@/lib/db";
const stageLabel: Record<string, string> = {
GROUP: "مرحله گروهی",
ROUND_OF_16: "یک‌هشتم نهایی",
QUARTER_FINAL: "یک‌چهارم نهایی",
SEMI_FINAL: "نیمه‌نهایی",
THIRD_PLACE: "رده‌بندی",
FINAL: "فینال",
};
const statusStyle: Record<string, string> = {
SCHEDULED: "bg-gray-100 text-gray-600",
LIVE: "bg-red-100 text-red-600 animate-pulse",
FINISHED: "bg-green-100 text-green-700",
};
const statusLabel: Record<string, string> = {
SCHEDULED: "برنامه‌ریزی شده",
LIVE: "🔴 زنده",
FINISHED: "پایان یافته",
};
export default async function MatchesPage() {
const matches = await db.match.findMany({
include: { homeTeam: true, awayTeam: true, round: true },
orderBy: { matchDate: "asc" },
});
const grouped = matches.reduce((acc, m) => {
const key = m.stage;
if (!acc[key]) acc[key] = [];
acc[key].push(m);
return acc;
}, {} as Record<string, typeof matches>);
return (
<div className="max-w-4xl mx-auto py-10 px-6">
<h1 className="text-3xl font-bold mb-8">بازیها</h1>
{Object.entries(grouped).map(([stage, stageMatches]) => (
<div key={stage} className="mb-10">
<h2 className="text-xl font-bold text-green-800 mb-4 border-b-2 border-green-200 pb-2">
{stageLabel[stage] ?? stage}
</h2>
<div className="flex flex-col gap-3">
{stageMatches.map((m) => (
<div key={m.id} className="bg-white rounded-2xl shadow p-5 flex items-center justify-between">
<div className="flex items-center gap-4 flex-1 justify-end">
<span className="font-bold text-lg">{m.homeTeam.name}</span>
<span className="text-2xl">{m.homeTeam.flagUrl ?? "🏳️"}</span>
</div>
<div className="mx-6 text-center min-w-[100px]">
{m.status === "FINISHED" || m.status === "LIVE" ? (
<div className="text-3xl font-bold">
{m.homeScore} - {m.awayScore}
</div>
) : (
<div className="text-sm text-gray-500">
{new Date(m.matchDate).toLocaleDateString("fa-IR")}
</div>
)}
<span className={`text-xs px-2 py-0.5 rounded-full mt-1 inline-block ${statusStyle[m.status]}`}>
{statusLabel[m.status]}
</span>
</div>
<div className="flex items-center gap-4 flex-1 justify-start">
<span className="text-2xl">{m.awayTeam.flagUrl ?? "🏳️"}</span>
<span className="font-bold text-lg">{m.awayTeam.name}</span>
</div>
</div>
))}
</div>
</div>
))}
{matches.length === 0 && (
<div className="text-center text-gray-400 py-20">هنوز بازیای ثبت نشده</div>
)}
</div>
);
}

14
app/not-found.tsx Normal file
View File

@@ -0,0 +1,14 @@
import Link from "next/link";
export default function NotFound() {
return (
<div className="min-h-[70vh] flex flex-col items-center justify-center text-center px-6">
<div className="text-8xl mb-6"></div>
<h1 className="text-4xl font-bold mb-3">۴۰۴</h1>
<p className="text-gray-500 mb-8">این صفحه پیدا نشد!</p>
<Link href="/" className="bg-green-700 text-white px-6 py-3 rounded-xl font-bold hover:bg-green-800 transition">
برگشت به خانه
</Link>
</div>
);
}

69
app/page.tsx Normal file
View File

@@ -0,0 +1,69 @@
import Link from "next/link";
import { db } from "@/lib/db";
export default async function HomePage() {
const [playerCount, matchCount, teamCount] = await Promise.all([
db.player.count(),
db.match.count(),
db.team.count(),
]);
return (
<main>
{/* Hero */}
<section className="bg-gradient-to-br from-green-900 via-green-700 to-green-500 text-white py-24 px-6 text-center">
<div className="max-w-3xl mx-auto">
<div className="text-7xl mb-6">🏆</div>
<h1 className="text-5xl font-bold mb-4">فانتزی جام جهانی</h1>
<p className="text-xl text-green-200 mb-10">
تیم رویاییت رو از بهترین بازیکنان جهان بساز، امتیاز جمع کن و با بقیه رقابت کن
</p>
<div className="flex gap-4 justify-center">
<Link href="/register" className="bg-white text-green-800 font-bold px-8 py-3 rounded-xl hover:bg-green-100 transition text-lg">
شروع کن
</Link>
<Link href="/players" className="border-2 border-white text-white font-bold px-8 py-3 rounded-xl hover:bg-white/10 transition text-lg">
بازیکنان
</Link>
</div>
</div>
</section>
{/* Stats */}
<section className="max-w-4xl mx-auto py-16 px-6 grid grid-cols-3 gap-6 text-center">
<div className="bg-white rounded-2xl shadow p-8">
<div className="text-4xl font-bold text-green-700">{playerCount}</div>
<div className="text-gray-500 mt-2">بازیکن</div>
</div>
<div className="bg-white rounded-2xl shadow p-8">
<div className="text-4xl font-bold text-green-700">{matchCount}</div>
<div className="text-gray-500 mt-2">بازی</div>
</div>
<div className="bg-white rounded-2xl shadow p-8">
<div className="text-4xl font-bold text-green-700">{teamCount}</div>
<div className="text-gray-500 mt-2">تیم فانتزی</div>
</div>
</section>
{/* How it works */}
<section className="bg-white py-16 px-6">
<div className="max-w-4xl mx-auto">
<h2 className="text-3xl font-bold text-center mb-12">چطور کار میکنه؟</h2>
<div className="grid grid-cols-3 gap-8">
{[
{ icon: "👤", title: "ثبت‌نام کن", desc: "یه حساب بساز و وارد شو" },
{ icon: "⚽", title: "تیم بساز", desc: "۱۵ بازیکن با بودجه ۱۰۰ میلیون انتخاب کن" },
{ icon: "📊", title: "امتیاز جمع کن", desc: "بر اساس عملکرد واقعی بازیکنا امتیاز بگیر" },
].map((item) => (
<div key={item.title} className="text-center">
<div className="text-5xl mb-4">{item.icon}</div>
<h3 className="text-xl font-bold mb-2">{item.title}</h3>
<p className="text-gray-500">{item.desc}</p>
</div>
))}
</div>
</div>
</section>
</main>
);
}

72
app/players/page.tsx Normal file
View File

@@ -0,0 +1,72 @@
import { db } from "@/lib/db";
import PositionBadge from "@/components/PositionBadge";
export default async function PlayersPage({
searchParams,
}: {
searchParams: { position?: string; country?: string };
}) {
const players = await db.player.findMany({
where: {
...(searchParams.position ? { position: searchParams.position as any } : {}),
...(searchParams.country ? { countryId: searchParams.country } : {}),
},
include: { country: true },
orderBy: { totalPoints: "desc" },
});
const countries = await db.country.findMany({ orderBy: { name: "asc" } });
return (
<div className="max-w-6xl mx-auto py-10 px-6">
<h1 className="text-3xl font-bold mb-8">بازیکنان</h1>
{/* فیلترها */}
<div className="flex gap-3 mb-6 flex-wrap">
{["", "GK", "DEF", "MID", "FWD"].map((pos) => (
<a
key={pos}
href={pos ? `?position=${pos}` : "/players"}
className={`px-4 py-2 rounded-lg font-medium text-sm transition ${
searchParams.position === pos || (!searchParams.position && !pos)
? "bg-green-700 text-white"
: "bg-white text-gray-700 hover:bg-gray-100 shadow"
}`}
>
{pos === "" ? "همه" : pos === "GK" ? "دروازه‌بان" : pos === "DEF" ? "مدافع" : pos === "MID" ? "هافبک" : "مهاجم"}
</a>
))}
</div>
{/* جدول */}
<div className="bg-white rounded-2xl shadow overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-green-800 text-white">
<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>
</tr>
</thead>
<tbody>
{players.map((p, i) => (
<tr key={p.id} className="border-t hover:bg-green-50 transition">
<td className="px-5 py-4 text-gray-400">{i + 1}</td>
<td className="px-5 py-4 font-semibold">{p.name}</td>
<td className="px-5 py-4">
<PositionBadge position={p.position} />
</td>
<td className="px-5 py-4 text-gray-600">{p.country.name}</td>
<td className="px-5 py-4 text-green-700 font-bold">{p.price}M</td>
<td className="px-5 py-4 font-bold text-blue-700">{p.totalPoints}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

71
app/register/page.tsx Normal file
View File

@@ -0,0 +1,71 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
export default function RegisterPage() {
const router = useRouter();
const [form, setForm] = useState({ name: "", email: "", password: "" });
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || "خطا در ثبت‌نام");
} else {
router.push("/login");
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<form onSubmit={handleSubmit} className="bg-white p-8 rounded-2xl shadow-lg w-full max-w-sm">
<h1 className="text-2xl font-bold mb-6 text-center">ثبتنام</h1>
{error && <p className="text-red-500 text-sm mb-4 text-center">{error}</p>}
<div className="mb-4">
<label className="block text-sm mb-1">نام</label>
<input
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500"
required
/>
</div>
<div className="mb-4">
<label className="block text-sm mb-1">ایمیل</label>
<input
type="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500"
required
/>
</div>
<div className="mb-6">
<label className="block text-sm mb-1">رمز عبور</label>
<input
type="password"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500"
required
/>
</div>
<button type="submit" className="w-full bg-green-600 text-white py-2 rounded-lg hover:bg-green-700 font-bold">
ثبتنام
</button>
<p className="text-center text-sm mt-4 text-gray-500">
حساب داری؟ <Link href="/login" className="text-green-600 hover:underline">ورود</Link>
</p>
</form>
</div>
);
}

75
app/shop/ShopClient.tsx Normal file
View File

@@ -0,0 +1,75 @@
"use client";
import { useState } from "react";
type Package = {
id: string;
name: string;
budgetBonus: number;
price: number;
description: string | null;
};
const ICONS: Record<string, string> = {
"pkg-silver": "🥈",
"pkg-gold": "🥇",
"pkg-diamond": "💎",
};
export default function ShopClient({ packages }: { packages: Package[] }) {
const [loading, setLoading] = useState<string | null>(null);
async function handleBuy(packageId: string) {
setLoading(packageId);
const res = await fetch("/api/payment/request", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ packageId }),
});
const data = await res.json();
if (res.ok && data.paymentUrl) {
window.location.href = data.paymentUrl;
} else {
alert(data.error ?? "خطا در اتصال به درگاه");
setLoading(null);
}
}
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{packages.map((pkg) => (
<div key={pkg.id}
className={`bg-white rounded-2xl shadow-lg p-6 flex flex-col items-center text-center border-2 transition hover:shadow-xl ${
pkg.id === "pkg-gold" ? "border-yellow-400 scale-105" : "border-transparent"
}`}>
{pkg.id === "pkg-gold" && (
<div className="bg-yellow-400 text-yellow-900 text-xs font-bold px-3 py-1 rounded-full mb-3">
محبوبترین
</div>
)}
<div className="text-5xl mb-4">{ICONS[pkg.id] ?? "📦"}</div>
<h3 className="text-xl font-bold mb-2">{pkg.name}</h3>
<p className="text-gray-500 text-sm mb-4">{pkg.description}</p>
<div className="bg-green-50 rounded-xl px-6 py-3 mb-6 w-full">
<div className="text-3xl font-bold text-green-700">+{pkg.budgetBonus}M</div>
<div className="text-xs text-gray-500">افزایش بودجه</div>
</div>
<div className="text-2xl font-bold mb-4">
{pkg.price.toLocaleString("fa-IR")} تومان
</div>
<button
onClick={() => handleBuy(pkg.id)}
disabled={loading === pkg.id}
className={`w-full py-3 rounded-xl font-bold transition disabled:opacity-50 ${
pkg.id === "pkg-gold"
? "bg-yellow-400 text-yellow-900 hover:bg-yellow-500"
: "bg-green-700 text-white hover:bg-green-800"
}`}
>
{loading === pkg.id ? "در حال اتصال..." : "خرید"}
</button>
</div>
))}
</div>
);
}

53
app/shop/page.tsx Normal file
View File

@@ -0,0 +1,53 @@
import { db } from "@/lib/db";
import { requireAuth } from "@/lib/session";
import ShopClient from "./ShopClient";
export default async function ShopPage({
searchParams,
}: {
searchParams: { status?: string; refId?: string };
}) {
const session = await requireAuth();
const userId = (session.user as any).id;
const [packages, team] = await Promise.all([
db.package.findMany({ where: { isActive: true }, orderBy: { price: "asc" } }),
db.team.findUnique({ where: { userId }, select: { budget: true, name: true } }),
]);
return (
<div className="max-w-4xl mx-auto py-10 px-6">
<h1 className="text-3xl font-bold mb-2">فروشگاه</h1>
<p className="text-gray-500 mb-8">بودجه تیمت رو افزایش بده و بازیکنان بهتری بگیر</p>
{searchParams.status === "success" && (
<div className="bg-green-50 border border-green-200 rounded-2xl p-5 mb-6 flex items-center gap-3">
<span className="text-3xl">🎉</span>
<div>
<div className="font-bold text-green-700">پرداخت موفق</div>
<div className="text-sm text-green-600">کد پیگیری: {searchParams.refId}</div>
</div>
</div>
)}
{(searchParams.status === "failed" || searchParams.status === "cancelled") && (
<div className="bg-red-50 border border-red-200 rounded-2xl p-5 mb-6">
<div className="font-bold text-red-600">پرداخت ناموفق بود</div>
<div className="text-sm text-red-500">لطفاً دوباره امتحان کنید</div>
</div>
)}
{team && (
<div className="bg-green-800 text-white rounded-2xl p-5 mb-8 flex justify-between items-center">
<div>
<div className="text-sm text-green-300">بودجه فعلی تیم</div>
<div className="text-3xl font-bold">{team.budget.toFixed(1)}M</div>
</div>
<div className="text-4xl">💰</div>
</div>
)}
<ShopClient packages={packages} />
</div>
);
}