Add dotenv package for environment variable management and update pnpm-lock.yaml. Adjust layout in RegisterPage and LoginPage components for improved responsiveness. Enhance AdminPanel with ChallengeManagement section and update navigation links for challenges. Refactor Prisma schema to include Challenge model and related enums.
This commit is contained in:
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import UserManagement from "@/components/admin/UserManagement";
|
||||
import EventManagement from "@/components/admin/EventManagement";
|
||||
import FeedbackManagement from "@/components/admin/FeedbackManagement";
|
||||
import ChallengeManagement from "@/components/admin/ChallengeManagement";
|
||||
import BackgroundPreferences from "@/components/admin/BackgroundPreferences";
|
||||
import { Button, Card, SectionTitle } from "@/components/ui";
|
||||
|
||||
@@ -18,7 +19,7 @@ interface AdminPanelProps {
|
||||
initialPreferences: SitePreferences;
|
||||
}
|
||||
|
||||
type AdminSection = "preferences" | "users" | "events" | "feedbacks";
|
||||
type AdminSection = "preferences" | "users" | "events" | "feedbacks" | "challenges";
|
||||
|
||||
export default function AdminPanel({ initialPreferences }: AdminPanelProps) {
|
||||
const [activeSection, setActiveSection] =
|
||||
@@ -67,6 +68,14 @@ export default function AdminPanel({ initialPreferences }: AdminPanelProps) {
|
||||
>
|
||||
Feedbacks
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setActiveSection("challenges")}
|
||||
variant={activeSection === "challenges" ? "primary" : "secondary"}
|
||||
size="md"
|
||||
className={activeSection === "challenges" ? "bg-pixel-gold/10" : ""}
|
||||
>
|
||||
Défis
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{activeSection === "preferences" && (
|
||||
@@ -108,6 +117,15 @@ export default function AdminPanel({ initialPreferences }: AdminPanelProps) {
|
||||
<FeedbackManagement />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeSection === "challenges" && (
|
||||
<Card variant="dark" className="p-6">
|
||||
<h2 className="text-2xl font-gaming font-bold mb-6 text-pixel-gold">
|
||||
Gestion des Défis
|
||||
</h2>
|
||||
<ChallengeManagement />
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
504
components/admin/ChallengeManagement.tsx
Normal file
504
components/admin/ChallengeManagement.tsx
Normal file
@@ -0,0 +1,504 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { validateChallenge, rejectChallenge, updateChallenge, deleteChallenge } from "@/actions/admin/challenges";
|
||||
import { Button, Card, Input, Textarea, Alert } from "@/components/ui";
|
||||
import { Avatar } from "@/components/ui";
|
||||
|
||||
interface Challenge {
|
||||
id: string;
|
||||
challenger: {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
};
|
||||
challenged: {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
};
|
||||
title: string;
|
||||
description: string;
|
||||
pointsReward: number;
|
||||
status: string;
|
||||
adminComment: string | null;
|
||||
createdAt: string;
|
||||
acceptedAt: string | null;
|
||||
}
|
||||
|
||||
export default function ChallengeManagement() {
|
||||
const [challenges, setChallenges] = useState<Challenge[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedChallenge, setSelectedChallenge] = useState<Challenge | null>(null);
|
||||
const [editingChallenge, setEditingChallenge] = useState<Challenge | null>(null);
|
||||
const [winnerId, setWinnerId] = useState<string>("");
|
||||
const [adminComment, setAdminComment] = useState("");
|
||||
const [editTitle, setEditTitle] = useState("");
|
||||
const [editDescription, setEditDescription] = useState("");
|
||||
const [editPointsReward, setEditPointsReward] = useState<number>(0);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchChallenges();
|
||||
}, []);
|
||||
|
||||
const fetchChallenges = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/admin/challenges");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setChallenges(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching challenges:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleValidate = async () => {
|
||||
if (!selectedChallenge || !winnerId) {
|
||||
setErrorMessage("Veuillez sélectionner un gagnant");
|
||||
setTimeout(() => setErrorMessage(null), 5000);
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await validateChallenge(
|
||||
selectedChallenge.id,
|
||||
winnerId,
|
||||
adminComment || undefined
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
setSuccessMessage("Défi validé avec succès ! Les points ont été attribués.");
|
||||
setSelectedChallenge(null);
|
||||
setWinnerId("");
|
||||
setAdminComment("");
|
||||
fetchChallenges();
|
||||
setTimeout(() => setSuccessMessage(null), 5000);
|
||||
} else {
|
||||
setErrorMessage(result.error || "Erreur lors de la validation");
|
||||
setTimeout(() => setErrorMessage(null), 5000);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleReject = async () => {
|
||||
if (!selectedChallenge) return;
|
||||
|
||||
if (!confirm("Êtes-vous sûr de vouloir rejeter ce défi ?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await rejectChallenge(
|
||||
selectedChallenge.id,
|
||||
adminComment || undefined
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
setSuccessMessage("Défi rejeté");
|
||||
setSelectedChallenge(null);
|
||||
setAdminComment("");
|
||||
fetchChallenges();
|
||||
setTimeout(() => setSuccessMessage(null), 5000);
|
||||
} else {
|
||||
setErrorMessage(result.error || "Erreur lors du rejet");
|
||||
setTimeout(() => setErrorMessage(null), 5000);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleEdit = (challenge: Challenge) => {
|
||||
setEditingChallenge(challenge);
|
||||
setEditTitle(challenge.title);
|
||||
setEditDescription(challenge.description);
|
||||
setEditPointsReward(challenge.pointsReward);
|
||||
};
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!editingChallenge) return;
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await updateChallenge(editingChallenge.id, {
|
||||
title: editTitle,
|
||||
description: editDescription,
|
||||
pointsReward: editPointsReward,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
setSuccessMessage("Défi mis à jour avec succès");
|
||||
setEditingChallenge(null);
|
||||
setEditTitle("");
|
||||
setEditDescription("");
|
||||
setEditPointsReward(0);
|
||||
fetchChallenges();
|
||||
setTimeout(() => setSuccessMessage(null), 5000);
|
||||
} else {
|
||||
setErrorMessage(result.error || "Erreur lors de la mise à jour");
|
||||
setTimeout(() => setErrorMessage(null), 5000);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = async (challengeId: string) => {
|
||||
if (!confirm("Êtes-vous sûr de vouloir supprimer ce défi ? Cette action est irréversible.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await deleteChallenge(challengeId);
|
||||
|
||||
if (result.success) {
|
||||
setSuccessMessage("Défi supprimé avec succès");
|
||||
fetchChallenges();
|
||||
setTimeout(() => setSuccessMessage(null), 5000);
|
||||
} else {
|
||||
setErrorMessage(result.error || "Erreur lors de la suppression");
|
||||
setTimeout(() => setErrorMessage(null), 5000);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center text-pixel-gold py-8">Chargement...</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (challenges.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-gray-400 py-8">
|
||||
Aucun défi en attente
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const acceptedChallenges = challenges.filter((c) => c.status === "ACCEPTED");
|
||||
const pendingChallenges = challenges.filter((c) => c.status === "PENDING");
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{successMessage && (
|
||||
<Alert variant="success" className="mb-4">
|
||||
{successMessage}
|
||||
</Alert>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<Alert variant="error" className="mb-4">
|
||||
{errorMessage}
|
||||
</Alert>
|
||||
)}
|
||||
<div className="text-sm text-gray-400 mb-4">
|
||||
{acceptedChallenges.length} défi{acceptedChallenges.length > 1 ? "s" : ""} en attente de validation
|
||||
{pendingChallenges.length > 0 && (
|
||||
<span className="ml-2">
|
||||
• {pendingChallenges.length} défi{pendingChallenges.length > 1 ? "s" : ""} en attente d'acceptation
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{challenges.map((challenge) => (
|
||||
<Card key={challenge.id} variant="dark" className="p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-bold text-pixel-gold mb-2">
|
||||
{challenge.title}
|
||||
</h3>
|
||||
<p className="text-gray-300 mb-4">{challenge.description}</p>
|
||||
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar
|
||||
src={challenge.challenger.avatar}
|
||||
username={challenge.challenger.username}
|
||||
size="sm"
|
||||
/>
|
||||
<span className="text-sm text-gray-300">
|
||||
{challenge.challenger.username}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">VS</span>
|
||||
<Avatar
|
||||
src={challenge.challenged.avatar}
|
||||
username={challenge.challenged.username}
|
||||
size="sm"
|
||||
/>
|
||||
<span className="text-sm text-gray-300">
|
||||
{challenge.challenged.username}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-400">
|
||||
Récompense: <span className="text-pixel-gold font-bold">{challenge.pointsReward} points</span>
|
||||
</div>
|
||||
<div className="text-xs mt-2">
|
||||
<span className={`px-2 py-1 rounded ${
|
||||
challenge.status === "ACCEPTED"
|
||||
? "bg-green-500/20 text-green-400"
|
||||
: "bg-yellow-500/20 text-yellow-400"
|
||||
}`}>
|
||||
{challenge.status === "ACCEPTED" ? "Accepté" : "En attente d'acceptation"}
|
||||
</span>
|
||||
</div>
|
||||
{challenge.acceptedAt && (
|
||||
<div className="text-xs text-gray-500 mt-2">
|
||||
Accepté le: {new Date(challenge.acceptedAt).toLocaleDateString("fr-FR")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
onClick={() => handleEdit(challenge)}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
Modifier
|
||||
</Button>
|
||||
{challenge.status === "ACCEPTED" && (
|
||||
<Button
|
||||
onClick={() => setSelectedChallenge(challenge)}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
Valider/Rejeter
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => handleDelete(challenge.id)}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
style={{
|
||||
color: "var(--destructive)",
|
||||
borderColor: "var(--destructive)",
|
||||
}}
|
||||
>
|
||||
Supprimer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* Modal de validation */}
|
||||
{selectedChallenge && (
|
||||
<div
|
||||
className="fixed inset-0 z-[200] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm"
|
||||
onClick={() => {
|
||||
setSelectedChallenge(null);
|
||||
setWinnerId("");
|
||||
setAdminComment("");
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
variant="dark"
|
||||
className="max-w-2xl w-full max-h-[90vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-6">
|
||||
<h2 className="text-2xl font-bold text-pixel-gold mb-4">
|
||||
Valider/Rejeter le défi
|
||||
</h2>
|
||||
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-bold text-gray-300 mb-2">
|
||||
{selectedChallenge.title}
|
||||
</h3>
|
||||
<p className="text-gray-400 mb-4">
|
||||
{selectedChallenge.description}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar
|
||||
src={selectedChallenge.challenger.avatar}
|
||||
username={selectedChallenge.challenger.username}
|
||||
size="md"
|
||||
/>
|
||||
<span className="text-gray-300">
|
||||
{selectedChallenge.challenger.username}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-gray-500">VS</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar
|
||||
src={selectedChallenge.challenged.avatar}
|
||||
username={selectedChallenge.challenged.username}
|
||||
size="md"
|
||||
/>
|
||||
<span className="text-gray-300">
|
||||
{selectedChallenge.challenged.username}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-bold text-pixel-gold mb-2">
|
||||
Sélectionner le gagnant
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="winner"
|
||||
value={selectedChallenge.challenger.id}
|
||||
checked={winnerId === selectedChallenge.challenger.id}
|
||||
onChange={(e) => setWinnerId(e.target.value)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="text-gray-300">
|
||||
{selectedChallenge.challenger.username}
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="winner"
|
||||
value={selectedChallenge.challenged.id}
|
||||
checked={winnerId === selectedChallenge.challenged.id}
|
||||
onChange={(e) => setWinnerId(e.target.value)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="text-gray-300">
|
||||
{selectedChallenge.challenged.username}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-bold text-pixel-gold mb-2">
|
||||
Commentaire (optionnel)
|
||||
</label>
|
||||
<textarea
|
||||
value={adminComment}
|
||||
onChange={(e) => setAdminComment(e.target.value)}
|
||||
className="w-full p-2 bg-black/60 border border-pixel-gold/30 rounded text-gray-300"
|
||||
rows={3}
|
||||
placeholder="Commentaire pour les joueurs..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
onClick={handleValidate}
|
||||
variant="primary"
|
||||
disabled={!winnerId || isPending}
|
||||
className="flex-1"
|
||||
>
|
||||
{isPending ? "Validation..." : "Valider le défi"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleReject}
|
||||
variant="secondary"
|
||||
disabled={isPending}
|
||||
className="flex-1"
|
||||
>
|
||||
{isPending ? "Rejet..." : "Rejeter le défi"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSelectedChallenge(null);
|
||||
setWinnerId("");
|
||||
setAdminComment("");
|
||||
}}
|
||||
variant="secondary"
|
||||
disabled={isPending}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal d'édition */}
|
||||
{editingChallenge && (
|
||||
<div
|
||||
className="fixed inset-0 z-[200] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm"
|
||||
onClick={() => {
|
||||
setEditingChallenge(null);
|
||||
setEditTitle("");
|
||||
setEditDescription("");
|
||||
setEditPointsReward(0);
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
variant="dark"
|
||||
className="max-w-2xl w-full max-h-[90vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-6">
|
||||
<h2 className="text-2xl font-bold text-pixel-gold mb-4">
|
||||
Modifier le défi
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
id="edit-title"
|
||||
label="Titre"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
required
|
||||
placeholder="Titre du défi"
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
id="edit-description"
|
||||
label="Description"
|
||||
value={editDescription}
|
||||
onChange={(e) => setEditDescription(e.target.value)}
|
||||
required
|
||||
rows={4}
|
||||
placeholder="Description du défi"
|
||||
/>
|
||||
|
||||
<Input
|
||||
id="edit-points"
|
||||
label="Récompense (points)"
|
||||
type="number"
|
||||
min="1"
|
||||
value={editPointsReward}
|
||||
onChange={(e) => setEditPointsReward(parseInt(e.target.value) || 0)}
|
||||
required
|
||||
placeholder="100"
|
||||
/>
|
||||
|
||||
<div className="flex gap-4 pt-4">
|
||||
<Button
|
||||
onClick={handleUpdate}
|
||||
variant="primary"
|
||||
disabled={isPending || !editTitle || !editDescription || editPointsReward <= 0}
|
||||
className="flex-1"
|
||||
>
|
||||
{isPending ? "Mise à jour..." : "Enregistrer"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditingChallenge(null);
|
||||
setEditTitle("");
|
||||
setEditDescription("");
|
||||
setEditPointsReward(0);
|
||||
}}
|
||||
variant="secondary"
|
||||
disabled={isPending}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
428
components/challenges/ChallengesSection.tsx
Normal file
428
components/challenges/ChallengesSection.tsx
Normal file
@@ -0,0 +1,428 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { createChallenge, acceptChallenge, cancelChallenge } from "@/actions/challenges/create";
|
||||
import { Button, Card, SectionTitle, Input, Textarea, Alert } from "@/components/ui";
|
||||
import { Avatar } from "@/components/ui";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
score: number;
|
||||
level: number;
|
||||
}
|
||||
|
||||
interface Challenge {
|
||||
id: string;
|
||||
challenger: {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
};
|
||||
challenged: {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
};
|
||||
title: string;
|
||||
description: string;
|
||||
pointsReward: number;
|
||||
status: string;
|
||||
adminComment: string | null;
|
||||
winner?: {
|
||||
id: string;
|
||||
username: string;
|
||||
} | null;
|
||||
createdAt: string;
|
||||
acceptedAt: string | null;
|
||||
completedAt: string | null;
|
||||
}
|
||||
|
||||
interface ChallengesSectionProps {
|
||||
backgroundImage: string;
|
||||
}
|
||||
|
||||
export default function ChallengesSection({ backgroundImage }: ChallengesSectionProps) {
|
||||
const { data: session } = useSession();
|
||||
const [challenges, setChallenges] = useState<Challenge[]>([]);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
// Form state
|
||||
const [challengedId, setChallengedId] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [pointsReward, setPointsReward] = useState(100);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchChallenges();
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
const fetchChallenges = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/challenges");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setChallenges(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching challenges:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/users");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUsers(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching users:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateChallenge = () => {
|
||||
if (!challengedId || !title || !description) {
|
||||
setErrorMessage("Veuillez remplir tous les champs");
|
||||
setTimeout(() => setErrorMessage(null), 5000);
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await createChallenge({
|
||||
challengedId,
|
||||
title,
|
||||
description,
|
||||
pointsReward,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
setSuccessMessage("Défi créé avec succès !");
|
||||
setShowCreateForm(false);
|
||||
setChallengedId("");
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setPointsReward(100);
|
||||
fetchChallenges();
|
||||
setTimeout(() => setSuccessMessage(null), 5000);
|
||||
} else {
|
||||
setErrorMessage(result.error || "Erreur lors de la création du défi");
|
||||
setTimeout(() => setErrorMessage(null), 5000);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleAcceptChallenge = (challengeId: string) => {
|
||||
startTransition(async () => {
|
||||
const result = await acceptChallenge(challengeId);
|
||||
|
||||
if (result.success) {
|
||||
setSuccessMessage("Défi accepté ! En attente de validation admin.");
|
||||
fetchChallenges();
|
||||
setTimeout(() => setSuccessMessage(null), 5000);
|
||||
} else {
|
||||
setErrorMessage(result.error || "Erreur lors de l'acceptation");
|
||||
setTimeout(() => setErrorMessage(null), 5000);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancelChallenge = (challengeId: string) => {
|
||||
if (!confirm("Êtes-vous sûr de vouloir annuler ce défi ?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await cancelChallenge(challengeId);
|
||||
|
||||
if (result.success) {
|
||||
setSuccessMessage("Défi annulé");
|
||||
fetchChallenges();
|
||||
setTimeout(() => setSuccessMessage(null), 5000);
|
||||
} else {
|
||||
setErrorMessage(result.error || "Erreur lors de l'annulation");
|
||||
setTimeout(() => setErrorMessage(null), 5000);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case "PENDING":
|
||||
return "En attente d'acceptation";
|
||||
case "ACCEPTED":
|
||||
return "Accepté - En attente de validation admin";
|
||||
case "COMPLETED":
|
||||
return "Complété";
|
||||
case "REJECTED":
|
||||
return "Rejeté";
|
||||
case "CANCELLED":
|
||||
return "Annulé";
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "PENDING":
|
||||
return "text-yellow-400";
|
||||
case "ACCEPTED":
|
||||
return "text-blue-400";
|
||||
case "COMPLETED":
|
||||
return "text-green-400";
|
||||
case "REJECTED":
|
||||
return "text-red-400";
|
||||
case "CANCELLED":
|
||||
return "text-gray-400";
|
||||
default:
|
||||
return "text-gray-300";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
className="relative w-full min-h-screen flex flex-col items-center overflow-hidden pt-24 pb-16"
|
||||
style={{
|
||||
backgroundImage: `url(${backgroundImage})`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm"></div>
|
||||
|
||||
<div className="relative z-10 w-full max-w-6xl mx-auto px-8 py-16">
|
||||
<SectionTitle variant="gradient" size="md" className="mb-8 text-center">
|
||||
DÉFIS ENTRE JOUEURS
|
||||
</SectionTitle>
|
||||
|
||||
{successMessage && (
|
||||
<Alert variant="success" className="mb-4">
|
||||
{successMessage}
|
||||
</Alert>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<Alert variant="error" className="mb-4">
|
||||
{errorMessage}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="mb-6 flex justify-center">
|
||||
<Button
|
||||
onClick={() => setShowCreateForm(!showCreateForm)}
|
||||
variant="primary"
|
||||
size="md"
|
||||
>
|
||||
{showCreateForm ? "Annuler" : "Créer un défi"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Create Form */}
|
||||
{showCreateForm && (
|
||||
<Card variant="dark" className="p-6 mb-8">
|
||||
<h2 className="text-xl font-bold text-pixel-gold mb-4">
|
||||
Créer un nouveau défi
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-pixel-gold mb-2">
|
||||
Défier qui ?
|
||||
</label>
|
||||
<select
|
||||
value={challengedId}
|
||||
onChange={(e) => setChallengedId(e.target.value)}
|
||||
className="w-full p-2 bg-black/60 border border-pixel-gold/30 rounded text-gray-300"
|
||||
>
|
||||
<option value="">Sélectionner un joueur</option>
|
||||
{users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.username} (Lv.{user.level} - {user.score} pts)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-pixel-gold mb-2">
|
||||
Titre du défi
|
||||
</label>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Ex: Qui participera à plus d'événements ce mois ?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-pixel-gold mb-2">
|
||||
Description
|
||||
</label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Décrivez les règles du défi..."
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-pixel-gold mb-2">
|
||||
Points à gagner (défaut: 100)
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={pointsReward}
|
||||
onChange={(e) => setPointsReward(parseInt(e.target.value) || 100)}
|
||||
min={1}
|
||||
max={1000}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleCreateChallenge}
|
||||
variant="primary"
|
||||
disabled={isPending || !challengedId || !title || !description}
|
||||
className="w-full"
|
||||
>
|
||||
{isPending ? "Création..." : "Créer le défi"}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Challenges List */}
|
||||
{loading ? (
|
||||
<div className="text-center text-pixel-gold py-8">Chargement...</div>
|
||||
) : challenges.length === 0 ? (
|
||||
<Card variant="dark" className="p-6 text-center">
|
||||
<p className="text-gray-400">
|
||||
Vous n'avez aucun défi pour le moment.
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{challenges.map((challenge) => {
|
||||
const currentUserId = session?.user?.id;
|
||||
const isChallenger = challenge.challenger.id === currentUserId;
|
||||
const isChallenged = challenge.challenged.id === currentUserId;
|
||||
const canAccept = challenge.status === "PENDING" && isChallenged;
|
||||
const canCancel =
|
||||
(challenge.status === "PENDING" || challenge.status === "ACCEPTED") &&
|
||||
(isChallenger || isChallenged);
|
||||
|
||||
return (
|
||||
<Card key={challenge.id} variant="dark" className="p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h3 className="text-lg font-bold text-pixel-gold">
|
||||
{challenge.title}
|
||||
</h3>
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded ${getStatusColor(
|
||||
challenge.status
|
||||
)} bg-black/40`}
|
||||
>
|
||||
{getStatusLabel(challenge.status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-300 mb-4">{challenge.description}</p>
|
||||
|
||||
<div className="flex items-center gap-4 mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar
|
||||
src={challenge.challenger.avatar}
|
||||
username={challenge.challenger.username}
|
||||
size="sm"
|
||||
/>
|
||||
<span className="text-sm text-gray-300">
|
||||
{challenge.challenger.username}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-gray-500">VS</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar
|
||||
src={challenge.challenged.avatar}
|
||||
username={challenge.challenged.username}
|
||||
size="sm"
|
||||
/>
|
||||
<span className="text-sm text-gray-300">
|
||||
{challenge.challenged.username}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-400">
|
||||
Récompense:{" "}
|
||||
<span className="text-pixel-gold font-bold">
|
||||
{challenge.pointsReward} points
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{challenge.winner && (
|
||||
<div className="text-sm text-green-400 mt-2">
|
||||
🏆 Gagnant: {challenge.winner.username}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{challenge.adminComment && (
|
||||
<div className="text-xs text-gray-500 mt-2 italic">
|
||||
Admin: {challenge.adminComment}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-gray-500 mt-2">
|
||||
Créé le: {new Date(challenge.createdAt).toLocaleDateString("fr-FR")}
|
||||
{challenge.acceptedAt &&
|
||||
` • Accepté le: ${new Date(challenge.acceptedAt).toLocaleDateString("fr-FR")}`}
|
||||
{challenge.completedAt &&
|
||||
` • Complété le: ${new Date(challenge.completedAt).toLocaleDateString("fr-FR")}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{canAccept && (
|
||||
<Button
|
||||
onClick={() => handleAcceptChallenge(challenge.id)}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={isPending}
|
||||
>
|
||||
Accepter
|
||||
</Button>
|
||||
)}
|
||||
{canCancel && (
|
||||
<Button
|
||||
onClick={() => handleCancelChallenge(challenge.id)}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={isPending}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -113,6 +113,21 @@ export default function Navigation({
|
||||
>
|
||||
LEADERBOARD
|
||||
</Link>
|
||||
{isAuthenticated && (
|
||||
<Link
|
||||
href="/challenges"
|
||||
className="transition text-xs font-normal uppercase tracking-widest"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
onMouseEnter={(e) =>
|
||||
(e.currentTarget.style.color = "var(--accent-color)")
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
(e.currentTarget.style.color = "var(--foreground)")
|
||||
}
|
||||
>
|
||||
DÉFIS
|
||||
</Link>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Link
|
||||
href="/admin"
|
||||
@@ -271,6 +286,22 @@ export default function Navigation({
|
||||
>
|
||||
LEADERBOARD
|
||||
</Link>
|
||||
{isAuthenticated && (
|
||||
<Link
|
||||
href="/challenges"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
className="transition text-xs font-normal uppercase tracking-widest py-2"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
onMouseEnter={(e) =>
|
||||
(e.currentTarget.style.color = "var(--accent-color)")
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
(e.currentTarget.style.color = "var(--foreground)")
|
||||
}
|
||||
>
|
||||
DÉFIS
|
||||
</Link>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Link
|
||||
href="/admin"
|
||||
|
||||
@@ -27,7 +27,8 @@ export default function Alert({
|
||||
color: "var(--success)",
|
||||
},
|
||||
error: {
|
||||
backgroundColor: "color-mix(in srgb, var(--destructive) 20%, transparent)",
|
||||
backgroundColor:
|
||||
"color-mix(in srgb, var(--destructive) 20%, transparent)",
|
||||
borderColor: "color-mix(in srgb, var(--destructive) 50%, transparent)",
|
||||
color: "var(--destructive)",
|
||||
},
|
||||
@@ -53,4 +54,3 @@ export default function Alert({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user