All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m47s
549 lines
19 KiB
TypeScript
549 lines
19 KiB
TypeScript
"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);
|
||
const [showExamples, setShowExamples] = useState(false);
|
||
|
||
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 désignation du gagnant.");
|
||
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 "En cours - En attente de désignation du gagnant";
|
||
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">
|
||
{/* Background Image */}
|
||
<div
|
||
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||
style={{
|
||
backgroundImage: `url('${backgroundImage}')`,
|
||
}}
|
||
>
|
||
{/* Dark overlay for readability */}
|
||
<div
|
||
className="absolute inset-0 bg-gradient-to-b"
|
||
style={{
|
||
background: `linear-gradient(to bottom,
|
||
color-mix(in srgb, var(--background) 70%, transparent),
|
||
color-mix(in srgb, var(--background) 60%, transparent),
|
||
color-mix(in srgb, var(--background) 80%, transparent)
|
||
)`,
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
<div className="relative z-10 w-full max-w-6xl mx-auto px-4 sm: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>
|
||
)}
|
||
|
||
{/* Examples Section */}
|
||
<Card variant="dark" className="mt-8">
|
||
<button
|
||
onClick={() => setShowExamples(!showExamples)}
|
||
className="w-full flex items-center justify-between p-4 text-left"
|
||
>
|
||
<h3 className="text-lg font-bold text-pixel-gold">
|
||
💡 Exemples de défis
|
||
</h3>
|
||
<span className="text-pixel-gold text-xl">
|
||
{showExamples ? "−" : "+"}
|
||
</span>
|
||
</button>
|
||
{showExamples && (
|
||
<div className="px-4 pb-4 space-y-4 border-t border-[var(--border)] pt-4">
|
||
<div className="space-y-3">
|
||
<div className="p-4 bg-black/40 rounded border border-[var(--border)]">
|
||
<h4 className="font-bold text-pixel-gold mb-2">
|
||
Qui participera à plus d'événements ce mois ?
|
||
</h4>
|
||
<p className="text-sm text-gray-300">
|
||
Le joueur qui participe au plus grand nombre d'événements
|
||
organisés ce mois remporte le défi. Les événements doivent
|
||
être validés par un admin pour compter.
|
||
</p>
|
||
<div className="mt-2 text-xs text-gray-400">
|
||
Points suggérés: 150
|
||
</div>
|
||
</div>
|
||
|
||
<div className="p-4 bg-black/40 rounded border border-[var(--border)]">
|
||
<h4 className="font-bold text-pixel-gold mb-2">
|
||
Premier à atteindre le niveau 10
|
||
</h4>
|
||
<p className="text-sm text-gray-300">
|
||
Le premier joueur à atteindre le niveau 10 remporte le défi.
|
||
Le niveau est calculé automatiquement selon le score total.
|
||
</p>
|
||
<div className="mt-2 text-xs text-gray-400">
|
||
Points suggérés: 200
|
||
</div>
|
||
</div>
|
||
|
||
<div className="p-4 bg-black/40 rounded border border-[var(--border)]">
|
||
<h4 className="font-bold text-pixel-gold mb-2">
|
||
Meilleur feedback sur un événement
|
||
</h4>
|
||
<p className="text-sm text-gray-300">
|
||
Le joueur qui donne le feedback le plus détaillé et
|
||
constructif sur un événement remporte le défi. L'admin
|
||
désignera le gagnant selon la qualité du feedback.
|
||
</p>
|
||
<div className="mt-2 text-xs text-gray-400">
|
||
Points suggérés: 100
|
||
</div>
|
||
</div>
|
||
|
||
<div className="p-4 bg-black/40 rounded border border-[var(--border)]">
|
||
<h4 className="font-bold text-pixel-gold mb-2">
|
||
Plus grand nombre de points gagnés cette semaine
|
||
</h4>
|
||
<p className="text-sm text-gray-300">
|
||
Le joueur qui accumule le plus de points cette semaine
|
||
remporte le défi. Seuls les points gagnés après l'acceptation
|
||
du défi comptent.
|
||
</p>
|
||
<div className="mt-2 text-xs text-gray-400">
|
||
Points suggérés: 250
|
||
</div>
|
||
</div>
|
||
|
||
<div className="p-4 bg-black/40 rounded border border-[var(--border)]">
|
||
<h4 className="font-bold text-pixel-gold mb-2">
|
||
Défi créatif : meilleure bio de profil
|
||
</h4>
|
||
<p className="text-sm text-gray-300">
|
||
Le joueur avec la bio de profil la plus créative et originale
|
||
remporte le défi. L'admin désignera le gagnant selon
|
||
l'originalité et la qualité de la bio.
|
||
</p>
|
||
<div className="mt-2 text-xs text-gray-400">
|
||
Points suggérés: 120
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</Card>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|