'use client'; import { useState, useEffect } from 'react'; import { useParams, useRouter } from 'next/navigation'; import Link from 'next/link'; import { Campaign, Proposition, Participant, Vote, PropositionWithVote } from '@/types'; import { campaignService, participantService, propositionService, voteService, settingsService } from '@/lib/services'; import { MarkdownContent } from '@/components/MarkdownContent'; import { PROJECT_CONFIG } from '@/lib/project.config'; import Footer from '@/components/Footer'; // Force dynamic rendering to avoid SSR issues with Supabase export const dynamic = 'force-dynamic'; export default function PublicVotePage() { const params = useParams(); const router = useRouter(); const campaignId = params.id as string; const participantId = params.participantId as string; const [campaign, setCampaign] = useState(null); const [participant, setParticipant] = useState(null); const [propositions, setPropositions] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); const [success, setSuccess] = useState(false); // Votes temporaires stockés localement const [localVotes, setLocalVotes] = useState>({}); const [totalVoted, setTotalVoted] = useState(0); const [isRandomOrder, setIsRandomOrder] = useState(false); const [isCompactView, setIsCompactView] = useState(false); const [currentVisibleProposition, setCurrentVisibleProposition] = useState(1); const [isOverBudget, setIsOverBudget] = useState(false); useEffect(() => { if (campaignId && participantId) { loadData(); } }, [campaignId, participantId]); // Écouter les changements de connectivité réseau useEffect(() => { const handleOnline = () => { console.log('Connexion réseau rétablie'); setError(''); }; const handleOffline = () => { console.log('Connexion réseau perdue'); setError('Connexion réseau perdue. Veuillez vérifier votre connexion internet.'); }; if (typeof window !== 'undefined') { window.addEventListener('online', handleOnline); window.addEventListener('offline', handleOffline); } return () => { if (typeof window !== 'undefined') { window.removeEventListener('online', handleOnline); window.removeEventListener('offline', handleOffline); } }; }, []); // Calculer le total voté à partir des votes locaux useEffect(() => { const total = Object.values(localVotes).reduce((sum, amount) => sum + amount, 0); setTotalVoted(total); // Vérifier si on dépasse le budget if (campaign && total > campaign.budget_per_user) { setIsOverBudget(true); // Arrêter la vibration après 1 seconde setTimeout(() => setIsOverBudget(false), 1000); } else { setIsOverBudget(false); } }, [localVotes, campaign]); // Observer les propositions visibles useEffect(() => { if (propositions.length === 0) return; const observer = new IntersectionObserver( (entries) => { let highestVisibleIndex = 1; entries.forEach((entry) => { if (entry.isIntersecting) { const propositionIndex = parseInt(entry.target.getAttribute('data-proposition-index') || '1'); if (propositionIndex > highestVisibleIndex) { highestVisibleIndex = propositionIndex; } } }); if (highestVisibleIndex > 1) { setCurrentVisibleProposition(highestVisibleIndex); } }, { threshold: 0.3, // La proposition doit être visible à 30% pour être considérée comme active rootMargin: '-10% 0px -10% 0px' // Zone de détection réduite } ); // Attendre que le DOM soit mis à jour setTimeout(() => { const propositionElements = document.querySelectorAll('[data-proposition-index]'); propositionElements.forEach((element) => observer.observe(element)); }, 100); return () => observer.disconnect(); }, [propositions, isCompactView]); const loadData = async () => { try { setLoading(true); setError(''); // Vérifier la connectivité réseau if (typeof window !== 'undefined' && !navigator.onLine) { throw new Error('Pas de connexion internet. Veuillez vérifier votre connexion réseau.'); } const [campaignData, participants, propositionsData] = await Promise.all([ campaignService.getById(campaignId), participantService.getByCampaign(campaignId), propositionService.getByCampaign(campaignId) ]); const participantData = participants.find(p => p.id === participantId); if (!campaignData) { setError('Campagne non trouvée'); return; } if (!participantData) { setError('Participant non trouvé'); return; } if (campaignData.status !== 'voting') { setError('Cette campagne n\'est pas en phase de vote'); return; } setCampaign(campaignData); setParticipant(participantData); // Charger les votes existants const votes = await voteService.getByParticipant(campaignId, participantId); // Combiner les propositions avec leurs votes let propositionsWithVotes = propositionsData.map(proposition => ({ ...proposition, vote: votes.find(vote => vote.proposition_id === proposition.id) })); // Vérifier si l'ordre aléatoire est activé const randomizePropositions = await settingsService.getBooleanValue('randomize_propositions', true); if (randomizePropositions) { // Mélanger les propositions de manière aléatoire propositionsWithVotes = propositionsWithVotes.sort(() => Math.random() - 0.5); setIsRandomOrder(true); } setPropositions(propositionsWithVotes); // Initialiser les votes locaux avec les votes existants const initialVotes: Record = {}; votes.forEach(vote => { initialVotes[vote.proposition_id] = vote.amount; }); setLocalVotes(initialVotes); } catch (error) { console.error('Erreur lors du chargement des données:', error); let errorMessage = 'Erreur lors du chargement des données'; if (error instanceof Error) { errorMessage = error.message; } else if (typeof error === 'object' && error !== null) { // Essayer d'extraire plus d'informations de l'erreur const errorObj = error as any; if (errorObj.message) { errorMessage = errorObj.message; } else if (errorObj.error) { errorMessage = errorObj.error; } else if (errorObj.details) { errorMessage = errorObj.details; } } setError(errorMessage); } finally { setLoading(false); } }; const handleVoteChange = (propositionId: string, amount: number) => { if (amount === 0) { // Si on sélectionne "Aucun vote", on supprime le vote local const newLocalVotes = { ...localVotes }; delete newLocalVotes[propositionId]; setLocalVotes(newLocalVotes); } else { // Sinon on met à jour le vote local setLocalVotes(prev => ({ ...prev, [propositionId]: amount })); } }; const handleSubmit = async () => { if (totalVoted !== campaign?.budget_per_user) { setError(`Vous devez dépenser exactement ${campaign?.budget_per_user}€`); return; } setSaving(true); setError(''); try { // Préparer les votes à sauvegarder (seulement ceux avec amount > 0) const votesToSave = Object.entries(localVotes) .filter(([_, amount]) => amount > 0) .map(([propositionId, amount]) => ({ proposition_id: propositionId, amount })); // Utiliser la méthode atomique pour remplacer tous les votes await voteService.replaceVotes(campaignId, participantId, votesToSave); setSuccess(true); } catch (error) { console.error('Erreur lors de la validation:', error); // Améliorer l'affichage de l'erreur let errorMessage = 'Erreur lors de la validation des votes'; if (error instanceof Error) { errorMessage = error.message; } else if (typeof error === 'object' && error !== null) { // Essayer d'extraire plus d'informations de l'erreur const errorObj = error as any; if (errorObj.message) { errorMessage = errorObj.message; } else if (errorObj.error) { errorMessage = errorObj.error; } else if (errorObj.details) { errorMessage = errorObj.details; } } setError(errorMessage); } finally { setSaving(false); } }; const getSpendingTiers = () => { if (!campaign) return []; return campaign.spending_tiers.split(',').map(tier => parseInt(tier.trim())).filter(tier => tier > 0); }; const getVoteStatus = () => { if (!campaign) return { status: 'error', message: 'Campagne non trouvée' }; const remaining = campaign.budget_per_user - totalVoted; if (remaining === 0) { return { status: 'success', message: 'Budget complet ! Vous pouvez valider votre vote.' }; } else if (remaining > 0) { return { status: 'warning', message: `Il vous reste ${remaining}€ à dépenser` }; } else { return { status: 'error', message: `Vous avez dépensé ${Math.abs(remaining)}€ de trop` }; } }; if (loading) { return (

