Files
got-gaming/components/events/EventsPageSection.tsx

872 lines
30 KiB
TypeScript

"use client";
import { useState, useEffect, useMemo, useRef, useTransition } from "react";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { calculateEventStatus } from "@/lib/eventStatus";
import FeedbackModal from "@/components/feedback/FeedbackModal";
import {
registerForEvent,
unregisterFromEvent,
} from "@/actions/events/register";
import {
Badge,
Button,
Modal,
CloseButton,
Card,
BackgroundSection,
SectionTitle,
} from "@/components/ui";
interface Event {
id: string;
date: string | Date;
name: string;
description: string;
type: "ATELIER" | "KATA" | "PRESENTATION" | "LEARNING_HOUR";
room?: string | null;
time?: string | null;
maxPlaces?: number | null;
}
interface EventsPageSectionProps {
events: Event[];
backgroundImage: string;
initialRegistrations?: Record<string, boolean>;
}
const getEventTypeColor = (type: Event["type"]) => {
switch (type) {
case "ATELIER":
return "from-blue-600 to-cyan-500";
case "KATA":
return "from-yellow-600 to-amber-500";
case "PRESENTATION":
return "from-purple-600 to-pink-500";
case "LEARNING_HOUR":
return "from-green-600 to-emerald-500";
default:
return "from-gray-600 to-gray-500";
}
};
const getEventTypeLabel = (type: Event["type"]) => {
switch (type) {
case "ATELIER":
return "Atelier";
case "KATA":
return "Kata";
case "PRESENTATION":
return "Présentation";
case "LEARNING_HOUR":
return "Learning Hour";
default:
return type;
}
};
const getStatusBadge = (status: "UPCOMING" | "LIVE" | "PAST") => {
switch (status) {
case "UPCOMING":
return (
<Badge variant="success" size="md">
À venir
</Badge>
);
case "LIVE":
return (
<Badge variant="danger" size="md" className="animate-pulse">
En direct
</Badge>
);
case "PAST":
return (
<Badge variant="default" size="md">
Passé
</Badge>
);
}
};
export default function EventsPageSection({
events,
backgroundImage,
initialRegistrations = {},
}: EventsPageSectionProps) {
const { data: session } = useSession();
const router = useRouter();
const [registrations, setRegistrations] =
useState<Record<string, boolean>>(initialRegistrations);
const [loading, setLoading] = useState<Record<string, boolean>>({});
const [error, setError] = useState<string>("");
const [currentMonth, setCurrentMonth] = useState(new Date());
const [selectedEvent, setSelectedEvent] = useState<Event | null>(null);
const [feedbackEventId, setFeedbackEventId] = useState<string | null>(null);
// Helper function pour obtenir le statut d'un événement
const getEventStatus = (event: Event) => calculateEventStatus(event.date);
// Déterminer si on a des données initiales valides
const hasInitialData = useMemo(
() => Object.keys(initialRegistrations).length > 0,
[initialRegistrations]
);
// Ref pour tracker si on a déjà utilisé les données initiales
const hasUsedInitialData = useRef(hasInitialData);
// Ref pour tracker si on a déjà fait les appels API
const hasFetchedRegistrations = useRef(false);
// Séparer et trier les événements (du plus récent au plus ancien)
// Le statut est calculé automatiquement en fonction de la date
const upcomingEvents = events
.filter((e) => {
const status = calculateEventStatus(e.date);
return status === "UPCOMING" || status === "LIVE";
})
.sort((a, b) => {
// Trier par date décroissante (du plus récent au plus ancien)
const dateA = typeof a.date === "string" ? new Date(a.date) : a.date;
const dateB = typeof b.date === "string" ? new Date(b.date) : b.date;
return dateB.getTime() - dateA.getTime();
});
const pastEvents = events
.filter((e) => calculateEventStatus(e.date) === "PAST")
.sort((a, b) => {
// Trier par date décroissante (du plus récent au plus ancien)
const dateA = typeof a.date === "string" ? new Date(a.date) : a.date;
const dateB = typeof b.date === "string" ? new Date(b.date) : b.date;
return dateB.getTime() - dateA.getTime();
});
// Créer un map des événements par date pour le calendrier
const eventsByDate: Record<string, Event[]> = {};
events.forEach((event) => {
// Convertir la date en string YYYY-MM-DD pour le calendrier
let eventDate: Date;
if (typeof event.date === "string") {
eventDate = new Date(event.date);
} else if (event.date instanceof Date) {
eventDate = event.date;
} else {
// Fallback si c'est déjà un objet Date
eventDate = new Date(event.date);
}
// Utiliser UTC pour éviter les problèmes de fuseau horaire
const year = eventDate.getUTCFullYear();
const month = String(eventDate.getUTCMonth() + 1).padStart(2, "0");
const day = String(eventDate.getUTCDate()).padStart(2, "0");
const dateKey = `${year}-${month}-${day}`; // YYYY-MM-DD
if (!eventsByDate[dateKey]) {
eventsByDate[dateKey] = [];
}
eventsByDate[dateKey].push(event);
});
// Mettre à jour le ref quand on a des données initiales
useEffect(() => {
if (hasInitialData) {
hasUsedInitialData.current = true;
}
}, [hasInitialData]);
// Ne charger depuis l'API que si on n'a pas de données initiales
// (cas où l'utilisateur se connecte après le chargement de la page)
useEffect(() => {
// Si on a déjà des données initiales, ne jamais charger depuis l'API
if (hasUsedInitialData.current) {
return;
}
// Si on a déjà fait les appels API, ne pas refaire
if (hasFetchedRegistrations.current) {
return;
}
// Si pas de session, ne rien faire (on garde les données vides)
if (!session?.user?.id) {
return;
}
// Si pas d'événements, ne rien faire
if (events.length === 0) {
return;
}
// Marquer qu'on va faire les appels
hasFetchedRegistrations.current = true;
// Charger les inscriptions depuis l'API seulement si on n'a pas de données initiales
// On charge pour tous les événements (passés et à venir) pour permettre le feedback
const checkRegistrations = async () => {
const registrationChecks = events.map(async (event) => {
try {
const response = await fetch(`/api/events/${event.id}/register`);
const data = await response.json();
return { eventId: event.id, registered: data.registered || false };
} catch {
return { eventId: event.id, registered: false };
}
});
const results = await Promise.all(registrationChecks);
const registrationsMap: Record<string, boolean> = {};
results.forEach(({ eventId, registered }) => {
registrationsMap[eventId] = registered;
});
setRegistrations(registrationsMap);
};
checkRegistrations();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session?.user?.id]);
// Fonctions pour le calendrier
const getDaysInMonth = (date: Date) => {
// Utiliser UTC pour correspondre au format des événements
const year = date.getUTCFullYear();
const month = date.getUTCMonth();
return new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
};
const getFirstDayOfMonth = (date: Date) => {
// Utiliser UTC pour correspondre au format des événements
// getUTCDay() retourne 0 (dimanche) à 6 (samedi)
// On convertit pour que lundi = 0, mardi = 1, ..., dimanche = 6
const year = date.getUTCFullYear();
const month = date.getUTCMonth();
const dayOfWeek = new Date(Date.UTC(year, month, 1)).getUTCDay();
// Convertir : dimanche (0) -> 6, lundi (1) -> 0, mardi (2) -> 1, etc.
return (dayOfWeek + 6) % 7;
};
const formatMonthYear = (date: Date) => {
return date.toLocaleDateString("fr-FR", {
month: "long",
year: "numeric",
});
};
const renderCalendar = () => {
const daysInMonth = getDaysInMonth(currentMonth);
const firstDay = getFirstDayOfMonth(currentMonth);
const days: (number | null)[] = [];
// Ajouter des jours vides pour le début du mois
for (let i = 0; i < firstDay; i++) {
days.push(null);
}
// Ajouter les jours du mois
for (let day = 1; day <= daysInMonth; day++) {
days.push(day);
}
// Utiliser UTC pour correspondre au format des événements
const year = currentMonth.getUTCFullYear();
const month = currentMonth.getUTCMonth() + 1;
return (
<div className="bg-black/60 border border-pixel-gold/30 rounded-lg p-4 backdrop-blur-sm max-w-2xl mx-auto">
{/* Header du calendrier */}
<div className="flex items-center justify-between mb-3">
<button
onClick={() => {
const year = currentMonth.getUTCFullYear();
const month = currentMonth.getUTCMonth();
setCurrentMonth(new Date(Date.UTC(year, month - 1, 1)));
}}
className="px-2 py-1 border border-pixel-gold/50 text-pixel-gold hover:bg-pixel-gold/10 rounded transition text-sm"
>
</button>
<h3 className="text-base font-bold text-white uppercase tracking-widest">
{formatMonthYear(currentMonth)}
</h3>
<button
onClick={() => {
const year = currentMonth.getUTCFullYear();
const month = currentMonth.getUTCMonth();
setCurrentMonth(new Date(Date.UTC(year, month + 1, 1)));
}}
className="px-2 py-1 border border-pixel-gold/50 text-pixel-gold hover:bg-pixel-gold/10 rounded transition text-sm"
>
</button>
</div>
{/* Jours de la semaine */}
<div className="grid grid-cols-7 gap-0.5 mb-1">
{["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"].map((day) => (
<div
key={day}
className="text-center text-[10px] text-gray-400 font-semibold py-1"
>
{day}
</div>
))}
</div>
{/* Grille du calendrier */}
<div className="grid grid-cols-7 gap-0.5">
{days.map((day, index) => {
if (day === null) {
return <div key={index} className="aspect-square"></div>;
}
const dateKey = `${year}-${String(month).padStart(2, "0")}-${String(
day
).padStart(2, "0")}`;
const dayEvents = eventsByDate[dateKey] || [];
// Vérifier si c'est aujourd'hui en utilisant UTC
const today = new Date();
const todayKey = `${today.getUTCFullYear()}-${String(
today.getUTCMonth() + 1
).padStart(2, "0")}-${String(today.getUTCDate()).padStart(2, "0")}`;
const isToday = todayKey === dateKey;
const hasEvents = dayEvents.length > 0;
// Déterminer la couleur principale selon le type d'événement
const hasUpcoming = dayEvents.some(
(e) => getEventStatus(e) === "UPCOMING"
);
const hasLive = dayEvents.some((e) => getEventStatus(e) === "LIVE");
const hasPast = dayEvents.some((e) => getEventStatus(e) === "PAST");
let eventBorderColor = "";
let eventBgColor = "";
if (hasLive) {
eventBorderColor = "border-red-500/80";
eventBgColor = "bg-red-500/20";
} else if (hasUpcoming) {
eventBorderColor = "border-green-500/80";
eventBgColor = "bg-green-500/20";
} else if (hasPast) {
eventBorderColor = "border-gray-500/60";
eventBgColor = "bg-gray-500/15";
}
return (
<div
key={index}
onClick={() => {
if (hasEvents && dayEvents.length > 0) {
setSelectedEvent(dayEvents[0]);
}
}}
className={`aspect-square border rounded flex flex-col items-center justify-center relative ${
isToday
? "bg-pixel-gold/30 border-pixel-gold/70 border-2"
: hasEvents
? `${eventBgColor} ${eventBorderColor} border-2`
: "border-pixel-gold/10"
} ${
hasEvents ? "ring-2 ring-offset-1 ring-offset-black" : ""
} ${
hasLive
? "ring-red-500/50"
: hasUpcoming
? "ring-green-500/50"
: ""
} ${
hasEvents
? "cursor-pointer hover:opacity-80 transition-opacity"
: ""
}`}
>
<div
className={`text-[11px] font-bold ${
isToday
? "text-pixel-gold"
: hasEvents
? "text-white"
: "text-gray-400"
}`}
>
{day}
</div>
{hasEvents && (
<div className="absolute bottom-0.5 left-0 right-0 flex justify-center gap-0.5">
{dayEvents.slice(0, 3).map((event) => {
const status = getEventStatus(event);
return (
<div
key={event.id}
className={`w-1 h-1 rounded-full ${
status === "UPCOMING"
? "bg-green-400"
: status === "LIVE"
? "bg-red-400 animate-pulse"
: "bg-gray-400"
}`}
title={event.name}
/>
);
})}
{dayEvents.length > 3 && (
<div className="w-1 h-1 rounded-full bg-gray-400" />
)}
</div>
)}
</div>
);
})}
</div>
</div>
);
};
const truncateDescription = (text: string, maxLength: number = 150) => {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength).trim() + "...";
};
const renderEventCard = (event: Event) => (
<Card
key={event.id}
onClick={() => setSelectedEvent(event)}
className="overflow-hidden hover:border-pixel-gold/50 transition group cursor-pointer"
>
{/* Event Header */}
<div
className={`h-2 bg-gradient-to-r ${getEventTypeColor(event.type)}`}
></div>
{/* Event Content */}
<div className="p-6">
{/* Status Badge */}
<div className="flex justify-between items-start mb-4">
{getStatusBadge(getEventStatus(event))}
<span className="text-pixel-gold text-xs uppercase tracking-widest">
{getEventTypeLabel(event.type)}
</span>
</div>
{/* Date */}
<div className="text-white text-sm font-bold uppercase tracking-widest mb-3">
{typeof event.date === "string"
? new Date(event.date).toLocaleDateString("fr-FR", {
day: "numeric",
month: "long",
year: "numeric",
})
: event.date.toLocaleDateString("fr-FR", {
day: "numeric",
month: "long",
year: "numeric",
})}
</div>
{/* Event Name */}
<h3 className="text-xl font-bold text-white mb-3 group-hover:text-pixel-gold transition">
{event.name}
</h3>
{/* Event Details */}
{(event.room || event.time || event.maxPlaces) && (
<div className="flex flex-wrap gap-3 mb-4 text-sm">
{event.room && (
<div className="flex items-center gap-1.5 text-gray-300">
<span className="text-pixel-gold">📍</span>
<span className="font-semibold">Salle:</span>
<span>{event.room}</span>
</div>
)}
{event.time && (
<div className="flex items-center gap-1.5 text-gray-300">
<span className="text-pixel-gold">🕐</span>
<span className="font-semibold">Heure:</span>
<span>{event.time}</span>
</div>
)}
{event.maxPlaces && (
<div className="flex items-center gap-1.5 text-gray-300">
<span className="text-pixel-gold">👥</span>
<span className="font-semibold">Places:</span>
<span>{event.maxPlaces}</span>
</div>
)}
</div>
)}
{/* Description (truncated) */}
<p className="text-gray-400 text-sm leading-relaxed mb-4">
{truncateDescription(event.description)}
</p>
{event.description.length > 150 && (
<p className="text-pixel-gold text-xs mb-4 italic">
Cliquez pour voir plus...
</p>
)}
{/* Action Button */}
{getEventStatus(event) === "UPCOMING" && (
<>
{registrations[event.id] ? (
<Button
onClick={(e) => {
e.stopPropagation();
handleUnregister(event.id);
}}
variant="success"
size="md"
disabled={loading[event.id]}
className="w-full"
>
{loading[event.id] ? "Annulation..." : "Inscrit ✓"}
</Button>
) : (
<Button
onClick={(e) => {
e.stopPropagation();
handleRegister(event.id);
}}
variant="primary"
size="md"
disabled={loading[event.id]}
className="w-full"
>
{loading[event.id] ? "Inscription..." : "S'inscrire maintenant"}
</Button>
)}
</>
)}
{getEventStatus(event) === "LIVE" && (
<Button variant="danger" size="md" className="w-full animate-pulse">
Connectez-vous à Teams, c&apos;est maintenant !
</Button>
)}
{getEventStatus(event) === "PAST" && (
<Button
onClick={(e) => {
e.stopPropagation();
if (!session?.user?.id) {
router.push("/login");
return;
}
setFeedbackEventId(event.id);
}}
variant="primary"
size="md"
className="w-full"
>
Donner un feedback
</Button>
)}
</div>
</Card>
);
const [, startTransition] = useTransition();
const handleRegister = async (eventId: string) => {
if (!session?.user?.id) {
router.push("/login");
return;
}
setLoading((prev) => ({ ...prev, [eventId]: true }));
setError("");
startTransition(async () => {
const result = await registerForEvent(eventId);
if (result.success) {
setRegistrations((prev) => ({
...prev,
[eventId]: true,
}));
// Rafraîchir le score dans le header
window.dispatchEvent(new Event("refreshUserScore"));
} else {
setError(result.error || "Une erreur est survenue");
}
setLoading((prev) => ({ ...prev, [eventId]: false }));
});
};
const handleUnregister = async (eventId: string) => {
setLoading((prev) => ({ ...prev, [eventId]: true }));
setError("");
startTransition(async () => {
const result = await unregisterFromEvent(eventId);
if (result.success) {
setRegistrations((prev) => ({
...prev,
[eventId]: false,
}));
// Rafraîchir le score dans le header
window.dispatchEvent(new Event("refreshUserScore"));
} else {
setError(result.error || "Une erreur est survenue");
}
setLoading((prev) => ({ ...prev, [eventId]: false }));
});
};
return (
<BackgroundSection backgroundImage={backgroundImage}>
{/* Title Section */}
<SectionTitle
variant="gradient"
size="xl"
subtitle="Événements à venir et passés"
className="mb-16"
>
EVENTS
</SectionTitle>
<p className="text-gray-400 text-sm max-w-2xl mx-auto text-center mb-16">
Rejoignez-nous pour des événements tech passionnants, des compétitions
et des célébrations tout au long de l&apos;année
</p>
{/* Événements à venir */}
{upcomingEvents.length > 0 && (
<div className="mb-16">
<h2 className="text-3xl font-bold text-white mb-8 text-center uppercase tracking-widest">
<span className="bg-gradient-to-r from-green-400 to-green-600 bg-clip-text text-transparent">
Événements à venir
</span>
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{upcomingEvents.map(renderEventCard)}
</div>
</div>
)}
{/* Calendrier */}
<div className="mb-16">
<h2 className="text-2xl font-bold text-white mb-6 text-center uppercase tracking-widest">
<span className="bg-gradient-to-r from-pixel-gold via-orange-400 to-pixel-gold bg-clip-text text-transparent">
Calendrier
</span>
</h2>
{renderCalendar()}
</div>
{/* Événements passés */}
{pastEvents.length > 0 && (
<div className="mb-16">
<h2 className="text-3xl font-bold text-white mb-8 text-center uppercase tracking-widest">
<span className="bg-gradient-to-r from-gray-400 to-gray-600 bg-clip-text text-transparent">
Événements passés
</span>
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{pastEvents.map(renderEventCard)}
</div>
</div>
)}
{/* Error Message */}
{error && (
<div className="mt-6 text-center">
<p className="text-red-400 text-sm">{error}</p>
</div>
)}
{/* Footer Info */}
{/* <div className="mt-12 text-center">
<p className="text-gray-500 text-sm">
Restez informé de nos derniers événements et annonces
</p>
</div> */}
{/* Event Modal */}
{selectedEvent && (
<Modal
isOpen={!!selectedEvent}
onClose={() => setSelectedEvent(null)}
size="lg"
>
<div className="p-8">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
{getStatusBadge(getEventStatus(selectedEvent))}
<Badge variant="default" size="md">
{getEventTypeLabel(selectedEvent.type)}
</Badge>
</div>
<h2 className="text-3xl font-bold text-white uppercase tracking-wide">
{selectedEvent.name}
</h2>
</div>
<CloseButton
onClick={() => setSelectedEvent(null)}
size="lg"
className="ml-4"
/>
</div>
{/* Event Header Color Bar */}
<div
className={`h-1 bg-gradient-to-r ${getEventTypeColor(
selectedEvent.type
)} mb-6 rounded`}
></div>
{/* Date */}
<div className="text-white text-lg font-bold uppercase tracking-widest mb-4">
{typeof selectedEvent.date === "string"
? new Date(selectedEvent.date).toLocaleDateString("fr-FR", {
day: "numeric",
month: "long",
year: "numeric",
})
: selectedEvent.date instanceof Date
? selectedEvent.date.toLocaleDateString("fr-FR", {
day: "numeric",
month: "long",
year: "numeric",
})
: new Date(selectedEvent.date).toLocaleDateString("fr-FR", {
day: "numeric",
month: "long",
year: "numeric",
})}
</div>
{/* Event Details */}
{(selectedEvent.room ||
selectedEvent.time ||
selectedEvent.maxPlaces) && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
{selectedEvent.room && (
<div className="flex items-center gap-2 text-gray-300 bg-black/40 p-3 rounded border border-pixel-gold/20">
<span className="text-pixel-gold text-xl">📍</span>
<div>
<div className="text-xs text-gray-400 uppercase tracking-wider">
Salle
</div>
<div className="font-semibold">{selectedEvent.room}</div>
</div>
</div>
)}
{selectedEvent.time && (
<div className="flex items-center gap-2 text-gray-300 bg-black/40 p-3 rounded border border-pixel-gold/20">
<span className="text-pixel-gold text-xl">🕐</span>
<div>
<div className="text-xs text-gray-400 uppercase tracking-wider">
Heure
</div>
<div className="font-semibold">{selectedEvent.time}</div>
</div>
</div>
)}
{selectedEvent.maxPlaces && (
<div className="flex items-center gap-2 text-gray-300 bg-black/40 p-3 rounded border border-pixel-gold/20">
<span className="text-pixel-gold text-xl">👥</span>
<div>
<div className="text-xs text-gray-400 uppercase tracking-wider">
Places
</div>
<div className="font-semibold">
{selectedEvent.maxPlaces}
</div>
</div>
</div>
)}
</div>
)}
{/* Full Description */}
<div className="mb-6">
<h3 className="text-pixel-gold text-sm uppercase tracking-widest mb-3">
Description
</h3>
<p className="text-gray-300 text-sm leading-relaxed whitespace-pre-line">
{selectedEvent.description}
</p>
</div>
{/* Action Button */}
{getEventStatus(selectedEvent) === "UPCOMING" && (
<div className="pt-4 border-t border-pixel-gold/20">
{registrations[selectedEvent.id] ? (
<Button
onClick={(e) => {
e.stopPropagation();
handleUnregister(selectedEvent.id);
setSelectedEvent(null);
}}
variant="success"
size="lg"
disabled={loading[selectedEvent.id]}
className="w-full"
>
{loading[selectedEvent.id]
? "Annulation..."
: "Se désinscrire"}
</Button>
) : (
<Button
onClick={(e) => {
e.stopPropagation();
handleRegister(selectedEvent.id);
setSelectedEvent(null);
}}
variant="primary"
size="lg"
disabled={loading[selectedEvent.id]}
className="w-full"
>
{loading[selectedEvent.id]
? "Inscription..."
: "S'inscrire maintenant"}
</Button>
)}
</div>
)}
{getEventStatus(selectedEvent) === "LIVE" && (
<div className="pt-4 border-t border-pixel-gold/20">
<Button
variant="danger"
size="lg"
className="w-full animate-pulse"
>
Connectez-vous à Teams, c&apos;est maintenant !
</Button>
</div>
)}
{getEventStatus(selectedEvent) === "PAST" && (
<div className="pt-4 border-t border-pixel-gold/20">
<Button
onClick={(e) => {
e.stopPropagation();
if (!session?.user?.id) {
router.push("/login");
setSelectedEvent(null);
return;
}
setFeedbackEventId(selectedEvent.id);
setSelectedEvent(null);
}}
variant="primary"
size="lg"
className="w-full"
>
Donner un feedback
</Button>
</div>
)}
</div>
</Modal>
)}
{/* Feedback Modal */}
<FeedbackModal
eventId={feedbackEventId}
onClose={() => setFeedbackEventId(null)}
/>
</BackgroundSection>
);
}