UI: reemplazar diálogos nativos del navegador por modales/avisos propios de la plataforma

Los confirm()/alert()/prompt() nativos se veían fuera de la marca ("<dominio> dice…").
Se agrega un DialogProvider (montado en el layout autenticado) con su estilo visual y se
consume via hooks useConfirm / usePrompt / useToast:
- confirmaciones -> modal propio (con variante "peligro" para eliminaciones)
- prompts (crear paquete / fase) -> modal con input
- alerts de error/éxito -> toasts

Reemplazados todos los diálogos nativos: RegistroHorasPanel, CatalogoClient,
ConfiguracionClient, CotizacionForm, ExportButtons, ListDeleteButton, DeleteButton,
CambiarEstadoButtons. Validado E2E (Playwright): al revertir una partida pagada aparece
el modal propio y NO se dispara el confirm nativo.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
urieljareth
2026-07-11 14:51:22 -06:00
co-authored by Claude Opus 4.8
parent 1aab45f29c
commit 26e6d3a9bf
10 changed files with 394 additions and 35 deletions
+39 -11
View File
@@ -17,6 +17,7 @@ import {
Upload, Upload,
} from "lucide-react"; } from "lucide-react";
import { formatCurrency, FASES as FASES_LABELS } from "@/lib/calculators"; import { formatCurrency, FASES as FASES_LABELS } from "@/lib/calculators";
import { useConfirm, usePrompt, useToast } from "@/components/ui/DialogProvider";
import clsx from "clsx"; import clsx from "clsx";
interface Servicio { interface Servicio {
@@ -71,6 +72,9 @@ type Tab = "categorias" | "paquetes" | "servicios";
const FASES = FASES_LABELS; const FASES = FASES_LABELS;
export function CatalogoClient({ initialServicios, initialCategorias, initialPaquetes }: Props) { export function CatalogoClient({ initialServicios, initialCategorias, initialPaquetes }: Props) {
const showConfirm = useConfirm();
const showPrompt = usePrompt();
const showToast = useToast();
const [tab, setTab] = useState<Tab>("servicios"); const [tab, setTab] = useState<Tab>("servicios");
const [servicios, setServicios] = useState<Servicio[]>(initialServicios); const [servicios, setServicios] = useState<Servicio[]>(initialServicios);
const [categorias, setCategorias] = useState<Categoria[]>(initialCategorias); const [categorias, setCategorias] = useState<Categoria[]>(initialCategorias);
@@ -106,14 +110,14 @@ export function CatalogoClient({ initialServicios, initialCategorias, initialPaq
if (res.ok) { if (res.ok) {
let msg = `${data.creados} servicio(s) importado(s).`; let msg = `${data.creados} servicio(s) importado(s).`;
if (data.omitidos > 0) msg += ` ${data.omitidos} omitido(s).`; if (data.omitidos > 0) msg += ` ${data.omitidos} omitido(s).`;
if (data.errores?.length) msg += `\n\nErrores:\n${data.errores.join("\n")}`; showToast(msg, "success");
alert(msg); if (data.errores?.length) showToast(`Errores:\n${data.errores.join("\n")}`, "error");
await refresh(); await refresh();
} else { } else {
alert(data.error || "Error al importar"); showToast(data.error || "Error al importar", "error");
} }
} catch { } catch {
alert("Error al importar CSV"); showToast("Error al importar CSV", "error");
} finally { } finally {
setImporting(false); setImporting(false);
e.target.value = ""; e.target.value = "";
@@ -123,7 +127,7 @@ export function CatalogoClient({ initialServicios, initialCategorias, initialPaq
const deleteCategoria = async (id: string) => { const deleteCategoria = async (id: string) => {
const res = await fetch(`/api/categorias/${id}`, { method: "DELETE" }); const res = await fetch(`/api/categorias/${id}`, { method: "DELETE" });
if (res.ok) setCategorias((p) => p.filter((c) => c.id !== id)); if (res.ok) setCategorias((p) => p.filter((c) => c.id !== id));
else { const e = await res.json(); alert(e.error); } else { const e = await res.json(); showToast(e.error, "error"); }
}; };
const toggleCategoriaActivo = async (cat: Categoria) => { const toggleCategoriaActivo = async (cat: Categoria) => {
@@ -139,13 +143,24 @@ export function CatalogoClient({ initialServicios, initialCategorias, initialPaq
}; };
const deletePaquete = async (id: string) => { const deletePaquete = async (id: string) => {
if (!confirm("Eliminar este paquete y todas sus fases?")) return; const ok = await showConfirm({
title: "Eliminar paquete",
message: "¿Eliminar este paquete y todas sus fases?",
confirmText: "Eliminar",
danger: true,
});
if (!ok) return;
const res = await fetch(`/api/paquetes/${id}`, { method: "DELETE" }); const res = await fetch(`/api/paquetes/${id}`, { method: "DELETE" });
if (res.ok) setPaquetes((p) => p.filter((pk) => pk.id !== id)); if (res.ok) setPaquetes((p) => p.filter((pk) => pk.id !== id));
}; };
const handleCreatePaquete = async () => { const handleCreatePaquete = async () => {
const nombre = prompt("Nombre del paquete:"); const nombre = await showPrompt({
title: "Nuevo paquete",
label: "Nombre del paquete",
placeholder: "Ej. Paquete Emprendedor",
confirmText: "Crear",
});
if (!nombre) return; if (!nombre) return;
const res = await fetch("/api/paquetes", { const res = await fetch("/api/paquetes", {
method: "POST", method: "POST",
@@ -159,7 +174,12 @@ export function CatalogoClient({ initialServicios, initialCategorias, initialPaq
}; };
const addFaseToPaquete = async (paqueteId: string) => { const addFaseToPaquete = async (paqueteId: string) => {
const nombre = prompt("Nombre de la fase:"); const nombre = await showPrompt({
title: "Nueva fase",
label: "Nombre de la fase",
placeholder: "Ej. Fase 1 - Setup",
confirmText: "Agregar",
});
if (!nombre) return; if (!nombre) return;
const pk = paquetes.find((p) => p.id === paqueteId); const pk = paquetes.find((p) => p.id === paqueteId);
const orden = pk ? pk.fases.length : 0; const orden = pk ? pk.fases.length : 0;
@@ -179,7 +199,13 @@ export function CatalogoClient({ initialServicios, initialCategorias, initialPaq
}; };
const deleteFase = async (paqueteId: string, faseId: string) => { const deleteFase = async (paqueteId: string, faseId: string) => {
if (!confirm("Eliminar esta fase?")) return; const ok = await showConfirm({
title: "Eliminar fase",
message: "¿Eliminar esta fase?",
confirmText: "Eliminar",
danger: true,
});
if (!ok) return;
const res = await fetch(`/api/paquetes/${paqueteId}/manage`, { const res = await fetch(`/api/paquetes/${paqueteId}/manage`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -343,7 +369,8 @@ export function CatalogoClient({ initialServicios, initialCategorias, initialPaq
<Pencil className="w-3.5 h-3.5" /> <Pencil className="w-3.5 h-3.5" />
</button> </button>
<button onClick={async () => { <button onClick={async () => {
if (!confirm(`Eliminar "${s.nombre}"?`)) return; const ok = await showConfirm({ title: "Eliminar servicio", message: `¿Eliminar "${s.nombre}"?`, confirmText: "Eliminar", danger: true });
if (!ok) return;
const res = await fetch(`/api/catalogo/${s.id}`, { method: "DELETE" }); const res = await fetch(`/api/catalogo/${s.id}`, { method: "DELETE" });
if (res.ok) { const d = await res.json(); if (d.archived) { setServicios((p) => p.map((x) => x.id === s.id ? { ...x, activo: false } : x)); } else { setServicios((p) => p.filter((x) => x.id !== s.id)); } } if (res.ok) { const d = await res.json(); if (d.archived) { setServicios((p) => p.map((x) => x.id === s.id ? { ...x, activo: false } : x)); } else { setServicios((p) => p.filter((x) => x.id !== s.id)); } }
}} className="p-1.5 text-muted hover:text-red-500 rounded" title="Eliminar"> }} className="p-1.5 text-muted hover:text-red-500 rounded" title="Eliminar">
@@ -529,6 +556,7 @@ function CreateCategoriaInline({ onCreated }: { onCreated: () => void }) {
const [nombre, setNombre] = useState(""); const [nombre, setNombre] = useState("");
const [color, setColor] = useState("#6b7280"); const [color, setColor] = useState("#6b7280");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const showToast = useToast();
const handleSave = async () => { const handleSave = async () => {
if (!nombre.trim()) return; if (!nombre.trim()) return;
@@ -540,7 +568,7 @@ function CreateCategoriaInline({ onCreated }: { onCreated: () => void }) {
body: JSON.stringify({ nombre: nombre.trim(), color }), body: JSON.stringify({ nombre: nombre.trim(), color }),
}); });
if (res.ok) { setNombre(""); onCreated(); } if (res.ok) { setNombre(""); onCreated(); }
else { const e = await res.json(); alert(e.error); } else { const e = await res.json(); showToast(e.error, "error"); }
} finally { setSaving(false); } } finally { setSaving(false); }
}; };
@@ -2,6 +2,7 @@
import { useState, useRef } from "react"; import { useState, useRef } from "react";
import { Save, Upload, Trash2, Loader2, Image as ImageIcon } from "lucide-react"; import { Save, Upload, Trash2, Loader2, Image as ImageIcon } from "lucide-react";
import { useToast } from "@/components/ui/DialogProvider";
const SECTIONS = [ const SECTIONS = [
{ {
@@ -63,6 +64,7 @@ export function ConfiguracionClient({ initial }: { initial: Record<string, strin
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const showToast = useToast();
const handleChange = (key: string, value: string) => { const handleChange = (key: string, value: string) => {
setConfig((prev) => ({ ...prev, [key]: value })); setConfig((prev) => ({ ...prev, [key]: value }));
@@ -73,7 +75,7 @@ export function ConfiguracionClient({ initial }: { initial: Record<string, strin
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
if (file.size > 500 * 1024) { if (file.size > 500 * 1024) {
alert("El logo debe ser menor a 500KB"); showToast("El logo debe ser menor a 500KB", "error");
return; return;
} }
const reader = new FileReader(); const reader = new FileReader();
@@ -102,10 +104,10 @@ export function ConfiguracionClient({ initial }: { initial: Record<string, strin
setSaved(true); setSaved(true);
setTimeout(() => setSaved(false), 2000); setTimeout(() => setSaved(false), 2000);
} else { } else {
alert("Error al guardar"); showToast("Error al guardar", "error");
} }
} catch { } catch {
alert("Error al guardar"); showToast("Error al guardar", "error");
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -2,20 +2,29 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useConfirm, useToast } from "@/components/ui/DialogProvider";
export function ListDeleteButton({ id }: { id: string }) { export function ListDeleteButton({ id }: { id: string }) {
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const router = useRouter(); const router = useRouter();
const showConfirm = useConfirm();
const showToast = useToast();
const handleDelete = async () => { const handleDelete = async () => {
if (!confirm("Eliminar esta cotizacion?")) return; const ok = await showConfirm({
title: "Eliminar cotización",
message: "¿Eliminar esta cotización?",
confirmText: "Eliminar",
danger: true,
});
if (!ok) return;
setDeleting(true); setDeleting(true);
try { try {
const res = await fetch(`/api/cotizaciones/${id}`, { method: "DELETE" }); const res = await fetch(`/api/cotizaciones/${id}`, { method: "DELETE" });
if (res.ok) router.refresh(); if (res.ok) router.refresh();
else alert("Error al eliminar"); else showToast("Error al eliminar", "error");
} catch { } catch {
alert("Error al eliminar"); showToast("Error al eliminar", "error");
} finally { } finally {
setDeleting(false); setDeleting(false);
} }
@@ -2,6 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useToast } from "@/components/ui/DialogProvider";
const OPCIONES = [ const OPCIONES = [
{ value: "borrador", label: "Borrador" }, { value: "borrador", label: "Borrador" },
@@ -13,6 +14,7 @@ const OPCIONES = [
export function CambiarEstadoButtons({ cotizacionId, estado }: { cotizacionId: string; estado: string }) { export function CambiarEstadoButtons({ cotizacionId, estado }: { cotizacionId: string; estado: string }) {
const router = useRouter(); const router = useRouter();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const showToast = useToast();
const handleChange = async (nuevoEstado: string) => { const handleChange = async (nuevoEstado: string) => {
if (nuevoEstado === estado) return; if (nuevoEstado === estado) return;
@@ -24,7 +26,7 @@ export function CambiarEstadoButtons({ cotizacionId, estado }: { cotizacionId: s
body: JSON.stringify({ estado: nuevoEstado }), body: JSON.stringify({ estado: nuevoEstado }),
}); });
if (res.ok) router.refresh(); if (res.ok) router.refresh();
else alert("Error al cambiar estado"); else showToast("Error al cambiar estado", "error");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -3,6 +3,7 @@
import { Trash2 } from "lucide-react"; import { Trash2 } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { useToast } from "@/components/ui/DialogProvider";
export function DeleteCotizacionButton({ export function DeleteCotizacionButton({
cotizacionId, cotizacionId,
@@ -14,6 +15,7 @@ export function DeleteCotizacionButton({
const router = useRouter(); const router = useRouter();
const [confirming, setConfirming] = useState(false); const [confirming, setConfirming] = useState(false);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const showToast = useToast();
const handleDelete = async () => { const handleDelete = async () => {
setDeleting(true); setDeleting(true);
@@ -24,11 +26,11 @@ export function DeleteCotizacionButton({
if (res.ok) { if (res.ok) {
router.push("/cotizaciones"); router.push("/cotizaciones");
} else { } else {
alert("Error al eliminar"); showToast("Error al eliminar", "error");
setConfirming(false); setConfirming(false);
} }
} catch { } catch {
alert("Error al eliminar"); showToast("Error al eliminar", "error");
setConfirming(false); setConfirming(false);
} finally { } finally {
setDeleting(false); setDeleting(false);
@@ -2,6 +2,7 @@
import { useMemo, useRef, useState } from "react"; import { useMemo, useRef, useState } from "react";
import { Clock, Plus, Trash2, Pencil, Download, Eye, X, CheckCircle2, CircleDashed, RotateCcw } from "lucide-react"; import { Clock, Plus, Trash2, Pencil, Download, Eye, X, CheckCircle2, CircleDashed, RotateCcw } from "lucide-react";
import { useConfirm, useToast } from "@/components/ui/DialogProvider";
import { import {
formatCurrency, formatCurrency,
formatFechaRegistro, formatFechaRegistro,
@@ -87,6 +88,8 @@ export function RegistroHorasPanel({
branding, branding,
registrosIniciales, registrosIniciales,
}: RegistroHorasPanelProps) { }: RegistroHorasPanelProps) {
const showConfirm = useConfirm();
const showToast = useToast();
const [registros, setRegistros] = useState<Registro[]>(registrosIniciales); const [registros, setRegistros] = useState<Registro[]>(registrosIniciales);
const [form, setForm] = useState(formVacio(tarifaSugerida)); const [form, setForm] = useState(formVacio(tarifaSugerida));
const [editId, setEditId] = useState<string | null>(null); const [editId, setEditId] = useState<string | null>(null);
@@ -216,7 +219,13 @@ export function RegistroHorasPanel({
}; };
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
if (!confirm("¿Eliminar este registro de horas?")) return; const ok = await showConfirm({
title: "Eliminar registro",
message: "¿Eliminar este registro de horas?",
confirmText: "Eliminar",
danger: true,
});
if (!ok) return;
const prev = registros; const prev = registros;
setRegistros((rs) => rs.filter((r) => r.id !== id)); setRegistros((rs) => rs.filter((r) => r.id !== id));
if (editId === id) resetForm(); if (editId === id) resetForm();
@@ -255,8 +264,13 @@ export function RegistroHorasPanel({
// Regresa una partida pagada por error a "Por pagar". Pide confirmación porque se // Regresa una partida pagada por error a "Por pagar". Pide confirmación porque se
// pierde la marca/fecha de pago y la partida vuelve a entrar en la nota por cobrar. // pierde la marca/fecha de pago y la partida vuelve a entrar en la nota por cobrar.
const regresarPorPagar = (r: Registro) => { const regresarPorPagar = async (r: Registro) => {
if (!confirm('¿Regresar esta partida a "Por pagar"? Se quitará la marca de pagada.')) return; const ok = await showConfirm({
title: "Regresar a Por pagar",
message: 'Se quitará la marca de pagada y esta partida volverá a contar como pendiente de cobro.',
confirmText: "Regresar a Por pagar",
});
if (!ok) return;
cambiarEstado(r, "por_pagar"); cambiarEstado(r, "por_pagar");
}; };
@@ -275,7 +289,7 @@ export function RegistroHorasPanel({
body: JSON.stringify({ estado: estadoFiltro, modo, from, to }), body: JSON.stringify({ estado: estadoFiltro, modo, from, to }),
}); });
if (!res.ok) { if (!res.ok) {
alert("No se pudo generar el PDF"); showToast("No se pudo generar el PDF", "error");
return; return;
} }
const blob = await res.blob(); const blob = await res.blob();
@@ -295,7 +309,7 @@ export function RegistroHorasPanel({
a.remove(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 2000); setTimeout(() => URL.revokeObjectURL(url), 2000);
} catch { } catch {
alert("Error de conexion al generar el PDF"); showToast("Error de conexión al generar el PDF", "error");
} finally { } finally {
setDescargando(false); setDescargando(false);
} }
+3 -2
View File
@@ -1,4 +1,5 @@
import Sidebar from "@/components/layout/Sidebar"; import Sidebar from "@/components/layout/Sidebar";
import { DialogProvider } from "@/components/ui/DialogProvider";
export default function AppLayout({ export default function AppLayout({
children, children,
@@ -6,11 +7,11 @@ export default function AppLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<> <DialogProvider>
<Sidebar /> <Sidebar />
<main className="flex-1 lg:ml-64"> <main className="flex-1 lg:ml-64">
<div className="p-4 lg:p-8 pt-16 lg:pt-8">{children}</div> <div className="p-4 lg:p-8 pt-16 lg:pt-8">{children}</div>
</main> </main>
</> </DialogProvider>
); );
} }
+4 -2
View File
@@ -37,6 +37,7 @@ import {
useCotizacionStore, useCotizacionStore,
ServicioSeleccionado, ServicioSeleccionado,
} from "@/lib/store"; } from "@/lib/store";
import { useToast } from "@/components/ui/DialogProvider";
import { import {
ExportPDFButtonDraft, ExportPDFButtonDraft,
ExportExcelButtonDraft, ExportExcelButtonDraft,
@@ -136,6 +137,7 @@ export function CotizacionForm({
}: CotizacionFormProps) { }: CotizacionFormProps) {
const router = useRouter(); const router = useRouter();
const store = useCotizacionStore(); const store = useCotizacionStore();
const showToast = useToast();
const [expandedFases, setExpandedFases] = useState<Set<number>>( const [expandedFases, setExpandedFases] = useState<Set<number>>(
new Set([0, 1, 2, 3]) new Set([0, 1, 2, 3])
@@ -369,10 +371,10 @@ export function CotizacionForm({
} }
} else { } else {
const err = await res.json(); const err = await res.json();
alert("Error: " + (err.error || "No se pudo guardar")); showToast("Error: " + (err.error || "No se pudo guardar"), "error");
} }
} catch { } catch {
alert("Error al guardar la cotizacion"); showToast("Error al guardar la cotización", "error");
} finally { } finally {
setSaving(false); setSaving(false);
} }
+9 -6
View File
@@ -2,6 +2,7 @@
import { Eye, FileDown, FileSpreadsheet, Loader2, X } from "lucide-react"; import { Eye, FileDown, FileSpreadsheet, Loader2, X } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useToast } from "@/components/ui/DialogProvider";
function getFilenameFromHeaders(headers: Headers, fallback: string): string { function getFilenameFromHeaders(headers: Headers, fallback: string): string {
const cd = headers.get("Content-Disposition"); const cd = headers.get("Content-Disposition");
@@ -14,6 +15,7 @@ function getFilenameFromHeaders(headers: Headers, fallback: string): string {
function useFileExport() { function useFileExport() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const showToast = useToast();
const download = async (url: string, fallback: string) => { const download = async (url: string, fallback: string) => {
setLoading(true); setLoading(true);
@@ -29,10 +31,10 @@ function useFileExport() {
a.click(); a.click();
window.URL.revokeObjectURL(blobUrl); window.URL.revokeObjectURL(blobUrl);
} else { } else {
alert("Error al exportar"); showToast("Error al exportar", "error");
} }
} catch { } catch {
alert("Error al exportar"); showToast("Error al exportar", "error");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -56,10 +58,10 @@ function useFileExport() {
a.click(); a.click();
window.URL.revokeObjectURL(blobUrl); window.URL.revokeObjectURL(blobUrl);
} else { } else {
alert("Error al exportar"); showToast("Error al exportar", "error");
} }
} catch { } catch {
alert("Error al exportar"); showToast("Error al exportar", "error");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -175,6 +177,7 @@ function PDFPreviewModal({
function usePDFPreview() { function usePDFPreview() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [preview, setPreview] = useState<{ url: string; filename: string } | null>(null); const [preview, setPreview] = useState<{ url: string; filename: string } | null>(null);
const showToast = useToast();
const open = async (fetcher: () => Promise<Response>, fallback: string) => { const open = async (fetcher: () => Promise<Response>, fallback: string) => {
setLoading(true); setLoading(true);
@@ -186,10 +189,10 @@ function usePDFPreview() {
const url = window.URL.createObjectURL(blob); const url = window.URL.createObjectURL(blob);
setPreview({ url, filename }); setPreview({ url, filename });
} else { } else {
alert("Error al generar la previsualización"); showToast("Error al generar la previsualización", "error");
} }
} catch { } catch {
alert("Error al generar la previsualización"); showToast("Error al generar la previsualización", "error");
} finally { } finally {
setLoading(false); setLoading(false);
} }
+296
View File
@@ -0,0 +1,296 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { AlertTriangle, CheckCircle2, Info, XCircle, X } from "lucide-react";
// Sistema de ventanas emergentes PROPIO de la plataforma (con su estilo visual),
// para reemplazar los diálogos nativos del navegador (confirm/alert/prompt), que
// se ven fuera de la marca ("<dominio> dice…"). Se monta una sola vez en el layout
// y se consume con los hooks useConfirm / usePrompt / useToast.
type ConfirmOptions = {
title?: string;
message: string;
confirmText?: string;
cancelText?: string;
danger?: boolean;
};
type PromptOptions = {
title?: string;
message?: string;
label?: string;
defaultValue?: string;
placeholder?: string;
confirmText?: string;
cancelText?: string;
};
type ToastKind = "success" | "error" | "info";
type ToastItem = { id: number; message: string; kind: ToastKind };
type DialogContextValue = {
confirm: (opts: ConfirmOptions | string) => Promise<boolean>;
prompt: (opts: PromptOptions | string) => Promise<string | null>;
toast: (message: string, kind?: ToastKind) => void;
};
const DialogContext = createContext<DialogContextValue | null>(null);
type ActiveConfirm = { kind: "confirm"; opts: ConfirmOptions; resolve: (v: boolean) => void };
type ActivePrompt = { kind: "prompt"; opts: PromptOptions; resolve: (v: string | null) => void };
type Active = ActiveConfirm | ActivePrompt | null;
export function DialogProvider({ children }: { children: React.ReactNode }) {
const [active, setActive] = useState<Active>(null);
const [promptValue, setPromptValue] = useState("");
const [toasts, setToasts] = useState<ToastItem[]>([]);
const toastSeq = useRef(0);
const inputRef = useRef<HTMLInputElement>(null);
const confirmBtnRef = useRef<HTMLButtonElement>(null);
const confirm = useCallback((o: ConfirmOptions | string) => {
const opts = typeof o === "string" ? { message: o } : o;
return new Promise<boolean>((resolve) => setActive({ kind: "confirm", opts, resolve }));
}, []);
const prompt = useCallback((o: PromptOptions | string) => {
const opts = typeof o === "string" ? { message: o } : o;
setPromptValue(opts.defaultValue ?? "");
return new Promise<string | null>((resolve) => setActive({ kind: "prompt", opts, resolve }));
}, []);
const toast = useCallback((message: string, kind: ToastKind = "info") => {
const id = ++toastSeq.current;
setToasts((t) => [...t, { id, message, kind }]);
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 4500);
}, []);
// Resuelven la promesa del diálogo activo y lo cierran. Se ejecutan en manejadores
// de eventos (post-render), capturando el `active` vigente: nada de efectos dentro
// del updater de setState (evita dobles resoluciones en StrictMode).
const finishConfirm = (v: boolean) => {
if (active?.kind === "confirm") active.resolve(v);
setActive(null);
};
const finishPrompt = (v: string | null) => {
if (active?.kind === "prompt") active.resolve(v);
setActive(null);
};
const dismiss = () => {
if (active?.kind === "confirm") active.resolve(false);
else if (active?.kind === "prompt") active.resolve(null);
setActive(null);
};
// Foco automático al abrir + cerrar con Escape.
useEffect(() => {
if (!active) return;
const t = setTimeout(() => {
if (active.kind === "prompt") inputRef.current?.focus();
else confirmBtnRef.current?.focus();
}, 30);
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
if (active.kind === "confirm") active.resolve(false);
else active.resolve(null);
setActive(null);
};
window.addEventListener("keydown", onKey);
return () => {
clearTimeout(t);
window.removeEventListener("keydown", onKey);
};
}, [active]);
const value = useMemo(() => ({ confirm, prompt, toast }), [confirm, prompt, toast]);
return (
<DialogContext.Provider value={value}>
{children}
{active && (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4"
onClick={dismiss}
role="presentation"
>
<div
className="w-full max-w-md rounded-xl bg-card-bg border border-border shadow-2xl overflow-hidden"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
>
{active.kind === "confirm" ? (
<ConfirmBody
opts={active.opts}
confirmBtnRef={confirmBtnRef}
onCancel={() => finishConfirm(false)}
onConfirm={() => finishConfirm(true)}
/>
) : (
<PromptBody
opts={active.opts}
value={promptValue}
setValue={setPromptValue}
inputRef={inputRef}
onCancel={() => finishPrompt(null)}
onSubmit={() => finishPrompt(promptValue)}
/>
)}
</div>
</div>
)}
{/* Avisos (toasts) */}
<div className="fixed bottom-4 right-4 z-[110] flex flex-col gap-2 max-w-[calc(100vw-2rem)]">
{toasts.map((t) => (
<ToastCard key={t.id} item={t} onClose={() => setToasts((ts) => ts.filter((x) => x.id !== t.id))} />
))}
</div>
</DialogContext.Provider>
);
}
function ConfirmBody({
opts,
onCancel,
onConfirm,
confirmBtnRef,
}: {
opts: ConfirmOptions;
onCancel: () => void;
onConfirm: () => void;
confirmBtnRef: React.RefObject<HTMLButtonElement | null>;
}) {
return (
<div className="p-5">
<div className="flex items-start gap-3">
<div
className={`mt-0.5 shrink-0 rounded-full p-2 ${
opts.danger ? "bg-red-100 text-red-600" : "bg-primary-light text-primary"
}`}
>
{opts.danger ? <AlertTriangle className="w-5 h-5" /> : <Info className="w-5 h-5" />}
</div>
<div className="min-w-0">
{opts.title && <h3 className="font-semibold text-base mb-1">{opts.title}</h3>}
<p className="text-sm text-foreground/80 whitespace-pre-line">{opts.message}</p>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 rounded-lg border border-border text-sm hover:bg-gray-50"
>
{opts.cancelText || "Cancelar"}
</button>
<button
ref={confirmBtnRef}
type="button"
onClick={onConfirm}
className={`px-4 py-2 rounded-lg text-sm text-white hover:opacity-90 ${
opts.danger ? "bg-red-600" : "bg-primary"
}`}
>
{opts.confirmText || "Aceptar"}
</button>
</div>
</div>
);
}
function PromptBody({
opts,
value,
setValue,
inputRef,
onCancel,
onSubmit,
}: {
opts: PromptOptions;
value: string;
setValue: (v: string) => void;
inputRef: React.RefObject<HTMLInputElement | null>;
onCancel: () => void;
onSubmit: () => void;
}) {
return (
<form
className="p-5"
onSubmit={(e) => {
e.preventDefault();
onSubmit();
}}
>
{opts.title && <h3 className="font-semibold text-base mb-1">{opts.title}</h3>}
{opts.message && <p className="text-sm text-foreground/80 mb-3">{opts.message}</p>}
{opts.label && <label className="block text-xs text-muted mb-1">{opts.label}</label>}
<input
ref={inputRef}
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={opts.placeholder}
className="w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-1 focus:ring-primary"
/>
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 rounded-lg border border-border text-sm hover:bg-gray-50"
>
{opts.cancelText || "Cancelar"}
</button>
<button
type="submit"
disabled={!value.trim()}
className="px-4 py-2 rounded-lg text-sm text-white bg-primary hover:opacity-90 disabled:opacity-50"
>
{opts.confirmText || "Aceptar"}
</button>
</div>
</form>
);
}
const TOAST_STYLE: Record<ToastKind, { cls: string; icon: React.ReactNode }> = {
success: { cls: "border-green-200 bg-green-50 text-green-800", icon: <CheckCircle2 className="w-4 h-4" /> },
error: { cls: "border-red-200 bg-red-50 text-red-800", icon: <XCircle className="w-4 h-4" /> },
info: { cls: "border-blue-200 bg-blue-50 text-blue-800", icon: <Info className="w-4 h-4" /> },
};
function ToastCard({ item, onClose }: { item: ToastItem; onClose: () => void }) {
const s = TOAST_STYLE[item.kind];
return (
<div
className={`flex items-start gap-2 rounded-lg border px-3 py-2 shadow-lg text-sm ${s.cls}`}
role="status"
>
<span className="mt-0.5 shrink-0">{s.icon}</span>
<span className="min-w-0 break-words">{item.message}</span>
<button onClick={onClose} className="ml-1 shrink-0 opacity-60 hover:opacity-100" aria-label="Cerrar aviso">
<X className="w-4 h-4" />
</button>
</div>
);
}
export function useDialogs(): DialogContextValue {
const ctx = useContext(DialogContext);
if (!ctx) throw new Error("useDialogs debe usarse dentro de <DialogProvider>");
return ctx;
}
export const useConfirm = () => useDialogs().confirm;
export const usePrompt = () => useDialogs().prompt;
export const useToast = () => useDialogs().toast;