ajout de slider sur la page de vote

This commit is contained in:
Yannick Le Duc
2025-08-25 15:32:15 +02:00
parent 06bfe11dcc
commit 4e8b592feb
3 changed files with 175 additions and 104 deletions

View File

@@ -214,9 +214,6 @@ export default function AdminPage() {
</div>
</div>
<div className="text-sm text-gray-500">
<span className="font-medium">Paliers de dépenses:</span> {campaign.spending_tiers}
</div>
{campaign.status === 'deposit' && (
<div className="mt-4 p-3 bg-blue-50 rounded-lg border border-blue-200">

View File

@@ -21,6 +21,9 @@ export default function PublicVotePage() {
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
// Votes temporaires stockés localement
const [localVotes, setLocalVotes] = useState<Record<string, number>>({});
const [totalVoted, setTotalVoted] = useState(0);
useEffect(() => {
@@ -29,6 +32,12 @@ export default function PublicVotePage() {
}
}, [campaignId, participantId]);
// Calculer le total voté à partir des votes locaux
useEffect(() => {
const total = Object.values(localVotes).reduce((sum, amount) => sum + amount, 0);
setTotalVoted(total);
}, [localVotes]);
const loadData = async () => {
try {
setLoading(true);
@@ -70,9 +79,12 @@ export default function PublicVotePage() {
setPropositions(propositionsWithVotes);
// Calculer le total voté
const total = votes.reduce((sum, vote) => sum + vote.amount, 0);
setTotalVoted(total);
// Initialiser les votes locaux avec les votes existants
const initialVotes: Record<string, number> = {};
votes.forEach(vote => {
initialVotes[vote.proposition_id] = vote.amount;
});
setLocalVotes(initialVotes);
} catch (error) {
console.error('Erreur lors du chargement des données:', error);
@@ -82,37 +94,18 @@ export default function PublicVotePage() {
}
};
const handleVoteChange = async (propositionId: string, amount: number) => {
try {
const existingVote = propositions.find(p => p.id === propositionId)?.vote;
const handleVoteChange = (propositionId: string, amount: number) => {
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);
}
// Si on sélectionne "Aucun vote", on supprime le vote local
const newLocalVotes = { ...localVotes };
delete newLocalVotes[propositionId];
setLocalVotes(newLocalVotes);
} 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');
// Sinon on met à jour le vote local
setLocalVotes(prev => ({
...prev,
[propositionId]: amount
}));
}
};
@@ -126,7 +119,24 @@ export default function PublicVotePage() {
setError('');
try {
// Les votes sont déjà sauvegardés, on affiche juste le succès
// Supprimer tous les votes existants pour ce participant
const existingVotes = await voteService.getByParticipant(campaignId, participantId);
for (const vote of existingVotes) {
await voteService.delete(vote.id);
}
// Créer les nouveaux votes
for (const [propositionId, amount] of Object.entries(localVotes)) {
if (amount > 0) {
await voteService.create({
campaign_id: campaignId,
participant_id: participantId,
proposition_id: propositionId,
amount
});
}
}
setSuccess(true);
} catch (error) {
console.error('Erreur lors de la validation:', error);
@@ -217,7 +227,7 @@ export default function PublicVotePage() {
return (
<div className="min-h-screen bg-gray-50">
{/* Header fixe avec le total */}
{/* Header fixe avec le total et le bouton de validation */}
<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">
@@ -239,6 +249,7 @@ export default function PublicVotePage() {
</div>
</div>
<div className="flex items-center space-x-4">
<div className="text-right">
<div className="text-2xl font-bold text-gray-900">
{totalVoted} / {campaign?.budget_per_user}
@@ -251,6 +262,19 @@ export default function PublicVotePage() {
{voteStatus.message}
</div>
</div>
<button
onClick={handleSubmit}
disabled={saving || totalVoted !== campaign?.budget_per_user}
className={`px-6 py-3 text-sm 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>
</div>
</div>
</div>
@@ -258,20 +282,11 @@ export default function PublicVotePage() {
<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 className="grid grid-cols-1 md:grid-cols-1 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>
@@ -297,62 +312,56 @@ export default function PublicVotePage() {
<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
Pour cette proposition, vous investissez :
</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>
)}
<div className="space-y-4">
{/* Slider */}
<div className="relative">
<input
type="range"
min="0"
max={spendingTiers.length}
step="1"
value={localVotes[proposition.id] ? spendingTiers.indexOf(localVotes[proposition.id]) + 1 : 0}
onChange={(e) => {
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"
/>
{/* 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>
{/* Marqueurs des paliers */}
<div className="flex justify-between mt-3 px-2">
<div className="text-center">
<div className="w-3 h-3 bg-gray-400 rounded-full mx-auto mb-2"></div>
<span className="text-xs text-gray-600 font-medium">0</span>
</div>
{spendingTiers.map((tier, index) => (
<div key={tier} className="text-center">
<div className="w-3 h-3 bg-indigo-500 rounded-full mx-auto mb-2"></div>
<span className="text-xs text-gray-600 font-medium">{tier}</span>
</div>
))}
</div>
</div>
{/* Valeur sélectionnée */}
<div className="text-center">
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-indigo-100 text-indigo-800">
Vote sélectionné : {localVotes[proposition.id] || 0}
</span>
</div>
</div>
</div>
</div>
</div>
))}
</div>
)}

View File

@@ -24,3 +24,68 @@ body {
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
/* Styles personnalisés pour le slider */
.slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 8px;
border-radius: 4px;
background: #e5e7eb;
outline: none;
cursor: pointer;
}
.slider::-webkit-slider-track {
width: 100%;
height: 8px;
border-radius: 4px;
background: #e5e7eb;
border: 1px solid #d1d5db;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 24px;
height: 24px;
border-radius: 50%;
background: #4f46e5;
cursor: pointer;
border: 2px solid #ffffff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
transition: all 0.2s ease;
}
.slider::-webkit-slider-thumb:hover {
background: #3730a3;
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.slider::-moz-range-track {
width: 100%;
height: 8px;
border-radius: 4px;
background: #e5e7eb;
border: 1px solid #d1d5db;
outline: none;
}
.slider::-moz-range-thumb {
width: 24px;
height: 24px;
border-radius: 50%;
background: #4f46e5;
cursor: pointer;
border: 2px solid #ffffff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
transition: all 0.2s ease;
}
.slider::-moz-range-thumb:hover {
background: #3730a3;
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}