feat: migración a PostgreSQL, tiempo real SSE, Dockerfile y compose para Coolify
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user