feat: implement notification dropdown in header with centralized navigation logic
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user