first commit
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user