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

47
.gitignore vendored Normal file
View File

@@ -0,0 +1,47 @@
# Dependencies
node_modules/
.pnp
.pnp.js
# Next.js
.next/
out/
build/
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
.env2
# Prisma
prisma/migrations/
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
*.log
# OS
.DS_Store
Thumbs.db
desktop.ini
# Editor
.vscode/
.idea/
*.swp
*.swo
# TypeScript
*.tsbuildinfo
next-env.d.ts
# Misc
.vercel
.turbo
coverage/

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>
);
}

43
components/Navbar.tsx Normal file
View File

@@ -0,0 +1,43 @@
"use client";
import Link from "next/link";
import { useSession, signOut } from "next-auth/react";
export default function Navbar() {
const { data: session } = useSession();
return (
<nav className="bg-green-800 text-white px-6 py-3 flex items-center justify-between shadow-lg">
<Link href="/" className="text-xl font-bold flex items-center gap-2">
فانتزی جام جهانی
</Link>
<div className="flex items-center gap-6 text-sm font-medium">
<Link href="/matches" className="hover:text-green-300 transition">بازیها</Link>
<Link href="/players" className="hover:text-green-300 transition">بازیکنان</Link>
<Link href="/leaderboard" className="hover:text-green-300 transition">جدول</Link>
{session ? (
<>
<Link href="/team" className="hover:text-green-300 transition">تیم من</Link>
<Link href="/shop" className="hover:text-green-300 transition">فروشگاه</Link>
<Link href="/profile" className="hover:text-green-300 transition">پروفایل</Link>
{(session.user as any).role === "ADMIN" && (
<Link href="/admin" className="bg-yellow-500 text-black px-3 py-1 rounded-lg hover:bg-yellow-400 transition">
ادمین
</Link>
)}
<button
onClick={() => signOut({ callbackUrl: "/" })}
className="bg-red-600 px-3 py-1 rounded-lg hover:bg-red-700 transition"
>
خروج
</button>
</>
) : (
<Link href="/login" className="bg-white text-green-800 px-4 py-1 rounded-lg font-bold hover:bg-green-100 transition">
ورود
</Link>
)}
</div>
</nav>
);
}

View File

@@ -0,0 +1,21 @@
const colors: Record<string, string> = {
GK: "bg-yellow-400 text-yellow-900",
DEF: "bg-blue-500 text-white",
MID: "bg-green-500 text-white",
FWD: "bg-red-500 text-white",
};
const labels: Record<string, string> = {
GK: "دروازه‌بان",
DEF: "مدافع",
MID: "هافبک",
FWD: "مهاجم",
};
export default function PositionBadge({ position }: { position: string }) {
return (
<span className={`text-xs font-bold px-2 py-0.5 rounded-full ${colors[position] ?? "bg-gray-200"}`}>
{labels[position] ?? position}
</span>
);
}

View File

@@ -0,0 +1,7 @@
"use client";
import { SessionProvider as NextSessionProvider } from "next-auth/react";
export default function SessionProvider({ children }: { children: React.ReactNode }) {
return <NextSessionProvider>{children}</NextSessionProvider>;
}

52
lib/auth.ts Normal file
View File

@@ -0,0 +1,52 @@
import { NextAuthOptions } from "next-auth";
import { PrismaAdapter } from "@auth/prisma-adapter";
import CredentialsProvider from "next-auth/providers/credentials";
import { db } from "@/lib/db";
import bcrypt from "bcryptjs";
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(db) as any,
session: { strategy: "jwt" },
pages: {
signIn: "/login",
},
providers: [
CredentialsProvider({
name: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) return null;
const user = await db.user.findUnique({
where: { email: credentials.email },
});
if (!user || !user.password) return null;
const isValid = await bcrypt.compare(credentials.password, user.password);
if (!isValid) return null;
return user;
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.role = (user as any).role;
token.id = user.id;
}
return token;
},
async session({ session, token }) {
if (session.user) {
(session.user as any).role = token.role;
(session.user as any).id = token.id;
}
return session;
},
},
};

13
lib/db.ts Normal file
View File

@@ -0,0 +1,13 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const db =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"],
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;

69
lib/points.ts Normal file
View File

