feat: notificaciones toast con sileo (pnpm) + aviso en campana

This commit is contained in:
Nurfog committed 2026-10-22 10:02:11 -03:00
1 parent 73e6e96d73
commit 0a08fa5192
6 files changed
+301 -105

No files matched your search

+6 -1
View File
@@ -1,8 +1,10 @@
import type { Metadata } from "next";
import { Inter, Fira_Sans } from "next/font/google";
import "bootstrap/dist/css/bootstrap.min.css";
import "sileo/styles.css";
import "./globals.css";
import { AuthProvider } from "@/lib/auth";
import SileoToaster from "@/components/SileoToaster";
const inter = Inter({
variable: "--font-inter",
@@ -34,7 +36,10 @@ export default function RootLayout({
/>
</head>
<body style={{ fontFamily: "var(--font-fira-sans), sans-serif" }}>
<AuthProvider>{children}</AuthProvider>
<AuthProvider>
{children}
<SileoToaster />
</AuthProvider>
</body>
</html>
);
+46 -3
View File
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import {
getNotifications,
@@ -9,6 +9,7 @@ import {
markAllNotificationsRead,
type AppNotification,
} from "@/lib/api";
import { notify } from "@/lib/notify";
import { formatDate } from "@/lib/format";
const POLL_MS = 60000;
@@ -49,17 +50,57 @@ export default function NotificationBell() {
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState("");
const [markingAll, setMarkingAll] = useState(false);
const seenIds = useRef<Set<number>>(new Set());
const initialized = useRef(false);
const toastNew = useCallback(
(list: AppNotification[]) => {
const fresh = list.filter((n) => !n.isRead && !seenIds.current.has(n.id));
// En la primera carga solo registra; evita una ráfaga de toasts históricos.
if (!initialized.current) {
initialized.current = true;
} else if (fresh.length > 0) {
// Un toast por notificación nueva (máx 3) o uno agrupado.
if (fresh.length <= 3) {
for (const n of fresh) {
if (n.ticketId) {
const ticketId = n.ticketId;
notify.action(n.title, "Ver", () => router.push(`/tickets/${ticketId}`), n.body ?? undefined);
} else {
notify.info(n.title, n.body ?? undefined);
}
}
} else {
notify.info(
`${fresh.length} notificaciones nuevas`,
fresh
.slice(0, 3)
.map((n) => n.title)
.join(" · ")
);
}
}
for (const n of list) seenIds.current.add(n.id);
// Acota memoria del set.
if (seenIds.current.size > 300) {
const keep = new Set(list.map((n) => n.id));
seenIds.current = keep;
}
},
[router]
);
const fetchAll = useCallback(() => {
Promise.all([getNotifications(50), getUnreadCount()])
.then(([list, { count }]) => {
setItems(list);
setUnread(count);
toastNew(list);
})
.catch(() => {
// Sin sesión o backend caído: se reintenta en el próximo ciclo.
});
}, []);
}, [toastNew]);
useEffect(() => {
fetchAll();
@@ -103,7 +144,9 @@ export default function NotificationBell() {
.catch(() => {});
}
})
.catch(() => {})
.catch(() => {
notify.error("No se pudieron marcar como leídas");
})
.finally(() => setMarkingAll(false));
};
+7
View File
@@ -0,0 +1,7 @@
"use client";
import { Toaster } from "sileo";
export default function SileoToaster() {
return <Toaster position="bottom-right" />;
}
+62
View File
@@ -0,0 +1,62 @@
"use client";
import { sileo, type SileoOptions } from "sileo";
// Wrapper fino sobre sileo para unificar copy en español y defaults.
// Uso: notify.success("Ticket creado"), notify.error("No se pudo guardar", err)
const DEFAULT_DURATION = 5000;
function base(title: string, description?: string): SileoOptions {
return { title, description, duration: DEFAULT_DURATION };
}
export const notify = {
success(title: string, description?: string, opts?: Partial<SileoOptions>) {
return sileo.success({ ...base(title, description), ...opts });
},
error(title: string, description?: string, opts?: Partial<SileoOptions>) {
return sileo.error({ ...base(title, description), ...opts });
},
warning(title: string, description?: string, opts?: Partial<SileoOptions>) {
return sileo.warning({ ...base(title, description), ...opts });
},
info(title: string, description?: string, opts?: Partial<SileoOptions>) {
return sileo.info({ ...base(title, description), ...opts });
},
action(
title: string,
buttonTitle: string,
onClick: () => void,
description?: string,
opts?: Partial<SileoOptions>
) {
return sileo.action({
...base(title, description),
button: { title: buttonTitle, onClick },
...opts,
});
},
promise<T>(
promise: Promise<T> | (() => Promise<T>),
messages: {
loading: string;
success: string | ((data: T) => string);
error: string | ((err: unknown) => string);
}
) {
const { loading, success, error } = messages;
return sileo.promise(promise, {
loading: { title: loading },
success:
typeof success === "function"
? (data: T) => ({ title: (success as (d: T) => string)(data) })
: { title: success },
error:
typeof error === "function"
? (err: unknown) => ({ title: (error as (e: unknown) => string)(err) })
: { title: error },
});
},
dismiss: sileo.dismiss,
clear: sileo.clear,
};