diff --git a/src/App.tsx b/src/App.tsx index 11ed30f..a615dd0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { VotingStep } from './components/VotingStep'; import { LiveResults } from './components/LiveResults'; import { AdminPanel } from './components/AdminPanel'; import { Footer } from './components/Footer'; +import { ToastContainer, ToastMessage } from './components/Toast'; import { ApiService } from './services/api'; import { CANDIDATOS_DEFAULT, CandidateStats, SyncStatus } from './types'; @@ -15,6 +16,16 @@ export default function App() { const [lastSyncTime, setLastSyncTime] = useState(null); const [isRefreshing, setIsRefreshing] = useState(false); const [isAdminOpen, setIsAdminOpen] = useState(false); + const [toasts, setToasts] = useState([]); + + 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 const fetchVotes = useCallback(async (isManual = false) => { @@ -103,7 +114,7 @@ export default function App() { // Emitir voto const handleCastVote = async (candidate: string) => { 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; } @@ -115,8 +126,9 @@ export default function App() { [normalizedVoter]: candidate.toUpperCase(), })); setLastSyncTime(new Date()); + addToast('success', `Tu voto por ${candidate.toUpperCase()} ha sido registrado exitosamente.`, '¡Voto Registrado!'); } 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; } }; @@ -202,7 +214,10 @@ export default function App() { voterName={voterName} existingVoteFor={existingVoteFor} onCastVote={handleCastVote} - onVoteRemoved={() => fetchVotes(true)} + onVoteRemoved={() => { + fetchVotes(true); + addToast('info', 'Tu voto ha sido removido del sistema.', 'Voto Removido'); + }} isLoading={isRefreshing} /> @@ -223,7 +238,10 @@ export default function App() {
fetchVotes(true)} + onVoteResetSuccess={async () => { + await fetchVotes(true); + addToast('success', 'Operación de administración completada con éxito.', 'Panel Admin'); + }} isOpen={isAdminOpen} onClose={() => setIsAdminOpen(false)} /> @@ -232,6 +250,9 @@ export default function App() { {/* Footer */}
+ + {/* Notificaciones Internas (Toast) */} + ); } diff --git a/src/components/AdminPanel.tsx b/src/components/AdminPanel.tsx index ac7ecd5..86d90b1 100644 --- a/src/components/AdminPanel.tsx +++ b/src/components/AdminPanel.tsx @@ -1,36 +1,28 @@ import React, { useState } from 'react'; import { KeyRound, Trash2, Copy, Check, Download, AlertTriangle, ShieldCheck, X } from 'lucide-react'; import { ApiService } from '../services/api'; +import { ConfirmModal } from './ConfirmModal'; interface AdminPanelProps { rawVotes: Record; onVoteResetSuccess: () => Promise; - isOpen?: boolean; - onClose?: () => void; + isOpen: boolean; + onClose: () => void; } export const AdminPanel: React.FC = ({ rawVotes, onVoteResetSuccess, - isOpen: controlledOpen, + isOpen, onClose, }) => { - const [internalOpen, setInternalOpen] = useState(false); const [selectedVoter, setSelectedVoter] = useState(''); const [password, setPassword] = useState(''); const [adminMsg, setAdminMsg] = useState<{ text: string; type: 'success' | 'error' } | null>(null); const [isDeleting, setIsDeleting] = useState(false); const [copiedCode, setCopiedCode] = useState(false); const [copiedSingleHtml, setCopiedSingleHtml] = useState(false); - - const isPanelOpen = controlledOpen !== undefined ? controlledOpen : internalOpen; - const togglePanel = () => { - if (onClose && controlledOpen !== undefined) { - onClose(); - } else { - setInternalOpen(!internalOpen); - } - }; + const [showConfirmResetAll, setShowConfirmResetAll] = useState(false); const registeredVoters = Object.keys(rawVotes).sort(); @@ -68,17 +60,19 @@ export const AdminPanel: React.FC = ({ } }; - const handleResetAllVotes = async () => { + const handlePromptResetAll = () => { const trimmedPass = password.trim(); if (!trimmedPass) { setAdminMsg({ text: 'Ingresa la contraseña de autorización para vaciar todos los votos.', type: 'error' }); return; } + setAdminMsg(null); + setShowConfirmResetAll(true); + }; - if (!window.confirm('¿Confirmas que deseas eliminar TODOS los votos para reiniciar pruebas?')) { - return; - } - + const handleExecuteResetAll = async () => { + setShowConfirmResetAll(false); + const trimmedPass = password.trim(); setIsDeleting(true); setAdminMsg(null); @@ -246,19 +240,34 @@ const VOTOS_EMBEBIDOS = ${JSON.stringify(rawVotes, null, 2)};`; } 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() { const voter = state.currentVoter.trim().toUpperCase(); const cand = state.selected || state.votes[voter]; - if (!voter || voter.length < 2) return alert("Escribe tu nombre antes de votar."); - if (!cand) return alert("Selecciona un candidato."); - if (voter === cand || voter.split(/\\s+/).includes(cand)) return alert("No puedes votar por ti mismo."); + if (!voter || voter.length < 2) return showNotify("Escribe tu nombre antes de votar.", true); + if (!cand) return showNotify("Selecciona un candidato.", true); + if (voter === cand || voter.split(/\\s+/).includes(cand)) return showNotify("No puedes votar por ti mismo.", true); try { await callRedis(["HSET", CONFIG.key, voter, cand]); state.votes[voter] = cand; localStorage.setItem('e3_voter_app', voter); render(); - alert("¡Voto registrado en la nube con éxito!"); - } catch (e) { alert("Error: " + e.message); } + showNotify("¡Voto registrado en la nube con éxito!", false); + } catch (e) { showNotify("Error: " + e.message, true); } } function render() { @@ -331,33 +340,20 @@ const VOTOS_EMBEBIDOS = ${JSON.stringify(rawVotes, null, 2)};`; URL.revokeObjectURL(url); }; + if (!isOpen) return null; + return (
- {/* Accordion / Toggle trigger if not controlled externally */} - {controlledOpen === undefined && ( -
+
+ {onClose && ( -
- )} - - {isPanelOpen && ( -
- {onClose && ( - - )} + )}
@@ -418,7 +414,7 @@ const VOTOS_EMBEBIDOS = ${JSON.stringify(rawVotes, null, 2)};`;
- )} + + {/* Modal Interno de Confirmación */} + setShowConfirmResetAll(false)} + />
); }; diff --git a/src/components/ConfirmModal.tsx b/src/components/ConfirmModal.tsx new file mode 100644 index 0000000..fc3b88f --- /dev/null +++ b/src/components/ConfirmModal.tsx @@ -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 = ({ + isOpen, + title, + message, + confirmLabel = 'Confirmar', + cancelLabel = 'Cancelar', + isDestructive = true, + onConfirm, + onCancel, +}) => { + if (!isOpen) return null; + + return ( +
+
+ + +
+
+ +
+
+

{title}

+

Confirmación de Acción

+
+
+ +

{message}

+ +
+ + +
+
+
+ ); +}; diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx new file mode 100644 index 0000000..38aefd1 --- /dev/null +++ b/src/components/Toast.tsx @@ -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 = ({ toasts, onDismiss }) => { + return ( +
+ {toasts.map((toast) => ( + onDismiss(toast.id)} /> + ))} +
+ ); +}; + +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: , + titleColor: 'text-[#00e676]', + }, + error: { + border: 'border-red-500/40', + bg: 'bg-[#220a0a]/95', + icon: , + titleColor: 'text-red-400', + }, + warning: { + border: 'border-amber-500/40', + bg: 'bg-[#241705]/95', + icon: , + titleColor: 'text-amber-400', + }, + info: { + border: 'border-[#00B7D4]/40', + bg: 'bg-[#051820]/95', + icon: , + titleColor: 'text-[#00B7D4]', + }, + }[toast.type]; + + return ( +
+ {config.icon} +
+ {toast.title && ( +

+ {toast.title} +

+ )} +

{toast.message}

+
+ +
+ ); +};