feat: implement notification dropdown in header with centralized navigation logic

This commit is contained in:
2026-07-21 21:51:13 -03:00
parent 98f0ec3102
commit b26c3e58d1
5 changed files with 580 additions and 102 deletions
+205 -12
View File
@@ -6,7 +6,7 @@ import NotificationIcon from "@mui/icons-material/NotificationsNone";
import Badge from "@mui/material/Badge";
import { useUserMenuStore } from "@store/UserMenu.Store";
import { HEADER_MODES, HEADER_WIDGETS, useHeaderStore } from "@store/Header.Store";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import useWindowSize from "@hooks/WindowSize";
import Textbox from "../Textbox/Textbox";
import { useNavigation } from "@core/app/hooks/goto";
@@ -15,6 +15,18 @@ import { NO_CATEGORY } from "@core/Models/Categories.type";
import { IconButton } from "@mui/material";
import { useSessionStore } from "@core/Store/Sesion.Store";
import { blurElementsOnLoad } from "@core/helpers/blur";
import {
ISystemNotification,
PaginateSystemNotificationsParams,
} from "@core/Models/SystemNotifications.model";
import {
findSystemNotifications,
updateSystemNotification,
} from "@core/app/user/profile/notifications/Notifications.Service";
import {
NOTIFICATIONS_ROUTE,
resolveNotificationRoute,
} from "@core/app/user/profile/notifications/Notifications.Navigation";
//import { useSessionStore, useSessionTokenStore } from "@core/Store/Sesion.Store";
@@ -27,6 +39,11 @@ export default function Header(): React.ReactElement {
const [windowWidth, windowHeight] = useWindowSize();
const [filterText, setFilterText] = useState("");
const [publicFilterText, setPublicFilterText] = useState("");
const [notificationsOpen, setNotificationsOpen] = useState(false);
const [notificationsLoading, setNotificationsLoading] = useState(false);
const [notificationsError, setNotificationsError] = useState("");
const [notifications, setNotifications] = useState<ISystemNotification[]>([]);
const notificationsRef = useRef<HTMLDivElement>(null);
const { goTo } = useNavigation();
const pathName = usePathname();
@@ -78,6 +95,35 @@ export default function Header(): React.ReactElement {
setPublicFilterText(headerState.publicFindText);
}, [headerState.publicFindText]);
useEffect(() => {
if (!notificationsOpen) {
return;
}
const handleClickOutside = (event: MouseEvent) => {
if (
notificationsRef.current &&
!notificationsRef.current.contains(event.target as Node)
) {
setNotificationsOpen(false);
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setNotificationsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [notificationsOpen]);
const toggleMenu = () => {
menuState.setVisible(!menuState.visible);
};
@@ -126,6 +172,85 @@ export default function Header(): React.ReactElement {
}
};
const loadLatestNotifications = () => {
if (!SessionInfo.userId) {
return;
}
const filterData: PaginateSystemNotificationsParams = {
userId: SessionInfo.userId,
sessionUser: SessionInfo.userId,
page: 1,
limit: 10,
};
setNotificationsLoading(true);
setNotificationsError("");
findSystemNotifications(filterData)
.then((res) => {
setNotifications(res.data || []);
})
.catch(() => {
setNotifications([]);
setNotificationsError("No pudimos cargar tus notificaciones.");
})
.finally(() => {
setNotificationsLoading(false);
});
};
const toggleNotifications = () => {
const nextOpen = !notificationsOpen;
setNotificationsOpen(nextOpen);
if (nextOpen) {
loadLatestNotifications();
}
};
const viewAllNotifications = () => {
setNotificationsOpen(false);
goTo(NOTIFICATIONS_ROUTE);
};
const openNotification = async (notification: ISystemNotification) => {
setNotificationsOpen(false);
try {
if (!notification.readed) {
await updateSystemNotification({
id: notification._id,
readed: true,
sessionUser: SessionInfo.userId,
});
const currentCount = useHeaderStore.getState().notificationsCount;
headerState.setNotificationsCount(Math.max(currentCount - 1, 0));
}
const route = await resolveNotificationRoute({
notification,
sessionUser: SessionInfo.userId,
organizationSubscriptions: SessionInfo.organizationSubscriptions,
});
goTo(route);
} catch (error) {
console.error("Error navigating from notification", error);
goTo(NOTIFICATIONS_ROUTE);
}
};
const formatNotificationDate = (date: Date) => {
if (!date) {
return "";
}
return new Date(date).toLocaleDateString("es-AR", {
day: "2-digit",
month: "short",
});
};
return (
<>
<div className={style.header}>
@@ -197,18 +322,86 @@ export default function Header(): React.ReactElement {
)}
{showNotificationIcon && (
<IconButton
sx={{ marginRight: "20px" }}
onClick={() => goTo("/user/profile/notifications")}
>
<Badge
color="warning"
variant={"standard"}
badgeContent={headerState.notificationsCount}
<div className={style.notificationsWrap} ref={notificationsRef}>
<IconButton
sx={{ marginRight: "20px" }}
onClick={toggleNotifications}
aria-label="Abrir notificaciones"
aria-expanded={notificationsOpen}
>
<NotificationIcon sx={{ color: "white" }} />
</Badge>
</IconButton>
<Badge
color="warning"
variant={"standard"}
badgeContent={headerState.notificationsCount}
>
<NotificationIcon sx={{ color: "white" }} />
</Badge>
</IconButton>
{notificationsOpen && (
<div className={style.notificationsMenu} role="menu">
<div className={style.notificationsHeader}>
<div>
<strong>Notificaciones</strong>
<span>Últimas novedades</span>
</div>
{headerState.notificationsCount > 0 && (
<b>{headerState.notificationsCount}</b>
)}
</div>
<div className={style.notificationsList}>
{notificationsLoading && (
<div className={style.notificationsState}>Cargando...</div>
)}
{!notificationsLoading && notificationsError && (
<div className={style.notificationsState}>{notificationsError}</div>
)}
{!notificationsLoading && !notificationsError && notifications.length === 0 && (
<div className={style.notificationsState}>
No tenés notificaciones recientes.
</div>
)}
{!notificationsLoading &&
!notificationsError &&
notifications.map((notification) => (
<button
key={notification._id}
type="button"
className={`${style.notificationItem} ${
notification.readed ? style.notificationRead : ""
}`}
onClick={() => openNotification(notification)}
>
<span className={style.notificationDot} />
<span className={style.notificationContent}>
<span className={style.notificationTitle}>
{notification.subject}
</span>
<span className={style.notificationMessage}>
{notification.message}
</span>
<span className={style.notificationDate}>
{formatNotificationDate(notification.notificationDate)}
</span>
</span>
</button>
))}
</div>
<button
type="button"
className={style.notificationsFooter}
onClick={viewAllNotifications}
>
Ver todas las notificaciones
</button>
</div>
)}
</div>
)}
<MenuIcon sx={{ color: "white" }} onClick={toggleMenu} className={style.menuIcon} />
@@ -8,7 +8,7 @@
.header {
position: fixed;
overflow: hidden;
overflow: visible;
left: 0px;
top: 0px;
right: 0px;
@@ -25,3 +25,191 @@
.menuIcon {
cursor: pointer;
}
.notificationsWrap {
position: relative;
}
.notificationsMenu {
position: absolute;
top: calc(100% + 12px);
right: 12px;
width: min(380px, calc(100vw - 32px));
max-height: calc(100vh - 90px);
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 22px;
background: var(--white);
box-shadow: 0 22px 60px rgba(0, 0, 0, 0.26);
color: var(--black-light);
}
.notificationsMenu::before {
content: "";
position: absolute;
top: -7px;
right: 28px;
width: 14px;
height: 14px;
transform: rotate(45deg);
background: var(--wine-darkest);
}
.notificationsHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 20px;
background: linear-gradient(135deg, var(--wine-darkest), var(--wine-dark));
color: var(--white);
}
.notificationsHeader strong,
.notificationsHeader span {
display: block;
}
.notificationsHeader strong {
font-size: 17px;
}
.notificationsHeader span {
margin-top: 3px;
color: var(--white-dark);
font-size: 12px;
}
.notificationsHeader b {
min-width: 28px;
padding: 5px 8px;
border-radius: 999px;
background: var(--wine-lighterX2);
color: var(--wine-superdark);
font-size: 13px;
text-align: center;
}
.notificationsList {
max-height: 390px;
overflow-y: auto;
padding: 8px;
background: var(--white-dark);
}
.notificationsState {
padding: 26px 18px;
color: var(--gray-dark);
font-size: 14px;
text-align: center;
}
.notificationItem {
width: 100%;
display: flex;
gap: 11px;
padding: 13px 12px;
border: 1px solid transparent;
border-radius: 16px;
background: var(--white);
color: inherit;
cursor: pointer;
text-align: left;
transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
}
.notificationItem + .notificationItem {
margin-top: 7px;
}
.notificationItem:hover {
transform: translateY(-1px);
border-color: var(--wine-lighterX2);
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.08);
}
.notificationDot {
flex: 0 0 auto;
width: 9px;
height: 9px;
margin-top: 5px;
border-radius: 999px;
background: var(--wine-red);
box-shadow: 0 0 0 4px var(--wine-lighterX2);
}
.notificationContent {
min-width: 0;
display: flex;
flex: 1;
flex-direction: column;
gap: 4px;
}
.notificationTitle {
overflow: hidden;
color: var(--wine-darkest);
font-size: 14px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.notificationMessage {
display: -webkit-box;
overflow: hidden;
color: var(--black-light);
font-size: 13px;
line-height: 1.35;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.notificationDate {
color: var(--gray-dark);
font-size: 11px;
}
.notificationRead {
background: var(--white);
opacity: 0.76;
}
.notificationRead .notificationDot {
background: var(--gray-light);
box-shadow: 0 0 0 4px var(--gray-lighter);
}
.notificationRead .notificationTitle {
color: var(--black-light);
font-weight: 600;
}
.notificationsFooter {
width: 100%;
padding: 14px 18px;
border: 0;
border-top: 1px solid var(--gray-lighter);
background: var(--white);
color: var(--wine-dark);
cursor: pointer;
font-size: 14px;
font-weight: 700;
transition: background-color 0.18s ease, color 0.18s ease;
}
.notificationsFooter:hover {
background: var(--wine-lighterX2);
color: var(--wine-darkest);
}
@media (max-width: 600px) {
.header {
padding-left: 18px;
padding-right: 18px;
}
.notificationsMenu {
right: -48px;
}
}