the-tip-top-frontend/app/admin/tirages/page.tsx
soufiane f20cf40fff feat: redesign admin panel with blanc cassé theme
- Update sidebar and header with off-white (#faf8f5) background
- Add ticket stats endpoint integration for global counts
- Redesign tirages page with animation and improved layout
- Add red accent color for admin avatar
- Update various button styles and remove unnecessary elements

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 19:43:14 +01:00

698 lines
27 KiB
TypeScript

'use client';
import { useState, useEffect, useCallback } from 'react';
import { Card } from '@/components/ui/Card';
import Button from '@/components/Button';
import toast from 'react-hot-toast';
import { API_BASE_URL } from '@/utils/constants';
import {
Trophy,
Users,
CheckCircle,
AlertCircle,
Download,
Mail,
Gift,
User,
RefreshCw,
Trash2,
} from 'lucide-react';
interface Participant {
id: string;
email: string;
first_name: string;
last_name: string;
is_verified: boolean;
tickets_played: number;
prizes_won: number;
}
interface DrawResult {
draw: {
id: string;
drawDate: string;
status: string;
};
winner: {
id: string;
email: string;
name: string;
ticketsPlayed: number;
};
statistics: {
totalParticipants: number;
eligibleParticipants: number;
criteria: any;
};
prize: {
name: string;
value: string;
};
}
interface ExistingDraw {
id: string;
draw_date: string;
winner_email: string;
winner_name: string;
prize_name: string;
prize_value: string;
total_participants: number;
eligible_participants: number;
status: string;
notified_at: string | null;
claimed_at: string | null;
}
export default function TiragesPage() {
const [participants, setParticipants] = useState<Participant[]>([]);
const [loading, setLoading] = useState(false);
const [drawResult, setDrawResult] = useState<DrawResult | null>(null);
const [existingDraw, setExistingDraw] = useState<ExistingDraw | null>(null);
const [hasExistingDraw, setHasExistingDraw] = useState(false);
const [isDrawing, setIsDrawing] = useState(false);
// Critères
const [minTickets, setMinTickets] = useState(1);
const [verifiedOnly, setVerifiedOnly] = useState(false);
const [prizeName, setPrizeName] = useState('An de thé');
const [prizeValue, setPrizeValue] = useState('360');
const [allowRedraw, setAllowRedraw] = useState(false);
const checkExistingDraw = useCallback(async () => {
try {
const token = localStorage.getItem('auth_token') || localStorage.getItem('token');
const response = await fetch(`${API_BASE_URL}/draw/check-existing`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (response.ok) {
const data = await response.json();
setHasExistingDraw(data.data.hasExistingDraw);
setExistingDraw(data.data.lastDraw);
}
} catch (error) {
console.error('Erreur:', error);
}
}, []);
const loadParticipants = useCallback(async () => {
setLoading(true);
try {
const token = localStorage.getItem('auth_token') || localStorage.getItem('token');
const response = await fetch(
`${API_BASE_URL}/draw/eligible-participants?minTickets=${minTickets}&verified=${verifiedOnly}`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
if (!response.ok) throw new Error('Erreur lors du chargement');
const data = await response.json();
setParticipants(data.data.participants);
toast.success(`${data.data.total} participants éligibles trouvés`);
} catch (error: any) {
toast.error(error.message || 'Erreur lors du chargement');
setParticipants([]);
} finally {
setLoading(false);
}
}, [minTickets, verifiedOnly]);
useEffect(() => {
checkExistingDraw();
// Charger automatiquement les participants au chargement de la page
loadParticipants();
}, [checkExistingDraw, loadParticipants]);
const conductDraw = async () => {
if (participants.length === 0) {
toast.error('Veuillez d\'abord charger les participants éligibles');
return;
}
setLoading(true);
setIsDrawing(true);
setDrawResult(null);
// Animation de tirage (3 secondes)
await new Promise(resolve => setTimeout(resolve, 3000));
try {
const token = localStorage.getItem('auth_token') || localStorage.getItem('token');
const response = await fetch(`${API_BASE_URL}/draw/conduct`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
criteria: {
minTickets,
verified: verifiedOnly,
},
prizeName,
prizeValue,
allowRedraw: hasExistingDraw,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Erreur lors du tirage');
}
const data = await response.json();
setDrawResult(data.data);
setHasExistingDraw(true);
toast.success('🎉 Tirage au sort effectué avec succès!');
await checkExistingDraw();
// Scroll vers le haut pour voir le résultat
window.scrollTo({ top: 0, behavior: 'smooth' });
} catch (error: any) {
toast.error(error.message || 'Erreur lors du tirage');
} finally {
setLoading(false);
setIsDrawing(false);
}
};
const downloadReport = async () => {
if (!existingDraw) return;
try {
const token = localStorage.getItem('auth_token') || localStorage.getItem('token');
const response = await fetch(
`${API_BASE_URL}/draw/${existingDraw.id}/report`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
if (!response.ok) throw new Error('Erreur lors de la génération du rapport');
const data = await response.json();
const report = data.data;
// Créer un rapport texte
const reportText = `
=================================================
RAPPORT DE TIRAGE AU SORT - THÉ TIP TOP
=================================================
📅 Date du tirage: ${new Date(report.draw.date).toLocaleString('fr-FR')}
👤 Effectué par: ${report.draw.conductedBy.name} (${report.draw.conductedBy.email})
📊 Statut: ${report.draw.status}
-------------------------------------------------
GAGNANT
-------------------------------------------------
🏆 Nom: ${report.winner.firstName} ${report.winner.lastName}
📧 Email: ${report.winner.email}
📱 Téléphone: ${report.winner.phone || 'Non renseigné'}
📍 Ville: ${report.winner.city || 'Non renseignée'}
🎫 Nombre de tickets joués: ${report.winner.totalTickets}
-------------------------------------------------
PRIX
-------------------------------------------------
🎁 Nom: ${report.prize.name}
💰 Valeur: ${report.prize.value}
-------------------------------------------------
STATISTIQUES
-------------------------------------------------
👥 Total de participants: ${report.statistics.totalParticipants}
✅ Participants éligibles: ${report.statistics.eligibleParticipants}
📋 Critères:
- Tickets minimum: ${report.statistics.criteria.minTickets}
- Email vérifié: ${report.statistics.criteria.verified ? 'Oui' : 'Non'}
-------------------------------------------------
TICKETS DU GAGNANT
-------------------------------------------------
${report.winner.tickets.map((t: any, i: number) =>
`${i + 1}. Code: ${t.code} | Lot: ${t.prize_name} | Statut: ${t.status} | Joué le: ${new Date(t.played_at).toLocaleDateString('fr-FR')}`
).join('\n')}
-------------------------------------------------
${report.draw.notifiedAt ? `📧 Gagnant notifié le: ${new Date(report.draw.notifiedAt).toLocaleString('fr-FR')}\n` : ''}${report.draw.claimedAt ? `✅ Lot récupéré le: ${new Date(report.draw.claimedAt).toLocaleString('fr-FR')}\n` : ''}-------------------------------------------------
📝 Notes: ${report.draw.notes || 'Aucune note'}
=================================================
Généré le ${new Date().toLocaleString('fr-FR')}
=================================================
`.trim();
// Télécharger le rapport
const blob = new Blob([reportText], { type: 'text/plain;charset=utf-8' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `rapport-tirage-${existingDraw.id}.txt`;
link.click();
toast.success('Rapport téléchargé!');
} catch (error: any) {
toast.error(error.message || 'Erreur lors du téléchargement');
}
};
const markAsNotified = async () => {
if (!existingDraw) return;
try {
const token = localStorage.getItem('auth_token') || localStorage.getItem('token');
const response = await fetch(
`${API_BASE_URL}/draw/${existingDraw.id}/notify`,
{
method: 'PUT',
headers: {
Authorization: `Bearer ${token}`,
},
}
);
if (!response.ok) throw new Error('Erreur');
toast.success('Gagnant marqué comme notifié');
await checkExistingDraw();
} catch (error: any) {
toast.error(error.message || 'Erreur');
}
};
const markAsClaimed = async () => {
if (!existingDraw) return;
const notes = prompt('Notes (optionnel):');
try {
const token = localStorage.getItem('auth_token') || localStorage.getItem('token');
const response = await fetch(
`${API_BASE_URL}/draw/${existingDraw.id}/claim`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ notes }),
}
);
if (!response.ok) throw new Error('Erreur');
toast.success('Lot marqué comme récupéré');
await checkExistingDraw();
} catch (error: any) {
toast.error(error.message || 'Erreur');
}
};
const deleteDraw = async () => {
if (!existingDraw) return;
setLoading(true);
try {
const token = localStorage.getItem('auth_token') || localStorage.getItem('token');
const response = await fetch(
`${API_BASE_URL}/draw/${existingDraw.id}`,
{
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
},
}
);
if (!response.ok) {
let errorMessage = 'Erreur lors de l\'annulation';
try {
const error = await response.json();
errorMessage = error.message || errorMessage;
} catch (e) {
// Si le parsing JSON échoue, utiliser le message par défaut
errorMessage = `Erreur ${response.status}: ${response.statusText}`;
}
throw new Error(errorMessage);
}
toast.success('🗑️ Tirage au sort annulé avec succès!');
setHasExistingDraw(false);
setExistingDraw(null);
setDrawResult(null);
setAllowRedraw(false);
await checkExistingDraw();
await loadParticipants();
} catch (error: any) {
console.error('Erreur lors de l\'annulation du tirage:', error);
toast.error(error.message || 'Erreur lors de l\'annulation');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-full bg-gradient-to-br from-gray-50 via-white to-gray-50 p-8">
{/* Animation de tirage en cours */}
{isDrawing && (
<div className="fixed inset-0 bg-black/70 backdrop-blur-sm z-50 flex items-center justify-center">
<div className="bg-white rounded-3xl p-12 text-center max-w-md mx-4 animate-pulse">
<div className="w-24 h-24 bg-gradient-to-br from-amber-400 to-orange-500 rounded-full flex items-center justify-center mx-auto mb-6 animate-spin">
<Trophy className="w-12 h-12 text-white" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">Tirage en cours...</h2>
<p className="text-gray-500">Sélection aléatoire du gagnant parmi {participants.length} participants</p>
<div className="mt-6 flex justify-center gap-2">
<div className="w-3 h-3 bg-amber-500 rounded-full animate-bounce" style={{ animationDelay: '0ms' }}></div>
<div className="w-3 h-3 bg-orange-500 rounded-full animate-bounce" style={{ animationDelay: '150ms' }}></div>
<div className="w-3 h-3 bg-red-500 rounded-full animate-bounce" style={{ animationDelay: '300ms' }}></div>
</div>
</div>
</div>
)}
{/* En-tête avec titre du prix */}
<div className="bg-gradient-to-r from-amber-500 to-orange-600 rounded-2xl p-6 mb-8">
<div className="flex items-center gap-4">
<div className="w-14 h-14 bg-white/20 rounded-xl flex items-center justify-center">
<Trophy className="w-7 h-7 text-white" />
</div>
<div>
<h1 className="text-3xl font-bold text-white">Tirage au Sort - {prizeName}</h1>
<p className="text-amber-100">
Prix à gagner : <span className="font-semibold text-white">{prizeValue}</span>
Participants ayant joué au moins {minTickets} ticket{minTickets > 1 ? 's' : ''}
</p>
</div>
</div>
</div>
{/* Résultat du tirage - Affiché en haut */}
{drawResult && (
<div className="mb-8 bg-gradient-to-br from-amber-50 via-yellow-50 to-orange-50 rounded-2xl border-2 border-amber-300 overflow-hidden animate-fadeIn">
<div className="bg-gradient-to-r from-amber-500 to-orange-500 px-6 py-4 text-center">
<Trophy className="w-12 h-12 text-white mx-auto mb-2" />
<h2 className="text-2xl font-bold text-white">
🎉 Félicitations au gagnant !
</h2>
</div>
<div className="p-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
{/* Gagnant */}
<div className="bg-white rounded-2xl p-6 shadow-lg border border-blue-100">
<div className="flex items-center gap-4 mb-4">
<div className="w-16 h-16 bg-gradient-to-br from-emerald-500 to-teal-600 rounded-full flex items-center justify-center text-white font-bold text-xl">
{drawResult.winner.name.split(' ').map(n => n[0]).join('')}
</div>
<div>
<p className="text-xs text-emerald-600 font-semibold uppercase">Gagnant</p>
<h3 className="text-xl font-bold text-gray-900">
{drawResult.winner.name}
</h3>
</div>
</div>
<p className="text-sm text-gray-600">{drawResult.winner.email}</p>
</div>
{/* Prix */}
<div className="bg-white rounded-2xl p-6 shadow-lg border border-purple-100">
<div className="flex items-center gap-4 mb-4">
<div className="w-16 h-16 bg-gradient-to-br from-purple-500 to-pink-600 rounded-full flex items-center justify-center">
<Gift className="w-8 h-8 text-white" />
</div>
<div>
<p className="text-xs text-purple-600 font-semibold uppercase">Prix gagné</p>
<h3 className="text-xl font-bold text-gray-900">
{drawResult.prize.name}
</h3>
</div>
</div>
<p className="text-3xl font-bold text-purple-600">
{drawResult.prize.value}
</p>
</div>
</div>
<div className="flex justify-center">
<Button onClick={downloadReport} className="!bg-gradient-to-r !from-amber-500 !to-orange-600 !text-white hover:!from-amber-600 hover:!to-orange-700 rounded-xl">
<Download className="w-4 h-4 mr-2" />
Télécharger le rapport
</Button>
</div>
</div>
</div>
)}
{/* Alerte si un tirage existe déjà */}
{hasExistingDraw && existingDraw && !drawResult && (
<div className="mb-8 bg-[#faf8f5] rounded-2xl border border-gray-200 overflow-hidden">
{/* Header */}
<div className="bg-gradient-to-r from-amber-500 to-orange-600 px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-14 h-14 bg-white/20 rounded-xl flex items-center justify-center">
<Trophy className="w-7 h-7 text-white" />
</div>
<div>
<h3 className="font-bold text-white text-xl">Tirage effectué</h3>
<p className="text-amber-100 text-sm">{new Date(existingDraw.draw_date).toLocaleString('fr-FR')}</p>
</div>
</div>
</div>
</div>
{/* Content */}
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
{/* Gagnant */}
<div className="bg-white rounded-2xl p-5 shadow-sm border border-gray-100">
<div className="flex items-center gap-4">
<div className="w-14 h-14 bg-gradient-to-br from-emerald-500 to-teal-600 rounded-full flex items-center justify-center text-white font-bold text-lg">
{existingDraw.winner_name.split(' ').map(n => n[0]).join('')}
</div>
<div>
<p className="text-xs text-emerald-600 font-semibold uppercase mb-1">Gagnant</p>
<p className="font-bold text-gray-900 text-lg">{existingDraw.winner_name}</p>
<p className="text-sm text-gray-500">{existingDraw.winner_email}</p>
</div>
</div>
</div>
{/* Prix */}
<div className="bg-white rounded-2xl p-5 shadow-sm border border-gray-100">
<div className="flex items-center gap-4">
<div className="w-14 h-14 bg-gradient-to-br from-purple-500 to-pink-600 rounded-full flex items-center justify-center">
<Gift className="w-7 h-7 text-white" />
</div>
<div>
<p className="text-xs text-purple-600 font-semibold uppercase mb-1">Prix gagné</p>
<p className="font-bold text-gray-900 text-lg">{existingDraw.prize_name}</p>
<p className="text-2xl font-bold text-purple-600">{existingDraw.prize_value}</p>
</div>
</div>
</div>
</div>
{/* Actions */}
<div className="flex gap-3 flex-wrap">
<Button
onClick={downloadReport}
size="sm"
className="!bg-gradient-to-r !from-amber-500 !to-orange-600 !text-white hover:!from-amber-600 hover:!to-orange-700 rounded-xl"
>
<Download className="w-4 h-4 mr-2" />
Télécharger rapport
</Button>
{existingDraw.status === 'COMPLETED' && (
<Button
onClick={markAsNotified}
size="sm"
className="!bg-white !text-black border border-gray-200 hover:!bg-gray-100 rounded-xl"
>
<Mail className="w-4 h-4 mr-2" />
Marquer comme notifié
</Button>
)}
{existingDraw.status === 'NOTIFIED' && (
<Button
onClick={markAsClaimed}
size="sm"
className="!bg-white !text-black border border-gray-200 hover:!bg-gray-100 rounded-xl"
>
<CheckCircle className="w-4 h-4 mr-2" />
Marquer comme récupéré
</Button>
)}
<Button
onClick={deleteDraw}
size="sm"
className="!bg-white !text-red-600 border border-red-200 hover:!bg-red-50 rounded-xl"
>
<Trash2 className="w-4 h-4 mr-2" />
Annuler ce tirage
</Button>
</div>
</div>
</div>
)}
{/* Liste des participants éligibles */}
<div className="bg-[#faf8f5] rounded-2xl border border-gray-200 mb-6 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-white rounded-xl flex items-center justify-center border border-gray-200">
<Users className="w-5 h-5 text-[#1e3a5f]" />
</div>
<div>
<h2 className="text-xl font-bold text-[#1e3a5f]">Participants Éligibles</h2>
<p className="text-gray-500 text-sm">
{loading
? 'Chargement en cours...'
: participants.length > 0
? `${participants.length} participant${participants.length > 1 ? 's' : ''} éligible${participants.length > 1 ? 's' : ''}`
: 'Aucun participant'}
</p>
</div>
</div>
<Button
onClick={loadParticipants}
isLoading={loading}
disabled={loading}
size="sm"
className="!bg-white !text-black hover:!bg-gray-100 rounded-xl border border-gray-200"
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Actualiser
</Button>
</div>
</div>
<div className="p-6">
{loading && (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">Chargement des participants éligibles...</p>
</div>
)}
{!loading && participants.length === 0 && (
<div className="text-center py-12 bg-gray-50 rounded-xl">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<Users className="w-8 h-8 text-gray-400" />
</div>
<p className="text-gray-900 text-lg font-medium">Aucun participant éligible</p>
<p className="text-gray-500 text-sm mt-2">
Vérifiez que des participants ont joué au moins {minTickets} ticket{minTickets > 1 ? 's' : ''}
</p>
</div>
)}
{!loading && participants.length > 0 && (
<>
<div className="overflow-x-auto rounded-xl border border-gray-100">
<table className="w-full">
<thead className="bg-gradient-to-r from-gray-50 to-gray-100">
<tr>
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
#
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
Participant
</th>
<th className="px-6 py-4 text-center text-xs font-semibold text-gray-600 uppercase tracking-wider">
Tickets Joués
</th>
<th className="px-6 py-4 text-center text-xs font-semibold text-gray-600 uppercase tracking-wider">
Lots Gagnés
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{participants.map((participant, index) => (
<tr key={participant.id} className="hover:bg-blue-50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap">
<span className="inline-flex items-center justify-center w-8 h-8 bg-gray-100 rounded-lg text-sm font-bold text-gray-600">
{index + 1}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-full flex items-center justify-center text-white font-bold text-sm">
{participant.first_name?.charAt(0)}{participant.last_name?.charAt(0)}
</div>
<div>
<p className="text-sm font-semibold text-gray-900">
{participant.first_name} {participant.last_name}
</p>
<p className="text-xs text-gray-500">{participant.email}</p>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-center">
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-semibold bg-blue-100 text-blue-700">
{participant.tickets_played}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-center">
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-semibold bg-purple-100 text-purple-700">
{participant.prizes_won}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Bouton de tirage au sort */}
<div className="mt-6 bg-gradient-to-r from-amber-500 via-orange-500 to-red-500 rounded-2xl p-1">
<div className="bg-white rounded-xl p-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-14 h-14 bg-gradient-to-br from-amber-400 to-orange-500 rounded-xl flex items-center justify-center shadow-lg">
<Trophy className="w-7 h-7 text-white" />
</div>
<div>
<h3 className="text-xl font-bold text-gray-900">
Prêt à lancer le tirage au sort ?
</h3>
<p className="text-sm text-gray-500 mt-1">
<span className="font-semibold text-orange-600">{participants.length}</span> participant{participants.length > 1 ? 's ont' : ' a'} une chance égale de gagner <span className="font-semibold text-purple-600">{prizeName}</span> ({prizeValue})
</p>
</div>
</div>
<button
onClick={conductDraw}
disabled={loading}
className="flex items-center gap-2 px-8 py-4 bg-gradient-to-r from-amber-500 via-orange-500 to-red-500 text-white font-bold text-lg rounded-xl shadow-lg hover:shadow-xl transform hover:scale-105 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none"
>
<Trophy className="w-6 h-6" />
Lancer le Tirage
</button>
</div>
</div>
</div>
</>
)}
</div>
</div>
</div>
);
}