Chargement de la page de vote...

); } if (error && !campaign) { return (

Erreur

{error}

Retour à l'accueil
); } if (success) { return (

Vote enregistré !

Votre vote a été enregistré avec succès. Vous pouvez revenir modifier vos choix tant que la campagne est en cours.

); } const voteStatus = getVoteStatus(); const spendingTiers = getSpendingTiers(); return (
{/* Header fixe avec le total et le bouton de validation */}

{campaign?.title}

{participant?.first_name} {participant?.last_name}

0 ? 'text-indigo-600' : 'text-gray-900' } ${isOverBudget ? 'animate-bounce' : ''}`}> {totalVoted}€ / {campaign?.budget_per_user}€
{voteStatus.message}
{/* Informations de la campagne */}
{/* Message discret sur l'ordre aléatoire */} {isRandomOrder && (
Les propositions sont affichées dans un ordre aléatoire pour éviter les biais liés à l'ordre de présentation
)} {/* Propositions */} {propositions.length === 0 ? (

Aucune proposition

Aucune proposition n'a été soumise pour cette campagne.

) : (
{propositions.map((proposition, index) => (
0 ? 'border-indigo-400 shadow-lg bg-indigo-100' : 'bg-white border-gray-200' }`} > {!isCompactView && (
Proposition
)}

{proposition.title}

{!isCompactView && ( )}
{!isCompactView && ( )}
{/* Slider */}
{ const index = parseInt(e.target.value); const amount = index === 0 ? 0 : spendingTiers[index - 1]; handleVoteChange(proposition.id, amount); }} className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer slider" /> {/* Marqueurs des paliers */}
{/* Marqueur 0€ */}
0€
{/* Marqueurs des paliers */} {spendingTiers.map((tier, index) => { // Calcul correct de la position pour correspondre au slider const position = ((index + 1) / spendingTiers.length) * 100; return (
{tier}€
); })}
{/* Valeur sélectionnée */} {(localVotes[proposition.id] && localVotes[proposition.id] > 0) && !isCompactView && (
Vote sélectionné : {localVotes[proposition.id]}€
)}
))}
)} {error && (
{error}
)} {/* Footer discret */}
{/* Barre fixe en bas */}
Proposition {currentVisibleProposition} / {propositions.length}
Juste les titres Avec descriptions
); }