first commit
This commit is contained in:
47
.gitignore
vendored
Normal file
47
.gitignore
vendored
Normal 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/
|
||||||
72
app/(admin)/admin/countries/CountryForm.tsx
Normal file
72
app/(admin)/admin/countries/CountryForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
app/(admin)/admin/countries/[id]/edit/page.tsx
Normal file
18
app/(admin)/admin/countries/[id]/edit/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
app/(admin)/admin/countries/new/page.tsx
Normal file
12
app/(admin)/admin/countries/new/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
app/(admin)/admin/countries/page.tsx
Normal file
43
app/(admin)/admin/countries/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
app/(admin)/admin/gameweeks/GameweekForm.tsx
Normal file
50
app/(admin)/admin/gameweeks/GameweekForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
app/(admin)/admin/gameweeks/page.tsx
Normal file
6
app/(admin)/admin/gameweeks/page.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
// هفتههای بازی به دورهای بازی تغییر نام داد
|
||||||
|
export default function GameweeksPage() {
|
||||||
|
redirect("/admin/rounds");
|
||||||
|
}
|
||||||
46
app/(admin)/admin/layout.tsx
Normal file
46
app/(admin)/admin/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
app/(admin)/admin/matches/MatchForm.tsx
Normal file
138
app/(admin)/admin/matches/MatchForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
app/(admin)/admin/matches/[id]/edit/page.tsx
Normal file
20
app/(admin)/admin/matches/[id]/edit/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
app/(admin)/admin/matches/new/page.tsx
Normal file
15
app/(admin)/admin/matches/new/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
app/(admin)/admin/matches/page.tsx
Normal file
67
app/(admin)/admin/matches/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
app/(admin)/admin/page.tsx
Normal file
84
app/(admin)/admin/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
app/(admin)/admin/players/PlayerForm.tsx
Normal file
106
app/(admin)/admin/players/PlayerForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
app/(admin)/admin/players/[id]/edit/DeleteButton.tsx
Normal file
34
app/(admin)/admin/players/[id]/edit/DeleteButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
app/(admin)/admin/players/[id]/edit/page.tsx
Normal file
32
app/(admin)/admin/players/[id]/edit/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
app/(admin)/admin/players/new/page.tsx
Normal file
12
app/(admin)/admin/players/new/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
app/(admin)/admin/players/page.tsx
Normal file
51
app/(admin)/admin/players/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
app/(admin)/admin/rounds/ActivateRoundButton.tsx
Normal file
25
app/(admin)/admin/rounds/ActivateRoundButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
app/(admin)/admin/rounds/RoundForm.tsx
Normal file
62
app/(admin)/admin/rounds/RoundForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
app/(admin)/admin/rounds/[id]/match/[matchId]/page.tsx
Normal file
31
app/(admin)/admin/rounds/[id]/match/[matchId]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
app/(admin)/admin/rounds/[id]/page.tsx
Normal file
74
app/(admin)/admin/rounds/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
app/(admin)/admin/rounds/page.tsx
Normal file
49
app/(admin)/admin/rounds/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
app/(admin)/admin/scoring/ScoringRulesEditor.tsx
Normal file
101
app/(admin)/admin/scoring/ScoringRulesEditor.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
app/(admin)/admin/scoring/page.tsx
Normal file
15
app/(admin)/admin/scoring/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
app/(admin)/admin/stats/StatsForm.tsx
Normal file
138
app/(admin)/admin/stats/StatsForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
app/(admin)/admin/stats/page.tsx
Normal file
55
app/(admin)/admin/stats/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
app/(admin)/admin/teams/TeamApprovalRow.tsx
Normal file
82
app/(admin)/admin/teams/TeamApprovalRow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
app/(admin)/admin/teams/page.tsx
Normal file
71
app/(admin)/admin/teams/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
app/(admin)/admin/users/page.tsx
Normal file
44
app/(admin)/admin/users/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
app/(user)/profile/ProfileForm.tsx
Normal file
54
app/(user)/profile/ProfileForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
app/(user)/profile/page.tsx
Normal file
56
app/(user)/profile/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
467
app/(user)/team/TeamBuilder.tsx
Normal file
467
app/(user)/team/TeamBuilder.tsx
Normal 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
24
app/(user)/team/page.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
78
app/api/admin/matches/[id]/calc-points/route.ts
Normal file
78
app/api/admin/matches/[id]/calc-points/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
13
app/api/admin/matches/[id]/events/[eventId]/route.ts
Normal file
13
app/api/admin/matches/[id]/events/[eventId]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
18
app/api/admin/matches/[id]/events/route.ts
Normal file
18
app/api/admin/matches/[id]/events/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
23
app/api/admin/matches/[id]/lineup/route.ts
Normal file
23
app/api/admin/matches/[id]/lineup/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
22
app/api/admin/scoring/route.ts
Normal file
22
app/api/admin/scoring/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
17
app/api/admin/teams/[id]/route.ts
Normal file
17
app/api/admin/teams/[id]/route.ts
Normal 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);
|
||||||
|
}
|
||||||
20
app/api/admin/teams/route.ts
Normal file
20
app/api/admin/teams/route.ts
Normal 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);
|
||||||
|
}
|
||||||
5
app/api/auth/[...nextauth]/route.ts
Normal file
5
app/api/auth/[...nextauth]/route.ts
Normal 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 };
|
||||||
23
app/api/auth/register/route.ts
Normal file
23
app/api/auth/register/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
23
app/api/countries/[id]/route.ts
Normal file
23
app/api/countries/[id]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
22
app/api/countries/route.ts
Normal file
22
app/api/countries/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
16
app/api/gameweeks/[id]/activate/route.ts
Normal file
16
app/api/gameweeks/[id]/activate/route.ts
Normal 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);
|
||||||
|
}
|
||||||
19
app/api/gameweeks/route.ts
Normal file
19
app/api/gameweeks/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
20
app/api/leaderboard/route.ts
Normal file
20
app/api/leaderboard/route.ts
Normal 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,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
32
app/api/matches/[id]/route.ts
Normal file
32
app/api/matches/[id]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
65
app/api/matches/[id]/stats/route.ts
Normal file
65
app/api/matches/[id]/stats/route.ts
Normal 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
29
app/api/matches/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
35
app/api/payment/request/route.ts
Normal file
35
app/api/payment/request/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
43
app/api/payment/verify/route.ts
Normal file
43
app/api/payment/verify/route.ts
Normal 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));
|
||||||
|
}
|
||||||
28
app/api/players/[id]/route.ts
Normal file
28
app/api/players/[id]/route.ts
Normal 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
32
app/api/players/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
14
app/api/rounds/[id]/activate/route.ts
Normal file
14
app/api/rounds/[id]/activate/route.ts
Normal 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
25
app/api/rounds/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
23
app/api/team/captain/route.ts
Normal file
23
app/api/team/captain/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
29
app/api/team/formation/route.ts
Normal file
29
app/api/team/formation/route.ts
Normal 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);
|
||||||
|
}
|
||||||
65
app/api/team/players/route.ts
Normal file
65
app/api/team/players/route.ts
Normal 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
34
app/api/team/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
33
app/api/team/submit/route.ts
Normal file
33
app/api/team/submit/route.ts
Normal 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);
|
||||||
|
}
|
||||||
16
app/api/user/profile/route.ts
Normal file
16
app/api/user/profile/route.ts
Normal 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
14
app/error.tsx
Normal 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
15
app/globals.css
Normal 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
22
app/layout.tsx
Normal 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
48
app/leaderboard/page.tsx
Normal 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
77
app/login/page.tsx
Normal 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
82
app/matches/page.tsx
Normal 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
14
app/not-found.tsx
Normal 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
69
app/page.tsx
Normal 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
72
app/players/page.tsx
Normal 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
71
app/register/page.tsx
Normal 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
75
app/shop/ShopClient.tsx
Normal 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
53
app/shop/page.tsx
Normal 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
43
components/Navbar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
components/PositionBadge.tsx
Normal file
21
components/PositionBadge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
components/SessionProvider.tsx
Normal file
7
components/SessionProvider.tsx
Normal 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
52
lib/auth.ts
Normal 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
13
lib/db.ts
Normal 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
69
lib/points.ts
Normal 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
15
lib/session.ts
Normal 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
86
lib/teamValidation.ts
Normal 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
55
lib/zarinpal.ts
Normal 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
5
next.config.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
2810
package-lock.json
generated
Normal file
2810
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
package.json
Normal file
34
package.json
Normal 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
7
postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
252
prisma/schema.prisma
Normal file
252
prisma/schema.prisma
Normal 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
235
prisma/seed.ts
Normal 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
21
proxy.ts
Normal 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
BIN
public/fonts/lahze.woff
Normal file
Binary file not shown.
BIN
public/fonts/lahze.woff2
Normal file
BIN
public/fonts/lahze.woff2
Normal file
Binary file not shown.
18
tailwind.config.ts
Normal file
18
tailwind.config.ts
Normal 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
41
tsconfig.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user