io
This commit is contained in:
@@ -2,47 +2,76 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import PersianDateField from "@/components/PersianDateField";
|
||||
|
||||
export default function GameweekForm() {
|
||||
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();
|
||||
|
||||
if (!form.deadline) {
|
||||
setError("ددلاین را انتخاب کنید.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError("");
|
||||
const res = await fetch("/api/gameweeks", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...form, number: parseInt(form.number) }),
|
||||
body: JSON.stringify({ ...form, number: parseInt(form.number, 10) }),
|
||||
});
|
||||
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 />
|
||||
<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 />
|
||||
<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">
|
||||
<PersianDateField
|
||||
label="ددلاین انتخاب تیم"
|
||||
value={form.deadline}
|
||||
onChange={(value) => setForm({ ...form, deadline: value })}
|
||||
mode="datetime"
|
||||
required
|
||||
/>
|
||||
<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>
|
||||
|
||||
@@ -7,6 +7,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
||||
const links = [
|
||||
{ href: "/admin", label: "داشبورد", icon: "📊" },
|
||||
{ href: "/admin/rounds", label: "دورهای بازی", icon: "🏆" },
|
||||
{ href: "/admin/quiz", label: "کوییز روزانه", icon: "📋" },
|
||||
{ href: "/admin/players", label: "بازیکنان", icon: "⚽" },
|
||||
{ href: "/admin/matches", label: "بازیها", icon: "🏟️" },
|
||||
{ href: "/admin/scoring", label: "قوانین امتیازدهی", icon: "⚙️" },
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import PersianDateField from "@/components/PersianDateField";
|
||||
|
||||
type Country = { id: string; name: string };
|
||||
type Round = { id: string; name: string; number: number };
|
||||
@@ -23,7 +24,7 @@ export default function MatchForm({
|
||||
awayTeamId: initial?.awayTeamId ?? "",
|
||||
stage: initial?.stage ?? "GROUP",
|
||||
status: initial?.status ?? "SCHEDULED",
|
||||
matchDate: initial?.matchDate ? new Date(initial.matchDate).toISOString().slice(0, 16) : "",
|
||||
matchDate: initial?.matchDate ?? "",
|
||||
homeScore: initial?.homeScore ?? "",
|
||||
awayScore: initial?.awayScore ?? "",
|
||||
roundId: initial?.roundId ?? "",
|
||||
@@ -33,11 +34,17 @@ export default function MatchForm({
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!form.matchDate) {
|
||||
setError("تاریخ و ساعت بازی را انتخاب کنید.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const payload = {
|
||||
...form,
|
||||
homeScore: form.homeScore !== "" ? parseInt(String(form.homeScore)) : null,
|
||||
awayScore: form.awayScore !== "" ? parseInt(String(form.awayScore)) : null,
|
||||
homeScore: form.homeScore !== "" ? parseInt(String(form.homeScore), 10) : null,
|
||||
awayScore: form.awayScore !== "" ? parseInt(String(form.awayScore), 10) : null,
|
||||
roundId: form.roundId || null,
|
||||
};
|
||||
const res = await fetch(matchId ? `/api/matches/${matchId}` : "/api/matches", {
|
||||
@@ -67,70 +74,115 @@ export default function MatchForm({
|
||||
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 className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<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>
|
||||
<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>)}
|
||||
{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>
|
||||
<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>)}
|
||||
{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 className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">گل میزبان</label>
|
||||
<input type="number" min="0" value={form.homeScore}
|
||||
<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" />
|
||||
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}
|
||||
<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" />
|
||||
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
|
||||
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">
|
||||
<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>
|
||||
<PersianDateField
|
||||
label="تاریخ و ساعت"
|
||||
value={form.matchDate}
|
||||
onChange={(value) => setForm({ ...form, matchDate: value })}
|
||||
mode="datetime"
|
||||
required
|
||||
/>
|
||||
<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">
|
||||
<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>)}
|
||||
{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">
|
||||
<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>
|
||||
|
||||
56
app/(admin)/admin/players/CardTierSelect.tsx
Normal file
56
app/(admin)/admin/players/CardTierSelect.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
type CardTier = "GOLD" | "SILVER" | "BRONZE";
|
||||
|
||||
const labels: Record<CardTier, string> = {
|
||||
GOLD: "طلایی",
|
||||
SILVER: "نقره ای",
|
||||
BRONZE: "برنزی",
|
||||
};
|
||||
|
||||
export default function CardTierSelect({
|
||||
playerId,
|
||||
initial,
|
||||
}: {
|
||||
playerId: string;
|
||||
initial: CardTier;
|
||||
}) {
|
||||
const [value, setValue] = useState<CardTier>(initial);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
async function handleChange(next: CardTier) {
|
||||
setValue(next);
|
||||
setLoading(true);
|
||||
|
||||
const res = await fetch(`/api/admin/players/${playerId}/card-tier`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ cardTier: next }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
setValue(initial);
|
||||
} else {
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => handleChange(e.target.value as CardTier)}
|
||||
disabled={loading}
|
||||
className="rounded-lg border border-slate-200 bg-white px-3 py-2 text-xs focus:outline-none focus:ring-2 focus:ring-emerald-500 disabled:opacity-50"
|
||||
>
|
||||
<option value="GOLD">{labels.GOLD}</option>
|
||||
<option value="SILVER">{labels.SILVER}</option>
|
||||
<option value="BRONZE">{labels.BRONZE}</option>
|
||||
</select>
|
||||
);
|
||||
}
|
||||
38
app/(admin)/admin/players/GoldenToggle.tsx
Normal file
38
app/(admin)/admin/players/GoldenToggle.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function GoldenToggle({ playerId, initial }: { playerId: string; initial: boolean }) {
|
||||
const [enabled, setEnabled] = useState(initial);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
async function toggle() {
|
||||
setLoading(true);
|
||||
const res = await fetch(`/api/admin/players/${playerId}/golden-toggle`, { method: "PATCH" });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setEnabled(data.isGoldenCardEligible);
|
||||
router.refresh();
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggle}
|
||||
disabled={loading}
|
||||
title="Golden Card Eligible"
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
||||
enabled ? "bg-yellow-400" : "bg-gray-300"
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${
|
||||
enabled ? "-translate-x-1" : "translate-x-1"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
|
||||
type Country = { id: string; name: string };
|
||||
type CardTier = "GOLD" | "SILVER" | "BRONZE";
|
||||
|
||||
export default function PlayerForm({
|
||||
countries,
|
||||
@@ -12,7 +13,7 @@ export default function PlayerForm({
|
||||
playerId,
|
||||
}: {
|
||||
countries: Country[];
|
||||
initial?: { name: string; position: string; countryId: string; price: number; image?: string | null };
|
||||
initial?: { name: string; position: string; countryId: string; price: number; image?: string | null; cardTier: CardTier };
|
||||
playerId?: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
@@ -22,6 +23,7 @@ export default function PlayerForm({
|
||||
countryId: initial?.countryId ?? "",
|
||||
price: initial?.price ?? 5.0,
|
||||
image: initial?.image ?? "",
|
||||
cardTier: initial?.cardTier ?? "BRONZE",
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
@@ -154,6 +156,18 @@ export default function PlayerForm({
|
||||
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.cardTier}
|
||||
onChange={(e) => setForm({ ...form, cardTier: e.target.value as CardTier })}
|
||||
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
>
|
||||
<option value="GOLD">طلایی</option>
|
||||
<option value="SILVER">نقره ای</option>
|
||||
<option value="BRONZE">برنزی</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
|
||||
@@ -27,6 +27,7 @@ export default async function EditPlayerPage({ params }: { params: Promise<{ id:
|
||||
countryId: player.countryId,
|
||||
price: player.price,
|
||||
image: player.image,
|
||||
cardTier: player.cardTier,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { db } from "@/lib/db";
|
||||
import Link from "next/link";
|
||||
import PositionBadge from "@/components/PositionBadge";
|
||||
import CardTierSelect from "./CardTierSelect";
|
||||
import { CARD_TIER_LABELS, getCardTierBadgeClass } from "@/lib/cardTier";
|
||||
|
||||
export default async function AdminPlayersPage() {
|
||||
const players = await db.player.findMany({
|
||||
@@ -25,6 +27,8 @@ export default async function AdminPlayersPage() {
|
||||
<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>
|
||||
@@ -36,6 +40,14 @@ export default async function AdminPlayersPage() {
|
||||
<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">
|
||||
<span className={`rounded-full px-2 py-1 text-xs font-bold ${getCardTierBadgeClass(p.cardTier)}`}>
|
||||
{CARD_TIER_LABELS[p.cardTier]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<CardTierSelect playerId={p.id} initial={p.cardTier} />
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<Link href={`/admin/players/${p.id}/edit`} className="text-blue-600 hover:underline text-xs">
|
||||
ویرایش
|
||||
|
||||
123
app/(admin)/admin/quiz/LotteryButton.tsx
Normal file
123
app/(admin)/admin/quiz/LotteryButton.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
type LotteryButtonProps = {
|
||||
quizId: string;
|
||||
goldWinnersCount: number;
|
||||
silverWinnersCount: number;
|
||||
bronzeWinnersCount: number;
|
||||
totalParticipants: number;
|
||||
perfectParticipants: number;
|
||||
};
|
||||
|
||||
export default function LotteryButton({
|
||||
quizId,
|
||||
goldWinnersCount,
|
||||
silverWinnersCount,
|
||||
bronzeWinnersCount,
|
||||
totalParticipants,
|
||||
perfectParticipants,
|
||||
}: LotteryButtonProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const correctPercentage = totalParticipants > 0
|
||||
? Math.round((perfectParticipants / totalParticipants) * 100)
|
||||
: 0;
|
||||
const incorrectParticipants = Math.max(totalParticipants - perfectParticipants, 0);
|
||||
const totalWinnersCount = goldWinnersCount + silverWinnersCount + bronzeWinnersCount;
|
||||
|
||||
async function run() {
|
||||
setLoading(true);
|
||||
|
||||
const res = await fetch(`/api/admin/quiz/${quizId}/lottery`, { method: "POST" });
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
setResult(`${data.winners.length} برنده انتخاب شد`);
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
} else {
|
||||
setResult(data.error ?? "خطا");
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={loading}
|
||||
className="bg-yellow-500 text-black text-xs px-3 py-1 rounded-lg hover:bg-yellow-400 transition disabled:opacity-50"
|
||||
>
|
||||
{loading ? "..." : "قرعهکشی"}
|
||||
</button>
|
||||
{result && <span className="text-xs text-gray-500">{result}</span>}
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl">
|
||||
<h3 className="text-lg font-bold text-gray-900">تایید قرعهکشی</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-gray-600">
|
||||
با اجرای قرعهکشی، پاسخ دادن به این کوییز بسته میشود و دیگر امکان ویرایش سوالها وجود ندارد.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="rounded-xl bg-slate-50 p-3">
|
||||
<div className="text-slate-500">کل شرکتکنندگان</div>
|
||||
<div className="mt-1 text-xl font-bold text-slate-900">{totalParticipants}</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-emerald-50 p-3">
|
||||
<div className="text-emerald-700">واجد دریافت کارت</div>
|
||||
<div className="mt-1 text-xl font-bold text-emerald-800">{perfectParticipants} نفر</div>
|
||||
<div className="text-xs text-emerald-700">{correctPercentage}%</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-rose-50 p-3">
|
||||
<div className="text-rose-700">سایر شرکتکنندگان</div>
|
||||
<div className="mt-1 text-xl font-bold text-rose-800">{incorrectParticipants} نفر</div>
|
||||
<div className="text-xs text-rose-700">{Math.max(100 - correctPercentage, 0)}%</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-amber-50 p-3">
|
||||
<div className="text-amber-700">تعداد برنده</div>
|
||||
<div className="mt-1 text-xl font-bold text-amber-800">{totalWinnersCount} نفر</div>
|
||||
<div className="text-xs text-amber-700">
|
||||
G:{goldWinnersCount} | S:{silverWinnersCount} | B:{bronzeWinnersCount}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={loading}
|
||||
className="rounded-xl border border-slate-200 px-4 py-2 text-sm text-slate-700 transition hover:bg-slate-50 disabled:opacity-50"
|
||||
>
|
||||
انصراف
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={run}
|
||||
disabled={loading}
|
||||
className="rounded-xl bg-yellow-500 px-4 py-2 text-sm font-bold text-black transition hover:bg-yellow-400 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "در حال اجرا..." : "اعمال قرعهکشی"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
86
app/(admin)/admin/quiz/QuizDeleteButton.tsx
Normal file
86
app/(admin)/admin/quiz/QuizDeleteButton.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
type QuizDeleteButtonProps = {
|
||||
quizId: string;
|
||||
submissionsCount: number;
|
||||
};
|
||||
|
||||
export default function QuizDeleteButton({ quizId, submissionsCount }: QuizDeleteButtonProps) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleDelete() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const res = await fetch(`/api/admin/quiz/${quizId}`, { method: "DELETE" });
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
setError(data.error ?? "خطا در حذف کوئیز");
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="text-red-600 hover:underline text-xs"
|
||||
>
|
||||
حذف
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl">
|
||||
<h3 className="text-lg font-bold text-gray-900">حذف کوئیز</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-gray-600">
|
||||
با حذف کوئیز، تمام سوالها و تمام پاسخهای ثبتشدهی این کوئیز هم پاک میشوند.
|
||||
</p>
|
||||
|
||||
{submissionsCount > 0 && (
|
||||
<div className="mt-4 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
{submissionsCount} کاربر به این کوئیز پاسخ دادهاند. آیا از حذف آن مطمئن هستید؟
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="mt-4 text-sm text-red-600">{error}</p>}
|
||||
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (loading) return;
|
||||
setOpen(false);
|
||||
setError(null);
|
||||
}}
|
||||
className="rounded-xl border border-slate-200 px-4 py-2 text-sm text-slate-700 transition hover:bg-slate-50"
|
||||
>
|
||||
انصراف
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={loading}
|
||||
className="rounded-xl bg-red-600 px-4 py-2 text-sm font-bold text-white transition hover:bg-red-500 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "در حال حذف..." : "حذف نهایی"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
424
app/(admin)/admin/quiz/QuizForm.tsx
Normal file
424
app/(admin)/admin/quiz/QuizForm.tsx
Normal file
@@ -0,0 +1,424 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import PersianDateField from "@/components/PersianDateField";
|
||||
import PersianTimeField from "@/components/PersianTimeField";
|
||||
import {
|
||||
dateValueToJalali,
|
||||
formatPersianDateTime,
|
||||
getDateFromJalaliDateTime,
|
||||
getGregorianDateInputValue,
|
||||
getTehranTimeInputValue,
|
||||
gregorianDateAndTimeToUtcIso,
|
||||
} from "@/lib/persianDate";
|
||||
|
||||
type Question = {
|
||||
questionText: string;
|
||||
options: string[];
|
||||
correctAnswer: number;
|
||||
};
|
||||
|
||||
type QuizFormProps = {
|
||||
quizId?: string;
|
||||
initial?: {
|
||||
date: Date;
|
||||
windowStart: Date;
|
||||
windowEnd: Date;
|
||||
goldWinnersCount: number;
|
||||
silverWinnersCount: number;
|
||||
bronzeWinnersCount: number;
|
||||
goldMinCorrect: number | null;
|
||||
silverMinCorrect: number | null;
|
||||
bronzeMinCorrect: number | null;
|
||||
questions: Question[];
|
||||
};
|
||||
submissionsCount?: number;
|
||||
};
|
||||
|
||||
const emptyQuestion = (): Question => ({
|
||||
questionText: "",
|
||||
options: ["", "", "", ""],
|
||||
correctAnswer: 0,
|
||||
});
|
||||
|
||||
function getQuizDateTimeSummary(date: string, time: string) {
|
||||
if (!date || !time) {
|
||||
return "بعد از انتخاب تاریخ و ساعت، زمان نهایی به صورت شمسی اینجا نمایش داده میشود.";
|
||||
}
|
||||
|
||||
const jalali = dateValueToJalali(date, "date");
|
||||
if (!jalali) {
|
||||
return "تاریخ انتخابی معتبر نیست.";
|
||||
}
|
||||
|
||||
return formatPersianDateTime(getDateFromJalaliDateTime(jalali.year, jalali.month, jalali.day, time));
|
||||
}
|
||||
|
||||
export default function QuizForm({ quizId, initial, submissionsCount = 0 }: QuizFormProps) {
|
||||
const router = useRouter();
|
||||
const [form, setForm] = useState({
|
||||
date: initial ? getGregorianDateInputValue(new Date(initial.date)) : "",
|
||||
windowStart: initial ? getTehranTimeInputValue(new Date(initial.windowStart)) : "12:00",
|
||||
windowEnd: initial ? getTehranTimeInputValue(new Date(initial.windowEnd)) : "13:00",
|
||||
goldWinnersCount: initial?.goldWinnersCount ?? 1,
|
||||
silverWinnersCount: initial?.silverWinnersCount ?? 0,
|
||||
bronzeWinnersCount: initial?.bronzeWinnersCount ?? 0,
|
||||
goldMinCorrect: initial?.goldMinCorrect?.toString() ?? "",
|
||||
silverMinCorrect: initial?.silverMinCorrect?.toString() ?? "",
|
||||
bronzeMinCorrect: initial?.bronzeMinCorrect?.toString() ?? "",
|
||||
});
|
||||
const [questions, setQuestions] = useState<Question[]>(
|
||||
initial?.questions?.length
|
||||
? initial.questions.map((question) => ({
|
||||
questionText: question.questionText,
|
||||
options: [...question.options],
|
||||
correctAnswer: question.correctAnswer,
|
||||
}))
|
||||
: [emptyQuestion()]
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
function updateQuestion(index: number, field: keyof Question, value: string | number | string[]) {
|
||||
setQuestions((prev) => {
|
||||
const updated = [...prev];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
function updateOption(qIndex: number, oIndex: number, value: string) {
|
||||
setQuestions((prev) => {
|
||||
const updated = [...prev];
|
||||
const opts = [...updated[qIndex].options];
|
||||
opts[oIndex] = value;
|
||||
updated[qIndex] = { ...updated[qIndex], options: opts };
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
function addQuestion() {
|
||||
setQuestions((prev) => [...prev, emptyQuestion()]);
|
||||
}
|
||||
|
||||
function removeQuestion(index: number) {
|
||||
setQuestions((prev) => prev.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
async function getErrorMessage(res: Response) {
|
||||
const text = await res.text();
|
||||
if (!text) {
|
||||
return "خطا در ذخیره";
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(text) as { error?: string };
|
||||
return data.error ?? "خطا در ذخیره";
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!form.date || !form.windowStart || !form.windowEnd) {
|
||||
setError("تاریخ و بازه زمانی را کامل وارد کنید.");
|
||||
return;
|
||||
}
|
||||
|
||||
const startUtc = gregorianDateAndTimeToUtcIso(form.date, form.windowStart);
|
||||
const endUtc = gregorianDateAndTimeToUtcIso(form.date, form.windowEnd);
|
||||
|
||||
if (!startUtc || !endUtc) {
|
||||
setError("تبدیل تاریخ یا ساعت نامعتبر است.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (new Date(startUtc) >= new Date(endUtc)) {
|
||||
setError("ساعت پایان باید بعد از ساعت شروع باشد.");
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizeThreshold = (value: string) => {
|
||||
if (!value.trim()) return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : NaN;
|
||||
};
|
||||
|
||||
const normalizeWinnerCount = (value: number) =>
|
||||
Number.isInteger(value) && value >= 0 ? value : NaN;
|
||||
|
||||
const goldMinCorrect = normalizeThreshold(form.goldMinCorrect);
|
||||
const silverMinCorrect = normalizeThreshold(form.silverMinCorrect);
|
||||
const bronzeMinCorrect = normalizeThreshold(form.bronzeMinCorrect);
|
||||
const goldWinnersCount = normalizeWinnerCount(form.goldWinnersCount);
|
||||
const silverWinnersCount = normalizeWinnerCount(form.silverWinnersCount);
|
||||
const bronzeWinnersCount = normalizeWinnerCount(form.bronzeWinnersCount);
|
||||
const questionCount = questions.length;
|
||||
|
||||
if ([goldMinCorrect, silverMinCorrect, bronzeMinCorrect].some((value) => Number.isNaN(value))) {
|
||||
setError("حداقل پاسخ صحیح کارتها باید عدد صحیح بزرگتر از صفر باشد.");
|
||||
return;
|
||||
}
|
||||
|
||||
if ([goldWinnersCount, silverWinnersCount, bronzeWinnersCount].some((value) => Number.isNaN(value))) {
|
||||
setError("تعداد برنده هر کارت باید عدد صحیح بزرگتر یا مساوی صفر باشد.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (goldWinnersCount + silverWinnersCount + bronzeWinnersCount <= 0) {
|
||||
setError("حداقل باید برای یک کارت، حداقل یک برنده تعریف کنید.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (goldWinnersCount > 0 && goldMinCorrect == null) {
|
||||
setError("برای کارت طلایی باید حداقل جواب صحیح را مشخص کنید.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (silverWinnersCount > 0 && silverMinCorrect == null) {
|
||||
setError("برای کارت نقرهای باید حداقل جواب صحیح را مشخص کنید.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (bronzeWinnersCount > 0 && bronzeMinCorrect == null) {
|
||||
setError("برای کارت برنزی باید حداقل جواب صحیح را مشخص کنید.");
|
||||
return;
|
||||
}
|
||||
|
||||
if ([goldMinCorrect, silverMinCorrect, bronzeMinCorrect].some((value) => value != null && value > questionCount)) {
|
||||
setError("حداقل پاسخ صحیح هر کارت نمیتواند بیشتر از تعداد سوالات باشد.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (goldMinCorrect != null && silverMinCorrect != null && silverMinCorrect >= goldMinCorrect) {
|
||||
setError("آستانه کارت نقرهای باید کمتر از طلایی باشد.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (silverMinCorrect != null && bronzeMinCorrect != null && bronzeMinCorrect >= silverMinCorrect) {
|
||||
setError("آستانه کارت برنزی باید کمتر از نقرهای باشد.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
const payload = {
|
||||
date: form.date,
|
||||
windowStart: startUtc,
|
||||
windowEnd: endUtc,
|
||||
goldWinnersCount,
|
||||
silverWinnersCount,
|
||||
bronzeWinnersCount,
|
||||
goldMinCorrect,
|
||||
silverMinCorrect,
|
||||
bronzeMinCorrect,
|
||||
questions,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch(quizId ? `/api/admin/quiz/${quizId}` : "/api/admin/quiz", {
|
||||
method: quizId ? "PUT" : "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
router.push("/admin/quiz");
|
||||
router.refresh();
|
||||
} else {
|
||||
setError(await getErrorMessage(res));
|
||||
}
|
||||
} catch {
|
||||
setError("خطا در ارتباط با سرور");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="bg-white rounded-2xl shadow p-6 flex flex-col gap-6">
|
||||
{submissionsCount > 0 && (
|
||||
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
{submissionsCount} کاربر قبلاً به این کوییز پاسخ دادهاند. اگر سوالها را ویرایش کنید، امتیاز پاسخهای قبلی بر اساس نسخه جدید دوباره محاسبه میشود.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-red-500 text-sm">{error}</p>}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<PersianDateField
|
||||
label="تاریخ"
|
||||
value={form.date}
|
||||
onChange={(value) => setForm({ ...form, date: value })}
|
||||
required
|
||||
/>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">تعداد برنده کارت طلایی</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.goldWinnersCount}
|
||||
onChange={(e) => setForm({ ...form, goldWinnersCount: Number(e.target.value) })}
|
||||
className="w-full rounded-2xl border border-slate-200 px-4 py-3 focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">تعداد برنده کارت نقرهای</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.silverWinnersCount}
|
||||
onChange={(e) => setForm({ ...form, silverWinnersCount: Number(e.target.value) })}
|
||||
className="w-full rounded-2xl border border-slate-200 px-4 py-3 focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">تعداد برنده کارت برنزی</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.bronzeWinnersCount}
|
||||
onChange={(e) => setForm({ ...form, bronzeWinnersCount: Number(e.target.value) })}
|
||||
className="w-full rounded-2xl border border-slate-200 px-4 py-3 focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">حداقل جواب صحیح کارت طلایی</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={questions.length}
|
||||
value={form.goldMinCorrect}
|
||||
onChange={(e) => setForm({ ...form, goldMinCorrect: e.target.value })}
|
||||
className="w-full rounded-2xl border border-slate-200 px-4 py-3 focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
placeholder={`مثلاً ${questions.length}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">حداقل جواب صحیح کارت نقرهای</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={questions.length}
|
||||
value={form.silverMinCorrect}
|
||||
onChange={(e) => setForm({ ...form, silverMinCorrect: e.target.value })}
|
||||
className="w-full rounded-2xl border border-slate-200 px-4 py-3 focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
placeholder="اختیاری"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">حداقل جواب صحیح کارت برنزی</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={questions.length}
|
||||
value={form.bronzeMinCorrect}
|
||||
onChange={(e) => setForm({ ...form, bronzeMinCorrect: e.target.value })}
|
||||
className="w-full rounded-2xl border border-slate-200 px-4 py-3 focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
placeholder="اختیاری"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<PersianTimeField
|
||||
label="شروع بازه"
|
||||
value={form.windowStart}
|
||||
onChange={(value) => setForm({ ...form, windowStart: value })}
|
||||
required
|
||||
/>
|
||||
<p className="mt-2 text-xs text-slate-500">{getQuizDateTimeSummary(form.date, form.windowStart)}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<PersianTimeField
|
||||
label="پایان بازه"
|
||||
value={form.windowEnd}
|
||||
onChange={(value) => setForm({ ...form, windowEnd: value })}
|
||||
required
|
||||
/>
|
||||
<p className="mt-2 text-xs text-slate-500">{getQuizDateTimeSummary(form.date, form.windowEnd)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="font-bold text-lg">سوالات</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addQuestion}
|
||||
className="text-sm text-green-700 border border-green-700 px-3 py-1 rounded-lg hover:bg-green-50 transition"
|
||||
>
|
||||
+ سوال جدید
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{questions.map((q, qi) => (
|
||||
<div key={qi} className="border rounded-xl p-4 flex flex-col gap-3 bg-gray-50">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-medium text-sm text-gray-600">سوال {qi + 1}</span>
|
||||
{questions.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeQuestion(qi)}
|
||||
className="text-red-500 text-xs hover:underline"
|
||||
>
|
||||
حذف
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="متن سوال"
|
||||
value={q.questionText}
|
||||
onChange={(e) => updateQuestion(qi, "questionText", e.target.value)}
|
||||
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500 bg-white"
|
||||
required
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
|
||||
{q.options.map((opt, oi) => (
|
||||
<div key={oi} className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name={`correct-${qi}`}
|
||||
checked={q.correctAnswer === oi}
|
||||
onChange={() => updateQuestion(qi, "correctAnswer", oi)}
|
||||
className="accent-green-600"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={`گزینه ${oi + 1}`}
|
||||
value={opt}
|
||||
onChange={(e) => updateOption(qi, oi, e.target.value)}
|
||||
className="flex-1 border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500 bg-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">گزینه صحیح را با دایره انتخاب کنید</p>
|
||||
</div>
|
||||
))}
|
||||
</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 ? "در حال ذخیره..." : quizId ? "ذخیره تغییرات کوییز" : "ذخیره کوییز"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
50
app/(admin)/admin/quiz/[id]/edit/page.tsx
Normal file
50
app/(admin)/admin/quiz/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { db } from "@/lib/db";
|
||||
import { requireAdmin } from "@/lib/session";
|
||||
import { notFound } from "next/navigation";
|
||||
import QuizForm from "../../QuizForm";
|
||||
|
||||
export default async function EditQuizPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
await requireAdmin();
|
||||
const { id } = await params;
|
||||
|
||||
const quiz = await db.dailyQuiz.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
questions: {
|
||||
orderBy: { order: "asc" },
|
||||
},
|
||||
_count: {
|
||||
select: { submissions: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!quiz) notFound();
|
||||
if (quiz.isProcessed) notFound();
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h1 className="text-2xl font-bold mb-6">ویرایش کوییز</h1>
|
||||
<QuizForm
|
||||
quizId={quiz.id}
|
||||
submissionsCount={quiz._count.submissions}
|
||||
initial={{
|
||||
date: quiz.date,
|
||||
windowStart: quiz.windowStart,
|
||||
windowEnd: quiz.windowEnd,
|
||||
goldWinnersCount: quiz.goldWinnersCount,
|
||||
silverWinnersCount: quiz.silverWinnersCount,
|
||||
bronzeWinnersCount: quiz.bronzeWinnersCount,
|
||||
goldMinCorrect: quiz.goldMinCorrect,
|
||||
silverMinCorrect: quiz.silverMinCorrect,
|
||||
bronzeMinCorrect: quiz.bronzeMinCorrect,
|
||||
questions: quiz.questions.map((question) => ({
|
||||
questionText: question.questionText,
|
||||
options: question.options,
|
||||
correctAnswer: question.correctAnswer,
|
||||
})),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
125
app/(admin)/admin/quiz/[id]/results/page.tsx
Normal file
125
app/(admin)/admin/quiz/[id]/results/page.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { db } from "@/lib/db";
|
||||
import { requireAdmin } from "@/lib/session";
|
||||
import { notFound } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import { formatPersianDate, formatPersianDateTime } from "@/lib/persianDate";
|
||||
import { CARD_TIER_LABELS, getCardTierBadgeClass, resolveQuizRewardTier } from "@/lib/cardTier";
|
||||
|
||||
export default async function QuizResultsPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
await requireAdmin();
|
||||
const { id } = await params;
|
||||
|
||||
const quiz = await db.dailyQuiz.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
questions: { orderBy: { order: "asc" } },
|
||||
submissions: {
|
||||
include: { user: { select: { id: true, name: true, email: true } } },
|
||||
orderBy: [{ score: "desc" }, { submittedAt: "asc" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!quiz) notFound();
|
||||
|
||||
const awardedCards = await db.goldenCard.findMany({
|
||||
where: { quizId: id },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
player: { include: { country: true } },
|
||||
},
|
||||
orderBy: { acquiredDate: "desc" },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold">نتایج کوییز - {formatPersianDate(new Date(quiz.date))}</h1>
|
||||
<span
|
||||
className={`text-sm px-3 py-1 rounded-full ${
|
||||
quiz.isProcessed ? "bg-green-100 text-green-700" : "bg-yellow-100 text-yellow-700"
|
||||
}`}
|
||||
>
|
||||
{quiz.isProcessed ? "تخصیص کارت انجام شده" : "در انتظار تخصیص کارت"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{awardedCards.length > 0 && (
|
||||
<div className="bg-white rounded-2xl shadow p-6">
|
||||
<h2 className="font-bold text-lg mb-4">کارت های تخصیص داده شده</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{awardedCards.map((card) => (
|
||||
<div key={card.id} className="border rounded-xl p-4 flex items-center gap-3 bg-slate-50">
|
||||
{card.player.image ? (
|
||||
<div className="relative w-12 h-12 rounded-full overflow-hidden border-2 border-slate-200">
|
||||
<Image src={`/uploads/players/${card.player.image}`} alt={card.player.name} fill className="object-cover" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-slate-200 flex items-center justify-center text-xl">*</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-bold text-sm">{card.user.name ?? card.user.email}</p>
|
||||
<p className="text-xs text-gray-500">{card.player.name} - {card.player.country.name}</p>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-bold ${getCardTierBadgeClass(card.cardTier)}`}>
|
||||
{CARD_TIER_LABELS[card.cardTier]}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
card.status === "OPENED" ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{card.status === "OPENED" ? "باز شده" : "مهر شده"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-2xl shadow overflow-hidden">
|
||||
<div className="px-5 py-4 border-b">
|
||||
<h2 className="font-bold">همه شرکت کنندگان ({quiz.submissions.length})</h2>
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{quiz.submissions.map((submission) => {
|
||||
const rewardTier = resolveQuizRewardTier(quiz, submission.correctAnswers);
|
||||
|
||||
return (
|
||||
<tr key={submission.id} className={`border-t ${rewardTier ? "bg-green-50" : ""}`}>
|
||||
<td className="px-5 py-3">{submission.user.name ?? submission.user.email}</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className={`font-bold ${rewardTier ? "text-green-600" : "text-gray-700"}`}>
|
||||
{submission.score}%
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">{submission.correctAnswers} جواب صحیح</span>
|
||||
{rewardTier && (
|
||||
<span className={`inline-flex w-fit rounded-full px-2 py-0.5 text-xs font-bold ${getCardTierBadgeClass(rewardTier)}`}>
|
||||
{CARD_TIER_LABELS[rewardTier]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-gray-500 text-xs">
|
||||
{formatPersianDateTime(new Date(submission.submittedAt))}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
app/(admin)/admin/quiz/new/page.tsx
Normal file
12
app/(admin)/admin/quiz/new/page.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { requireAdmin } from "@/lib/session";
|
||||
import QuizForm from "../QuizForm";
|
||||
|
||||
export default async function NewQuizPage() {
|
||||
await requireAdmin();
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h1 className="text-2xl font-bold mb-6">کوییز جدید</h1>
|
||||
<QuizForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
129
app/(admin)/admin/quiz/page.tsx
Normal file
129
app/(admin)/admin/quiz/page.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { db } from "@/lib/db";
|
||||
import { requireAdmin } from "@/lib/session";
|
||||
import Link from "next/link";
|
||||
import LotteryButton from "./LotteryButton";
|
||||
import QuizDeleteButton from "./QuizDeleteButton";
|
||||
import { formatPersianDate, formatPersianTime } from "@/lib/persianDate";
|
||||
import { resolveQuizRewardTier } from "@/lib/cardTier";
|
||||
|
||||
function getTotalWinnersCount(quiz: {
|
||||
goldWinnersCount: number;
|
||||
silverWinnersCount: number;
|
||||
bronzeWinnersCount: number;
|
||||
}) {
|
||||
return quiz.goldWinnersCount + quiz.silverWinnersCount + quiz.bronzeWinnersCount;
|
||||
}
|
||||
|
||||
export default async function AdminQuizPage() {
|
||||
await requireAdmin();
|
||||
|
||||
const quizzes = await db.dailyQuiz.findMany({
|
||||
orderBy: { date: "desc" },
|
||||
include: {
|
||||
questions: true,
|
||||
submissions: {
|
||||
select: { score: true, correctAnswers: true },
|
||||
},
|
||||
_count: { select: { submissions: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold">کوییز روزانه</h1>
|
||||
<Link
|
||||
href="/admin/quiz/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="px-5 py-4"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{quizzes.map((q) => {
|
||||
const eligibleParticipants =
|
||||
q.goldMinCorrect != null || q.silverMinCorrect != null || q.bronzeMinCorrect != null
|
||||
? q.submissions.filter((submission) => resolveQuizRewardTier(q, submission.correctAnswers) !== null).length
|
||||
: q.submissions.filter((submission) => submission.score === 100).length;
|
||||
|
||||
return (
|
||||
<tr key={q.id} className="border-t hover:bg-gray-50 transition">
|
||||
<td className="px-5 py-3 font-medium">
|
||||
{formatPersianDate(new Date(q.date))}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-gray-600 text-xs">
|
||||
{formatPersianTime(new Date(q.windowStart))}
|
||||
{" - "}
|
||||
{formatPersianTime(new Date(q.windowEnd))}
|
||||
</td>
|
||||
<td className="px-5 py-3">{q.questions.length}</td>
|
||||
<td className="px-5 py-3">{q._count.submissions}</td>
|
||||
<td className="px-5 py-3 text-green-700 font-bold">
|
||||
{getTotalWinnersCount(q)}
|
||||
<div className="text-[11px] font-normal text-gray-500">
|
||||
G:{q.goldWinnersCount} | S:{q.silverWinnersCount} | B:{q.bronzeWinnersCount}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
{q.isProcessed ? (
|
||||
<span className="bg-green-100 text-green-700 text-xs px-2 py-1 rounded-full">انجام شده</span>
|
||||
) : (
|
||||
<span className="bg-yellow-100 text-yellow-700 text-xs px-2 py-1 rounded-full">در انتظار</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3 flex gap-2">
|
||||
<Link href={`/admin/quiz/${q.id}/results`} className="text-blue-600 hover:underline text-xs">
|
||||
نتایج
|
||||
</Link>
|
||||
{!q.isProcessed && (
|
||||
<Link href={`/admin/quiz/${q.id}/edit`} className="text-emerald-700 hover:underline text-xs">
|
||||
ویرایش
|
||||
</Link>
|
||||
)}
|
||||
{!q.isProcessed && (
|
||||
<LotteryButton
|
||||
quizId={q.id}
|
||||
goldWinnersCount={q.goldWinnersCount}
|
||||
silverWinnersCount={q.silverWinnersCount}
|
||||
bronzeWinnersCount={q.bronzeWinnersCount}
|
||||
totalParticipants={q._count.submissions}
|
||||
perfectParticipants={eligibleParticipants}
|
||||
/>
|
||||
)}
|
||||
{!q.isProcessed && (
|
||||
<QuizDeleteButton
|
||||
quizId={q.id}
|
||||
submissionsCount={q._count.submissions}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{quizzes.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-center py-10 text-gray-400">
|
||||
هیچ کوییزی ثبت نشده
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import PersianDateField from "@/components/PersianDateField";
|
||||
|
||||
type Round = {
|
||||
id: string;
|
||||
@@ -15,27 +16,33 @@ export default function RoundForm({ editRound }: { editRound?: Round }) {
|
||||
const [form, setForm] = useState({
|
||||
number: editRound?.number.toString() ?? "",
|
||||
name: editRound?.name ?? "",
|
||||
deadline: editRound ? new Date(editRound.deadline).toISOString().slice(0, 16) : "",
|
||||
deadline: editRound ? String(editRound.deadline) : "",
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!form.deadline) {
|
||||
setError("مهلت انتخاب تیم را مشخص کنید.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
|
||||
const method = editRound ? "PUT" : "POST";
|
||||
const body = editRound
|
||||
? { id: editRound.id, ...form, number: parseInt(form.number) }
|
||||
: { ...form, number: parseInt(form.number) };
|
||||
const body = editRound
|
||||
? { id: editRound.id, ...form, number: parseInt(form.number, 10) }
|
||||
: { ...form, number: parseInt(form.number, 10) };
|
||||
|
||||
const res = await fetch("/api/rounds", {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
|
||||
if (res.ok) {
|
||||
setForm({ number: "", name: "", deadline: "" });
|
||||
router.refresh();
|
||||
@@ -52,28 +59,38 @@ export default function RoundForm({ editRound }: { editRound?: Round }) {
|
||||
{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}
|
||||
<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 />
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">نام دور</label>
|
||||
<input type="text" value={form.name}
|
||||
<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 />
|
||||
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">
|
||||
<PersianDateField
|
||||
label="آخرین مهلت انتخاب تیم"
|
||||
value={form.deadline}
|
||||
onChange={(value) => setForm({ ...form, deadline: value })}
|
||||
mode="datetime"
|
||||
required
|
||||
/>
|
||||
<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 ? "در حال ذخیره..." : editRound ? "ویرایش دور" : "افزودن دور"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
290
app/(user)/golden-cards/GoldenCardsClient.tsx
Normal file
290
app/(user)/golden-cards/GoldenCardsClient.tsx
Normal file
@@ -0,0 +1,290 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
type Player = {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string | null;
|
||||
position: "GK" | "DEF" | "MID" | "FWD";
|
||||
price: number;
|
||||
country: { name: string; flagUrl: string | null };
|
||||
};
|
||||
|
||||
type Card = {
|
||||
id: string;
|
||||
status: "SEALED" | "OPENED";
|
||||
state: "IN_INVENTORY" | "IN_TEAM" | "SOLD";
|
||||
acquiredDate: string;
|
||||
openedAt: string | null;
|
||||
player: Player;
|
||||
};
|
||||
|
||||
const POSITION_LABELS: Record<Player["position"], string> = {
|
||||
GK: "دروازهبان",
|
||||
DEF: "مدافع",
|
||||
MID: "هافبک",
|
||||
FWD: "مهاجم",
|
||||
};
|
||||
|
||||
function saleValue(price: number) {
|
||||
return Math.round(price * 0.7);
|
||||
}
|
||||
|
||||
function SealedCard({ card, onReveal }: { card: Card; onReveal: (id: string) => void }) {
|
||||
const [opening, setOpening] = useState(false);
|
||||
|
||||
async function handleOpen() {
|
||||
setOpening(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||
onReveal(card.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative rounded-2xl border border-yellow-400/30 bg-gradient-to-br from-yellow-900/30 to-amber-900/20 backdrop-blur p-6 flex flex-col items-center gap-4 cursor-pointer hover:border-yellow-400/60 transition-all duration-300 ${opening ? "scale-95 opacity-70" : "hover:scale-105"}`}
|
||||
onClick={!opening ? handleOpen : undefined}
|
||||
>
|
||||
<div className="absolute inset-0 rounded-2xl bg-yellow-400/5 blur-xl" />
|
||||
<div className="relative z-10 flex flex-col items-center gap-3">
|
||||
<div className={`w-20 h-20 rounded-2xl bg-gradient-to-br from-yellow-400 to-amber-600 flex items-center justify-center text-4xl shadow-lg shadow-yellow-500/30 ${opening ? "animate-spin" : "animate-pulse"}`}>
|
||||
🎴
|
||||
</div>
|
||||
<p className="font-bold text-yellow-300">کارت ویژه مهر شده</p>
|
||||
<p className="text-xs text-gray-400">دریافت: {new Date(card.acquiredDate).toLocaleDateString("fa-IR")}</p>
|
||||
<button className="mt-1 px-5 py-2 rounded-xl bg-gradient-to-r from-yellow-500 to-amber-500 text-black font-bold text-sm hover:opacity-90 transition shadow-lg shadow-yellow-500/20">
|
||||
{opening ? "در حال باز شدن..." : "باز کردن کارت"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OpenedCard({
|
||||
card,
|
||||
loading,
|
||||
onAdd,
|
||||
onSell,
|
||||
}: {
|
||||
card: Card;
|
||||
loading: boolean;
|
||||
onAdd?: () => void;
|
||||
onSell?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative rounded-2xl border border-white/10 bg-white/5 backdrop-blur overflow-hidden">
|
||||
<div className="relative z-10 p-5 flex flex-col items-center gap-3">
|
||||
<div className="relative w-20 h-20 rounded-full overflow-hidden bg-gray-900 border-2 border-amber-400/50">
|
||||
{card.player.image ? (
|
||||
<Image
|
||||
src={`/uploads/players/${card.player.image}`}
|
||||
alt={card.player.name}
|
||||
width={80}
|
||||
height={80}
|
||||
className="object-cover w-full h-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-3xl">⚽</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="font-bold text-white">{card.player.name}</p>
|
||||
<p className="text-xs text-gray-400">{card.player.country.name}</p>
|
||||
</div>
|
||||
|
||||
<span className="text-xs font-bold px-3 py-1 rounded-full bg-amber-500 text-white">
|
||||
{POSITION_LABELS[card.player.position]}
|
||||
</span>
|
||||
|
||||
<div className="text-xs text-amber-300">
|
||||
{card.state === "IN_TEAM" ? "در تیم" : `فروش: ${saleValue(card.player.price)}M`}
|
||||
</div>
|
||||
|
||||
<div className="w-full flex gap-2 pt-2">
|
||||
{card.state === "IN_INVENTORY" && onAdd && (
|
||||
<button
|
||||
onClick={onAdd}
|
||||
disabled={loading}
|
||||
className="flex-1 rounded-xl bg-green-700 py-2 text-sm font-bold text-white hover:bg-green-800 disabled:opacity-50 transition"
|
||||
>
|
||||
افزودن به تیم
|
||||
</button>
|
||||
)}
|
||||
{card.state !== "SOLD" && onSell && (
|
||||
<button
|
||||
onClick={onSell}
|
||||
disabled={loading}
|
||||
className="flex-1 rounded-xl bg-amber-500 py-2 text-sm font-bold text-white hover:bg-amber-600 disabled:opacity-50 transition"
|
||||
>
|
||||
فروش
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GoldenCardsClient({ initialCards }: { initialCards: Card[] }) {
|
||||
const [cards, setCards] = useState<Card[]>(initialCards);
|
||||
const [revealedCard, setRevealedCard] = useState<Card | null>(null);
|
||||
const [loadingId, setLoadingId] = useState<string | null>(null);
|
||||
const [replacementDialog, setReplacementDialog] = useState<{
|
||||
card: Card;
|
||||
candidates: Array<{ playerId: string; name: string; isBench: boolean; isSpecial: boolean }>;
|
||||
} | null>(null);
|
||||
|
||||
async function handleReveal(cardId: string) {
|
||||
const res = await fetch(`/api/golden-cards/${cardId}/reveal`, { method: "POST" });
|
||||
if (res.ok) {
|
||||
const updated: Card = await res.json();
|
||||
setCards((prev) => prev.map((card) => (card.id === cardId ? updated : card)));
|
||||
setRevealedCard(updated);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSell(cardId: string) {
|
||||
setLoadingId(cardId);
|
||||
const res = await fetch(`/api/golden-cards/${cardId}/sell`, { method: "POST" });
|
||||
if (res.ok) {
|
||||
setCards((prev) => prev.map((card) => (card.id === cardId ? { ...card, state: "SOLD" } : card)));
|
||||
}
|
||||
setLoadingId(null);
|
||||
}
|
||||
|
||||
async function handleAdd(cardId: string, replacePlayerId?: string) {
|
||||
setLoadingId(cardId);
|
||||
const res = await fetch(`/api/golden-cards/${cardId}/add-to-team`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(replacePlayerId ? { replacePlayerId } : {}),
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
if (res.ok) {
|
||||
setCards((prev) =>
|
||||
prev.map((card) => {
|
||||
if (card.id === cardId) return { ...card, state: "IN_TEAM" };
|
||||
if (data?.replacedGoldenCardId && card.id === data.replacedGoldenCardId) return { ...card, state: "IN_INVENTORY" };
|
||||
return card;
|
||||
})
|
||||
);
|
||||
setReplacementDialog(null);
|
||||
} else if (res.status === 409 && data?.needsReplacement) {
|
||||
const card = cards.find((item) => item.id === cardId);
|
||||
if (card) {
|
||||
setReplacementDialog({ card, candidates: data.candidates });
|
||||
}
|
||||
}
|
||||
setLoadingId(null);
|
||||
}
|
||||
|
||||
const sealed = cards.filter((card) => card.status === "SEALED");
|
||||
const opened = cards.filter((card) => card.status === "OPENED" && card.state !== "SOLD");
|
||||
const sold = cards.filter((card) => card.state === "SOLD");
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{replacementDialog && (
|
||||
<div className="fixed inset-0 z-50 bg-black/70 p-4 flex items-center justify-center">
|
||||
<div className="w-full max-w-md rounded-3xl bg-white p-6 shadow-2xl">
|
||||
<h3 className="text-xl font-bold mb-2">پست بازیکن پر است</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
برای اضافه کردن {replacementDialog.card.player.name} یکی از بازیکنان این پست را انتخاب کنید.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{replacementDialog.candidates.map((candidate) => (
|
||||
<button
|
||||
key={candidate.playerId}
|
||||
onClick={() => handleAdd(replacementDialog.card.id, candidate.playerId)}
|
||||
className="w-full rounded-2xl border border-gray-200 px-4 py-3 text-right hover:border-green-500 hover:bg-green-50 transition"
|
||||
>
|
||||
<div className="font-bold text-black">{candidate.name}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{candidate.isBench ? "ذخیره" : "فیکس"}
|
||||
{candidate.isSpecial ? " | کارت ویژه" : ""}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={() => setReplacementDialog(null)} className="w-full mt-4 rounded-2xl bg-gray-100 py-3 text-sm font-bold text-gray-700">
|
||||
بستن
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-center mb-10">
|
||||
<h1 className="text-3xl font-black mb-2 text-transparent bg-clip-text bg-gradient-to-r from-yellow-400 to-amber-500">
|
||||
کارت ویژه
|
||||
</h1>
|
||||
<p className="text-gray-400">بازیکنان ویژه را به تیم اضافه کنید یا با فروش آنها بودجه بگیرید.</p>
|
||||
</div>
|
||||
|
||||
{revealedCard && (
|
||||
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4" onClick={() => setRevealedCard(null)}>
|
||||
<div className="bg-gray-900 border border-yellow-400/30 rounded-3xl p-8 max-w-sm w-full text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<p className="text-yellow-400 font-bold mb-4 text-lg">کارت شما باز شد</p>
|
||||
<OpenedCard
|
||||
card={revealedCard}
|
||||
loading={loadingId === revealedCard.id}
|
||||
onAdd={revealedCard.state === "IN_INVENTORY" ? () => handleAdd(revealedCard.id) : undefined}
|
||||
onSell={() => handleSell(revealedCard.id)}
|
||||
/>
|
||||
<button onClick={() => setRevealedCard(null)} className="mt-6 px-6 py-2 rounded-xl bg-white/10 hover:bg-white/20 transition text-sm">
|
||||
بستن
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cards.length === 0 && (
|
||||
<div className="text-center py-20 text-gray-500">
|
||||
<div className="text-5xl mb-4">🎴</div>
|
||||
<p>هنوز کارت ویژه ندارید</p>
|
||||
<p className="text-sm mt-1">در کوییز روزانه شرکت کنید تا شانس دریافت کارت ویژه داشته باشید.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sealed.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h2 className="font-bold text-lg mb-4 text-yellow-400">کارتهای مهر شده ({sealed.length})</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{sealed.map((card) => (
|
||||
<SealedCard key={card.id} card={card} onReveal={handleReveal} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{opened.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h2 className="font-bold text-lg mb-4 text-gray-300">کارتهای آماده استفاده ({opened.length})</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{opened.map((card) => (
|
||||
<OpenedCard
|
||||
key={card.id}
|
||||
card={card}
|
||||
loading={loadingId === card.id}
|
||||
onAdd={card.state === "IN_INVENTORY" ? () => handleAdd(card.id) : undefined}
|
||||
onSell={() => handleSell(card.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sold.length > 0 && (
|
||||
<div>
|
||||
<h2 className="font-bold text-lg mb-4 text-gray-500">فروختهشدهها ({sold.length})</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 opacity-70">
|
||||
{sold.map((card) => (
|
||||
<OpenedCard key={card.id} card={card} loading={false} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
app/(user)/golden-cards/page.tsx
Normal file
20
app/(user)/golden-cards/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { requireAuth } from "@/lib/session";
|
||||
import { db } from "@/lib/db";
|
||||
import GoldenCardsClient from "./GoldenCardsClient";
|
||||
|
||||
export default async function GoldenCardsPage() {
|
||||
const session = await requireAuth();
|
||||
const userId = (session.user as any).id;
|
||||
|
||||
const cards = await db.goldenCard.findMany({
|
||||
where: { userId },
|
||||
include: { player: { include: { country: true } } },
|
||||
orderBy: { acquiredDate: "desc" },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 text-white py-10 px-4">
|
||||
<GoldenCardsClient initialCards={cards as any} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
254
app/(user)/quiz/DailyQuizClient.tsx
Normal file
254
app/(user)/quiz/DailyQuizClient.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
type Question = { id: string; questionText: string; options: string[]; order: number };
|
||||
type Quiz = {
|
||||
id: string;
|
||||
windowStart: string | Date;
|
||||
windowEnd: string | Date;
|
||||
isProcessed: boolean;
|
||||
questions: Question[];
|
||||
};
|
||||
|
||||
function useCountdown(target: Date) {
|
||||
const calc = useCallback(() => Math.max(0, target.getTime() - Date.now()), [target]);
|
||||
const [ms, setMs] = useState(calc);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setMs(calc()), 1000);
|
||||
return () => clearInterval(t);
|
||||
}, [calc]);
|
||||
|
||||
const s = Math.floor(ms / 1000);
|
||||
return { hours: Math.floor(s / 3600), minutes: Math.floor((s % 3600) / 60), seconds: s % 60, done: ms === 0 };
|
||||
}
|
||||
|
||||
function CountdownUnit({ value, label }: { value: number; label: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="bg-white/10 backdrop-blur border border-white/20 rounded-xl w-16 h-16 flex items-center justify-center text-2xl font-bold tabular-nums">
|
||||
{String(value).padStart(2, "0")}
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 mt-1">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DailyQuizClient({
|
||||
quiz,
|
||||
alreadySubmitted,
|
||||
}: {
|
||||
quiz: Quiz | null;
|
||||
alreadySubmitted: boolean;
|
||||
}) {
|
||||
const [answers, setAnswers] = useState<(number | null)[]>(
|
||||
quiz ? Array(quiz.questions.length).fill(null) : []
|
||||
);
|
||||
const [step, setStep] = useState(0);
|
||||
const [submitted, setSubmitted] = useState(alreadySubmitted);
|
||||
const [result, setResult] = useState<{
|
||||
score: number;
|
||||
correct: number;
|
||||
total: number;
|
||||
rewardTier: "GOLD" | "SILVER" | "BRONZE" | null;
|
||||
rewardTierLabel: string | null;
|
||||
} | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const windowEnd = quiz ? new Date(quiz.windowEnd) : new Date();
|
||||
const windowStart = quiz ? new Date(quiz.windowStart) : new Date();
|
||||
const countdown = useCountdown(windowEnd);
|
||||
const startCountdown = useCountdown(windowStart);
|
||||
|
||||
const now = new Date();
|
||||
const isProcessed = quiz?.isProcessed ?? false;
|
||||
const isActive = quiz ? !isProcessed && now >= windowStart && now <= windowEnd : false;
|
||||
const notStarted = quiz ? !isProcessed && now < windowStart : false;
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!quiz) return;
|
||||
if (answers.some((a) => a === null)) {
|
||||
setError("لطفاً به همه سوالات پاسخ دهید");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
const res = await fetch("/api/quiz/submit", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ quizId: quiz.id, answers }),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
setResult(data);
|
||||
setSubmitted(true);
|
||||
} else {
|
||||
setError(data.error ?? "خطا");
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
if (!quiz) {
|
||||
return (
|
||||
<div className="max-w-lg mx-auto text-center py-20">
|
||||
<div className="text-6xl mb-4">*</div>
|
||||
<h1 className="text-2xl font-bold mb-2">کوییزی برای امروز وجود ندارد</h1>
|
||||
<p className="text-gray-400">فردا دوباره بیا</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className="max-w-lg mx-auto text-center py-20">
|
||||
<div className="bg-white/5 backdrop-blur border border-white/10 rounded-2xl p-8">
|
||||
{result ? (
|
||||
<>
|
||||
<div className="text-6xl mb-4">{result.score === 100 ? "*" : result.score >= 50 ? "+" : "-"}</div>
|
||||
<h2 className="text-2xl font-bold mb-2">نتیجه شما</h2>
|
||||
<div className="text-5xl font-black text-transparent bg-clip-text bg-gradient-to-r from-yellow-400 to-amber-500 my-4">
|
||||
{result.score}%
|
||||
</div>
|
||||
<p className="text-gray-300">{result.correct} از {result.total} سوال صحیح</p>
|
||||
{result.score === 100 && (
|
||||
<p className="mt-4 text-green-400 font-medium">شما در قرعهکشی Golden Card شرکت دارید</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-5xl mb-4">OK</div>
|
||||
<h2 className="text-xl font-bold">پاسخهای شما ثبت شد</h2>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (notStarted) {
|
||||
return (
|
||||
<div className="max-w-lg mx-auto text-center py-20">
|
||||
<div className="bg-white/5 backdrop-blur border border-white/10 rounded-2xl p-8">
|
||||
<p className="text-gray-400 mb-4">کوییز هنوز شروع نشده و در این زمان باز میشود:</p>
|
||||
<div className="flex justify-center gap-3">
|
||||
<CountdownUnit value={startCountdown.hours} label="ساعت" />
|
||||
<CountdownUnit value={startCountdown.minutes} label="دقیقه" />
|
||||
<CountdownUnit value={startCountdown.seconds} label="ثانیه" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isActive) {
|
||||
return (
|
||||
<div className="max-w-lg mx-auto text-center py-20">
|
||||
<div className="text-5xl mb-4">!</div>
|
||||
<h2 className="text-xl font-bold">
|
||||
{isProcessed ? "این کوییز بعد از قرعهکشی بسته شده است" : "بازه زمانی کوییز به پایان رسیده"}
|
||||
</h2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const q = quiz.questions[step];
|
||||
const progress = ((step + 1) / quiz.questions.length) * 100;
|
||||
|
||||
return (
|
||||
<div className="max-w-xl mx-auto">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-bold mb-1">کوییز روزانه</h1>
|
||||
<p className="text-gray-400 text-sm">پاسخ صحیح = شانس برنده شدن Golden Card</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center gap-3 mb-8">
|
||||
<CountdownUnit value={countdown.hours} label="ساعت" />
|
||||
<CountdownUnit value={countdown.minutes} label="دقیقه" />
|
||||
<CountdownUnit value={countdown.seconds} label="ثانیه" />
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between text-xs text-gray-400 mb-1">
|
||||
<span>سوال {step + 1} از {quiz.questions.length}</span>
|
||||
<span>{Math.round(progress)}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-white/10 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-yellow-400 to-amber-500 rounded-full transition-all duration-500"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/5 backdrop-blur border border-white/10 rounded-2xl p-6 mb-4">
|
||||
<p className="text-lg font-medium mb-6 leading-relaxed">{q.questionText}</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
{q.options.map((opt, oi) => (
|
||||
<button
|
||||
key={oi}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const updated = [...answers];
|
||||
updated[step] = oi;
|
||||
setAnswers(updated);
|
||||
}}
|
||||
className={`text-right px-4 py-3 rounded-xl border transition-all text-sm ${
|
||||
answers[step] === oi
|
||||
? "border-yellow-400 bg-yellow-400/10 text-yellow-300"
|
||||
: "border-white/10 hover:border-white/30 hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
<span className="font-bold ml-2 text-gray-400">{["الف", "ب", "ج", "د"][oi]})</span>
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-red-400 text-sm text-center mb-3">{error}</p>}
|
||||
|
||||
<div className="flex gap-3">
|
||||
{step > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep(step - 1)}
|
||||
className="flex-1 py-3 rounded-xl border border-white/20 hover:bg-white/5 transition text-sm"
|
||||
>
|
||||
قبلی
|
||||
</button>
|
||||
)}
|
||||
{step < quiz.questions.length - 1 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (answers[step] === null) {
|
||||
setError("لطفاً یک گزینه انتخاب کنید");
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
setStep(step + 1);
|
||||
}}
|
||||
className="flex-1 py-3 rounded-xl bg-gradient-to-r from-yellow-500 to-amber-500 text-black font-bold hover:opacity-90 transition text-sm"
|
||||
>
|
||||
بعدی
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
className="flex-1 py-3 rounded-xl bg-gradient-to-r from-green-500 to-emerald-500 text-white font-bold hover:opacity-90 transition text-sm disabled:opacity-50"
|
||||
>
|
||||
{loading ? "در حال ارسال..." : "ثبت پاسخها"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
app/(user)/quiz/history/page.tsx
Normal file
115
app/(user)/quiz/history/page.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { requireAuth } from "@/lib/session";
|
||||
import { db } from "@/lib/db";
|
||||
import { formatPersianDate, formatPersianDateTime } from "@/lib/persianDate";
|
||||
|
||||
export default async function QuizHistoryPage() {
|
||||
const session = await requireAuth();
|
||||
const userId = (session.user as any).id;
|
||||
|
||||
const submissions = await db.quizSubmission.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
quiz: {
|
||||
include: { questions: { orderBy: { order: "asc" } } },
|
||||
},
|
||||
},
|
||||
orderBy: { submittedAt: "desc" },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 text-white py-10 px-4">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<h1 className="text-2xl font-bold mb-6">تاریخچه کوییزها</h1>
|
||||
|
||||
{submissions.length === 0 && (
|
||||
<div className="text-center py-20 text-gray-500">
|
||||
<div className="text-5xl mb-4">📋</div>
|
||||
<p>هنوز در هیچ کوییزی شرکت نکردهاید</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{submissions.map((sub) => {
|
||||
const correct = sub.answers.filter((ans, i) => ans === sub.quiz.questions[i]?.correctAnswer).length;
|
||||
const total = sub.quiz.questions.length;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={sub.id}
|
||||
className={`bg-white/5 backdrop-blur border rounded-2xl p-5 ${
|
||||
sub.score === 100 ? "border-yellow-400/30" : "border-white/10"
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<p className="font-bold text-lg">
|
||||
{formatPersianDate(new Date(sub.quiz.date))}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{formatPersianDateTime(new Date(sub.submittedAt))}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div
|
||||
className={`text-2xl font-black ${
|
||||
sub.score === 100
|
||||
? "text-yellow-400"
|
||||
: sub.score >= 50
|
||||
? "text-green-400"
|
||||
: "text-red-400"
|
||||
}`}
|
||||
>
|
||||
{sub.score}%
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">
|
||||
{correct} از {total}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sub.score === 100 && (
|
||||
<div className="bg-yellow-400/10 border border-yellow-400/30 rounded-lg px-3 py-2 text-xs text-yellow-300 flex items-center gap-2">
|
||||
<span>🏆</span>
|
||||
<span>واجد شرایط قرعهکشی Golden Card</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show answers */}
|
||||
<details className="mt-4">
|
||||
<summary className="cursor-pointer text-sm text-gray-400 hover:text-white transition">
|
||||
مشاهده جزئیات
|
||||
</summary>
|
||||
<div className="mt-3 flex flex-col gap-3">
|
||||
{sub.quiz.questions.map((q, i) => {
|
||||
const userAnswer = sub.answers[i];
|
||||
const isCorrect = userAnswer === q.correctAnswer;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={q.id}
|
||||
className={`border rounded-lg p-3 text-sm ${
|
||||
isCorrect ? "border-green-500/30 bg-green-500/5" : "border-red-500/30 bg-red-500/5"
|
||||
}`}
|
||||
>
|
||||
<p className="font-medium mb-2">{q.questionText}</p>
|
||||
<div className="flex flex-col gap-1 text-xs">
|
||||
<p className={isCorrect ? "text-green-400" : "text-red-400"}>
|
||||
پاسخ شما: {q.options[userAnswer ?? 0]} {isCorrect ? "✓" : "✗"}
|
||||
</p>
|
||||
{!isCorrect && (
|
||||
<p className="text-green-400">پاسخ صحیح: {q.options[q.correctAnswer]}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
app/(user)/quiz/page.tsx
Normal file
36
app/(user)/quiz/page.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { requireAuth } from "@/lib/session";
|
||||
import { db } from "@/lib/db";
|
||||
import DailyQuizClient from "./DailyQuizClient";
|
||||
|
||||
export default async function QuizPage() {
|
||||
const session = await requireAuth();
|
||||
const userId = (session.user as any).id;
|
||||
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now);
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
const todayEnd = new Date(now);
|
||||
todayEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
const quiz = await db.dailyQuiz.findFirst({
|
||||
where: { date: { gte: todayStart, lte: todayEnd } },
|
||||
include: {
|
||||
questions: {
|
||||
orderBy: { order: "asc" },
|
||||
select: { id: true, questionText: true, options: true, order: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const alreadySubmitted = quiz
|
||||
? !!(await db.quizSubmission.findUnique({
|
||||
where: { userId_quizId: { userId, quizId: quiz.id } },
|
||||
}))
|
||||
: false;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 text-white py-10 px-4">
|
||||
<DailyQuizClient quiz={quiz} alreadySubmitted={alreadySubmitted} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import PositionBadge from "@/components/PositionBadge";
|
||||
import Image from "next/image";
|
||||
|
||||
@@ -8,14 +8,15 @@ type Player = {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string | null;
|
||||
position: string;
|
||||
position: "GK" | "DEF" | "MID" | "FWD";
|
||||
price: number;
|
||||
totalPoints: number;
|
||||
country: { name: string; code: string; flagUrl?: string | null };
|
||||
country: { name: string; code: string; flagUrl?: string | null; isEliminated?: boolean };
|
||||
};
|
||||
|
||||
type TeamPlayer = {
|
||||
playerId: string;
|
||||
goldenCardId: string | null;
|
||||
isCaptain: boolean;
|
||||
isViceCaptain: boolean;
|
||||
isBench: boolean;
|
||||
@@ -33,53 +34,103 @@ type Team = {
|
||||
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 },
|
||||
type SpecialCard = {
|
||||
id: string;
|
||||
status: "SEALED" | "OPENED";
|
||||
state: "IN_INVENTORY" | "IN_TEAM" | "SOLD";
|
||||
acquiredDate: string;
|
||||
openedAt: string | null;
|
||||
player: Player;
|
||||
teamPlayer?: { playerId: string; teamId: string } | null;
|
||||
};
|
||||
|
||||
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",
|
||||
type ReplacementCandidate = {
|
||||
playerId: string;
|
||||
name: string;
|
||||
isBench: boolean;
|
||||
isSpecial: boolean;
|
||||
};
|
||||
|
||||
const FORMATIONS: Record<string, { label: string; def: number; mid: number; fwd: number }> = {
|
||||
"4-3-3": { label: "4-3-3", def: 4, mid: 3, fwd: 3 },
|
||||
"4-4-2": { label: "4-4-2", def: 4, mid: 4, fwd: 2 },
|
||||
"4-5-1": { label: "4-5-1", def: 4, mid: 5, fwd: 1 },
|
||||
"3-5-2": { label: "3-5-2", def: 3, mid: 5, fwd: 2 },
|
||||
"3-4-3": { label: "3-4-3", def: 3, mid: 4, fwd: 3 },
|
||||
"5-3-2": { label: "5-3-2", def: 5, mid: 3, fwd: 2 },
|
||||
"5-4-1": { label: "5-4-1", def: 5, mid: 4, fwd: 1 },
|
||||
};
|
||||
|
||||
const POSITION_LABELS: Record<Player["position"], string> = {
|
||||
GK: "دروازهبان",
|
||||
DEF: "مدافع",
|
||||
MID: "هافبک",
|
||||
FWD: "مهاجم",
|
||||
};
|
||||
|
||||
function formatSaleValue(price: number) {
|
||||
return Math.round(price * 0.7);
|
||||
}
|
||||
|
||||
function isSpecialTeamPlayer(tp: TeamPlayer) {
|
||||
return Boolean(tp.goldenCardId);
|
||||
}
|
||||
|
||||
export default function TeamBuilder({
|
||||
team: initialTeam,
|
||||
allPlayers,
|
||||
initialSpecialCards,
|
||||
}: {
|
||||
team: Team;
|
||||
allPlayers: Player[];
|
||||
initialSpecialCards: SpecialCard[];
|
||||
}) {
|
||||
const [team, setTeam] = useState<Team>(initialTeam);
|
||||
const [specialCards, setSpecialCards] = useState<SpecialCard[]>(initialSpecialCards);
|
||||
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 [replacementDialog, setReplacementDialog] = useState<{
|
||||
card: SpecialCard;
|
||||
candidates: ReplacementCandidate[];
|
||||
} | null>(null);
|
||||
|
||||
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 specialPlayerIds = useMemo(
|
||||
() => new Set(specialCards.filter((card) => card.state !== "SOLD").map((card) => card.player.id)),
|
||||
[specialCards]
|
||||
);
|
||||
|
||||
const spent = team?.players
|
||||
.filter((tp) => !isSpecialTeamPlayer(tp))
|
||||
.reduce((sum, tp) => sum + tp.player.price, 0) ?? 0;
|
||||
const remaining = (team?.budget ?? 100) - spent;
|
||||
|
||||
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 specialSlotsUsed = team?.players.filter(isSpecialTeamPlayer).length ?? 0;
|
||||
|
||||
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");
|
||||
|
||||
const myPlayerIds = new Set(team?.players.map((tp) => tp.playerId) ?? []);
|
||||
const filtered = allPlayers.filter(
|
||||
(p) =>
|
||||
!myPlayerIds.has(p.id) &&
|
||||
!specialPlayerIds.has(p.id) &&
|
||||
(posFilter ? p.position === posFilter : true) &&
|
||||
(filter ? p.name.includes(filter) || p.country.name.includes(filter) : true)
|
||||
);
|
||||
|
||||
const inventoryCards = specialCards.filter((card) => card.state === "IN_INVENTORY");
|
||||
const inTeamCards = specialCards.filter((card) => card.state === "IN_TEAM");
|
||||
const sealedCount = specialCards.filter((card) => card.status === "SEALED").length;
|
||||
|
||||
async function createTeam() {
|
||||
if (!teamName.trim()) return;
|
||||
setLoading(true);
|
||||
@@ -89,8 +140,12 @@ export default function TeamBuilder({
|
||||
body: JSON.stringify({ name: teamName, formation }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) setTeam({ ...data, players: [] });
|
||||
else setMsg({ text: data.error, type: "error" });
|
||||
if (res.ok) {
|
||||
setTeam({ ...data, players: [] });
|
||||
setMsg(null);
|
||||
} else {
|
||||
setMsg({ text: data.error, type: "error" });
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -104,7 +159,9 @@ export default function TeamBuilder({
|
||||
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);
|
||||
setTeam((current) =>
|
||||
current ? { ...current, players: [...current.players, { ...data, goldenCardId: null, player }] } : current
|
||||
);
|
||||
setMsg(null);
|
||||
} else {
|
||||
setMsg({ text: data.error, type: "error" });
|
||||
@@ -114,12 +171,125 @@ export default function TeamBuilder({
|
||||
|
||||
async function removePlayer(playerId: string) {
|
||||
setLoading(true);
|
||||
await fetch("/api/team/players", {
|
||||
const teamPlayer = team?.players.find((tp) => tp.playerId === playerId) ?? null;
|
||||
const res = 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);
|
||||
const data = await res.json().catch(() => null);
|
||||
|
||||
if (res.ok) {
|
||||
setTeam((current) =>
|
||||
current ? { ...current, players: current.players.filter((tp) => tp.playerId !== playerId) } : current
|
||||
);
|
||||
if (teamPlayer?.goldenCardId) {
|
||||
setSpecialCards((current) =>
|
||||
current.map((card) => (card.id === teamPlayer.goldenCardId ? { ...card, state: "IN_INVENTORY" } : card))
|
||||
);
|
||||
setMsg({ text: "بازیکن ویژه از تیم خارج شد و به کارت ویژه برگشت", type: "success" });
|
||||
} else {
|
||||
setMsg(null);
|
||||
}
|
||||
} else if (data?.error) {
|
||||
setMsg({ text: data.error, type: "error" });
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function sellSpecialCard(cardId: string) {
|
||||
setLoading(true);
|
||||
const res = await fetch(`/api/golden-cards/${cardId}/sell`, { method: "POST" });
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
setSpecialCards((current) => current.map((card) => (card.id === cardId ? { ...card, state: "SOLD" } : card)));
|
||||
setTeam((current) => {
|
||||
if (!current) return current;
|
||||
const soldCard = specialCards.find((card) => card.id === cardId);
|
||||
return {
|
||||
...current,
|
||||
budget: current.budget + data.addedBudget,
|
||||
players: soldCard ? current.players.filter((tp) => tp.goldenCardId !== cardId) : current.players,
|
||||
};
|
||||
});
|
||||
setMsg({ text: `${data.addedBudget} میلیون به بودجه تیم اضافه شد`, type: "success" });
|
||||
} else {
|
||||
setMsg({ text: data.error, type: "error" });
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
function mergeSpecialPlayer(card: SpecialCard, teamPlayer: { playerId: string; isBench: boolean; goldenCardId: string }) {
|
||||
setTeam((current) => {
|
||||
if (!current) return current;
|
||||
const existing = current.players.find((tp) => tp.playerId === teamPlayer.playerId);
|
||||
if (existing) {
|
||||
return {
|
||||
...current,
|
||||
players: current.players.map((tp) =>
|
||||
tp.playerId === teamPlayer.playerId ? { ...tp, goldenCardId: card.id, isBench: teamPlayer.isBench } : tp
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
players: [
|
||||
...current.players,
|
||||
{
|
||||
playerId: card.player.id,
|
||||
goldenCardId: card.id,
|
||||
isCaptain: false,
|
||||
isViceCaptain: false,
|
||||
isBench: teamPlayer.isBench,
|
||||
positionIndex: 0,
|
||||
player: card.player,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function addSpecialCardToTeam(card: SpecialCard, replacePlayerId?: string) {
|
||||
setLoading(true);
|
||||
const res = await fetch(`/api/golden-cards/${card.id}/add-to-team`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(replacePlayerId ? { replacePlayerId } : {}),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
setReplacementDialog(null);
|
||||
setSpecialCards((current) => current.map((item) => (item.id === card.id ? { ...item, state: "IN_TEAM" } : item)));
|
||||
if (data.replacedGoldenCardId) {
|
||||
setSpecialCards((current) =>
|
||||
current.map((item) => (item.id === data.replacedGoldenCardId ? { ...item, state: "IN_INVENTORY" } : item))
|
||||
);
|
||||
}
|
||||
|
||||
if (data.replacedPlayerId) {
|
||||
setTeam((current) =>
|
||||
current
|
||||
? { ...current, players: current.players.filter((tp) => tp.playerId !== data.replacedPlayerId) }
|
||||
: current
|
||||
);
|
||||
}
|
||||
|
||||
mergeSpecialPlayer(card, {
|
||||
playerId: card.player.id,
|
||||
isBench: data.teamPlayer.isBench,
|
||||
goldenCardId: card.id,
|
||||
});
|
||||
|
||||
setMsg({ text: data.message ?? `بازیکن ویژه در ${data.placement} قرار گرفت`, type: "success" });
|
||||
} else if (res.status === 409 && data.needsReplacement) {
|
||||
setReplacementDialog({ card, candidates: data.candidates });
|
||||
} else {
|
||||
setMsg({ text: data.error, type: "error" });
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -129,11 +299,11 @@ export default function TeamBuilder({
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ playerId, type }),
|
||||
});
|
||||
setTeam((t) => {
|
||||
if (!t) return t;
|
||||
setTeam((current) => {
|
||||
if (!current) return current;
|
||||
return {
|
||||
...t,
|
||||
players: t.players.map((tp) => ({
|
||||
...current,
|
||||
players: current.players.map((tp) => ({
|
||||
...tp,
|
||||
isCaptain: type === "captain" ? tp.playerId === playerId : tp.isCaptain,
|
||||
isViceCaptain: type === "vice" ? tp.playerId === playerId : tp.isViceCaptain,
|
||||
@@ -147,69 +317,52 @@ export default function TeamBuilder({
|
||||
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" });
|
||||
setTeam((current) => (current ? { ...current, status: "ACTIVE" } : current));
|
||||
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";
|
||||
const isComplete = starters.length === 11 && bench.length === 4;
|
||||
const canSubmit = isComplete && team?.status !== "ACTIVE";
|
||||
|
||||
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}
|
||||
<h1 className="text-2xl font-bold mb-2">تیمت را بساز</h1>
|
||||
<p className="text-gray-500 mb-8 text-sm">با بودجه 100 میلیون، 15 بازیکن برای تیمت انتخاب کن.</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" />
|
||||
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"}`}>
|
||||
{Object.entries(FORMATIONS).map(([key]) => (
|
||||
<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
|
||||
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>
|
||||
@@ -218,35 +371,56 @@ export default function TeamBuilder({
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto py-6 px-4">
|
||||
{/* هدر */}
|
||||
{replacementDialog && (
|
||||
<div className="fixed inset-0 z-50 bg-black/70 p-4 flex items-center justify-center">
|
||||
<div className="w-full max-w-md rounded-3xl bg-white p-6 shadow-2xl">
|
||||
<h3 className="text-xl font-bold mb-2">پست {POSITION_LABELS[replacementDialog.card.player.position]} پر است</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
برای اضافه کردن {replacementDialog.card.player.name} یکی از بازیکنان این پست را جایگزین کنید.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{replacementDialog.candidates.map((candidate) => (
|
||||
<button
|
||||
key={candidate.playerId}
|
||||
onClick={() => addSpecialCardToTeam(replacementDialog.card, candidate.playerId)}
|
||||
className="w-full rounded-2xl border border-gray-200 px-4 py-3 text-right hover:border-green-500 hover:bg-green-50 transition"
|
||||
>
|
||||
<div className="font-bold">{candidate.name}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{candidate.isBench ? "ذخیره" : "فیکس"}
|
||||
{candidate.isSpecial ? " | کارت ویژه" : ""}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setReplacementDialog(null)}
|
||||
className="w-full mt-4 rounded-2xl bg-gray-100 py-3 text-sm font-bold text-gray-700 hover:bg-gray-200 transition"
|
||||
>
|
||||
بستن
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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
|
||||
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>
|
||||
<Metric label="امتیاز" value={team.totalPoints} tone="text-blue-700" />
|
||||
<Metric label="بودجه" value={`${remaining.toFixed(1)}M`} tone={remaining < 0 ? "text-red-600" : "text-green-700"} />
|
||||
<Metric label="ویژه" value={`${specialSlotsUsed}/3`} tone="text-amber-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -257,24 +431,11 @@ export default function TeamBuilder({
|
||||
)}
|
||||
|
||||
<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 }}>
|
||||
{/* خطوط */}
|
||||
<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" />
|
||||
@@ -285,209 +446,218 @@ export default function TeamBuilder({
|
||||
</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} />
|
||||
<PitchRow players={fwdSlots} slots={FORMATIONS[formation]?.fwd ?? 3} onRemove={removePlayer} onCaptain={setCaptain} onSell={sellSpecialCard} />
|
||||
<PitchRow players={midSlots} slots={FORMATIONS[formation]?.mid ?? 3} onRemove={removePlayer} onCaptain={setCaptain} onSell={sellSpecialCard} />
|
||||
<PitchRow players={defSlots} slots={FORMATIONS[formation]?.def ?? 4} onRemove={removePlayer} onCaptain={setCaptain} onSell={sellSpecialCard} />
|
||||
<PitchRow players={gkSlots} slots={1} onRemove={removePlayer} onCaptain={setCaptain} onSell={sellSpecialCard} />
|
||||
</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>
|
||||
<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 />
|
||||
<PitchCard key={tp.playerId} tp={tp} onRemove={removePlayer} onCaptain={setCaptain} onSell={sellSpecialCard} small />
|
||||
))}
|
||||
{Array.from({ length: Math.max(0, 4 - bench.length) }).map((_, i) => (
|
||||
<EmptySlot key={i} label="ذخیره" />
|
||||
{Array.from({ length: Math.max(0, 4 - bench.length) }).map((_, index) => (
|
||||
<EmptySlot key={index} 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
|
||||
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 p-4" style={{ maxHeight: 520, overflowY: "auto" }}>
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
{filtered.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
draggable
|
||||
onDragStart={() => setDraggedId(p.id)}
|
||||
className="flex-shrink-0 bg-gray-50 rounded-xl p-2 cursor-move hover:bg-gray-100 transition border-2 border-transparent hover:border-green-500"
|
||||
style={{ width: "90px" }}
|
||||
>
|
||||
<div className="relative w-20 h-20 rounded-lg overflow-hidden bg-gray-200 mb-1 mx-auto">
|
||||
{p.image ? (
|
||||
<Image
|
||||
src={`/uploads/players/${p.image}`}
|
||||
alt={p.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-400 text-3xl">
|
||||
👤
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[10px] font-bold text-gray-800 text-center leading-tight mb-1">
|
||||
{p.name.split(" ").slice(-1)[0]}
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-1 mb-1">
|
||||
<span className="text-xs">{p.country.flagUrl}</span>
|
||||
<PositionBadge position={p.position} />
|
||||
</div>
|
||||
<div className="text-[9px] text-center text-gray-600 mb-2">
|
||||
<span className="text-green-700 font-bold">{p.price}M</span>
|
||||
<span className="mx-1">·</span>
|
||||
<span className="text-blue-700 font-bold">{p.totalPoints}pts</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => addPlayer(p.id)}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
disabled={loading || p.price > remaining + 0.01}
|
||||
className="w-full bg-green-600 text-white text-xs py-1 rounded-lg hover:bg-green-700 transition disabled:opacity-30 font-bold"
|
||||
>
|
||||
+ افزودن
|
||||
</button>
|
||||
</div>
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<section className="bg-white rounded-2xl shadow p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-bold">کارتهای ویژه</h2>
|
||||
<span className="text-xs rounded-full bg-amber-100 px-3 py-1 font-bold text-amber-700">
|
||||
ظرفیت تیم: {specialSlotsUsed}/3
|
||||
</span>
|
||||
</div>
|
||||
{sealedCount > 0 && <p className="text-xs text-gray-500 mb-3">{sealedCount} کارت ویژه هنوز باز نشده است.</p>}
|
||||
<div className="space-y-3">
|
||||
{inventoryCards.map((card) => (
|
||||
<SpecialCardRow
|
||||
key={card.id}
|
||||
card={card}
|
||||
loading={loading}
|
||||
onAdd={() => addSpecialCardToTeam(card)}
|
||||
onSell={() => sellSpecialCard(card.id)}
|
||||
/>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className="w-full text-center text-gray-400 py-8">بازیکنی پیدا نشد</div>
|
||||
{inTeamCards.map((card) => (
|
||||
<SpecialCardRow
|
||||
key={card.id}
|
||||
card={card}
|
||||
loading={loading}
|
||||
onSell={() => sellSpecialCard(card.id)}
|
||||
/>
|
||||
))}
|
||||
{inventoryCards.length === 0 && inTeamCards.length === 0 && (
|
||||
<div className="rounded-2xl border border-dashed border-gray-200 px-4 py-6 text-center text-sm text-gray-500">
|
||||
کارت ویژه آماده استفاده ندارید.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<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 p-4 max-h-[520px] overflow-y-auto">
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
{filtered.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="flex-shrink-0 bg-gray-50 rounded-xl p-2 border-2 border-transparent hover:border-green-500 transition"
|
||||
style={{ width: "90px" }}
|
||||
>
|
||||
<div className="relative w-20 h-20 rounded-lg overflow-hidden bg-gray-200 mb-1 mx-auto">
|
||||
{p.image ? (
|
||||
<Image src={`/uploads/players/${p.image}`} alt={p.name} fill className="object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-400 text-3xl">👤</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[10px] font-bold text-gray-800 text-center leading-tight mb-1">
|
||||
{p.name.split(" ").slice(-1)[0]}
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-1 mb-1">
|
||||
<PositionBadge position={p.position} />
|
||||
</div>
|
||||
<div className="text-[9px] text-center text-gray-600 mb-2">
|
||||
<span className="text-green-700 font-bold">{p.price}M</span>
|
||||
<span className="mx-1">|</span>
|
||||
<span className="text-blue-700 font-bold">{p.totalPoints}pts</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => addPlayer(p.id)}
|
||||
disabled={loading || p.price > remaining + 0.01}
|
||||
className="w-full bg-green-600 text-white text-xs py-1 rounded-lg hover:bg-green-700 transition disabled:opacity-30 font-bold"
|
||||
>
|
||||
+ افزودن
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{filtered.length === 0 && <div className="w-full text-center text-gray-400 py-8">بازیکنی پیدا نشد</div>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</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;
|
||||
function Metric({ label, value, tone }: { label: string; value: string | number; tone: string }) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div className={`text-2xl font-bold ${tone}`}>{value}</div>
|
||||
<div className="text-xs text-gray-500">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PitchRow({
|
||||
players,
|
||||
slots,
|
||||
onRemove,
|
||||
onCaptain,
|
||||
onSell,
|
||||
}: {
|
||||
players: TeamPlayer[];
|
||||
slots: number;
|
||||
onRemove: (id: string) => void;
|
||||
onCaptain: (id: string, t: "captain" | "vice") => void;
|
||||
onSell: (cardId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex justify-center gap-2 flex-wrap py-1">
|
||||
{Array.from({ length: slots }).map((_, i) => {
|
||||
const tp = players[i];
|
||||
{Array.from({ length: slots }).map((_, index) => {
|
||||
const tp = players[index];
|
||||
return tp ? (
|
||||
<PitchCard key={tp.playerId} tp={tp} onRemove={onRemove}
|
||||
onDragStart={onDragStart} onDrop={onDrop} onCaptain={onCaptain} draggedId={draggedId} />
|
||||
<PitchCard key={tp.playerId} tp={tp} onRemove={onRemove} onCaptain={onCaptain} onSell={onSell} />
|
||||
) : (
|
||||
<EmptySlot key={i} label={position} />
|
||||
<EmptySlot key={index} label="خالی" />
|
||||
);
|
||||
})}
|
||||
</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;
|
||||
function PitchCard({
|
||||
tp,
|
||||
onRemove,
|
||||
onCaptain,
|
||||
onSell,
|
||||
small,
|
||||
}: {
|
||||
tp: TeamPlayer;
|
||||
onRemove: (id: string) => void;
|
||||
onCaptain: (id: string, t: "captain" | "vice") => void;
|
||||
onSell: (cardId: string) => void;
|
||||
small?: boolean;
|
||||
}) {
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const isDragging = draggedId === tp.playerId;
|
||||
const isEliminated = (tp.player as any).country?.isEliminated;
|
||||
const isEliminated = Boolean(tp.player.country?.isEliminated);
|
||||
const shortName = tp.player.name.split(" ").slice(-1)[0];
|
||||
|
||||
const special = isSpecialTeamPlayer(tp);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative group ${isDragging ? "opacity-50" : ""}`}
|
||||
draggable
|
||||
onDragStart={() => onDragStart(tp.playerId)}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={() => onDrop(tp.playerId)}
|
||||
>
|
||||
<div className={`bg-white/95 rounded-xl p-2 cursor-move hover:bg-white transition shadow-lg ${small ? "w-16" : "w-20"}`}>
|
||||
<div className="relative group">
|
||||
<div className={`rounded-xl p-2 shadow-lg ${special ? "bg-amber-50 border border-amber-300" : "bg-white/95"} ${small ? "w-16" : "w-20"}`}>
|
||||
<div className={`relative ${small ? "w-12 h-12" : "w-16 h-16"} rounded-lg overflow-hidden bg-gray-200 mb-1 mx-auto`}>
|
||||
{tp.player.image ? (
|
||||
<Image
|
||||
src={`/uploads/players/${tp.player.image}`}
|
||||
alt={tp.player.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
<Image src={`/uploads/players/${tp.player.image}`} alt={tp.player.name} fill className="object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-400 text-2xl">
|
||||
👤
|
||||
</div>
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-400 text-2xl">👤</div>
|
||||
)}
|
||||
{isEliminated && (
|
||||
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
|
||||
<span className="text-white text-xs font-bold">✕</span>
|
||||
<span className="text-white text-xs font-bold">×</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`text-[10px] font-bold text-gray-800 text-center leading-tight ${isEliminated ? "opacity-50" : ""}`}>
|
||||
{shortName}
|
||||
</div>
|
||||
|
||||
|
||||
<div className={`text-[10px] font-bold text-center leading-tight ${isEliminated ? "opacity-50" : "text-gray-800"}`}>{shortName}</div>
|
||||
{special && <div className="mt-1 text-center text-[8px] font-bold text-amber-700">کارت ویژه</div>}
|
||||
|
||||
<div className="flex items-center justify-center gap-1 mt-1">
|
||||
{tp.isCaptain && (
|
||||
<div className="bg-yellow-400 text-yellow-900 rounded-full w-4 h-4 flex items-center justify-center text-[8px] font-bold">
|
||||
C
|
||||
</div>
|
||||
)}
|
||||
{tp.isViceCaptain && (
|
||||
<div className="bg-gray-400 text-white rounded-full w-4 h-4 flex items-center justify-center text-[8px] font-bold">
|
||||
V
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-[8px] text-center text-gray-600 mt-1">
|
||||
{tp.player.totalPoints}pts
|
||||
{tp.isCaptain && <div className="bg-yellow-400 text-yellow-900 rounded-full w-4 h-4 flex items-center justify-center text-[8px] font-bold">C</div>}
|
||||
{tp.isViceCaptain && <div className="bg-gray-400 text-white rounded-full w-4 h-4 flex items-center justify-center text-[8px] font-bold">V</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute -bottom-8 left-1/2 -translate-x-1/2 opacity-0 group-hover:opacity-100 transition flex gap-1 z-20">
|
||||
|
||||
<div className="absolute -bottom-10 left-1/2 -translate-x-1/2 opacity-0 group-hover:opacity-100 transition flex gap-1 z-20">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -497,6 +667,17 @@ function PitchCard({ tp, onRemove, onDragStart, onDrop, onCaptain, draggedId, sm
|
||||
>
|
||||
{tp.isCaptain ? "VC" : "C"}
|
||||
</button>
|
||||
{special && tp.goldenCardId ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSell(tp.goldenCardId!);
|
||||
}}
|
||||
className="bg-amber-500 text-white text-[8px] px-2 py-0.5 rounded-full font-bold shadow"
|
||||
>
|
||||
فروش
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -504,7 +685,62 @@ function PitchCard({ tp, onRemove, onDragStart, onDrop, onCaptain, draggedId, sm
|
||||
}}
|
||||
className="bg-red-500 text-white text-[8px] px-2 py-0.5 rounded-full font-bold shadow"
|
||||
>
|
||||
حذف
|
||||
{special ? "برداشتن" : "حذف"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SpecialCardRow({
|
||||
card,
|
||||
loading,
|
||||
onAdd,
|
||||
onSell,
|
||||
}: {
|
||||
card: SpecialCard;
|
||||
loading: boolean;
|
||||
onAdd?: () => void;
|
||||
onSell: () => void;
|
||||
}) {
|
||||
const canAdd = card.state === "IN_INVENTORY";
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-amber-200 bg-amber-50/70 p-3">
|
||||
<div className="flex gap-3">
|
||||
<div className="relative w-16 h-16 rounded-xl overflow-hidden bg-white shrink-0">
|
||||
{card.player.image ? (
|
||||
<Image src={`/uploads/players/${card.player.image}`} alt={card.player.name} fill className="object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-400 text-2xl">👤</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-bold truncate">{card.player.name}</div>
|
||||
<div className="text-xs text-gray-600 mt-1">
|
||||
{POSITION_LABELS[card.player.position]} | {card.player.price}M
|
||||
</div>
|
||||
<div className="text-xs font-bold text-amber-700 mt-1">
|
||||
{card.state === "IN_TEAM" ? "در تیم" : `فروش: ${formatSaleValue(card.player.price)}M`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-3">
|
||||
{canAdd && (
|
||||
<button
|
||||
onClick={onAdd}
|
||||
disabled={loading}
|
||||
className="flex-1 rounded-xl bg-green-700 py-2 text-sm font-bold text-white hover:bg-green-800 disabled:opacity-50 transition"
|
||||
>
|
||||
افزودن به تیم
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onSell}
|
||||
disabled={loading}
|
||||
className="flex-1 rounded-xl bg-amber-500 py-2 text-sm font-bold text-white hover:bg-amber-600 disabled:opacity-50 transition"
|
||||
>
|
||||
فروش
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,5 +20,18 @@ export default async function TeamPage() {
|
||||
orderBy: { totalPoints: "desc" },
|
||||
});
|
||||
|
||||
return <TeamBuilder team={team} allPlayers={allPlayers} />;
|
||||
const specialCards = await db.goldenCard.findMany({
|
||||
where: {
|
||||
userId,
|
||||
status: "OPENED",
|
||||
state: { not: "SOLD" },
|
||||
},
|
||||
include: {
|
||||
player: { include: { country: true } },
|
||||
teamPlayer: true,
|
||||
},
|
||||
orderBy: { acquiredDate: "desc" },
|
||||
});
|
||||
|
||||
return <TeamBuilder team={team} allPlayers={allPlayers} initialSpecialCards={specialCards as any} />;
|
||||
}
|
||||
|
||||
@@ -52,9 +52,9 @@ export async function POST(_: NextRequest, { params }: { params: Promise<{ id: s
|
||||
});
|
||||
|
||||
const stat = await db.playerMatchStat.upsert({
|
||||
where: { playerId_matchId: { playerId, matchId: params.id } },
|
||||
where: { playerId_matchId: { playerId, matchId: 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 },
|
||||
create: { playerId, matchId: id, goals, assists, yellowCards, redCards, minutesPlayed, cleanSheet, penaltySaved, penaltyMissed, ownGoals, isMotm, extraTimeBonus, points },
|
||||
});
|
||||
|
||||
// آپدیت totalPoints بازیکن
|
||||
|
||||
30
app/api/admin/players/[id]/card-tier/route.ts
Normal file
30
app/api/admin/players/[id]/card-tier/route.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
|
||||
const validTiers = new Set(["GOLD", "SILVER", "BRONZE"]);
|
||||
|
||||
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session || (session.user as any).role !== "ADMIN") {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const { cardTier } = await req.json();
|
||||
|
||||
if (!validTiers.has(cardTier)) {
|
||||
return NextResponse.json({ error: "Invalid card tier" }, { status: 400 });
|
||||
}
|
||||
|
||||
const updated = await db.player.update({
|
||||
where: { id },
|
||||
data: {
|
||||
cardTier,
|
||||
isGoldenCardEligible: cardTier === "GOLD",
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(updated);
|
||||
}
|
||||
24
app/api/admin/players/[id]/golden-toggle/route.ts
Normal file
24
app/api/admin/players/[id]/golden-toggle/route.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
|
||||
// PATCH /api/admin/players/[id]/golden-toggle
|
||||
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session || (session.user as any).role !== "ADMIN") {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const player = await db.player.findUnique({ where: { id } });
|
||||
if (!player) return NextResponse.json({ error: "Player not found" }, { status: 404 });
|
||||
|
||||
const updated = await db.player.update({
|
||||
where: { id },
|
||||
data: { isGoldenCardEligible: !player.isGoldenCardEligible },
|
||||
});
|
||||
|
||||
return NextResponse.json({ isGoldenCardEligible: updated.isGoldenCardEligible });
|
||||
}
|
||||
105
app/api/admin/quiz/[id]/lottery/route.ts
Normal file
105
app/api/admin/quiz/[id]/lottery/route.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { CARD_TIER_LABELS, resolveQuizRewardTier } from "@/lib/cardTier";
|
||||
|
||||
function shuffleArray<T>(items: T[]) {
|
||||
return [...items].sort(() => Math.random() - 0.5);
|
||||
}
|
||||
|
||||
// POST /api/admin/quiz/[id]/lottery - run reward distribution for a quiz
|
||||
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session || (session.user as any).role !== "ADMIN") {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const quiz = await db.dailyQuiz.findUnique({
|
||||
where: { id },
|
||||
include: { questions: true },
|
||||
});
|
||||
|
||||
if (!quiz) return NextResponse.json({ error: "Quiz not found" }, { status: 404 });
|
||||
if (quiz.isProcessed) {
|
||||
return NextResponse.json({ error: "قرعه کشی قبلا انجام شده" }, { status: 400 });
|
||||
}
|
||||
|
||||
const submissions = await db.quizSubmission.findMany({
|
||||
where: { quizId: id },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
const tierLimits = {
|
||||
GOLD: quiz.goldWinnersCount,
|
||||
SILVER: quiz.silverWinnersCount,
|
||||
BRONZE: quiz.bronzeWinnersCount,
|
||||
} as const;
|
||||
|
||||
const candidatesByTier = {
|
||||
GOLD: submissions.filter((submission) => resolveQuizRewardTier(quiz, submission.correctAnswers) === "GOLD"),
|
||||
SILVER: submissions.filter((submission) => resolveQuizRewardTier(quiz, submission.correctAnswers) === "SILVER"),
|
||||
BRONZE: submissions.filter((submission) => resolveQuizRewardTier(quiz, submission.correctAnswers) === "BRONZE"),
|
||||
};
|
||||
|
||||
const rewardQueue = (["GOLD", "SILVER", "BRONZE"] as const).flatMap((cardTier) =>
|
||||
shuffleArray(candidatesByTier[cardTier])
|
||||
.slice(0, Math.max(tierLimits[cardTier], 0))
|
||||
.map((submission) => ({ submission, cardTier }))
|
||||
);
|
||||
|
||||
if (rewardQueue.length === 0) {
|
||||
await db.dailyQuiz.update({ where: { id }, data: { isProcessed: true } });
|
||||
return NextResponse.json({ winners: [], message: "هیچ شرکت کننده ای واجد دریافت کارت نبود" });
|
||||
}
|
||||
|
||||
const players = await db.player.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
cardTier: { in: ["GOLD", "SILVER", "BRONZE"] },
|
||||
},
|
||||
include: { country: true },
|
||||
});
|
||||
|
||||
const playersByTier = {
|
||||
GOLD: players.filter((player) => player.cardTier === "GOLD"),
|
||||
SILVER: players.filter((player) => player.cardTier === "SILVER"),
|
||||
BRONZE: players.filter((player) => player.cardTier === "BRONZE"),
|
||||
};
|
||||
|
||||
for (const tier of ["GOLD", "SILVER", "BRONZE"] as const) {
|
||||
if (rewardQueue.some((item) => item.cardTier === tier) && playersByTier[tier].length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `برای کارت ${CARD_TIER_LABELS[tier]} هیچ بازیکن فعالی تعریف نشده است` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const createdCards = await db.$transaction(
|
||||
rewardQueue.map(({ submission, cardTier }) => {
|
||||
const tierPlayers = playersByTier[cardTier];
|
||||
const randomPlayer = tierPlayers[Math.floor(Math.random() * tierPlayers.length)];
|
||||
|
||||
return db.goldenCard.create({
|
||||
data: {
|
||||
userId: submission.userId,
|
||||
quizId: id,
|
||||
playerId: randomPlayer.id,
|
||||
cardTier,
|
||||
status: "SEALED",
|
||||
},
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
player: { include: { country: true } },
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
await db.dailyQuiz.update({ where: { id }, data: { isProcessed: true } });
|
||||
|
||||
return NextResponse.json({ winners: createdCards });
|
||||
}
|
||||
191
app/api/admin/quiz/[id]/route.ts
Normal file
191
app/api/admin/quiz/[id]/route.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
async function requireAdmin() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session || (session.user as any).role !== "ADMIN") {
|
||||
return null;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
function calculateResult(answers: number[], questions: Array<{ correctAnswer: number }>) {
|
||||
let correct = 0;
|
||||
questions.forEach((question, index) => {
|
||||
if (answers[index] === question.correctAnswer) {
|
||||
correct += 1;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
correct,
|
||||
score: questions.length > 0 ? Math.round((correct / questions.length) * 100) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function validateTierConfig(input: {
|
||||
goldWinnersCount: number;
|
||||
silverWinnersCount: number;
|
||||
bronzeWinnersCount: number;
|
||||
goldMinCorrect: number | null;
|
||||
silverMinCorrect: number | null;
|
||||
bronzeMinCorrect: number | null;
|
||||
}) {
|
||||
if (input.goldWinnersCount < 0 || input.silverWinnersCount < 0 || input.bronzeWinnersCount < 0) {
|
||||
return "Winner counts cannot be negative";
|
||||
}
|
||||
|
||||
if (input.goldWinnersCount + input.silverWinnersCount + input.bronzeWinnersCount <= 0) {
|
||||
return "At least one winner must be configured";
|
||||
}
|
||||
|
||||
if (input.goldWinnersCount > 0 && input.goldMinCorrect == null) {
|
||||
return "Gold minimum correct answers is required";
|
||||
}
|
||||
|
||||
if (input.silverWinnersCount > 0 && input.silverMinCorrect == null) {
|
||||
return "Silver minimum correct answers is required";
|
||||
}
|
||||
|
||||
if (input.bronzeWinnersCount > 0 && input.bronzeMinCorrect == null) {
|
||||
return "Bronze minimum correct answers is required";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const session = await requireAdmin();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const { id } = await params;
|
||||
const {
|
||||
date,
|
||||
windowStart,
|
||||
windowEnd,
|
||||
goldWinnersCount,
|
||||
silverWinnersCount,
|
||||
bronzeWinnersCount,
|
||||
goldMinCorrect,
|
||||
silverMinCorrect,
|
||||
bronzeMinCorrect,
|
||||
questions,
|
||||
} = await req.json();
|
||||
|
||||
const parsedInput = {
|
||||
goldWinnersCount: Number(goldWinnersCount),
|
||||
silverWinnersCount: Number(silverWinnersCount),
|
||||
bronzeWinnersCount: Number(bronzeWinnersCount),
|
||||
goldMinCorrect: goldMinCorrect == null ? null : Number(goldMinCorrect),
|
||||
silverMinCorrect: silverMinCorrect == null ? null : Number(silverMinCorrect),
|
||||
bronzeMinCorrect: bronzeMinCorrect == null ? null : Number(bronzeMinCorrect),
|
||||
};
|
||||
|
||||
const validationError = validateTierConfig(parsedInput);
|
||||
if (validationError) {
|
||||
return NextResponse.json({ error: validationError }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!Array.isArray(questions) || questions.length === 0) {
|
||||
return NextResponse.json({ error: "At least one question is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const quiz = await db.dailyQuiz.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
submissions: {
|
||||
select: { id: true, answers: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!quiz) return NextResponse.json({ error: "Quiz not found" }, { status: 404 });
|
||||
if (quiz.isProcessed) {
|
||||
return NextResponse.json({ error: "Quiz can no longer be edited after lottery processing" }, { status: 400 });
|
||||
}
|
||||
|
||||
const normalizedQuestions = questions.map((q: any, index: number) => ({
|
||||
questionText: q.questionText,
|
||||
options: q.options,
|
||||
correctAnswer: Number(q.correctAnswer),
|
||||
order: index,
|
||||
}));
|
||||
|
||||
const updatedQuiz = await db.$transaction(async (tx) => {
|
||||
await tx.quizQuestion.deleteMany({ where: { quizId: id } });
|
||||
|
||||
const updated = await tx.dailyQuiz.update({
|
||||
where: { id },
|
||||
data: {
|
||||
date: new Date(`${date}T00:00:00.000Z`),
|
||||
windowStart: new Date(windowStart),
|
||||
windowEnd: new Date(windowEnd),
|
||||
goldWinnersCount: parsedInput.goldWinnersCount,
|
||||
silverWinnersCount: parsedInput.silverWinnersCount,
|
||||
bronzeWinnersCount: parsedInput.bronzeWinnersCount,
|
||||
goldMinCorrect: parsedInput.goldMinCorrect,
|
||||
silverMinCorrect: parsedInput.silverMinCorrect,
|
||||
bronzeMinCorrect: parsedInput.bronzeMinCorrect,
|
||||
questions: {
|
||||
create: normalizedQuestions,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
questions: { orderBy: { order: "asc" } },
|
||||
},
|
||||
});
|
||||
|
||||
for (const submission of quiz.submissions) {
|
||||
const result = calculateResult(submission.answers, normalizedQuestions);
|
||||
await tx.quizSubmission.update({
|
||||
where: { id: submission.id },
|
||||
data: {
|
||||
score: result.score,
|
||||
correctAnswers: result.correct,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
return NextResponse.json(updatedQuiz);
|
||||
} catch (error) {
|
||||
console.error("Failed to update quiz", error);
|
||||
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === "P2002") {
|
||||
return NextResponse.json({ error: "Quiz date already exists" }, { status: 409 });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "Failed to update quiz" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await requireAdmin();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const quiz = await db.dailyQuiz.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
isProcessed: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!quiz) return NextResponse.json({ error: "Quiz not found" }, { status: 404 });
|
||||
if (quiz.isProcessed) {
|
||||
return NextResponse.json({ error: "Quiz can no longer be deleted after lottery processing" }, { status: 400 });
|
||||
}
|
||||
|
||||
await db.dailyQuiz.delete({ where: { id } });
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
132
app/api/admin/quiz/route.ts
Normal file
132
app/api/admin/quiz/route.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
async function adminOnly(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session || (session.user as any).role !== "ADMIN") return null;
|
||||
return session;
|
||||
}
|
||||
|
||||
function validateTierConfig(input: {
|
||||
goldWinnersCount: number;
|
||||
silverWinnersCount: number;
|
||||
bronzeWinnersCount: number;
|
||||
goldMinCorrect: number | null;
|
||||
silverMinCorrect: number | null;
|
||||
bronzeMinCorrect: number | null;
|
||||
}) {
|
||||
if (input.goldWinnersCount < 0 || input.silverWinnersCount < 0 || input.bronzeWinnersCount < 0) {
|
||||
return "Winner counts cannot be negative";
|
||||
}
|
||||
|
||||
if (input.goldWinnersCount + input.silverWinnersCount + input.bronzeWinnersCount <= 0) {
|
||||
return "At least one winner must be configured";
|
||||
}
|
||||
|
||||
if (input.goldWinnersCount > 0 && input.goldMinCorrect == null) {
|
||||
return "Gold minimum correct answers is required";
|
||||
}
|
||||
|
||||
if (input.silverWinnersCount > 0 && input.silverMinCorrect == null) {
|
||||
return "Silver minimum correct answers is required";
|
||||
}
|
||||
|
||||
if (input.bronzeWinnersCount > 0 && input.bronzeMinCorrect == null) {
|
||||
return "Bronze minimum correct answers is required";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// GET /api/admin/quiz - list all quizzes
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await adminOnly(req);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const quizzes = await db.dailyQuiz.findMany({
|
||||
orderBy: { date: "desc" },
|
||||
include: {
|
||||
questions: { orderBy: { order: "asc" } },
|
||||
_count: { select: { submissions: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(quizzes);
|
||||
}
|
||||
|
||||
// POST /api/admin/quiz - create quiz
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await adminOnly(req);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const {
|
||||
date,
|
||||
windowStart,
|
||||
windowEnd,
|
||||
goldWinnersCount,
|
||||
silverWinnersCount,
|
||||
bronzeWinnersCount,
|
||||
goldMinCorrect,
|
||||
silverMinCorrect,
|
||||
bronzeMinCorrect,
|
||||
questions,
|
||||
} = await req.json();
|
||||
|
||||
const parsedInput = {
|
||||
goldWinnersCount: Number(goldWinnersCount),
|
||||
silverWinnersCount: Number(silverWinnersCount),
|
||||
bronzeWinnersCount: Number(bronzeWinnersCount),
|
||||
goldMinCorrect: goldMinCorrect == null ? null : Number(goldMinCorrect),
|
||||
silverMinCorrect: silverMinCorrect == null ? null : Number(silverMinCorrect),
|
||||
bronzeMinCorrect: bronzeMinCorrect == null ? null : Number(bronzeMinCorrect),
|
||||
};
|
||||
|
||||
const validationError = validateTierConfig(parsedInput);
|
||||
if (validationError) {
|
||||
return NextResponse.json({ error: validationError }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!Array.isArray(questions) || questions.length === 0) {
|
||||
return NextResponse.json({ error: "At least one question is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const quiz = await db.dailyQuiz.create({
|
||||
data: {
|
||||
date: new Date(`${date}T00:00:00.000Z`),
|
||||
windowStart: new Date(windowStart),
|
||||
windowEnd: new Date(windowEnd),
|
||||
goldWinnersCount: parsedInput.goldWinnersCount,
|
||||
silverWinnersCount: parsedInput.silverWinnersCount,
|
||||
bronzeWinnersCount: parsedInput.bronzeWinnersCount,
|
||||
goldMinCorrect: parsedInput.goldMinCorrect,
|
||||
silverMinCorrect: parsedInput.silverMinCorrect,
|
||||
bronzeMinCorrect: parsedInput.bronzeMinCorrect,
|
||||
questions: {
|
||||
create: questions.map((q: any, i: number) => ({
|
||||
questionText: q.questionText,
|
||||
options: q.options,
|
||||
correctAnswer: Number(q.correctAnswer),
|
||||
order: i,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { questions: true },
|
||||
});
|
||||
|
||||
return NextResponse.json(quiz, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error("Failed to create quiz", error);
|
||||
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === "P2002") {
|
||||
return NextResponse.json({ error: "Quiz date already exists" }, { status: 409 });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "Failed to create quiz" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,15 @@ 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 } }) {
|
||||
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
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 },
|
||||
where: { id },
|
||||
data: { status },
|
||||
});
|
||||
return NextResponse.json(team);
|
||||
|
||||
@@ -3,7 +3,8 @@ import { db } from "@/lib/db";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
|
||||
export async function POST(_: NextRequest, { params }: { params: { id: string } }) {
|
||||
export async function POST(_: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session || (session.user as any).role !== "ADMIN")
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
@@ -11,6 +12,6 @@ export async function POST(_: NextRequest, { params }: { params: { id: string }
|
||||
// غیرفعال کردن همه
|
||||
await db.gameweek.updateMany({ data: { isActive: false } });
|
||||
// فعال کردن این هفته
|
||||
const gw = await db.gameweek.update({ where: { id: params.id }, data: { isActive: true } });
|
||||
const gw = await db.gameweek.update({ where: { id }, data: { isActive: true } });
|
||||
return NextResponse.json(gw);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,11 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const body = await req.json();
|
||||
const gw = await db.gameweek.create({ data: body });
|
||||
const gw = await db.gameweek.create({
|
||||
data: {
|
||||
...body,
|
||||
deadline: new Date(body.deadline),
|
||||
},
|
||||
});
|
||||
return NextResponse.json(gw, { status: 201 });
|
||||
}
|
||||
|
||||
173
app/api/golden-cards/[id]/add-to-team/route.ts
Normal file
173
app/api/golden-cards/[id]/add-to-team/route.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import {
|
||||
getAutoPlacement,
|
||||
getPositionLabel,
|
||||
SPECIAL_CARD_TEAM_LIMIT,
|
||||
} from "@/lib/specialCards";
|
||||
|
||||
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const userId = (session.user as any).id;
|
||||
const { id } = await params;
|
||||
const { replacePlayerId } = await req.json().catch(() => ({}));
|
||||
|
||||
const team = await db.team.findUnique({
|
||||
where: { userId },
|
||||
include: {
|
||||
players: {
|
||||
include: {
|
||||
player: true,
|
||||
goldenCard: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!team) return NextResponse.json({ error: "ابتدا تیم بساز" }, { status: 400 });
|
||||
|
||||
const card = await db.goldenCard.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
player: { include: { country: true } },
|
||||
teamPlayer: true,
|
||||
},
|
||||
});
|
||||
if (!card) return NextResponse.json({ error: "کارت ویژه پیدا نشد" }, { status: 404 });
|
||||
if (card.userId !== userId) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
if (card.status !== "OPENED") return NextResponse.json({ error: "ابتدا کارت را باز کنید" }, { status: 400 });
|
||||
if (card.state === "SOLD") return NextResponse.json({ error: "این کارت فروخته شده است" }, { status: 400 });
|
||||
if (card.state === "IN_TEAM") return NextResponse.json({ error: "این کارت همین حالا در تیم است" }, { status: 400 });
|
||||
|
||||
const existingSpecialCount = team.players.filter((item) => item.goldenCardId).length;
|
||||
const sameCountry = team.players.filter((item) => item.player.countryId === card.player.countryId).length;
|
||||
const existingPlayer = team.players.find((item) => item.playerId === card.playerId);
|
||||
|
||||
if (!existingPlayer && sameCountry >= 3) {
|
||||
return NextResponse.json({ error: "حداکثر 3 بازیکن از یک تیم ملی" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (existingPlayer) {
|
||||
if (existingPlayer.goldenCardId) {
|
||||
return NextResponse.json({ error: "نسخه ویژه این بازیکن در تیم شما وجود دارد" }, { status: 400 });
|
||||
}
|
||||
if (existingSpecialCount >= SPECIAL_CARD_TEAM_LIMIT) {
|
||||
return NextResponse.json({ error: "ظرفیت 3 کارت ویژه تیم پر است" }, { status: 400 });
|
||||
}
|
||||
|
||||
const updatedTeamPlayer = await db.$transaction(async (tx) => {
|
||||
const updatedPlayer = await tx.teamPlayer.update({
|
||||
where: { teamId_playerId: { teamId: team.id, playerId: existingPlayer.playerId } },
|
||||
data: { goldenCardId: card.id },
|
||||
});
|
||||
|
||||
await tx.goldenCard.update({
|
||||
where: { id: card.id },
|
||||
data: { state: "IN_TEAM" },
|
||||
});
|
||||
|
||||
return updatedPlayer;
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
action: "converted_existing",
|
||||
placement: existingPlayer.isBench ? "ذخیره" : "فیکس",
|
||||
teamPlayer: updatedTeamPlayer,
|
||||
card: { ...card, state: "IN_TEAM" },
|
||||
message: "بازیکن موجود تیم شما به نسخه ویژه تبدیل شد",
|
||||
});
|
||||
}
|
||||
|
||||
const autoPlacement = getAutoPlacement(team.formation, team.players as any, card.player.position);
|
||||
|
||||
if (!replacePlayerId && !autoPlacement) {
|
||||
const candidates = team.players
|
||||
.filter((item) => item.player.position === card.player.position)
|
||||
.map((item) => ({
|
||||
playerId: item.playerId,
|
||||
name: item.player.name,
|
||||
isBench: item.isBench,
|
||||
isSpecial: Boolean(item.goldenCardId),
|
||||
}));
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `پست ${getPositionLabel(card.player.position)} در ترکیب اصلی و ذخیره پر است`,
|
||||
needsReplacement: true,
|
||||
candidates,
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const replacingPlayer = replacePlayerId
|
||||
? team.players.find((item) => item.playerId === replacePlayerId)
|
||||
: null;
|
||||
|
||||
if (replacePlayerId && (!replacingPlayer || replacingPlayer.player.position !== card.player.position)) {
|
||||
return NextResponse.json({ error: "بازیکن انتخابشده برای تعویض معتبر نیست" }, { status: 400 });
|
||||
}
|
||||
|
||||
const nextSpecialCount = existingSpecialCount + 1 - (replacingPlayer?.goldenCardId ? 1 : 0);
|
||||
if (nextSpecialCount > SPECIAL_CARD_TEAM_LIMIT) {
|
||||
return NextResponse.json({ error: "ظرفیت 3 کارت ویژه تیم پر است" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!replacingPlayer && team.players.length >= 15) {
|
||||
return NextResponse.json({ error: "تیم پر است" }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await db.$transaction(async (tx) => {
|
||||
if (replacingPlayer) {
|
||||
await tx.teamPlayer.delete({
|
||||
where: { teamId_playerId: { teamId: team.id, playerId: replacingPlayer.playerId } },
|
||||
});
|
||||
|
||||
if (replacingPlayer.goldenCardId) {
|
||||
await tx.goldenCard.update({
|
||||
where: { id: replacingPlayer.goldenCardId },
|
||||
data: { state: "IN_INVENTORY" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const teamPlayer = await tx.teamPlayer.create({
|
||||
data: {
|
||||
teamId: team.id,
|
||||
playerId: card.playerId,
|
||||
goldenCardId: card.id,
|
||||
isBench: replacingPlayer ? replacingPlayer.isBench : autoPlacement!.isBench,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.goldenCard.update({
|
||||
where: { id: card.id },
|
||||
data: { state: "IN_TEAM" },
|
||||
});
|
||||
|
||||
return teamPlayer;
|
||||
});
|
||||
|
||||
const placement = replacingPlayer
|
||||
? replacingPlayer.isBench
|
||||
? "ذخیره"
|
||||
: "فیکس"
|
||||
: autoPlacement!.placementLabel;
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
action: replacingPlayer ? "replaced" : "added",
|
||||
placement,
|
||||
replacedPlayerId: replacingPlayer?.playerId ?? null,
|
||||
replacedGoldenCardId: replacingPlayer?.goldenCardId ?? null,
|
||||
card: { ...card, state: "IN_TEAM" },
|
||||
teamPlayer: result,
|
||||
message: replacingPlayer
|
||||
? "بازیکن ویژه جایگزین بازیکن انتخابشده شد"
|
||||
: `بازیکن ویژه به صورت خودکار در ${placement} قرار گرفت`,
|
||||
});
|
||||
}
|
||||
26
app/api/golden-cards/[id]/reveal/route.ts
Normal file
26
app/api/golden-cards/[id]/reveal/route.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
|
||||
// POST /api/golden-cards/[id]/reveal
|
||||
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const userId = (session.user as any).id;
|
||||
const { id } = await params;
|
||||
|
||||
const card = await db.goldenCard.findUnique({ where: { id } });
|
||||
if (!card) return NextResponse.json({ error: "Card not found" }, { status: 404 });
|
||||
if (card.userId !== userId) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
if (card.status === "OPENED") return NextResponse.json({ error: "کارت قبلاً باز شده" }, { status: 400 });
|
||||
|
||||
const updated = await db.goldenCard.update({
|
||||
where: { id },
|
||||
data: { status: "OPENED", openedAt: new Date() },
|
||||
include: { player: { include: { country: true } } },
|
||||
});
|
||||
|
||||
return NextResponse.json(updated);
|
||||
}
|
||||
52
app/api/golden-cards/[id]/sell/route.ts
Normal file
52
app/api/golden-cards/[id]/sell/route.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { getSpecialCardSalePrice } from "@/lib/specialCards";
|
||||
|
||||
export async function POST(_: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const userId = (session.user as any).id;
|
||||
const { id } = await params;
|
||||
|
||||
const card = await db.goldenCard.findUnique({
|
||||
where: { id },
|
||||
include: { player: true, teamPlayer: true },
|
||||
});
|
||||
|
||||
if (!card) return NextResponse.json({ error: "کارت ویژه پیدا نشد" }, { status: 404 });
|
||||
if (card.userId !== userId) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
if (card.status !== "OPENED") return NextResponse.json({ error: "ابتدا کارت را باز کنید" }, { status: 400 });
|
||||
if (card.state === "SOLD") return NextResponse.json({ error: "این کارت قبلاً فروخته شده" }, { status: 400 });
|
||||
|
||||
const team = await db.team.findUnique({ where: { userId } });
|
||||
if (!team) return NextResponse.json({ error: "تیم پیدا نشد" }, { status: 404 });
|
||||
|
||||
const addedBudget = getSpecialCardSalePrice(card.player.price);
|
||||
|
||||
await db.$transaction(async (tx) => {
|
||||
if (card.teamPlayer) {
|
||||
await tx.teamPlayer.delete({
|
||||
where: { teamId_playerId: { teamId: card.teamPlayer.teamId, playerId: card.teamPlayer.playerId } },
|
||||
});
|
||||
}
|
||||
|
||||
await tx.goldenCard.update({
|
||||
where: { id },
|
||||
data: { state: "SOLD" },
|
||||
});
|
||||
|
||||
await tx.team.update({
|
||||
where: { id: team.id },
|
||||
data: { budget: { increment: addedBudget } },
|
||||
});
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
addedBudget,
|
||||
cardId: id,
|
||||
});
|
||||
}
|
||||
22
app/api/golden-cards/route.ts
Normal file
22
app/api/golden-cards/route.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
|
||||
// GET /api/golden-cards - get current user's golden cards
|
||||
export async function GET() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const userId = (session.user as any).id;
|
||||
|
||||
const cards = await db.goldenCard.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
player: { include: { country: true } },
|
||||
},
|
||||
orderBy: { acquiredDate: "desc" },
|
||||
});
|
||||
|
||||
return NextResponse.json(cards);
|
||||
}
|
||||
@@ -20,7 +20,13 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const body = await req.json();
|
||||
const match = await db.match.update({ where: { id }, data: body });
|
||||
const match = await db.match.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...body,
|
||||
matchDate: new Date(body.matchDate),
|
||||
},
|
||||
});
|
||||
return NextResponse.json(match);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@ 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";
|
||||
import { calculateMatchPoints } from "@/lib/points";
|
||||
|
||||
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session || (session.user as any).role !== "ADMIN")
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
@@ -25,12 +26,25 @@ export async function POST(req: NextRequest, { params }: { params: { id: string
|
||||
const player = await db.player.findUnique({ where: { id: stat.playerId } });
|
||||
if (!player) continue;
|
||||
|
||||
const points = calculatePoints({ position: player.position, ...stat });
|
||||
const points = await calculateMatchPoints({
|
||||
position: player.position,
|
||||
goals: stat.goals,
|
||||
assists: stat.assists,
|
||||
yellowCards: stat.yellowCards,
|
||||
redCards: stat.redCards,
|
||||
minutesPlayed: stat.minutesPlayed,
|
||||
cleanSheet: stat.cleanSheet,
|
||||
penaltySaved: 0,
|
||||
penaltyMissed: 0,
|
||||
ownGoals: 0,
|
||||
isMotm: false,
|
||||
extraTimeBonus: 0,
|
||||
});
|
||||
|
||||
const record = await db.playerMatchStat.upsert({
|
||||
where: { playerId_matchId: { playerId: stat.playerId, matchId: params.id } },
|
||||
where: { playerId_matchId: { playerId: stat.playerId, matchId: id } },
|
||||
update: { ...stat, points },
|
||||
create: { ...stat, matchId: params.id, points },
|
||||
create: { ...stat, matchId: id, points },
|
||||
});
|
||||
|
||||
// آپدیت امتیاز کل بازیکن
|
||||
|
||||
@@ -22,7 +22,10 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const body = await req.json();
|
||||
const match = await db.match.create({
|
||||
data: body,
|
||||
data: {
|
||||
...body,
|
||||
matchDate: new Date(body.matchDate),
|
||||
},
|
||||
include: { homeTeam: true, awayTeam: true },
|
||||
});
|
||||
return NextResponse.json(match, { status: 201 });
|
||||
|
||||
11
app/api/openapi/route.ts
Normal file
11
app/api/openapi/route.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { openApiSpec } from "@/lib/openapi";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(openApiSpec, {
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,7 +13,11 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
const body = await req.json();
|
||||
const player = await db.player.update({
|
||||
where: { id },
|
||||
data: body,
|
||||
data: {
|
||||
...body,
|
||||
cardTier: body.cardTier ?? undefined,
|
||||
isGoldenCardEligible: body.cardTier ? body.cardTier === "GOLD" : undefined,
|
||||
},
|
||||
});
|
||||
return NextResponse.json(player);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,12 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const player = await db.player.create({ data: body });
|
||||
const player = await db.player.create({
|
||||
data: {
|
||||
...body,
|
||||
cardTier: body.cardTier ?? "BRONZE",
|
||||
isGoldenCardEligible: (body.cardTier ?? "BRONZE") === "GOLD",
|
||||
},
|
||||
});
|
||||
return NextResponse.json(player, { status: 201 });
|
||||
}
|
||||
|
||||
24
app/api/quiz/my-results/route.ts
Normal file
24
app/api/quiz/my-results/route.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
|
||||
// GET /api/quiz/my-results
|
||||
export async function GET() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const userId = (session.user as any).id;
|
||||
|
||||
const submissions = await db.quizSubmission.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
quiz: {
|
||||
include: { questions: { orderBy: { order: "asc" } } },
|
||||
},
|
||||
},
|
||||
orderBy: { submittedAt: "desc" },
|
||||
});
|
||||
|
||||
return NextResponse.json(submissions);
|
||||
}
|
||||
31
app/api/quiz/route.ts
Normal file
31
app/api/quiz/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
// GET /api/quiz - get today's active quiz
|
||||
export async function GET() {
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now);
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
const todayEnd = new Date(now);
|
||||
todayEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
const quiz = await db.dailyQuiz.findFirst({
|
||||
where: { date: { gte: todayStart, lte: todayEnd } },
|
||||
include: {
|
||||
questions: {
|
||||
orderBy: { order: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
questionText: true,
|
||||
options: true,
|
||||
order: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!quiz) return NextResponse.json(null);
|
||||
|
||||
const isActive = !quiz.isProcessed && now >= quiz.windowStart && now <= quiz.windowEnd;
|
||||
return NextResponse.json({ ...quiz, isActive });
|
||||
}
|
||||
61
app/api/quiz/submit/route.ts
Normal file
61
app/api/quiz/submit/route.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { CARD_TIER_LABELS, resolveQuizRewardTier } from "@/lib/cardTier";
|
||||
|
||||
// POST /api/quiz/submit
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const userId = (session.user as any).id;
|
||||
const { quizId, answers } = await req.json();
|
||||
|
||||
if (!quizId || !Array.isArray(answers)) {
|
||||
return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
|
||||
}
|
||||
|
||||
const quiz = await db.dailyQuiz.findUnique({
|
||||
where: { id: quizId },
|
||||
include: { questions: { orderBy: { order: "asc" } } },
|
||||
});
|
||||
|
||||
if (!quiz) return NextResponse.json({ error: "Quiz not found" }, { status: 404 });
|
||||
if (quiz.isProcessed) {
|
||||
return NextResponse.json({ error: "این کوییز بعد از قرعهکشی بسته شده است" }, { status: 400 });
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
if (now < quiz.windowStart || now > quiz.windowEnd) {
|
||||
return NextResponse.json({ error: "خارج از بازه زمانی مجاز" }, { status: 400 });
|
||||
}
|
||||
|
||||
const existing = await db.quizSubmission.findUnique({
|
||||
where: { userId_quizId: { userId, quizId } },
|
||||
});
|
||||
if (existing) return NextResponse.json({ error: "قبلاً شرکت کردهاید" }, { status: 400 });
|
||||
|
||||
let correct = 0;
|
||||
quiz.questions.forEach((q, i) => {
|
||||
if (answers[i] === q.correctAnswer) correct++;
|
||||
});
|
||||
|
||||
const score = quiz.questions.length > 0
|
||||
? Math.round((correct / quiz.questions.length) * 100)
|
||||
: 0;
|
||||
const rewardTier = resolveQuizRewardTier(quiz, correct);
|
||||
|
||||
const submission = await db.quizSubmission.create({
|
||||
data: { userId, quizId, answers, correctAnswers: correct, score },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
score,
|
||||
correct,
|
||||
total: quiz.questions.length,
|
||||
rewardTier,
|
||||
rewardTierLabel: rewardTier ? CARD_TIER_LABELS[rewardTier] : null,
|
||||
submission,
|
||||
});
|
||||
}
|
||||
@@ -3,7 +3,6 @@ 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 });
|
||||
@@ -13,7 +12,7 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const team = await db.team.findUnique({
|
||||
where: { userId },
|
||||
include: { players: { include: { player: true } } },
|
||||
include: { players: { include: { player: true, goldenCard: true } } },
|
||||
});
|
||||
|
||||
if (!team) return NextResponse.json({ error: "ابتدا تیم بساز" }, { status: 400 });
|
||||
@@ -21,32 +20,34 @@ export async function POST(req: NextRequest) {
|
||||
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)
|
||||
const spent = team.players
|
||||
.filter((item) => !item.goldenCardId)
|
||||
.reduce((sum, item) => sum + item.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 });
|
||||
if (team.players.length >= 15) {
|
||||
return NextResponse.json({ error: "تیم پر است (حداکثر 15 بازیکن)" }, { status: 400 });
|
||||
}
|
||||
|
||||
// چک تکراری
|
||||
const exists = team.players.find((tp) => tp.playerId === playerId);
|
||||
if (exists) return NextResponse.json({ error: "این بازیکن قبلاً انتخاب شده" }, { status: 400 });
|
||||
const exists = team.players.find((item) => item.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 sameCountry = team.players.filter((item) => item.player.countryId === player.countryId).length;
|
||||
if (sameCountry >= 3) {
|
||||
return NextResponse.json({ error: "حداکثر 3 بازیکن از یک تیم ملی" }, { status: 400 });
|
||||
}
|
||||
|
||||
const tp = await db.teamPlayer.create({
|
||||
const teamPlayer = await db.teamPlayer.create({
|
||||
data: { teamId: team.id, playerId, isBench: isBench ?? false },
|
||||
});
|
||||
|
||||
return NextResponse.json(tp, { status: 201 });
|
||||
return NextResponse.json(teamPlayer, { status: 201 });
|
||||
}
|
||||
|
||||
// حذف بازیکن از تیم
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
@@ -54,11 +55,26 @@ export async function DELETE(req: NextRequest) {
|
||||
const { playerId } = await req.json();
|
||||
const userId = (session.user as any).id;
|
||||
|
||||
const team = await db.team.findUnique({ where: { userId } });
|
||||
const team = await db.team.findUnique({
|
||||
where: { userId },
|
||||
include: { players: true },
|
||||
});
|
||||
if (!team) return NextResponse.json({ error: "تیم پیدا نشد" }, { status: 404 });
|
||||
|
||||
await db.teamPlayer.delete({
|
||||
where: { teamId_playerId: { teamId: team.id, playerId } },
|
||||
const teamPlayer = team.players.find((item) => item.playerId === playerId);
|
||||
if (!teamPlayer) return NextResponse.json({ error: "بازیکن در تیم نیست" }, { status: 404 });
|
||||
|
||||
await db.$transaction(async (tx) => {
|
||||
await tx.teamPlayer.delete({
|
||||
where: { teamId_playerId: { teamId: team.id, playerId } },
|
||||
});
|
||||
|
||||
if (teamPlayer.goldenCardId) {
|
||||
await tx.goldenCard.update({
|
||||
where: { id: teamPlayer.goldenCardId },
|
||||
data: { state: "IN_INVENTORY" },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
|
||||
71
app/swagger/route.ts
Normal file
71
app/swagger/route.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Swagger UI - Football Next</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
background: #f5f7fb;
|
||||
font-family: Tahoma, sans-serif;
|
||||
}
|
||||
.topbar {
|
||||
padding: 18px 24px;
|
||||
background: linear-gradient(135deg, #0f172a, #1d4ed8);
|
||||
color: #fff;
|
||||
}
|
||||
.topbar h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 22px;
|
||||
}
|
||||
.topbar p {
|
||||
margin: 0;
|
||||
opacity: 0.9;
|
||||
line-height: 1.8;
|
||||
}
|
||||
#swagger-ui {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.swagger-ui .topbar {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="topbar">
|
||||
<h1>مستندات Swagger پروژه Football Next</h1>
|
||||
<p>خروجی OpenAPI از مسیر <code>/api/openapi</code> خوانده میشود. اگر قبلاً در همین مرورگر لاگین کرده باشید، تست Endpointهای Session-based هم قابل انجام است.</p>
|
||||
</div>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-standalone-preset.js"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
window.ui = SwaggerUIBundle({
|
||||
url: "/api/openapi",
|
||||
dom_id: "#swagger-ui",
|
||||
deepLinking: true,
|
||||
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
|
||||
layout: "StandaloneLayout",
|
||||
persistAuthorization: true,
|
||||
docExpansion: "list",
|
||||
defaultModelsExpandDepth: 2,
|
||||
displayRequestDuration: true,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
export async function GET() {
|
||||
return new Response(html, {
|
||||
headers: {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user