feat: reemplazar todos los alerts y confirms del navegador por notificaciones y modales internos in-app

This commit is contained in:
urieljareth
2026-08-13 17:51:46 -06:00
parent 72416e46ee
commit ec31f94a21
4 changed files with 238 additions and 50 deletions
+25 -4
View File
@@ -5,6 +5,7 @@ import { VotingStep } from './components/VotingStep';
import { LiveResults } from './components/LiveResults'; import { LiveResults } from './components/LiveResults';
import { AdminPanel } from './components/AdminPanel'; import { AdminPanel } from './components/AdminPanel';
import { Footer } from './components/Footer'; import { Footer } from './components/Footer';
import { ToastContainer, ToastMessage } from './components/Toast';
import { ApiService } from './services/api'; import { ApiService } from './services/api';
import { CANDIDATOS_DEFAULT, CandidateStats, SyncStatus } from './types'; import { CANDIDATOS_DEFAULT, CandidateStats, SyncStatus } from './types';
@@ -15,6 +16,16 @@ export default function App() {
const [lastSyncTime, setLastSyncTime] = useState<Date | null>(null); const [lastSyncTime, setLastSyncTime] = useState<Date | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const [isAdminOpen, setIsAdminOpen] = useState(false); const [isAdminOpen, setIsAdminOpen] = useState(false);
const [toasts, setToasts] = useState<ToastMessage[]>([]);
const addToast = useCallback((type: 'success' | 'error' | 'warning' | 'info', message: string, title?: string) => {
const id = Math.random().toString(36).substring(2, 9);
setToasts((prev) => [...prev, { id, type, message, title }]);
}, []);
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
// Sincronización REST con la API PostgreSQL // Sincronización REST con la API PostgreSQL
const fetchVotes = useCallback(async (isManual = false) => { const fetchVotes = useCallback(async (isManual = false) => {
@@ -103,7 +114,7 @@ export default function App() {
// Emitir voto // Emitir voto
const handleCastVote = async (candidate: string) => { const handleCastVote = async (candidate: string) => {
if (!normalizedVoter || normalizedVoter.length < 2) { if (!normalizedVoter || normalizedVoter.length < 2) {
alert('Por favor escribe tu nombre completo en el Paso 1 antes de votar.'); addToast('warning', 'Por favor escribe tu nombre completo en el Paso 1 antes de votar.', 'Identificación Requerida');
return; return;
} }
@@ -115,8 +126,9 @@ export default function App() {
[normalizedVoter]: candidate.toUpperCase(), [normalizedVoter]: candidate.toUpperCase(),
})); }));
setLastSyncTime(new Date()); setLastSyncTime(new Date());
addToast('success', `Tu voto por ${candidate.toUpperCase()} ha sido registrado exitosamente.`, '¡Voto Registrado!');
} catch (err) { } catch (err) {
alert(`Error al registrar el voto en PostgreSQL: ${err instanceof Error ? err.message : 'Desconocido'}`); addToast('error', err instanceof Error ? err.message : 'Error desconocido al registrar voto', 'Error de Votación');
throw err; throw err;
} }
}; };
@@ -202,7 +214,10 @@ export default function App() {
voterName={voterName} voterName={voterName}
existingVoteFor={existingVoteFor} existingVoteFor={existingVoteFor}
onCastVote={handleCastVote} onCastVote={handleCastVote}
onVoteRemoved={() => fetchVotes(true)} onVoteRemoved={() => {
fetchVotes(true);
addToast('info', 'Tu voto ha sido removido del sistema.', 'Voto Removido');
}}
isLoading={isRefreshing} isLoading={isRefreshing}
/> />
</section> </section>
@@ -223,7 +238,10 @@ export default function App() {
<div className="max-w-7xl mx-auto w-full"> <div className="max-w-7xl mx-auto w-full">
<AdminPanel <AdminPanel
rawVotes={votes} rawVotes={votes}
onVoteResetSuccess={() => fetchVotes(true)} onVoteResetSuccess={async () => {
await fetchVotes(true);
addToast('success', 'Operación de administración completada con éxito.', 'Panel Admin');
}}
isOpen={isAdminOpen} isOpen={isAdminOpen}
onClose={() => setIsAdminOpen(false)} onClose={() => setIsAdminOpen(false)}
/> />
@@ -232,6 +250,9 @@ export default function App() {
{/* Footer */} {/* Footer */}
<Footer /> <Footer />
</div> </div>
{/* Notificaciones Internas (Toast) */}
<ToastContainer toasts={toasts} onDismiss={removeToast} />
</div> </div>
); );
} }
+47 -40
View File
@@ -1,36 +1,28 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { KeyRound, Trash2, Copy, Check, Download, AlertTriangle, ShieldCheck, X } from 'lucide-react'; import { KeyRound, Trash2, Copy, Check, Download, AlertTriangle, ShieldCheck, X } from 'lucide-react';
import { ApiService } from '../services/api'; import { ApiService } from '../services/api';
import { ConfirmModal } from './ConfirmModal';
interface AdminPanelProps { interface AdminPanelProps {
rawVotes: Record<string, string>; rawVotes: Record<string, string>;
onVoteResetSuccess: () => Promise<void>; onVoteResetSuccess: () => Promise<void>;
isOpen?: boolean; isOpen: boolean;
onClose?: () => void; onClose: () => void;
} }
export const AdminPanel: React.FC<AdminPanelProps> = ({ export const AdminPanel: React.FC<AdminPanelProps> = ({
rawVotes, rawVotes,
onVoteResetSuccess, onVoteResetSuccess,
isOpen: controlledOpen, isOpen,
onClose, onClose,
}) => { }) => {
const [internalOpen, setInternalOpen] = useState(false);
const [selectedVoter, setSelectedVoter] = useState(''); const [selectedVoter, setSelectedVoter] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [adminMsg, setAdminMsg] = useState<{ text: string; type: 'success' | 'error' } | null>(null); const [adminMsg, setAdminMsg] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [copiedCode, setCopiedCode] = useState(false); const [copiedCode, setCopiedCode] = useState(false);
const [copiedSingleHtml, setCopiedSingleHtml] = useState(false); const [copiedSingleHtml, setCopiedSingleHtml] = useState(false);
const [showConfirmResetAll, setShowConfirmResetAll] = useState(false);
const isPanelOpen = controlledOpen !== undefined ? controlledOpen : internalOpen;
const togglePanel = () => {
if (onClose && controlledOpen !== undefined) {
onClose();
} else {
setInternalOpen(!internalOpen);
}
};
const registeredVoters = Object.keys(rawVotes).sort(); const registeredVoters = Object.keys(rawVotes).sort();
@@ -68,17 +60,19 @@ export const AdminPanel: React.FC<AdminPanelProps> = ({
} }
}; };
const handleResetAllVotes = async () => { const handlePromptResetAll = () => {
const trimmedPass = password.trim(); const trimmedPass = password.trim();
if (!trimmedPass) { if (!trimmedPass) {
setAdminMsg({ text: 'Ingresa la contraseña de autorización para vaciar todos los votos.', type: 'error' }); setAdminMsg({ text: 'Ingresa la contraseña de autorización para vaciar todos los votos.', type: 'error' });
return; return;
} }
setAdminMsg(null);
setShowConfirmResetAll(true);
};
if (!window.confirm('¿Confirmas que deseas eliminar TODOS los votos para reiniciar pruebas?')) { const handleExecuteResetAll = async () => {
return; setShowConfirmResetAll(false);
} const trimmedPass = password.trim();
setIsDeleting(true); setIsDeleting(true);
setAdminMsg(null); setAdminMsg(null);
@@ -246,19 +240,34 @@ const VOTOS_EMBEBIDOS = ${JSON.stringify(rawVotes, null, 2)};`;
} catch (e) { console.error(e); } } catch (e) { console.error(e); }
} }
function showNotify(msg, isErr) {
let el = document.getElementById('standaloneNotify');
if (!el) {
el = document.createElement('div');
el.id = 'standaloneNotify';
el.style.cssText = 'position:fixed;bottom:20px;right:20px;padding:12px 18px;border-radius:12px;font-size:13px;font-weight:600;z-index:9999;transition:all 0.3s;box-shadow:0 10px 30px rgba(0,0,0,0.8);backdrop-filter:blur(10px);';
document.body.appendChild(el);
}
el.style.background = isErr ? 'rgba(239,68,68,0.9)' : 'rgba(0,183,212,0.95)';
el.style.color = isErr ? '#fff' : '#000';
el.innerText = msg;
el.style.display = 'block';
setTimeout(() => { if (el) el.style.display = 'none'; }, 4000);
}
async function submitVote() { async function submitVote() {
const voter = state.currentVoter.trim().toUpperCase(); const voter = state.currentVoter.trim().toUpperCase();
const cand = state.selected || state.votes[voter]; const cand = state.selected || state.votes[voter];
if (!voter || voter.length < 2) return alert("Escribe tu nombre antes de votar."); if (!voter || voter.length < 2) return showNotify("Escribe tu nombre antes de votar.", true);
if (!cand) return alert("Selecciona un candidato."); if (!cand) return showNotify("Selecciona un candidato.", true);
if (voter === cand || voter.split(/\\s+/).includes(cand)) return alert("No puedes votar por ti mismo."); if (voter === cand || voter.split(/\\s+/).includes(cand)) return showNotify("No puedes votar por ti mismo.", true);
try { try {
await callRedis(["HSET", CONFIG.key, voter, cand]); await callRedis(["HSET", CONFIG.key, voter, cand]);
state.votes[voter] = cand; state.votes[voter] = cand;
localStorage.setItem('e3_voter_app', voter); localStorage.setItem('e3_voter_app', voter);
render(); render();
alert("¡Voto registrado en la nube con éxito!"); showNotify("¡Voto registrado en la nube con éxito!", false);
} catch (e) { alert("Error: " + e.message); } } catch (e) { showNotify("Error: " + e.message, true); }
} }
function render() { function render() {
@@ -331,23 +340,10 @@ const VOTOS_EMBEBIDOS = ${JSON.stringify(rawVotes, null, 2)};`;
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}; };
if (!isOpen) return null;
return ( return (
<div className="w-full mt-6"> <div className="w-full mt-6">
{/* Accordion / Toggle trigger if not controlled externally */}
{controlledOpen === undefined && (
<div className="flex justify-end mb-3">
<button
type="button"
onClick={togglePanel}
className="text-xs font-mono uppercase tracking-widest text-gray-500 hover:text-[#00B7D4] transition-colors flex items-center gap-1.5 cursor-pointer"
>
<KeyRound className="w-3.5 h-3.5" />
<span>{isPanelOpen ? 'Cerrar Panel Admin' : 'Panel de Administración'}</span>
</button>
</div>
)}
{isPanelOpen && (
<div className="glass-panel p-6 border border-white/10 shadow-2xl relative animate-in fade-in zoom-in-95 duration-200"> <div className="glass-panel p-6 border border-white/10 shadow-2xl relative animate-in fade-in zoom-in-95 duration-200">
{onClose && ( {onClose && (
<button <button
@@ -418,7 +414,7 @@ const VOTOS_EMBEBIDOS = ${JSON.stringify(rawVotes, null, 2)};`;
<button <button
type="button" type="button"
onClick={handleResetAllVotes} onClick={handlePromptResetAll}
disabled={isDeleting || registeredVoters.length === 0} disabled={isDeleting || registeredVoters.length === 0}
title="Vaciar todos los votos registrados para pruebas" title="Vaciar todos los votos registrados para pruebas"
className="px-3 py-2.5 bg-amber-950/40 hover:bg-amber-900/60 border border-amber-500/40 text-amber-300 rounded-xl text-xs font-bold uppercase tracking-wider transition-colors cursor-pointer disabled:opacity-30 flex items-center justify-center gap-1" className="px-3 py-2.5 bg-amber-950/40 hover:bg-amber-900/60 border border-amber-500/40 text-amber-300 rounded-xl text-xs font-bold uppercase tracking-wider transition-colors cursor-pointer disabled:opacity-30 flex items-center justify-center gap-1"
@@ -482,7 +478,18 @@ const VOTOS_EMBEBIDOS = ${JSON.stringify(rawVotes, null, 2)};`;
</div> </div>
</div> </div>
</div> </div>
)}
{/* Modal Interno de Confirmación */}
<ConfirmModal
isOpen={showConfirmResetAll}
title="¿Vaciar Todos los Votos?"
message="Esta acción eliminará todos los votos registrados en PostgreSQL para reiniciar las pruebas. No se puede deshacer."
confirmLabel="Sí, Vaciar Todo"
cancelLabel="Cancelar"
isDestructive={true}
onConfirm={handleExecuteResetAll}
onCancel={() => setShowConfirmResetAll(false)}
/>
</div> </div>
); );
}; };
+79
View File
@@ -0,0 +1,79 @@
import React from 'react';
import { AlertTriangle, X } from 'lucide-react';
interface ConfirmModalProps {
isOpen: boolean;
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
isDestructive?: boolean;
onConfirm: () => void;
onCancel: () => void;
}
export const ConfirmModal: React.FC<ConfirmModalProps> = ({
isOpen,
title,
message,
confirmLabel = 'Confirmar',
cancelLabel = 'Cancelar',
isDestructive = true,
onConfirm,
onCancel,
}) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-in fade-in duration-150">
<div className="w-full max-w-md bg-[#0d0f12] border border-white/10 rounded-2xl p-6 shadow-2xl relative space-y-4">
<button
type="button"
onClick={onCancel}
className="absolute top-4 right-4 text-gray-400 hover:text-white p-1 cursor-pointer"
>
<X className="w-4 h-4" />
</button>
<div className="flex items-center gap-3">
<div
className={`w-10 h-10 rounded-xl flex items-center justify-center ${
isDestructive
? 'bg-red-500/10 text-red-400 border border-red-500/20'
: 'bg-[#00B7D4]/10 text-[#00B7D4] border border-[#00B7D4]/20'
}`}
>
<AlertTriangle className="w-5 h-5" />
</div>
<div>
<h3 className="text-base font-bold text-white uppercase tracking-wider title-font">{title}</h3>
<p className="text-[11px] text-gray-400 font-mono">Confirmación de Acción</p>
</div>
</div>
<p className="text-xs text-gray-300 leading-relaxed font-medium">{message}</p>
<div className="flex justify-end gap-2.5 pt-2">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 rounded-xl bg-white/[0.04] hover:bg-white/[0.08] border border-white/10 text-xs font-mono text-gray-300 transition-all cursor-pointer"
>
{cancelLabel}
</button>
<button
type="button"
onClick={onConfirm}
className={`px-4 py-2 rounded-xl text-xs font-bold uppercase tracking-wider transition-all cursor-pointer ${
isDestructive
? 'bg-red-600 hover:bg-red-500 text-white shadow-[0_0_15px_rgba(239,68,68,0.4)]'
: 'bg-[#00B7D4] hover:bg-[#38bdf8] text-black shadow-[0_0_15px_rgba(0,183,212,0.4)]'
}`}
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
};
+81
View File
@@ -0,0 +1,81 @@
import React, { useEffect } from 'react';
import { CheckCircle2, AlertCircle, AlertTriangle, Info, X } from 'lucide-react';
export interface ToastMessage {
id: string;
type: 'success' | 'error' | 'warning' | 'info';
title?: string;
message: string;
}
interface ToastContainerProps {
toasts: ToastMessage[];
onDismiss: (id: string) => void;
}
export const ToastContainer: React.FC<ToastContainerProps> = ({ toasts, onDismiss }) => {
return (
<div className="fixed bottom-6 right-6 z-50 flex flex-col gap-2.5 max-w-sm w-full pointer-events-none px-4 sm:px-0">
{toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} onDismiss={() => onDismiss(toast.id)} />
))}
</div>
);
};
const ToastItem: React.FC<{ toast: ToastMessage; onDismiss: () => void }> = ({ toast, onDismiss }) => {
useEffect(() => {
const timer = setTimeout(onDismiss, 4500);
return () => clearTimeout(timer);
}, [onDismiss]);
const config = {
success: {
border: 'border-[#00e676]/40',
bg: 'bg-[#0a1f14]/95',
icon: <CheckCircle2 className="w-5 h-5 text-[#00e676] shrink-0" />,
titleColor: 'text-[#00e676]',
},
error: {
border: 'border-red-500/40',
bg: 'bg-[#220a0a]/95',
icon: <AlertCircle className="w-5 h-5 text-red-400 shrink-0" />,
titleColor: 'text-red-400',
},
warning: {
border: 'border-amber-500/40',
bg: 'bg-[#241705]/95',
icon: <AlertTriangle className="w-5 h-5 text-amber-400 shrink-0" />,
titleColor: 'text-amber-400',
},
info: {
border: 'border-[#00B7D4]/40',
bg: 'bg-[#051820]/95',
icon: <Info className="w-5 h-5 text-[#00B7D4] shrink-0" />,
titleColor: 'text-[#00B7D4]',
},
}[toast.type];
return (
<div
className={`pointer-events-auto p-4 rounded-xl border ${config.border} ${config.bg} backdrop-blur-xl shadow-2xl flex items-start gap-3 transition-all animate-in slide-in-from-bottom-5 duration-200`}
>
{config.icon}
<div className="flex-1 min-w-0">
{toast.title && (
<h4 className={`text-xs font-bold uppercase tracking-wider ${config.titleColor} mb-0.5`}>
{toast.title}
</h4>
)}
<p className="text-xs text-gray-200 leading-relaxed font-medium">{toast.message}</p>
</div>
<button
type="button"
onClick={onDismiss}
className="text-gray-400 hover:text-white p-0.5 cursor-pointer shrink-0 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
);
};