@@ -0,0 +1,69 @@
import type { Position, EventType } from "@prisma/client";
export const DEFAULT_RULES: Record<string, Partial<Record<string, number>>> = {
GK: {
GOAL: 10, ASSIST: 3, YELLOW_CARD: -1, RED_CARD: -3, SECOND_YELLOW: -3,
CLEAN_SHEET: 6, PENALTY_SAVED: 5, PENALTY_MISSED: -2, OWN_GOAL: -2,
MOTM: 3, EXTRA_TIME_BONUS: 1, INJURY_NO_SUB: -1,
},
DEF: {
GOAL: 8, ASSIST: 3, YELLOW_CARD: -1, RED_CARD: -3, SECOND_YELLOW: -3,
CLEAN_SHEET: 4, PENALTY_SAVED: 0, PENALTY_MISSED: -2, OWN_GOAL: -2,
MOTM: 3, EXTRA_TIME_BONUS: 1, INJURY_NO_SUB: -1,
},
MID: {
GOAL: 5, ASSIST: 3, YELLOW_CARD: -2, RED_CARD: -3, SECOND_YELLOW: -3,
CLEAN_SHEET: 1, PENALTY_SAVED: 0, PENALTY_MISSED: -2, OWN_GOAL: -2,
MOTM: 3, EXTRA_TIME_BONUS: 1, INJURY_NO_SUB: -1,
},
FWD: {
GOAL: 4, ASSIST: 3, YELLOW_CARD: -1, RED_CARD: -3, SECOND_YELLOW: -3,
CLEAN_SHEET: 0, PENALTY_SAVED: 0, PENALTY_MISSED: -2, OWN_GOAL: -2,
MOTM: 3, EXTRA_TIME_BONUS: 1, INJURY_NO_SUB: -1,
},
};
export const APPEARANCE_POINTS = { PLAYED_60_PLUS: 2, PLAYED_UNDER_60: 1 };
// این تابع db رو lazy import می‌کنه تا در seed مشکل نداشته باشیم
export async function getPointsForEvent(position: Position, eventType: EventType): Promise<number> {
const { db } = await import("@/lib/db");
const rule = await db.scoringRule.findUnique({
where: { position_eventType: { position, eventType } },
});
if (rule) return rule.points;
return DEFAULT_RULES[position]?.[eventType] ?? 0;
}
export async function calculateMatchPoints(stat: {
position: Position;
goals: number;
assists: number;
yellowCards: number;
redCards: number;
minutesPlayed: number;
cleanSheet: boolean;
penaltySaved: number;
penaltyMissed: number;
ownGoals: number;
isMotm: boolean;
extraTimeBonus: number;
}): Promise<number> {
let pts = 0;
if (stat.minutesPlayed >= 60) pts += APPEARANCE_POINTS.PLAYED_60_PLUS;
else if (stat.minutesPlayed > 0) pts += APPEARANCE_POINTS.PLAYED_UNDER_60;
pts += stat.goals * (await getPointsForEvent(stat.position, "GOAL"));
pts += stat.assists * (await getPointsForEvent(stat.position, "ASSIST"));
pts += stat.yellowCards * (await getPointsForEvent(stat.position, "YELLOW_CARD"));
pts += stat.redCards * (await getPointsForEvent(stat.position, "RED_CARD"));
pts += stat.penaltySaved * (await getPointsForEvent(stat.position, "PENALTY_SAVED"));
pts += stat.penaltyMissed * (await getPointsForEvent(stat.position, "PENALTY_MISSED"));
pts += stat.ownGoals * (await getPointsForEvent(stat.position, "OWN_GOAL"));
pts += stat.extraTimeBonus * (await getPointsForEvent(stat.position, "EXTRA_TIME_BONUS"));
if (stat.cleanSheet) pts += await getPointsForEvent(stat.position, "CLEAN_SHEET");
if (stat.isMotm) pts += await getPointsForEvent(stat.position, "MOTM");
return pts;
}

15
lib/session.ts Normal file
View File

@@ -0,0 +1,15 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { redirect } from "next/navigation";
export async function requireAuth() {
const session = await getServerSession(authOptions);
if (!session) redirect("/login");
return session;
}
export async function requireAdmin() {
const session = await getServerSession(authOptions);
if (!session || (session.user as any).role !== "ADMIN") redirect("/");
return session;
}

86
lib/teamValidation.ts Normal file
View File

