first commit

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

View File

@@ -0,0 +1,467 @@
"use client";
import { useState, useRef } from "react";
import PositionBadge from "@/components/PositionBadge";
type Player = {
id: string;
name: string;
position: string;
price: number;
totalPoints: number;
country: { name: string; code: string; flagUrl?: string | null };
};
type TeamPlayer = {
playerId: string;
isCaptain: boolean;
isViceCaptain: boolean;
isBench: boolean;
positionIndex: number;
player: Player;
};
type Team = {
id: string;
name: string;
budget: number;
totalPoints: number;
formation: string;
status: string;
players: TeamPlayer[];
} | null;
const FORMATIONS: Record<string, { label: string; def: number; mid: number; fwd: number }> = {
"4-3-3": { label: "۴-۳-۳", def: 4, mid: 3, fwd: 3 },
"4-4-2": { label: "۴-۴-۲", def: 4, mid: 4, fwd: 2 },
"4-5-1": { label: "۴-۵-۱", def: 4, mid: 5, fwd: 1 },
"3-5-2": { label: "۳-۵-۲", def: 3, mid: 5, fwd: 2 },
"3-4-3": { label: "۳-۴-۳", def: 3, mid: 4, fwd: 3 },
"5-3-2": { label: "۵-۳-۲", def: 5, mid: 3, fwd: 2 },
"5-4-1": { label: "۵-۴-۱", def: 5, mid: 4, fwd: 1 },
};
const POS_COLORS: Record<string, string> = {
GK: "bg-yellow-400 text-yellow-900 border-yellow-500",
DEF: "bg-blue-500 text-white border-blue-600",
MID: "bg-green-500 text-white border-green-600",
FWD: "bg-red-500 text-white border-red-600",
};
export default function TeamBuilder({
team: initialTeam,
allPlayers,
}: {
team: Team;
allPlayers: Player[];
}) {
const [team, setTeam] = useState<Team>(initialTeam);
const [teamName, setTeamName] = useState("");
const [formation, setFormation] = useState(initialTeam?.formation ?? "4-3-3");
const [filter, setFilter] = useState("");
const [posFilter, setPosFilter] = useState("");
const [loading, setLoading] = useState(false);
const [msg, setMsg] = useState<{ text: string; type: "error" | "success" } | null>(null);
const [draggedId, setDraggedId] = useState<string | null>(null);
const [submitLoading, setSubmitLoading] = useState(false);
const spent = team?.players.filter((tp) => !tp.isBench).reduce((s, tp) => s + tp.player.price, 0) ?? 0;
const benchSpent = team?.players.filter((tp) => tp.isBench).reduce((s, tp) => s + tp.player.price, 0) ?? 0;
const remaining = (team?.budget ?? 100) - spent - benchSpent;
const fmt = FORMATIONS[formation] ?? FORMATIONS["4-3-3"];
const starters = team?.players.filter((tp) => !tp.isBench) ?? [];
const bench = team?.players.filter((tp) => tp.isBench) ?? [];
const gkSlots = starters.filter((tp) => tp.player.position === "GK");
const defSlots = starters.filter((tp) => tp.player.position === "DEF");
const midSlots = starters.filter((tp) => tp.player.position === "MID");
const fwdSlots = starters.filter((tp) => tp.player.position === "FWD");
async function createTeam() {
if (!teamName.trim()) return;
setLoading(true);
const res = await fetch("/api/team", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: teamName, formation }),
});
const data = await res.json();
if (res.ok) setTeam({ ...data, players: [] });
else setMsg({ text: data.error, type: "error" });
setLoading(false);
}
async function addPlayer(playerId: string, isBench = false) {
setLoading(true);
const res = await fetch("/api/team/players", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ playerId, isBench }),
});
const data = await res.json();
if (res.ok) {
const player = allPlayers.find((p) => p.id === playerId)!;
setTeam((t) => t ? { ...t, players: [...t.players, { ...data, player }] } : t);
setMsg(null);
} else {
setMsg({ text: data.error, type: "error" });
}
setLoading(false);
}
async function removePlayer(playerId: string) {
setLoading(true);
await fetch("/api/team/players", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ playerId }),
});
setTeam((t) => t ? { ...t, players: t.players.filter((tp) => tp.playerId !== playerId) } : t);
setLoading(false);
}
async function setCaptain(playerId: string, type: "captain" | "vice") {
await fetch("/api/team/captain", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ playerId, type }),
});
setTeam((t) => {
if (!t) return t;
return {
...t,
players: t.players.map((tp) => ({
...tp,
isCaptain: type === "captain" ? tp.playerId === playerId : tp.isCaptain,
isViceCaptain: type === "vice" ? tp.playerId === playerId : tp.isViceCaptain,
})),
};
});
}
async function submitTeam() {
setSubmitLoading(true);
const res = await fetch("/api/team/submit", { method: "POST" });
const data = await res.json();
if (res.ok) {
setTeam((t) => t ? { ...t, status: "PENDING" } : t);
setMsg({ text: "تیم برای تایید ارسال شد", type: "success" });
} else {
setMsg({ text: data.error, type: "error" });
}
setSubmitLoading(false);
}
// drag & drop swap
function onDragStart(playerId: string) { setDraggedId(playerId); }
function onDrop(targetId: string) {
if (!draggedId || draggedId === targetId) return;
setTeam((t) => {
if (!t) return t;
const a = t.players.find((p) => p.playerId === draggedId);
const b = t.players.find((p) => p.playerId === targetId);
if (!a || !b) return t;
// swap bench status
return {
...t,
players: t.players.map((p) => {
if (p.playerId === draggedId) return { ...p, isBench: b.isBench };
if (p.playerId === targetId) return { ...p, isBench: a.isBench };
return p;
}),
};
});
setDraggedId(null);
}
const myPlayerIds = new Set(team?.players.map((tp) => tp.playerId) ?? []);
const filtered = allPlayers.filter(
(p) =>
!myPlayerIds.has(p.id) &&
(posFilter ? p.position === posFilter : true) &&
(filter ? p.name.includes(filter) || p.country.name.includes(filter) : true)
);
const isComplete = starters.length === 11 && bench.length >= 4;
const canSubmit = isComplete && team?.status === "INACTIVE";
if (!team) {
return (
<div className="max-w-md mx-auto py-20 px-6 text-center">
<div className="text-6xl mb-6"></div>
<h1 className="text-2xl font-bold mb-2">تیمت رو بساز</h1>
<p className="text-gray-500 mb-8 text-sm">با بودجه ۱۰۰ میلیون، ۱۵ بازیکن انتخاب کن</p>
<input type="text" placeholder="نام تیم" value={teamName}
onChange={(e) => setTeamName(e.target.value)}
className="w-full border-2 rounded-xl px-4 py-3 mb-4 focus:outline-none focus:border-green-500 text-center text-lg" />
<div className="mb-4">
<label className="block text-sm font-medium mb-2 text-right">ترکیب</label>
<div className="grid grid-cols-4 gap-2">
{Object.entries(FORMATIONS).map(([key, val]) => (
<button key={key} type="button" onClick={() => setFormation(key)}
className={`py-2 rounded-xl text-sm font-bold border-2 transition ${formation === key ? "bg-green-700 text-white border-green-700" : "bg-white border-gray-200 hover:border-green-400"}`}>
{key}
</button>
))}
</div>
</div>
<button onClick={createTeam} disabled={loading || !teamName.trim()}
className="w-full bg-green-700 text-white py-3 rounded-xl font-bold text-lg hover:bg-green-800 transition disabled:opacity-50">
ساخت تیم
</button>
</div>
);
}
return (
<div className="max-w-7xl mx-auto py-6 px-4">
{/* هدر */}
<div className="flex flex-wrap items-center justify-between gap-4 mb-6">
<div>
<h1 className="text-2xl font-bold">{team.name}</h1>
<div className="flex items-center gap-3 mt-1">
<span className={`text-xs px-2 py-1 rounded-full font-medium ${
team.status === "ACTIVE" ? "bg-green-100 text-green-700" :
"bg-gray-100 text-gray-600"
}`}>
{team.status === "ACTIVE" ? "✓ فعال - در رقابت" : "در حال تکمیل"}
</span>
<span className="text-sm text-gray-500">ترکیب: {formation}</span>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-blue-700">{team.totalPoints}</div>
<div className="text-xs text-gray-500">امتیاز</div>
</div>
<div className="text-center">
<div className={`text-2xl font-bold ${remaining < 0 ? "text-red-600" : "text-green-700"}`}>
{remaining.toFixed(1)}M
</div>
<div className="text-xs text-gray-500">بودجه</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-gray-700">{starters.length}/11</div>
<div className="text-xs text-gray-500">بازیکن</div>
</div>
</div>
</div>
{msg && (
<div className={`mb-4 px-4 py-3 rounded-xl text-sm ${msg.type === "error" ? "bg-red-50 text-red-600" : "bg-green-50 text-green-700"}`}>
{msg.text}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
{/* زمین - ۳ ستون */}
<div className="lg:col-span-3">
{/* انتخاب ترکیب */}
{team.status === "DRAFT" && (
<div className="flex gap-2 mb-3 flex-wrap">
{Object.keys(FORMATIONS).map((f) => (
<button key={f} onClick={() => setFormation(f)}
className={`px-3 py-1 rounded-lg text-xs font-bold border transition ${formation === f ? "bg-green-700 text-white border-green-700" : "bg-white border-gray-200 hover:border-green-400"}`}>
{f}
</button>
))}
</div>
)}
{/* زمین فوتبال */}
<div className="relative rounded-2xl overflow-hidden shadow-xl"
style={{ background: "linear-gradient(180deg,#1a5c35 0%,#2d8653 20%,#3a9e63 40%,#2d8653 60%,#1a5c35 100%)", minHeight: 500 }}>
{/* خطوط */}
<svg className="absolute inset-0 w-full h-full opacity-20" viewBox="0 0 400 500" preserveAspectRatio="none">
<line x1="200" y1="0" x2="200" y2="500" stroke="white" strokeWidth="1" />
<circle cx="200" cy="250" r="50" stroke="white" strokeWidth="1" fill="none" />
<rect x="120" y="0" width="160" height="60" stroke="white" strokeWidth="1" fill="none" />
<rect x="120" y="440" width="160" height="60" stroke="white" strokeWidth="1" fill="none" />
<rect x="160" y="0" width="80" height="25" stroke="white" strokeWidth="1" fill="none" />
<rect x="160" y="475" width="80" height="25" stroke="white" strokeWidth="1" fill="none" />
</svg>
<div className="relative z-10 p-4 flex flex-col gap-3 h-full">
{/* مهاجمان */}
<PitchRow players={fwdSlots} slots={fmt.fwd} position="FWD"
onRemove={removePlayer} onDragStart={onDragStart} onDrop={onDrop}
onCaptain={setCaptain} draggedId={draggedId} />
{/* هافبک‌ها */}
<PitchRow players={midSlots} slots={fmt.mid} position="MID"
onRemove={removePlayer} onDragStart={onDragStart} onDrop={onDrop}
onCaptain={setCaptain} draggedId={draggedId} />
{/* مدافعان */}
<PitchRow players={defSlots} slots={fmt.def} position="DEF"
onRemove={removePlayer} onDragStart={onDragStart} onDrop={onDrop}
onCaptain={setCaptain} draggedId={draggedId} />
{/* دروازه‌بان */}
<PitchRow players={gkSlots} slots={1} position="GK"
onRemove={removePlayer} onDragStart={onDragStart} onDrop={onDrop}
onCaptain={setCaptain} draggedId={draggedId} />
</div>
</div>
{/* ذخیره‌ها */}
<div className="mt-3 bg-gray-800 rounded-2xl p-4">
<p className="text-gray-400 text-xs mb-3 font-medium">ذخیرهها (حداکثر ۴ نفر)</p>
<div className="flex gap-3 justify-center flex-wrap">
{bench.map((tp) => (
<PitchCard key={tp.playerId} tp={tp} onRemove={removePlayer}
onDragStart={onDragStart} onDrop={onDrop} onCaptain={setCaptain}
draggedId={draggedId} small />
))}
{Array.from({ length: Math.max(0, 4 - bench.length) }).map((_, i) => (
<EmptySlot key={i} label="ذخیره" />
))}
</div>
</div>
{/* دکمه ارسال */}
{canSubmit && (
<button onClick={submitTeam} disabled={submitLoading}
className="w-full mt-4 bg-green-700 text-white py-3 rounded-xl font-bold text-lg hover:bg-green-800 transition disabled:opacity-50">
{submitLoading ? "در حال ثبت..." : "تیم کامله! وارد رقابت شو ✓"}
</button>
)}
{!isComplete && team.status === "DRAFT" && (
<p className="text-center text-sm text-gray-400 mt-3">
برای ورود به رقابت باید ۱۱ بازیکن اصلی + ۴ ذخیره (هر پست ۱ ذخیره) داشته باشی
</p>
)}
</div>
{/* لیست بازیکنان - ۲ ستون */}
<div className="lg:col-span-2">
<h2 className="text-lg font-bold mb-3">انتخاب بازیکن</h2>
<div className="flex gap-2 mb-3 flex-wrap">
{["", "GK", "DEF", "MID", "FWD"].map((pos) => (
<button key={pos} onClick={() => setPosFilter(pos)}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition ${posFilter === pos ? "bg-green-700 text-white" : "bg-white shadow text-gray-700 hover:bg-gray-50"}`}>
{pos === "" ? "همه" : pos}
</button>
))}
</div>
<input type="text" placeholder="🔍 جستجو..." value={filter}
onChange={(e) => setFilter(e.target.value)}
className="w-full border rounded-xl px-4 py-2 text-sm mb-3 focus:outline-none focus:ring-2 focus:ring-green-400" />
<div className="bg-white rounded-2xl shadow overflow-hidden" style={{ maxHeight: 520, overflowY: "auto" }}>
<table className="w-full text-sm">
<thead className="bg-green-800 text-white sticky top-0">
<tr>
<th className="text-right px-3 py-3">بازیکن</th>
<th className="px-2 py-3">قیمت</th>
<th className="px-2 py-3">pts</th>
<th className="px-2 py-3"></th>
</tr>
</thead>
<tbody>
{filtered.map((p) => (
<tr key={p.id} className="border-t hover:bg-green-50 transition">
<td className="px-3 py-2">
<div className="font-medium text-sm">{p.name}</div>
<div className="flex items-center gap-1 mt-0.5">
<span className="text-xs text-gray-400">{p.country.flagUrl} {p.country.name}</span>
<PositionBadge position={p.position} />
</div>
</td>
<td className="px-2 py-2 text-center text-green-700 font-bold text-xs">{p.price}M</td>
<td className="px-2 py-2 text-center text-blue-700 font-bold text-xs">{p.totalPoints}</td>
<td className="px-2 py-2">
<button onClick={() => addPlayer(p.id)}
disabled={loading || p.price > remaining + 0.01}
className="bg-green-600 text-white w-7 h-7 rounded-lg text-lg font-bold hover:bg-green-700 disabled:opacity-30 transition flex items-center justify-center">
+
</button>
</td>
</tr>
))}
{filtered.length === 0 && (
<tr><td colSpan={4} className="text-center text-gray-400 py-8">بازیکنی پیدا نشد</td></tr>
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}
function PitchRow({ players, slots, position, onRemove, onDragStart, onDrop, onCaptain, draggedId, }: {
players: TeamPlayer[]; slots: number; position: string;
onRemove: (id: string) => void; onDragStart: (id: string) => void;
onDrop: (id: string) => void; onCaptain: (id: string, t: "captain" | "vice") => void;
draggedId: string | null;
}) {
return (
<div className="flex justify-center gap-2 flex-wrap py-1">
{Array.from({ length: slots }).map((_, i) => {
const tp = players[i];
return tp ? (
<PitchCard key={tp.playerId} tp={tp} onRemove={onRemove}
onDragStart={onDragStart} onDrop={onDrop} onCaptain={onCaptain} draggedId={draggedId} />
) : (
<EmptySlot key={i} label={position} />
);
})}
</div>
);
}
function PitchCard({ tp, onRemove, onDragStart, onDrop, onCaptain, draggedId, small }: {
tp: TeamPlayer; onRemove: (id: string) => void; onDragStart: (id: string) => void;
onDrop: (id: string) => void; onCaptain: (id: string, t: "captain" | "vice") => void;
draggedId: string | null; small?: boolean;
}) {
const [showMenu, setShowMenu] = useState(false);
const isDragging = draggedId === tp.playerId;
const isEliminated = (tp.player as any).country?.isEliminated;
const color = isEliminated
? "bg-gray-500 text-gray-300 border-gray-600"
: POS_COLORS[tp.player.position] ?? "bg-gray-400 text-white border-gray-500";
const shortName = tp.player.name.split(" ").slice(-1)[0];
return (
<div className={`relative flex flex-col items-center gap-1 cursor-grab select-none transition-opacity ${isDragging ? "opacity-40" : ""} ${small ? "w-14" : "w-16"} group`}
draggable
onDragStart={() => onDragStart(tp.playerId)}
onDragOver={(e) => e.preventDefault()}
onDrop={() => onDrop(tp.playerId)}
onClick={() => setShowMenu((v) => !v)}>
<div className={`relative ${small ? "w-11 h-11 text-lg" : "w-14 h-14 text-xl"} rounded-full border-2 flex items-center justify-center font-bold shadow-lg ${color} ${isEliminated ? "grayscale opacity-60" : ""}`}>
{tp.player.position === "GK" ? "🧤" : tp.player.position === "DEF" ? "🛡️" : tp.player.position === "MID" ? "⚙️" : "⚡"}
{isEliminated && <div className="absolute -top-1 -right-1 w-4 h-4 bg-red-500 rounded-full flex items-center justify-center text-white text-[8px] font-bold"></div>}
</div>
<div className={`text-center font-medium leading-tight truncate w-full ${small ? "text-[9px]" : "text-[10px]"} ${isEliminated ? "text-gray-400" : "text-white"}`}>
{shortName}
</div>
<div className="flex items-center gap-1">
{tp.isCaptain && <span className="text-yellow-300 text-xs font-bold">©</span>}
{tp.isViceCaptain && <span className="text-gray-300 text-xs font-bold">VC</span>}
<span className={`text-[9px] ${isEliminated ? "text-gray-500" : "text-white/60"}`}>{tp.player.totalPoints}pts</span>
</div>
{isEliminated && (
<div className="absolute -top-8 left-1/2 -translate-x-1/2 bg-gray-900 text-white text-[9px] px-2 py-1 rounded whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none z-50 transition-opacity">
تیم ملی حذف شده
</div>
)}
{showMenu && (
<div className="absolute top-full mt-1 bg-white rounded-xl shadow-xl z-50 text-xs w-36 overflow-hidden border" onClick={(e) => e.stopPropagation()}>
{isEliminated && <div className="px-3 py-2 bg-red-50 text-red-600 text-[10px] border-b"> تیم ملی حذف شده</div>}
<button onClick={() => { onCaptain(tp.playerId, "captain"); setShowMenu(false); }} className="w-full text-right px-3 py-2 hover:bg-gray-50 border-b">کاپیتان ©</button>
<button onClick={() => { onCaptain(tp.playerId, "vice"); setShowMenu(false); }} className="w-full text-right px-3 py-2 hover:bg-gray-50 border-b">نایب کاپیتان VC</button>
<button onClick={() => { onRemove(tp.playerId); setShowMenu(false); }} className="w-full text-right px-3 py-2 hover:bg-red-50 text-red-600">حذف از تیم</button>
</div>
)}
</div>
);
}
function EmptySlot({ label }: { label: string }) {
return (
<div className="w-14 h-14 rounded-full border-2 border-dashed border-white/30 flex items-center justify-center">
<span className="text-white/30 text-[9px]">{label}</span>
</div>
);
}

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

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