feat: migración a PostgreSQL, tiempo real SSE, Dockerfile y compose para Coolify

This commit is contained in:
urieljareth
2026-08-13 15:01:58 -06:00
commit f7fded5a44
28 changed files with 7314 additions and 0 deletions
+183
View File
@@ -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>
);
};