@@ -0,0 +1,86 @@
export const FORMATIONS: Record<string, { def: number; mid: number; fwd: number }> = {
"4-3-3": { def: 4, mid: 3, fwd: 3 },
"4-4-2": { def: 4, mid: 4, fwd: 2 },
"4-5-1": { def: 4, mid: 5, fwd: 1 },
"3-5-2": { def: 3, mid: 5, fwd: 2 },
"3-4-3": { def: 3, mid: 4, fwd: 3 },
"5-3-2": { def: 5, mid: 3, fwd: 2 },
"5-4-1": { def: 5, mid: 4, fwd: 1 },
};
export type FormationKey = keyof typeof FORMATIONS;
// تعداد مورد نیاز هر پست = ترکیب + 1 ذخیره
export function requiredPerPosition(formation: string) {
const fmt = FORMATIONS[formation] ?? FORMATIONS["4-3-3"];
return {
GK: 2, // 1 اصلی + 1 ذخیره
DEF: fmt.def + 1,
MID: fmt.mid + 1,
FWD: fmt.fwd + 1,
};
}
export type PlayerLike = { position: string; isBench: boolean };
export function validateTeamComposition(
players: PlayerLike[],
formation: string
): { valid: boolean; errors: string[] } {
const fmt = FORMATIONS[formation] ?? FORMATIONS["4-3-3"];
const errors: string[] = [];
const starters = players.filter((p) => !p.isBench);
const bench = players.filter((p) => p.isBench);
// تعداد کل
if (players.length !== 15) {
errors.push(`تیم باید دقیقاً ۱۵ بازیکن داشته باشد (الان: ${players.length})`);
}
// ترکیب اصلی
const startersByPos = countByPos(starters);
if (startersByPos.GK !== 1) errors.push("باید دقیقاً ۱ دروازه‌بان اصلی داشته باشی");
if (startersByPos.DEF !== fmt.def) errors.push(`باید ${fmt.def} مدافع اصلی داشته باشی (الان: ${startersByPos.DEF ?? 0})`);
if (startersByPos.MID !== fmt.mid) errors.push(`باید ${fmt.mid} هافبک اصلی داشته باشی (الان: ${startersByPos.MID ?? 0})`);
if (startersByPos.FWD !== fmt.fwd) errors.push(`باید ${fmt.fwd} مهاجم اصلی داشته باشی (الان: ${startersByPos.FWD ?? 0})`);
// ذخیره‌ها - هر پست باید ۱ ذخیره داشته باشه
const benchByPos = countByPos(bench);
if ((benchByPos.GK ?? 0) < 1) errors.push("باید ۱ دروازه‌بان ذخیره داشته باشی");
if ((benchByPos.DEF ?? 0) < 1) errors.push("باید حداقل ۱ مدافع ذخیره داشته باشی");
if ((benchByPos.MID ?? 0) < 1) errors.push("باید حداقل ۱ هافبک ذخیره داشته باشی");
if ((benchByPos.FWD ?? 0) < 1) errors.push("باید حداقل ۱ مهاجم ذخیره داشته باشی");
return { valid: errors.length === 0, errors };
}
function countByPos(players: PlayerLike[]): Record<string, number> {
return players.reduce((acc, p) => {
acc[p.position] = (acc[p.position] ?? 0) + 1;
return acc;
}, {} as Record<string, number>);
}
// وقتی ترکیب عوض میشه، چک کن آیا بازیکنان فعلی با ترکیب جدید سازگارن
export function getFormationChangeIssues(
players: PlayerLike[],
oldFormation: string,
newFormation: string
): string[] {
const oldFmt = FORMATIONS[oldFormation] ?? FORMATIONS["4-3-3"];
const newFmt = FORMATIONS[newFormation] ?? FORMATIONS["4-3-3"];
const issues: string[] = [];
const starters = players.filter((p) => !p.isBench);
const byPos = countByPos(starters);
if ((byPos.DEF ?? 0) > newFmt.def)
issues.push(`باید ${(byPos.DEF ?? 0) - newFmt.def} مدافع رو به ذخیره ببری یا حذف کنی`);
if ((byPos.MID ?? 0) > newFmt.mid)
issues.push(`باید ${(byPos.MID ?? 0) - newFmt.mid} هافبک رو به ذخیره ببری یا حذف کنی`);
if ((byPos.FWD ?? 0) > newFmt.fwd)
issues.push(`باید ${(byPos.FWD ?? 0) - newFmt.fwd} مهاجم رو به ذخیره ببری یا حذف کنی`);
return issues;
}

55
lib/zarinpal.ts Normal file
View File

@@ -0,0 +1,55 @@
const ZARINPAL_MERCHANT = process.env.ZARINPAL_MERCHANT_ID ?? "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX";
const IS_SANDBOX = process.env.NODE_ENV !== "production";
const BASE_URL = IS_SANDBOX
? "https://sandbox.zarinpal.com/pg/v4/payment"
: "https://api.zarinpal.com/pg/v4/payment";
const GATEWAY = IS_SANDBOX
? "https://sandbox.zarinpal.com/pg/StartPay"
: "https://www.zarinpal.com/pg/StartPay";
export async function requestPayment(amount: number, description: string, callbackUrl: string) {
const res = await fetch(`${BASE_URL}/request.json`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
merchant_id: ZARINPAL_MERCHANT,
amount, // تومان
description,
callback_url: callbackUrl,
}),
});
const data = await res.json();
if (data.data?.code === 100) {
return {
success: true,
authority: data.data.authority as string,
paymentUrl: `${GATEWAY}/${data.data.authority}`,
};
}
return { success: false, error: data.errors?.message ?? "خطا در اتصال به درگاه" };
}
export async function verifyPayment(authority: string, amount: number) {
const res = await fetch(`${BASE_URL}/verify.json`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
merchant_id: ZARINPAL_MERCHANT,
amount,
authority,
}),
});
const data = await res.json();
if (data.data?.code === 100 || data.data?.code === 101) {
return { success: true, refId: String(data.data.ref_id) };
}
return { success: false, error: "پرداخت تایید نشد" };
}

5
next.config.ts Normal file
View File

@@ -0,0 +1,5 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {};
export default nextConfig;

2810
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"prisma": {
"seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
},
"dependencies": {
"@auth/prisma-adapter": "^2.11.1",
"@prisma/adapter-pg": "^7.6.0",
"@prisma/client": "^6.19.3",
"@tailwindcss/postcss": "^4.2.2",
"@types/node": "^25.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"autoprefixer": "^10.4.27",
"bcryptjs": "^3.0.3",
"next": "^16.2.2",
"next-auth": "^4.24.13",
"pg": "^8.20.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwindcss": "^4.2.2",
"typescript": "^6.0.2"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/pg": "^8.20.0",
"prisma": "^6.19.3",
"ts-node": "^10.9.2"
}
}

7
postcss.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

