184 lines
6.6 KiB
TypeScript
184 lines
6.6 KiB
TypeScript
import React, { useState } from 'react';
|
|
import confetti from 'canvas-confetti';
|
|
import { CheckCircle2, Sparkles, RefreshCw, Zap } from 'lucide-react';
|
|
import { CANDIDATOS_DEFAULT } from '../types';
|
|
|
|
interface VotingStepProps {
|
|
voterName: string;
|
|
existingVoteFor: string | null;
|
|
onCastVote: (candidate: string) => Promise<void>;
|
|
isLoading: boolean;
|
|
}
|
|
|
|
export const VotingStep: React.FC<VotingStepProps> = ({
|
|
voterName,
|
|
existingVoteFor,
|
|
onCastVote,
|
|
isLoading,
|
|
}) => {
|
|
const [selectedCandidate, setSelectedCandidate] = useState<string | null>(null);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
const normalizedVoter = voterName.trim().toUpperCase();
|
|
|
|
// Función anti-autovoto
|
|
const isSelfVote = (candidateName: string): boolean => {
|
|
if (!normalizedVoter) return false;
|
|
const candUpper = candidateName.toUpperCase();
|
|
if (normalizedVoter === candUpper) return true;
|
|
const voterWords = normalizedVoter.split(/\s+/);
|
|
if (voterWords.includes(candUpper)) return true;
|
|
return false;
|
|
};
|
|
|
|
const handleSelect = (candidate: string) => {
|
|
if (isSelfVote(candidate) || isLoading || submitting) return;
|
|
setSelectedCandidate(candidate);
|
|
};
|
|
|
|
const handleConfirmVote = async () => {
|
|
const candidateToVote = selectedCandidate || existingVoteFor;
|
|
if (!candidateToVote || isSelfVote(candidateToVote) || submitting || isLoading) return;
|
|
|
|
setSubmitting(true);
|
|
try {
|
|
await onCastVote(candidateToVote);
|
|
|
|
// Confetti celebratory animation
|
|
try {
|
|
confetti({
|
|
particleCount: 75,
|
|
spread: 70,
|
|
origin: { y: 0.6 },
|
|
colors: ['#00B7D4', '#00e676', '#ffffff', '#38bdf8'],
|
|
});
|
|
} catch {
|
|
// ignore
|
|
}
|
|
} catch (err) {
|
|
console.error('Error al emitir voto:', err);
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex-1 flex flex-col justify-between">
|
|
<div>
|
|
<div className="flex items-center justify-between mb-3">
|
|
<label className="block text-xs uppercase tracking-wider text-gray-400 font-semibold">
|
|
Paso 2: Elige el Proyecto Ganador
|
|
</label>
|
|
<span className="text-[10px] text-gray-500 font-mono uppercase">
|
|
9 Candidatos
|
|
</span>
|
|
</div>
|
|
|
|
{/* Candidatos Pills Grid (Design Theme) */}
|
|
<div className="grid grid-cols-2 gap-2.5">
|
|
{CANDIDATOS_DEFAULT.map((cand, index) => {
|
|
const isSelf = isSelfVote(cand);
|
|
const isVoted = existingVoteFor === cand;
|
|
const isSelected = selectedCandidate === cand || (!selectedCandidate && isVoted);
|
|
const isLast = index === CANDIDATOS_DEFAULT.length - 1 && CANDIDATOS_DEFAULT.length % 2 !== 0;
|
|
|
|
return (
|
|
<button
|
|
key={cand}
|
|
id={`btn-cand-${cand.toLowerCase()}`}
|
|
type="button"
|
|
disabled={isSelf || isLoading || submitting}
|
|
onClick={() => {
|
|
handleSelect(cand);
|
|
// Auto-submit on tap for seamless UX if no prior selection
|
|
if (!existingVoteFor) {
|
|
onCastVote(cand).then(() => {
|
|
confetti({
|
|
particleCount: 65,
|
|
spread: 60,
|
|
origin: { y: 0.6 },
|
|
colors: ['#00B7D4', '#00e676', '#ffffff'],
|
|
});
|
|
}).catch(() => {});
|
|
}
|
|
}}
|
|
className={`btn-pill flex items-center justify-between px-3.5 py-3 ${
|
|
isLast ? 'col-span-2' : ''
|
|
} ${
|
|
isVoted || (isSelected && selectedCandidate === cand)
|
|
? 'active'
|
|
: isSelf
|
|
? 'opacity-30 cursor-not-allowed bg-white/[0.01]'
|
|
: ''
|
|
}`}
|
|
>
|
|
<span className="font-semibold tracking-wider truncate text-xs sm:text-sm">
|
|
{cand}
|
|
</span>
|
|
|
|
{isSelf ? (
|
|
<span className="text-[9px] uppercase font-mono px-1.5 py-0.5 rounded bg-white/10 text-gray-400">
|
|
(Tú)
|
|
</span>
|
|
) : isVoted ? (
|
|
<span className="flex items-center gap-1 text-[10px] font-bold">
|
|
<CheckCircle2 className="w-3.5 h-3.5" />
|
|
VOTADO
|
|
</span>
|
|
) : null}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Confirmation and Atomic Action Button */}
|
|
<div className="mt-6 pt-4 border-t border-white/5 space-y-3">
|
|
{existingVoteFor && (
|
|
<div className="p-3 rounded-xl bg-[#00B7D4]/10 border border-[#00B7D4]/30 text-xs text-white flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Sparkles className="w-4 h-4 text-[#00B7D4] shrink-0" />
|
|
<span>
|
|
Voto emitido para:{' '}
|
|
<strong className="text-[#00B7D4] font-bold uppercase tracking-wider">
|
|
{existingVoteFor}
|
|
</strong>
|
|
</span>
|
|
</div>
|
|
<span className="text-[10px] font-mono text-gray-400 uppercase">
|
|
Atómico
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
type="button"
|
|
disabled={!normalizedVoter || submitting || isLoading || (!selectedCandidate && !existingVoteFor)}
|
|
onClick={handleConfirmVote}
|
|
className="w-full py-3.5 rounded-xl bg-[#00B7D4] hover:bg-[#38bdf8] text-black font-extrabold text-xs uppercase tracking-widest transition-all duration-200 shadow-[0_0_20px_rgba(0,183,212,0.35)] hover:shadow-[0_0_25px_rgba(0,183,212,0.6)] cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed flex items-center justify-center gap-2 active:scale-[0.99]"
|
|
>
|
|
{submitting ? (
|
|
<>
|
|
<RefreshCw className="w-4 h-4 animate-spin" />
|
|
<span>REGISTRANDO EN LA NUBE...</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Zap className="w-4 h-4 fill-current" />
|
|
<span>
|
|
{existingVoteFor && selectedCandidate && selectedCandidate !== existingVoteFor
|
|
? `CAMBIAR VOTO A ${selectedCandidate}`
|
|
: existingVoteFor
|
|
? `VOTO CONFIRMADO: ${existingVoteFor}`
|
|
: selectedCandidate
|
|
? `REGISTRAR VOTO POR ${selectedCandidate}`
|
|
: 'REGISTRAR VOTO ATÓMICO'}
|
|
</span>
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|