liens publics pour voter pour les participants

This commit is contained in:
Yannick Le Duc
2025-08-25 15:04:27 +02:00
parent 30a228e14f
commit 06bfe11dcc
8 changed files with 583 additions and 17 deletions

View File

@@ -3,8 +3,8 @@
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { Campaign, Participant } from '@/types';
import { campaignService, participantService } from '@/lib/services';
import { Campaign, Participant, ParticipantWithVoteStatus } from '@/types';
import { campaignService, participantService, voteService } from '@/lib/services';
import AddParticipantModal from '@/components/AddParticipantModal';
import EditParticipantModal from '@/components/EditParticipantModal';
import DeleteParticipantModal from '@/components/DeleteParticipantModal';
@@ -17,12 +17,13 @@ export default function CampaignParticipantsPage() {
const campaignId = params.id as string;
const [campaign, setCampaign] = useState<Campaign | null>(null);
const [participants, setParticipants] = useState<Participant[]>([]);
const [participants, setParticipants] = useState<ParticipantWithVoteStatus[]>([]);
const [loading, setLoading] = useState(true);
const [showAddModal, setShowAddModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [selectedParticipant, setSelectedParticipant] = useState<Participant | null>(null);
const [copiedParticipantId, setCopiedParticipantId] = useState<string | null>(null);
useEffect(() => {
if (campaignId) {
@@ -33,13 +34,13 @@ export default function CampaignParticipantsPage() {
const loadData = async () => {
try {
setLoading(true);
const [campaignData, participantsData] = await Promise.all([
const [campaigns, participantsWithVoteStatus] = await Promise.all([
campaignService.getAll().then(campaigns => campaigns.find(c => c.id === campaignId)),
participantService.getByCampaign(campaignId)
voteService.getParticipantVoteStatus(campaignId)
]);
setCampaign(campaignData || null);
setParticipants(participantsData);
setCampaign(campaigns || null);
setParticipants(participantsWithVoteStatus);
} catch (error) {
console.error('Erreur lors du chargement des données:', error);
} finally {
@@ -181,9 +182,65 @@ export default function CampaignParticipantsPage() {
</p>
</div>
</div>
<p className="text-xs text-gray-500">
Inscrit le {new Date(participant.created_at).toLocaleDateString('fr-FR')}
</p>
<div className="flex items-center space-x-4 text-xs text-gray-500">
<span>
<strong>Inscrit le :</strong> {new Date(participant.created_at).toLocaleDateString('fr-FR')}
</span>
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
participant.has_voted
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
}`}>
{participant.has_voted ? 'A voté' : 'N\'a pas voté'}
</span>
{participant.has_voted && participant.total_voted_amount && (
<span>
<strong>Total voté :</strong> {participant.total_voted_amount}
</span>
)}
</div>
{campaign?.status === 'voting' && (
<div className="mt-4 p-3 bg-blue-50 rounded-lg border border-blue-200">
<div className="flex items-center justify-between">
<div className="flex-1">
<h4 className="text-sm font-medium text-blue-900 mb-1">Lien de vote personnel</h4>
<div className="flex items-center space-x-2">
<input
type="text"
readOnly
value={`${window.location.origin}/campaigns/${campaignId}/vote/${participant.id}`}
className="flex-1 text-xs bg-white border border-blue-300 rounded px-2 py-1 text-blue-700 font-mono"
/>
<button
onClick={() => {
navigator.clipboard.writeText(`${window.location.origin}/campaigns/${campaignId}/vote/${participant.id}`);
setCopiedParticipantId(participant.id);
setTimeout(() => setCopiedParticipantId(null), 2000);
}}
className="inline-flex items-center px-2 py-1 border border-blue-300 rounded text-xs font-medium text-blue-700 bg-white hover:bg-blue-50"
title="Copier le lien"
>
{copiedParticipantId === participant.id ? (
<>
<svg className="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Copié !
</>
) : (
<>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</>
)}
</button>
</div>
</div>
</div>
</div>
)}
</div>
<div className="flex items-center space-x-2 ml-6">
<button

View File

@@ -0,0 +1,367 @@
'use client';
import { useState, useEffect } from 'react';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import { Campaign, Proposition, Participant, Vote, PropositionWithVote } from '@/types';
import { campaignService, participantService, propositionService, voteService } from '@/lib/services';
// Force dynamic rendering to avoid SSR issues with Supabase
export const dynamic = 'force-dynamic';
export default function PublicVotePage() {
const params = useParams();
const campaignId = params.id as string;
const participantId = params.participantId as string;
const [campaign, setCampaign] = useState<Campaign | null>(null);
const [participant, setParticipant] = useState<Participant | null>(null);
const [propositions, setPropositions] = useState<PropositionWithVote[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const [totalVoted, setTotalVoted] = useState(0);
useEffect(() => {
if (campaignId && participantId) {
loadData();
}
}, [campaignId, participantId]);
const loadData = async () => {
try {
setLoading(true);
const [campaigns, participants, propositionsData] = await Promise.all([
campaignService.getAll(),
participantService.getByCampaign(campaignId),
propositionService.getByCampaign(campaignId)
]);
const campaignData = campaigns.find(c => c.id === 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
const propositionsWithVotes = propositionsData.map(proposition => ({
...proposition,
vote: votes.find(vote => vote.proposition_id === proposition.id)
}));
setPropositions(propositionsWithVotes);
// Calculer le total voté
const total = votes.reduce((sum, vote) => sum + vote.amount, 0);
setTotalVoted(total);
} catch (error) {
console.error('Erreur lors du chargement des données:', error);
setError('Erreur lors du chargement des données');
} finally {
setLoading(false);
}
};
const handleVoteChange = async (propositionId: string, amount: number) => {
try {
const existingVote = propositions.find(p => p.id === propositionId)?.vote;
if (amount === 0) {
// Si on sélectionne "Aucun vote", on supprime le vote existant s'il y en a un
if (existingVote) {
await voteService.delete(existingVote.id);
}
} else {
// Sinon on crée ou met à jour le vote
if (existingVote) {
// Mettre à jour le vote existant
await voteService.update(existingVote.id, { amount });
} else {
// Créer un nouveau vote
await voteService.create({
campaign_id: campaignId,
participant_id: participantId,
proposition_id: propositionId,
amount
});
}
}
// Recharger les données
await loadData();
} catch (error) {
console.error('Erreur lors du vote:', error);
setError('Erreur lors de l\'enregistrement du vote');
}
};
const handleSubmit = async () => {
if (totalVoted !== campaign?.budget_per_user) {
setError(`Vous devez dépenser exactement ${campaign?.budget_per_user}`);
return;
}
setSaving(true);
setError('');
try {
// Les votes sont déjà sauvegardés, on affiche juste le succès
setSuccess(true);
} catch (error) {
console.error('Erreur lors de la validation:', error);
setError('Erreur lors de la validation des votes');
} 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 (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Chargement de la page de vote...</p>
</div>
</div>
);
}
if (error && !campaign) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md mx-auto">
<svg className="mx-auto h-12 w-12 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<h2 className="mt-4 text-lg font-medium text-gray-900">Erreur</h2>
<p className="mt-2 text-sm text-gray-600">{error}</p>
<Link
href="/"
className="mt-4 inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700"
>
Retour à l'accueil
</Link>
</div>
</div>
</div>
);
}
if (success) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md mx-auto">
<svg className="mx-auto h-12 w-12 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h2 className="mt-4 text-lg font-medium text-gray-900">Vote enregistré !</h2>
<p className="mt-2 text-sm text-gray-600">
Votre vote a été enregistré avec succès. Vous pouvez revenir modifier vos choix tant que la campagne est en cours.
</p>
<Link
href="/"
className="mt-4 inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700"
>
Retour à l'accueil
</Link>
</div>
</div>
</div>
);
}
const voteStatus = getVoteStatus();
const spendingTiers = getSpendingTiers();
return (
<div className="min-h-screen bg-gray-50">
{/* Header fixe avec le total */}
<div className="sticky top-0 z-40 bg-white shadow-sm border-b border-gray-200">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<Link
href="/"
className="inline-flex items-center text-sm text-indigo-600 hover:text-indigo-500"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Retour
</Link>
<div>
<h1 className="text-lg font-semibold text-gray-900">Vote - {campaign?.title}</h1>
<p className="text-sm text-gray-600">
{participant?.first_name} {participant?.last_name}
</p>
</div>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-gray-900">
{totalVoted} / {campaign?.budget_per_user}
</div>
<div className={`text-sm font-medium ${
voteStatus.status === 'success' ? 'text-green-600' :
voteStatus.status === 'warning' ? 'text-yellow-600' :
'text-red-600'
}`}>
{voteStatus.message}
</div>
</div>
</div>
</div>
</div>
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Informations de la campagne */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-8">
<h2 className="text-lg font-medium text-gray-900 mb-4">Informations sur la campagne</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<h3 className="text-sm font-medium text-gray-500">Description</h3>
<p className="mt-1 text-sm text-gray-900">{campaign?.description}</p>
</div>
<div>
<h3 className="text-sm font-medium text-gray-500">Budget par participant</h3>
<p className="mt-1 text-sm text-gray-900">{campaign?.budget_per_user}</p>
</div>
<div>
<h3 className="text-sm font-medium text-gray-500">Paliers de dépenses</h3>
<p className="mt-1 text-sm text-gray-900">{campaign?.spending_tiers}</p>
</div>
</div>
</div>
{/* Propositions */}
{propositions.length === 0 ? (
<div className="text-center py-12">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">Aucune proposition</h3>
<p className="mt-1 text-sm text-gray-500">Aucune proposition n'a été soumise pour cette campagne.</p>
</div>
) : (
<div className="space-y-6">
{propositions.map((proposition) => (
<div key={proposition.id} className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<div className="p-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="text-lg font-medium text-gray-900 mb-2">
{proposition.title}
</h3>
<p className="text-sm text-gray-600 mb-4">
{proposition.description}
</p>
<div className="text-xs text-gray-500">
<span className="font-medium">Auteur :</span> {proposition.author_first_name} {proposition.author_last_name}
</div>
</div>
</div>
<div className="mt-6">
<label className="block text-sm font-medium text-gray-700 mb-3">
Votre vote pour cette proposition
</label>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
{spendingTiers.map((tier) => (
<button
key={tier}
onClick={() => handleVoteChange(proposition.id, tier)}
className={`px-4 py-3 text-sm font-medium rounded-lg border-2 transition-all duration-200 ${
proposition.vote?.amount === tier
? 'border-indigo-500 bg-indigo-50 text-indigo-700'
: 'border-gray-200 bg-white text-gray-700 hover:border-gray-300 hover:bg-gray-50'
}`}
>
{tier}€
</button>
))}
<button
onClick={() => handleVoteChange(proposition.id, 0)}
className={`px-4 py-3 text-sm font-medium rounded-lg border-2 transition-all duration-200 ${
!proposition.vote
? 'border-gray-400 bg-gray-100 text-gray-600'
: 'border-gray-200 bg-white text-gray-700 hover:border-gray-300 hover:bg-gray-50'
}`}
>
Aucun vote
</button>
</div>
</div>
</div>
</div>
))}
</div>
)}
{/* Bouton de validation */}
{propositions.length > 0 && (
<div className="mt-8 flex justify-center">
<button
onClick={handleSubmit}
disabled={saving || totalVoted !== campaign?.budget_per_user}
className={`px-8 py-4 text-lg font-medium rounded-lg transition-all duration-200 ${
totalVoted === campaign?.budget_per_user
? 'bg-green-600 text-white hover:bg-green-700 shadow-lg'
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
}`}
>
{saving ? 'Enregistrement...' : 'Valider mon vote'}
</button>
</div>
)}
{error && (
<div className="mt-6 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
{error}
</div>
)}
</div>
</div>
);
}

View File

@@ -23,9 +23,9 @@ export default function AddPropositionModal({
const [formData, setFormData] = useState({
title: '',
description: '',
author_first_name: '',
author_last_name: '',
author_email: ''
author_first_name: 'admin',
author_last_name: 'admin',
author_email: 'admin@example.com'
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');

View File

@@ -78,7 +78,7 @@ export default function DeleteParticipantModal({
<strong>Email :</strong> {participant.email}
</p>
<p>
Cette action supprimera définitivement le participant et toutes les données associées.
Cette action supprimera définitivement le participant et toutes les données associées (votes, etc.).
</p>
<p className="text-red-600 font-medium mt-3">
Cette action est irréversible !

View File

@@ -99,7 +99,7 @@ export default function EditParticipantModal({
onChange={handleChange}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Prénom du participant"
placeholder="Prénom"
/>
</div>
@@ -115,7 +115,7 @@ export default function EditParticipantModal({
onChange={handleChange}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Nom du participant"
placeholder="Nom"
/>
</div>

View File

@@ -1,5 +1,5 @@
import { supabase } from './supabase';
import { Campaign, Proposition, Participant } from '@/types';
import { Campaign, Proposition, Participant, Vote, ParticipantWithVoteStatus } from '@/types';
// Services pour les campagnes
export const campaignService = {
@@ -173,3 +173,99 @@ export const participantService = {
if (error) throw error;
}
};
// Services pour les votes
export const voteService = {
async getByParticipant(campaignId: string, participantId: string): Promise<Vote[]> {
const { data, error } = await supabase
.from('votes')
.select('*')
.eq('campaign_id', campaignId)
.eq('participant_id', participantId);
if (error) throw error;
return data || [];
},
async getByProposition(propositionId: string): Promise<Vote[]> {
const { data, error } = await supabase
.from('votes')
.select('*')
.eq('proposition_id', propositionId);
if (error) throw error;
return data || [];
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async create(vote: any): Promise<Vote> {
const { data, error } = await supabase
.from('votes')
.insert(vote)
.select()
.single();
if (error) throw error;
return data;
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async update(id: string, updates: any): Promise<Vote> {
const { data, error } = await supabase
.from('votes')
.update(updates)
.eq('id', id)
.select()
.single();
if (error) throw error;
return data;
},
async upsert(vote: { campaign_id: string; participant_id: string; proposition_id: string; amount: number }): Promise<Vote> {
const { data, error } = await supabase
.from('votes')
.upsert(vote, { onConflict: 'participant_id,proposition_id' })
.select()
.single();
if (error) throw error;
return data;
},
async delete(id: string): Promise<void> {
const { error } = await supabase
.from('votes')
.delete()
.eq('id', id);
if (error) throw error;
},
async getParticipantVoteStatus(campaignId: string): Promise<ParticipantWithVoteStatus[]> {
const { data: participants, error: participantsError } = await supabase
.from('participants')
.select('*')
.eq('campaign_id', campaignId);
if (participantsError) throw participantsError;
const { data: votes, error: votesError } = await supabase
.from('votes')
.select('*')
.eq('campaign_id', campaignId);
if (votesError) throw votesError;
return participants.map(participant => {
const participantVotes = votes.filter(vote => vote.participant_id === participant.id);
const totalVotedAmount = participantVotes.reduce((sum, vote) => sum + vote.amount, 0);
return {
...participant,
has_voted: participantVotes.length > 0,
total_voted_amount: totalVotedAmount
};
});
}
};

View File

@@ -38,6 +38,25 @@ export interface Participant {
created_at: string;
}
export interface Vote {
id: string;
campaign_id: string;
participant_id: string;
proposition_id: string;
amount: number;
created_at: string;
updated_at: string;
}
export interface PropositionWithVote extends Proposition {
vote?: Vote;
}
export interface ParticipantWithVoteStatus extends Participant {
has_voted: boolean;
total_voted_amount?: number;
}
export interface Database {
public: {
Tables: {