fine tux à max la page de vote (better ux)

This commit is contained in:
Yannick Le Duc
2025-08-26 21:49:45 +02:00
parent 01864e6081
commit bd4f63b99c

View File

@@ -27,6 +27,9 @@ export default function PublicVotePage() {
const [localVotes, setLocalVotes] = useState<Record<string, number>>({}); const [localVotes, setLocalVotes] = useState<Record<string, number>>({});
const [totalVoted, setTotalVoted] = useState(0); const [totalVoted, setTotalVoted] = useState(0);
const [isRandomOrder, setIsRandomOrder] = useState(false); const [isRandomOrder, setIsRandomOrder] = useState(false);
const [isCompactView, setIsCompactView] = useState(false);
const [currentVisibleProposition, setCurrentVisibleProposition] = useState(1);
const [isOverBudget, setIsOverBudget] = useState(false);
useEffect(() => { useEffect(() => {
if (campaignId && participantId) { if (campaignId && participantId) {
@@ -38,7 +41,52 @@ export default function PublicVotePage() {
useEffect(() => { useEffect(() => {
const total = Object.values(localVotes).reduce((sum, amount) => sum + amount, 0); const total = Object.values(localVotes).reduce((sum, amount) => sum + amount, 0);
setTotalVoted(total); setTotalVoted(total);
}, [localVotes]);
// 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 () => { const loadData = async () => {
try { try {
@@ -253,10 +301,18 @@ export default function PublicVotePage() {
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<div className="text-right"> <div className="text-right">
<div className="text-2xl font-bold text-gray-900"> <div className={`text-2xl font-bold transition-all duration-300 ${
isOverBudget
? 'text-red-600 animate-pulse'
: totalVoted === campaign?.budget_per_user
? 'text-green-600 scale-105'
: totalVoted > 0
? 'text-indigo-600'
: 'text-gray-900'
} ${isOverBudget ? 'animate-bounce' : ''}`}>
{totalVoted}€ / {campaign?.budget_per_user}€ {totalVoted}€ / {campaign?.budget_per_user}€
</div> </div>
<div className={`text-sm font-medium ${ <div className={`text-sm font-medium transition-colors duration-300 ${
voteStatus.status === 'success' ? 'text-green-600' : voteStatus.status === 'success' ? 'text-green-600' :
voteStatus.status === 'warning' ? 'text-yellow-600' : voteStatus.status === 'warning' ? 'text-yellow-600' :
'text-red-600' 'text-red-600'
@@ -281,18 +337,15 @@ export default function PublicVotePage() {
</div> </div>
</div> </div>
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8 pb-20">
{/* Informations de la campagne */} {/* Informations de la campagne */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-8"> <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-8">
<div className="grid grid-cols-1 md:grid-cols-1 gap-6"> <div className="grid grid-cols-1 md:grid-cols-1 gap-6">
<div> <div>
<p className="mt-1 text-sm text-gray-900 whitespace-pre-wrap">{campaign?.description}</p> <p className="mt-1 text-base font-medium text-gray-900 whitespace-pre-wrap leading-relaxed">{campaign?.description}</p>
{isRandomOrder && ( {isRandomOrder && (
<div className="mt-3 p-2 bg-blue-50 border border-blue-200 rounded-md"> <div className="mt-4 text-xs text-gray-500 italic">
<p className="text-xs text-blue-700 flex items-center gap-1"> Les propositions sont affichées dans un ordre aléatoire pour éviter les biais liés à l'ordre de présentation.
<span className="text-blue-500"></span>
Les propositions sont affichées dans un ordre aléatoire pour éviter les biais liés à l'ordre de présentation.
</p>
</div> </div>
)} )}
</div> </div>
@@ -309,35 +362,42 @@ export default function PublicVotePage() {
<p className="mt-1 text-sm text-gray-500">Aucune proposition n'a été soumise pour cette campagne.</p> <p className="mt-1 text-sm text-gray-500">Aucune proposition n'a été soumise pour cette campagne.</p>
</div> </div>
) : ( ) : (
<div className="space-y-6"> <div className={`${isCompactView ? 'space-y-3' : 'space-y-6'}`}>
{propositions.map((proposition) => ( {propositions.map((proposition, index) => (
<div <div
key={proposition.id} key={proposition.id}
data-proposition-index={index + 1}
className={`rounded-lg shadow-sm border overflow-hidden transition-all duration-200 relative ${ className={`rounded-lg shadow-sm border overflow-hidden transition-all duration-200 relative ${
localVotes[proposition.id] && localVotes[proposition.id] > 0 localVotes[proposition.id] && localVotes[proposition.id] > 0
? 'border-indigo-400 shadow-lg bg-indigo-100' ? 'border-indigo-400 shadow-lg bg-indigo-100'
: 'bg-white border-gray-200' : 'bg-white border-gray-200'
}`} }`}
> >
{!isCompactView && (
<div className="absolute -top-1 left-4 bg-white px-2 text-xs text-gray-500 font-medium z-10 border border-gray-200 rounded-t"> <div className="absolute -top-1 left-4 bg-white px-2 text-xs text-gray-500 font-medium z-10 border border-gray-200 rounded-t">
Proposition Proposition
</div> </div>
<div className="p-6"> )}
<div className={`${isCompactView ? 'p-4' : 'p-6'}`}>
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex-1"> <div className="flex-1">
<h3 className="text-lg font-medium text-gray-900 mb-2"> <h3 className={`font-medium text-gray-900 ${isCompactView ? 'text-base mb-1' : 'text-lg mb-2'}`}>
{proposition.title} {proposition.title}
</h3> </h3>
{!isCompactView && (
<p className="text-sm text-gray-600 mb-4 whitespace-pre-wrap"> <p className="text-sm text-gray-600 mb-4 whitespace-pre-wrap">
{proposition.description} {proposition.description}
</p> </p>
)}
</div> </div>
</div> </div>
<div className="mt-6"> <div className={isCompactView ? 'mt-3' : 'mt-6'}>
{!isCompactView && (
<label className="block text-sm font-medium text-gray-700 mb-3"> <label className="block text-sm font-medium text-gray-700 mb-3">
Pour cette proposition, vous investissez : Pour cette proposition, vous investissez :
</label> </label>
)}
<div className="space-y-4"> <div className="space-y-4">
{/* Slider */} {/* Slider */}
<div className="relative"> <div className="relative">
@@ -384,7 +444,7 @@ export default function PublicVotePage() {
</div> </div>
{/* Valeur sélectionnée */} {/* Valeur sélectionnée */}
{(localVotes[proposition.id] && localVotes[proposition.id] > 0) && ( {(localVotes[proposition.id] && localVotes[proposition.id] > 0) && !isCompactView && (
<div className="text-center mt-12"> <div className="text-center mt-12">
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-indigo-100 text-indigo-800"> <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]}€ Vote sélectionné : {localVotes[proposition.id]}€
@@ -405,6 +465,34 @@ export default function PublicVotePage() {
</div> </div>
)} )}
</div> </div>
{/* Barre fixe en bas */}
<div className="fixed bottom-0 left-0 right-0 z-40 bg-white shadow-lg border-t border-gray-200">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600">
Proposition {currentVisibleProposition} / {propositions.length}
</div>
<div className="flex items-center space-x-2 text-xs text-gray-500">
<span>Juste les titres</span>
<button
onClick={() => setIsCompactView(!isCompactView)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 ${
!isCompactView ? 'bg-indigo-600' : 'bg-gray-200'
}`}
>
<span
className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${
!isCompactView ? 'translate-x-5' : 'translate-x-1'
}`}
/>
</button>
<span>Avec descriptions</span>
</div>
</div>
</div>
</div>
</div> </div>
); );
} }