252
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,252 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum Role {
USER
ADMIN
}
enum Position {
GK
DEF
MID
FWD
}
enum MatchStage {
GROUP
ROUND_OF_16
QUARTER_FINAL
SEMI_FINAL
THIRD_PLACE
FINAL
}
enum MatchStatus {
SCHEDULED
LIVE
FINISHED
}
enum TeamStatus {
ACTIVE
INACTIVE
}
enum PaymentStatus {
PENDING
SUCCESS
FAILED
}
enum EventType {
GOAL
ASSIST
YELLOW_CARD
RED_CARD
SECOND_YELLOW
SUBSTITUTION_IN
SUBSTITUTION_OUT
INJURY_NO_SUB
CLEAN_SHEET
PENALTY_SAVED
PENALTY_MISSED
OWN_GOAL
EXTRA_TIME_BONUS
MOTM
}
model Country {
id String @id @default(cuid())
name String @unique
code String @unique
flagUrl String?
defaultFormation String @default("4-3-3")
group Group? @relation(fields: [groupId], references: [id])
groupId String?
isEliminated Boolean @default(false)
players Player[]
homeMatches Match[] @relation("HomeTeam")
awayMatches Match[] @relation("AwayTeam")
}
model Group {
id String @id @default(cuid())
name String @unique
countries Country[]
}
model Player {
id String @id @default(cuid())
name String
position Position
countryId String
country Country @relation(fields: [countryId], references: [id])
price Float @default(5.0)
totalPoints Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
matchStats PlayerMatchStat[]
teamPlayers TeamPlayer[]
events MatchEvent[]
}
model Match {
id String @id @default(cuid())
homeTeamId String
awayTeamId String
homeTeam Country @relation("HomeTeam", fields: [homeTeamId], references: [id])
awayTeam Country @relation("AwayTeam", fields: [awayTeamId], references: [id])
homeScore Int?
awayScore Int?
stage MatchStage @default(GROUP)
status MatchStatus @default(SCHEDULED)
matchDate DateTime
roundId String?
round Round? @relation(fields: [roundId], references: [id])
playerStats PlayerMatchStat[]
events MatchEvent[]
lineups MatchLineup[]
createdAt DateTime @default(now())
}
model Round {
id String @id @default(cuid())
number Int @unique
name String
isActive Boolean @default(false)
deadline DateTime
matches Match[]
createdAt DateTime @default(now())
}
model MatchEvent {
id String @id @default(cuid())
matchId String
playerId String
match Match @relation(fields: [matchId], references: [id], onDelete: Cascade)
player Player @relation(fields: [playerId], references: [id], onDelete: Cascade)
type EventType
minute Int?
extraInfo String?
createdAt DateTime @default(now())
}
model MatchLineup {
id String @id @default(cuid())
matchId String
countryId String
match Match @relation(fields: [matchId], references: [id], onDelete: Cascade)
formation String
playerIds String[]
}
model PlayerMatchStat {
id String @id @default(cuid())
playerId String
matchId String
player Player @relation(fields: [playerId], references: [id], onDelete: Cascade)
match Match @relation(fields: [matchId], references: [id], onDelete: Cascade)
goals Int @default(0)
assists Int @default(0)
yellowCards Int @default(0)
redCards Int @default(0)
minutesPlayed Int @default(0)
cleanSheet Boolean @default(false)
penaltySaved Int @default(0)
penaltyMissed Int @default(0)
ownGoals Int @default(0)
isMotm Boolean @default(false)
extraTimeBonus Int @default(0)
points Int @default(0)
@@unique([playerId, matchId])
}
model ScoringRule {
id String @id @default(cuid())
position Position
eventType EventType
points Int
updatedAt DateTime @updatedAt
updatedBy String?
@@unique([position, eventType])
}
model User {
id String @id @default(cuid())
name String?
email String @unique
password String
role Role @default(USER)
createdAt DateTime @default(now())
team Team?
sessions Session[]
payments Payment[]
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Team {
id String @id @default(cuid())
name String
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
budget Float @default(100.0)
totalPoints Int @default(0)
formation String @default("4-3-3")
status TeamStatus @default(INACTIVE)
createdAt DateTime @default(now())
players TeamPlayer[]
}
model TeamPlayer {
teamId String
playerId String
isCaptain Boolean @default(false)
isViceCaptain Boolean @default(false)
isBench Boolean @default(false)
positionIndex Int @default(0)
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
player Player @relation(fields: [playerId], references: [id], onDelete: Cascade)
@@id([teamId, playerId])
}
model Package {
id String @id @default(cuid())
name String
budgetBonus Float
price Int
description String?
isActive Boolean @default(true)
payments Payment[]
}
model Payment {
id String @id @default(cuid())
userId String
packageId String
user User @relation(fields: [userId], references: [id])
package Package @relation(fields: [packageId], references: [id])
amount Int
authority String? @unique
refId String?
status PaymentStatus @default(PENDING)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

235
prisma/seed.ts Normal file
View File

@@ -0,0 +1,235 @@
import { PrismaClient } from "@prisma/client";
import bcrypt from "bcryptjs";
// DEFAULT_RULES رو مستقیم اینجا تعریف می‌کنیم تا از @/ alias استفاده نکنیم
const DEFAULT_RULES = {
GK: { GOAL: 10, ASSIST: 3, YELLOW_CARD: -1, RED_CARD: -3, SECOND_YELLOW: -3, CLEAN_SHEET: 6, PENALTY_SAVED: 5, PENALTY_MISSED: -2, OWN_GOAL: -2, MOTM: 3, EXTRA_TIME_BONUS: 1, INJURY_NO_SUB: -1 },
DEF: { GOAL: 8, ASSIST: 3, YELLOW_CARD: -1, RED_CARD: -3, SECOND_YELLOW: -3, CLEAN_SHEET: 4, PENALTY_SAVED: 0, PENALTY_MISSED: -2, OWN_GOAL: -2, MOTM: 3, EXTRA_TIME_BONUS: 1, INJURY_NO_SUB: -1 },
MID: { GOAL: 5, ASSIST: 3, YELLOW_CARD: -2, RED_CARD: -3, SECOND_YELLOW: -3, CLEAN_SHEET: 1, PENALTY_SAVED: 0, PENALTY_MISSED: -2, OWN_GOAL: -2, MOTM: 3, EXTRA_TIME_BONUS: 1, INJURY_NO_SUB: -1 },
FWD: { GOAL: 4, ASSIST: 3, YELLOW_CARD: -1, RED_CARD: -3, SECOND_YELLOW: -3, CLEAN_SHEET: 0, PENALTY_SAVED: 0, PENALTY_MISSED: -2, OWN_GOAL: -2, MOTM: 3, EXTRA_TIME_BONUS: 1, INJURY_NO_SUB: -1 },
} as const;
const prisma = new PrismaClient();
// ترکیب پیش‌فرض تیم‌های ملی معروف (بر اساس آخرین اطلاعات)
const COUNTRY_FORMATIONS: Record<string, string> = {
BRA: "4-3-3", FRA: "4-3-3", ARG: "4-3-3", ENG: "4-3-3",
ESP: "4-3-3", GER: "4-2-3-1", POR: "4-3-3", NED: "4-3-3",
BEL: "4-3-3", CRO: "4-3-3", MAR: "4-3-3", IRN: "4-5-1",
URU: "4-3-3", SEN: "4-3-3", KOR: "4-3-3", JPN: "4-3-3",
MEX: "4-3-3", USA: "4-3-3", CAN: "4-3-3", AUS: "4-3-3",
POL: "4-3-3", DEN: "4-3-3", SUI: "4-2-3-1", SRB: "3-4-3",
WAL: "5-3-2", TUN: "4-3-3", CMR: "4-3-3", GHA: "4-2-3-1",
ECU: "4-3-3", QAT: "5-3-2", KSA: "4-3-3", CRC: "5-4-1",
};
const COUNTRIES = [
{ name: "برزیل", code: "BRA", flag: "🇧🇷", group: "G" },
{ name: "سربیا", code: "SRB", flag: "🇷🇸", group: "G" },
{ name: "سوئیس", code: "SUI", flag: "🇨🇭", group: "G" },
{ name: "کامرون", code: "CMR", flag: "🇨🇲", group: "G" },
{ name: "فرانسه", code: "FRA", flag: "🇫🇷", group: "D" },
{ name: "استرالیا", code: "AUS", flag: "🇦🇺", group: "D" },
{ name: "دانمارک", code: "DEN", flag: "🇩🇰", group: "D" },
{ name: "تونس", code: "TUN", flag: "🇹🇳", group: "D" },
{ name: "آرژانتین", code: "ARG", flag: "🇦🇷", group: "C" },
{ name: "عربستان", code: "KSA", flag: "🇸🇦", group: "C" },
{ name: "مکزیک", code: "MEX", flag: "🇲🇽", group: "C" },
{ name: "لهستان", code: "POL", flag: "🇵🇱", group: "C" },
{ name: "ایران", code: "IRN", flag: "🇮🇷", group: "B" },
{ name: "انگلیس", code: "ENG", flag: "🏴󠁧󠁢󠁥󠁮󠁧󠁿", group: "B" },
{ name: "آمریکا", code: "USA", flag: "🇺🇸", group: "B" },
{ name: "ولز", code: "WAL", flag: "🏴󠁧󠁢󠁷󠁬󠁳󠁿", group: "B" },
{ name: "آلمان", code: "GER", flag: "🇩🇪", group: "E" },
{ name: "ژاپن", code: "JPN", flag: "🇯🇵", group: "E" },
{ name: "اسپانیا", code: "ESP", flag: "🇪🇸", group: "E" },
{ name: "کاستاریکا", code: "CRC", flag: "🇨🇷", group: "E" },
{ name: "بلژیک", code: "BEL", flag: "🇧🇪", group: "F" },
{ name: "کانادا", code: "CAN", flag: "🇨🇦", group: "F" },
{ name: "مراکش", code: "MAR", flag: "🇲🇦", group: "F" },
{ name: "کرواسی", code: "CRO", flag: "🇭🇷", group: "F" },
{ name: "پرتغال", code: "POR", flag: "🇵🇹", group: "H" },
{ name: "غنا", code: "GHA", flag: "🇬🇭", group: "H" },
{ name: "اروگوئه", code: "URU", flag: "🇺🇾", group: "H" },
{ name: "کره جنوبی", code: "KOR", flag: "🇰🇷", group: "H" },
{ name: "هلند", code: "NED", flag: "🇳🇱", group: "A" },
{ name: "سنگال", code: "SEN", flag: "🇸🇳", group: "A" },
{ name: "اکوادور", code: "ECU", flag: "🇪🇨", group: "A" },
{ name: "قطر", code: "QAT", flag: "🇶🇦", group: "A" },
];
const PLAYERS_DATA = [
{ name: "آلیسون بکر", pos: "GK", code: "BRA", price: 6.0, pts: 42 },
{ name: "تیاگو سیلوا", pos: "DEF", code: "BRA", price: 6.5, pts: 38 },
{ name: "مارکینیوس", pos: "DEF", code: "BRA", price: 6.0, pts: 35 },
{ name: "کاسمیرو", pos: "MID", code: "BRA", price: 8.0, pts: 52 },
{ name: "نیمار", pos: "FWD", code: "BRA", price: 11.5, pts: 68 },
{ name: "وینیسیوس جونیور", pos: "FWD", code: "BRA", price: 10.5, pts: 72 },
{ name: "ریچارلیسون", pos: "FWD", code: "BRA", price: 8.5, pts: 55 },
{ name: "هوگو لوریس", pos: "GK", code: "FRA", price: 6.0, pts: 40 },
{ name: "رافائل واران", pos: "DEF", code: "FRA", price: 6.5, pts: 36 },
{ name: "کیلیان امباپه", pos: "FWD", code: "FRA", price: 12.5, pts: 88 },
{ name: "آنتوان گریزمان", pos: "MID", code: "FRA", price: 9.5, pts: 62 },
{ name: "اولیویه ژیرو", pos: "FWD", code: "FRA", price: 7.5, pts: 48 },
{ name: "اوسمان دمبله", pos: "FWD", code: "FRA", price: 8.0, pts: 44 },
{ name: "امیلیانو مارتینز", pos: "GK", code: "ARG", price: 6.5, pts: 55 },
{ name: "لیونل مسی", pos: "FWD", code: "ARG", price: 12.5, pts: 92 },
{ name: "خولیان آلوارز", pos: "FWD", code: "ARG", price: 8.5, pts: 60 },
{ name: "رودریگو دپاول", pos: "MID", code: "ARG", price: 7.5, pts: 45 },
{ name: "نیکولاس اوتامندی", pos: "DEF", code: "ARG", price: 5.5, pts: 32 },
{ name: "جوردن پیکفورد", pos: "GK", code: "ENG", price: 5.5, pts: 38 },
{ name: "جود بلینگهام", pos: "MID", code: "ENG", price: 10.5, pts: 75 },
{ name: "هری کین", pos: "FWD", code: "ENG", price: 11.0, pts: 70 },
{ name: "بوکایو ساکا", pos: "MID", code: "ENG", price: 8.5, pts: 58 },
{ name: "فیل فودن", pos: "MID", code: "ENG", price: 9.0, pts: 62 },
{ name: "جان استونز", pos: "DEF", code: "ENG", price: 6.0, pts: 34 },
{ name: "اونای سیمون", pos: "GK", code: "ESP", price: 5.5, pts: 36 },
{ name: "پدری", pos: "MID", code: "ESP", price: 9.0, pts: 60 },
{ name: "گاوی", pos: "MID", code: "ESP", price: 8.5, pts: 55 },
{ name: "آلوارو موراتا", pos: "FWD", code: "ESP", price: 7.5, pts: 46 },
{ name: "دنی اولمو", pos: "MID", code: "ESP", price: 7.0, pts: 42 },
{ name: "مانوئل نویر", pos: "GK", code: "GER", price: 6.0, pts: 38 },
{ name: "توماس مولر", pos: "MID", code: "GER", price: 8.0, pts: 50 },
{ name: "کای هاورتز", pos: "MID", code: "GER", price: 8.5, pts: 52 },
{ name: "یامله موسیالا", pos: "MID", code: "GER", price: 9.0, pts: 65 },
{ name: "آنتونیو رودیگر", pos: "DEF", code: "GER", price: 5.5, pts: 30 },
{ name: "دیوگو کوستا", pos: "GK", code: "POR", price: 5.5, pts: 35 },
{ name: "کریستیانو رونالدو", pos: "FWD", code: "POR", price: 11.0, pts: 65 },
{ name: "برونو فرناندز", pos: "MID", code: "POR", price: 10.0, pts: 70 },
{ name: "رافائل لئائو", pos: "FWD", code: "POR", price: 8.5, pts: 55 },
{ name: "روبن دیاز", pos: "DEF", code: "POR", price: 6.0, pts: 36 },
{ name: "علیرضا بیرانوند", pos: "GK", code: "IRN", price: 5.0, pts: 28 },
{ name: "مهدی طارمی", pos: "FWD", code: "IRN", price: 8.0, pts: 50 },
{ name: "سردار آزمون", pos: "FWD", code: "IRN", price: 7.5, pts: 44 },
{ name: "علی کریمی", pos: "MID", code: "IRN", price: 6.0, pts: 32 },
{ name: "رامین رضاییان", pos: "DEF", code: "IRN", price: 5.0, pts: 22 },
{ name: "یاسین بونو", pos: "GK", code: "MAR", price: 6.5, pts: 58 },
{ name: "اشرف حکیمی", pos: "DEF", code: "MAR", price: 8.0, pts: 62 },
{ name: "حکیم زیاش", pos: "MID", code: "MAR", price: 7.5, pts: 48 },
{ name: "یوسف النصیری", pos: "FWD", code: "MAR", price: 7.0, pts: 45 },
{ name: "دومینیک لیواکوویچ", pos: "GK", code: "CRO", price: 6.0, pts: 50 },
{ name: "لوکا مودریچ", pos: "MID", code: "CRO", price: 9.5, pts: 68 },
{ name: "ایوان پریشیچ", pos: "MID", code: "CRO", price: 7.5, pts: 48 },
{ name: "آندره کراماریچ", pos: "FWD", code: "CRO", price: 7.0, pts: 44 },
{ name: "آندریس نوپرت", pos: "GK", code: "NED", price: 5.5, pts: 36 },
{ name: "ویرخیل فان دایک", pos: "DEF", code: "NED", price: 7.0, pts: 48 },
{ name: "دنزل دامفریس", pos: "DEF", code: "NED", price: 7.5, pts: 52 },
{ name: "کودی گاکپو", pos: "FWD", code: "NED", price: 8.0, pts: 58 },
{ name: "تیبو کورتوا", pos: "GK", code: "BEL", price: 6.5, pts: 44 },
{ name: "کوین دبروینه", pos: "MID", code: "BEL", price: 11.0, pts: 72 },
{ name: "رومله لوکاکو", pos: "FWD", code: "BEL", price: 9.5, pts: 55 },
];
async function main() {
console.log("🌱 Seeding...");
// ─── ادمین و کاربران ─────────────────────────────────
const adminPwd = await bcrypt.hash("admin123", 10);
await prisma.user.upsert({ where: { email: "admin@worldcup.com" }, update: {},
create: { email: "admin@worldcup.com", name: "ادمین", password: adminPwd, role: "ADMIN" } });
const userPwd = await bcrypt.hash("user123", 10);
const users = [];
for (const u of [{ email: "ali@test.com", name: "علی احمدی" }, { email: "sara@test.com", name: "سارا رضایی" }, { email: "reza@test.com", name: "رضا محمدی" }]) {
const user = await prisma.user.upsert({ where: { email: u.email }, update: {}, create: { ...u, password: userPwd } });
users.push(user);
}
// ─── گروه‌ها ──────────────────────────────────────────
const groupMap: Record<string, string> = {};
for (const name of ["A","B","C","D","E","F","G","H"]) {
const g = await prisma.group.upsert({ where: { name }, update: {}, create: { name } });
groupMap[name] = g.id;
}
// ─── تیم‌های ملی ─────────────────────────────────────
const countryMap: Record<string, string> = {};
for (const c of COUNTRIES) {
const country = await prisma.country.upsert({ where: { code: c.code }, update: { flagUrl: c.flag, defaultFormation: COUNTRY_FORMATIONS[c.code] ?? "4-3-3" },
create: { name: c.name, code: c.code, flagUrl: c.flag, groupId: groupMap[c.group], defaultFormation: COUNTRY_FORMATIONS[c.code] ?? "4-3-3" } });
countryMap[c.code] = country.id;
}
// ─── بازیکنان ─────────────────────────────────────────
for (const p of PLAYERS_DATA) {
const countryId = countryMap[p.code];
if (!countryId) continue;
await prisma.player.create({ data: { name: p.name, position: p.pos as any, countryId, price: p.price, totalPoints: p.pts } });
}
// ─── قوانین امتیازدهی پیش‌فرض ────────────────────────
const positions = ["GK", "DEF", "MID", "FWD"] as const;
for (const pos of positions) {
const rules = DEFAULT_RULES[pos];
for (const [eventType, points] of Object.entries(rules)) {
await prisma.scoringRule.upsert({
where: { position_eventType: { position: pos, eventType: eventType as any } },
update: { points: points as number },
create: { position: pos, eventType: eventType as any, points: points as number },
});
}
}
// ─── دورها ────────────────────────────────────────────
const round1 = await prisma.round.upsert({ where: { number: 1 }, update: {},
create: { number: 1, name: "دور اول - مرحله گروهی", isActive: true, deadline: new Date("2026-06-15T10:00:00Z") } });
const round2 = await prisma.round.upsert({ where: { number: 2 }, update: {},
create: { number: 2, name: "دور دوم - مرحله گروهی", deadline: new Date("2026-06-22T10:00:00Z") } });
const round3 = await prisma.round.upsert({ where: { number: 3 }, update: {},
create: { number: 3, name: "دور سوم - مرحله گروهی", deadline: new Date("2026-06-29T10:00:00Z") } });
const round4 = await prisma.round.upsert({ where: { number: 4 }, update: {},
create: { number: 4, name: "دور چهارم - یک‌هشتم نهایی", deadline: new Date("2026-07-05T10:00:00Z") } });
// ─── بازی‌ها ──────────────────────────────────────────
const matchesData = [
// دور اول - بازی اول هر تیم
{ home: "QAT", away: "ECU", hS: 0, aS: 2, st: "FINISHED", date: "2026-06-20T16:00:00Z", rId: round1.id },
{ home: "ENG", away: "IRN", hS: 6, aS: 2, st: "FINISHED", date: "2026-06-21T13:00:00Z", rId: round1.id },
{ home: "ARG", away: "KSA", hS: 1, aS: 2, st: "FINISHED", date: "2026-06-22T10:00:00Z", rId: round1.id },
{ home: "FRA", away: "AUS", hS: 4, aS: 1, st: "FINISHED", date: "2026-06-22T19:00:00Z", rId: round1.id },
{ home: "GER", away: "JPN", hS: 1, aS: 2, st: "FINISHED", date: "2026-06-23T13:00:00Z", rId: round1.id },
{ home: "ESP", away: "CRC", hS: 7, aS: 0, st: "FINISHED", date: "2026-06-23T19:00:00Z", rId: round1.id },
{ home: "BEL", away: "CAN", hS: 1, aS: 0, st: "FINISHED", date: "2026-06-23T16:00:00Z", rId: round1.id },
{ home: "BRA", away: "SRB", hS: 2, aS: 0, st: "FINISHED", date: "2026-06-24T19:00:00Z", rId: round1.id },
{ home: "POR", away: "GHA", hS: 3, aS: 2, st: "FINISHED", date: "2026-06-24T16:00:00Z", rId: round1.id },
{ home: "MAR", away: "CRO", hS: 0, aS: 0, st: "FINISHED", date: "2026-06-23T10:00:00Z", rId: round1.id },
{ home: "NED", away: "SEN", hS: 2, aS: 0, st: "FINISHED", date: "2026-06-21T16:00:00Z", rId: round1.id },
// دور دوم - بازی دوم هر تیم
{ home: "IRN", away: "WAL", hS: 2, aS: 0, st: "FINISHED", date: "2026-06-25T13:00:00Z", rId: round2.id },
{ home: "FRA", away: "DEN", hS: 2, aS: 1, st: "FINISHED", date: "2026-06-26T19:00:00Z", rId: round2.id },
{ home: "ARG", away: "MEX", hS: 2, aS: 0, st: "FINISHED", date: "2026-06-26T22:00:00Z", rId: round2.id },
{ home: "BRA", away: "SUI", hS: 1, aS: 0, st: "FINISHED", date: "2026-06-28T19:00:00Z", rId: round2.id },
{ home: "ENG", away: "USA", hS: 0, aS: 0, st: "FINISHED", date: "2026-06-25T19:00:00Z", rId: round2.id },
// دور سوم - بازی سوم گروهی
{ home: "BRA", away: "CMR", hS: 1, aS: 0, st: "SCHEDULED", date: "2026-07-02T19:00:00Z", rId: round3.id },
{ home: "FRA", away: "TUN", hS: null, aS: null, st: "SCHEDULED", date: "2026-07-01T16:00:00Z", rId: round3.id },
// دور چهارم - یک‌هشتم
{ home: "NED", away: "ARG", hS: 2, aS: 2, st: "FINISHED", date: "2026-12-09T19:00:00Z", rId: round4.id, stage: "ROUND_OF_16" },
{ home: "FRA", away: "ENG", hS: 2, aS: 1, st: "FINISHED", date: "2026-12-10T19:00:00Z", rId: round4.id, stage: "ROUND_OF_16" },
{ home: "ARG", away: "FRA", hS: 3, aS: 3, st: "FINISHED", date: "2026-12-18T15:00:00Z", rId: round4.id, stage: "FINAL" },
];
for (const m of matchesData) {
const homeId = countryMap[m.home], awayId = countryMap[m.away];
if (!homeId || !awayId) continue;
await prisma.match.create({ data: {
homeTeamId: homeId, awayTeamId: awayId,
homeScore: m.hS ?? null, awayScore: m.aS ?? null,
status: m.st as any, stage: (m.stage ?? "GROUP") as any,
matchDate: new Date(m.date), roundId: m.rId,
}});
}
// ─── پکیج‌ها ──────────────────────────────────────────
for (const pkg of [
{ id: "pkg-silver", name: "پکیج نقره‌ای", budgetBonus: 10, price: 50000, description: "۱۰ میلیون به بودجه اضافه کن" },
{ id: "pkg-gold", name: "پکیج طلایی", budgetBonus: 20, price: 90000, description: "۲۰ میلیون به بودجه اضافه کن" },
{ id: "pkg-diamond", name: "پکیج الماس", budgetBonus: 30, price: 120000, description: "۳۰ میلیون به بودجه اضافه کن" },
]) {
await prisma.package.upsert({ where: { id: pkg.id }, update: {}, create: pkg });
}
console.log("✅ Seed done! admin@worldcup.com / admin123 | ali@test.com / user123");
}
main().catch(console.error).finally(() => prisma.$disconnect());

21
proxy.ts Normal file
View File

@@ -0,0 +1,21 @@
import { withAuth } from "next-auth/middleware";
import { NextResponse } from "next/server";
export default withAuth(
function middleware(req) {
const token = req.nextauth.token;
const isAdminRoute = req.nextUrl.pathname.startsWith("/admin");
if (isAdminRoute && token?.role !== "ADMIN") {
return NextResponse.redirect(new URL("/", req.url));
}
},
{
callbacks: {
authorized: ({ token }) => !!token,
},
}
);
export const config = {
matcher: ["/admin/:path*", "/team/:path*"],
};

BIN
public/fonts/lahze.woff Normal file

Binary file not shown.

BIN
public/fonts/lahze.woff2 Normal file

Binary file not shown.

18
tailwind.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./app/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
],
theme: {
extend: {
fontFamily: {
sans: ["Lahze", "sans-serif"],
},
},
},
plugins: [],
};
export default config;

41
tsconfig.json Normal file
View File

@@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}