feat: reemplazar todos los alerts y confirms del navegador por notificaciones y modales internos in-app
This commit is contained in:
+25
-4
@@ -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<Date | null>(null);
|
||||
const [isRefreshing, setIsRefreshing] = 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
|
||||
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}
|
||||
/>
|
||||
</section>
|
||||
@@ -223,7 +238,10 @@ export default function App() {
|
||||
<div className="max-w-7xl mx-auto w-full">
|
||||
<AdminPanel
|
||||
rawVotes={votes}
|
||||
onVoteResetSuccess={() => 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 */}
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
{/* Notificaciones Internas (Toast) */}
|
||||
<ToastContainer toasts={toasts} onDismiss={removeToast} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string, string>;
|
||||
onVoteResetSuccess: () => Promise<void>;
|
||||
isOpen?: boolean;
|
||||
onClose?: () => void;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const AdminPanel: React.FC<AdminPanelProps> = ({
|
||||
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<AdminPanelProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="w-full mt-6">
|
||||
{/* Accordion / Toggle trigger if not controlled externally */}
|
||||
{controlledOpen === undefined && (
|
||||
<div className="flex justify-end mb-3">
|
||||
<div className="glass-panel p-6 border border-white/10 shadow-2xl relative animate-in fade-in zoom-in-95 duration-200">
|
||||
{onClose && (
|
||||
<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"
|
||||
onClick={onClose}
|
||||
className="absolute top-5 right-5 text-gray-400 hover:text-white p-1 cursor-pointer"
|
||||
>
|
||||
<KeyRound className="w-3.5 h-3.5" />
|
||||
<span>{isPanelOpen ? 'Cerrar Panel Admin' : 'Panel de Administración'}</span>
|
||||
<X className="w-4 h-4" />
|
||||
</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">
|
||||
{onClose && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute top-5 right-5 text-gray-400 hover:text-white p-1 cursor-pointer"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-8 h-8 rounded-lg bg-[#00B7D4]/10 border border-[#00B7D4]/30 flex items-center justify-center text-[#00B7D4]">
|
||||
@@ -418,7 +414,7 @@ const VOTOS_EMBEBIDOS = ${JSON.stringify(rawVotes, null, 2)};`;
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResetAllVotes}
|
||||
onClick={handlePromptResetAll}
|
||||
disabled={isDeleting || registeredVoters.length === 0}
|
||||
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"
|
||||
@@ -482,7 +478,18 @@ const VOTOS_EMBEBIDOS = ${JSON.stringify(rawVotes, null, 2)};`;
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user