This commit is contained in:
chamikaJ
2025-04-17 18:28:54 +05:30
parent f583291d8a
commit 8825b0410a
2837 changed files with 241385 additions and 127578 deletions

View File

@@ -0,0 +1,117 @@
import { teamsApiService } from '@/api/teams/teams.api.service';
import { verifyAuthentication } from '@/features/auth/authSlice';
import { setActiveTeam } from '@/features/teams/teamSlice';
import { setUser } from '@/features/user/userSlice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { createAuthService } from '@/services/auth/auth.service';
import { ITeamInvitationViewModel } from '@/types/notifications/notifications.types';
import { IAcceptTeamInvite } from '@/types/teams/team.type';
import logger from '@/utils/errorLogger';
import { TFunction } from 'i18next';
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
interface InvitationItemProps {
item: ITeamInvitationViewModel;
isUnreadNotifications: boolean;
t: TFunction;
}
const InvitationItem: React.FC<InvitationItemProps> = ({ item, isUnreadNotifications, t }) => {
const [accepting, setAccepting] = useState(false);
const [joining, setJoining] = useState(false);
const dispatch = useAppDispatch();
const navigate = useNavigate();
const authService = createAuthService(navigate);
const inProgress = () => accepting || joining;
const acceptInvite = async (showAlert?: boolean) => {
if (!item.team_member_id) return;
try {
setAccepting(true);
const body: IAcceptTeamInvite = {
team_member_id: item.team_member_id,
show_alert: showAlert,
};
const res = await teamsApiService.acceptInvitation(body);
setAccepting(false);
if (res.done && res.body.id) {
return res.body;
}
} catch (error) {
logger.error('Error accepting invitation', error);
}
return null;
};
const handleVerifyAuth = async () => {
const result = await dispatch(verifyAuthentication()).unwrap();
if (result.authenticated) {
dispatch(setUser(result.user));
authService.setCurrentSession(result.user);
}
};
const acceptAndJoin = async () => {
try {
const res = await acceptInvite(true);
if (res && res.id) {
setJoining(true);
await dispatch(setActiveTeam(res.id));
await handleVerifyAuth();
window.location.reload();
setJoining(false);
}
} catch (error) {
logger.error('Error accepting and joining invitation', error);
} finally {
setAccepting(false);
setJoining(false);
}
};
return (
<div
style={{ width: 'auto' }}
className="ant-notification-notice worklenz-notification rounded-4"
>
<div className="ant-notification-notice-content">
<div className="ant-notification-notice-description">
You have been invited to work with <b>{item.team_name}</b>.
</div>
{isUnreadNotifications && (
<div className="mt-2" style={{ display: 'flex', gap: '8px', justifyContent: 'space-between' }}>
<button
onClick={() => acceptInvite(true)}
disabled={inProgress()}
className="p-0"
style={{
background: 'none',
border: 'none',
cursor: inProgress() ? 'not-allowed' : 'pointer',
}}
>
{item.accepting ? 'Loading...' : <u>{t('notificationsDrawer.markAsRead')}</u>}
</button>
<button
onClick={() => acceptAndJoin()}
disabled={inProgress()}
style={{
background: 'none',
border: 'none',
cursor: inProgress() ? 'not-allowed' : 'pointer',
}}
>
{item.joining ? 'Loading...' : <u>{t('notificationsDrawer.readAndJoin')}</u>}
</button>
</div>
)}
</div>
</div>
);
};
export default InvitationItem;

View File

@@ -0,0 +1,303 @@
import { Drawer, Empty, Segmented, Typography, Spin, Button, Flex } from 'antd';
import { useEffect, useState } from 'react';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import {
fetchInvitations,
fetchNotifications,
setNotificationType,
toggleDrawer,
} from '../../../../../features/navbar/notificationSlice';
import { NOTIFICATION_OPTION_READ, NOTIFICATION_OPTION_UNREAD } from '@/shared/constants';
import { useTranslation } from 'react-i18next';
import { SocketEvents } from '@/shared/socket-events';
import { IWorklenzNotification } from '@/types/notifications/notifications.types';
import { useSocket } from '@/socket/socketContext';
import { ITeamInvitationViewModel } from '@/types/notifications/notifications.types';
import logger from '@/utils/errorLogger';
import NotificationItem from './notification-item';
import InvitationItem from './invitation-item';
import { notificationsApiService } from '@/api/notifications/notifications.api.service';
import { profileSettingsApiService } from '@/api/settings/profile/profile-settings.api.service';
import { INotificationSettings } from '@/types/settings/notifications.types';
import { toQueryString } from '@/utils/toQueryString';
import { showNotification } from './push-notification-template';
import { teamsApiService } from '@/api/teams/teams.api.service';
import { verifyAuthentication } from '@/features/auth/authSlice';
import { getUserSession } from '@/utils/session-helper';
import { setUser } from '@/features/user/userSlice';
import { useNavigate } from 'react-router-dom';
import { createAuthService } from '@/services/auth/auth.service';
const HTML_TAG_REGEXP = /<[^>]*>/g;
const NotificationDrawer = () => {
const { isDrawerOpen, notificationType, notifications, invitations } = useAppSelector(
state => state.notificationReducer
);
const dispatch = useAppDispatch();
const { t } = useTranslation('navbar');
const { socket, connected } = useSocket();
const [notificationsSettings, setNotificationsSettings] = useState<INotificationSettings>({});
const [showBrowserPush, setShowBrowserPush] = useState(false);
const notificationCount = notifications?.length || 0;
const [isLoading, setIsLoading] = useState(false);
const isPushEnabled = () => {
return notificationsSettings.popup_notifications_enabled && showBrowserPush;
};
const navigate = useNavigate();
const authService = createAuthService(navigate);
const createPush = (message: string, title: string, teamId: string | null, url?: string) => {
if (Notification.permission === 'granted' && showBrowserPush) {
const img = 'https://worklenz.com/assets/icons/icon-128x128.png';
const notification = new Notification(title, {
body: message.replace(HTML_TAG_REGEXP, ''),
icon: img,
badge: img,
});
notification.onclick = async event => {
if (url) {
window.focus();
if (teamId) {
await teamsApiService.setActiveTeam(teamId);
}
window.location.href = url;
}
};
}
};
const handleInvitationsUpdate = (data: ITeamInvitationViewModel[]) => {
dispatch(fetchInvitations());
};
const handleNotificationsUpdate = async (notification: IWorklenzNotification) => {
dispatch(fetchNotifications(notificationType));
dispatch(fetchInvitations());
if (isPushEnabled()) {
const title = notification.team ? `${notification.team} | Worklenz` : 'Worklenz';
let url = notification.url;
if (url && notification.params && Object.keys(notification.params).length) {
const q = toQueryString(notification.params);
url += q;
}
createPush(notification.message, title, notification.team_id, url);
}
// Show notification using the template
showNotification(notification);
};
const handleTeamInvitationsUpdate = async (data: ITeamInvitationViewModel) => {
const notification: IWorklenzNotification = {
id: data.id || '',
team: data.team_name || '',
team_id: data.team_id || '',
message: `You have been invited to join ${data.team_name || 'a team'}`,
};
if (isPushEnabled()) {
createPush(
notification.message,
notification.team || 'Worklenz',
notification.team_id || null
);
}
// Show notification using the template
showNotification(notification);
dispatch(fetchInvitations());
};
const askPushPermission = () => {
if ('Notification' in window && 'serviceWorker' in navigator && 'PushManager' in window) {
if (Notification.permission !== 'granted') {
Notification.requestPermission().then(permission => {
if (permission === 'granted') {
setShowBrowserPush(true);
logger.info('Permission granted');
}
});
} else if (Notification.permission === 'granted') {
setShowBrowserPush(true);
}
} else {
logger.error('This browser does not support notification permission.');
return;
}
};
const markNotificationAsRead = async (id: string) => {
if (!id) return;
const res = await notificationsApiService.updateNotification(id);
if (res.done) {
dispatch(fetchNotifications(notificationType));
dispatch(fetchInvitations());
}
};
const handleVerifyAuth = async () => {
const result = await dispatch(verifyAuthentication()).unwrap();
if (result.authenticated) {
dispatch(setUser(result.user));
authService.setCurrentSession(result.user);
}
};
const goToUrl = async (event: React.MouseEvent, notification: IWorklenzNotification) => {
event.preventDefault();
event.stopPropagation();
if (notification.url) {
dispatch(toggleDrawer());
setIsLoading(true);
try {
const currentSession = getUserSession();
if (currentSession?.team_id && notification.team_id !== currentSession.team_id) {
await handleVerifyAuth();
}
if (notification.project && notification.task_id) {
navigate(`${notification.url}${toQueryString({task: notification.params?.task, tab: notification.params?.tab})}`);
}
} catch (error) {
console.error('Error navigating to URL:', error);
} finally {
setIsLoading(false);
}
}
};
const fetchNotificationsSettings = async () => {
try {
setIsLoading(true);
const res = await profileSettingsApiService.getNotificationSettings();
if (res.done) {
setNotificationsSettings(res.body);
}
} catch (error) {
logger.error('Error fetching notifications settings', error);
} finally {
setIsLoading(false);
}
};
const handleMarkAllAsRead = async () => {
await notificationsApiService.readAllNotifications();
dispatch(fetchNotifications(notificationType));
dispatch(fetchInvitations());
};
useEffect(() => {
socket?.on(SocketEvents.INVITATIONS_UPDATE.toString(), handleInvitationsUpdate);
socket?.on(SocketEvents.NOTIFICATIONS_UPDATE.toString(), handleNotificationsUpdate);
socket?.on(SocketEvents.TEAM_MEMBER_REMOVED.toString(), handleTeamInvitationsUpdate);
fetchNotificationsSettings();
askPushPermission();
return () => {
socket?.removeListener(SocketEvents.INVITATIONS_UPDATE.toString(), handleInvitationsUpdate);
socket?.removeListener(
SocketEvents.NOTIFICATIONS_UPDATE.toString(),
handleNotificationsUpdate
);
socket?.removeListener(
SocketEvents.TEAM_MEMBER_REMOVED.toString(),
handleTeamInvitationsUpdate
);
};
}, [socket]);
useEffect(() => {
setIsLoading(true);
dispatch(fetchInvitations());
if (notificationType) {
dispatch(fetchNotifications(notificationType)).finally(() => setIsLoading(false));
}
}, [notificationType, dispatch]);
return (
<Drawer
title={
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
{notificationType === NOTIFICATION_OPTION_READ
? t('notificationsDrawer.read')
: t('notificationsDrawer.unread')}{' '}
({notificationCount})
</Typography.Text>
}
open={isDrawerOpen}
onClose={() => dispatch(toggleDrawer())}
width={400}
>
<Flex justify="space-between" align="center">
<Segmented<string>
options={['Unread', 'Read']}
defaultValue={NOTIFICATION_OPTION_UNREAD}
onChange={(value: string) => {
if (value === NOTIFICATION_OPTION_UNREAD)
dispatch(setNotificationType(NOTIFICATION_OPTION_UNREAD));
if (value === NOTIFICATION_OPTION_READ)
dispatch(setNotificationType(NOTIFICATION_OPTION_READ));
}}
/>
<Button type="link" onClick={handleMarkAllAsRead}>
{t('notificationsDrawer.markAsRead')}
</Button>
</Flex>
{isLoading && (
<div style={{ display: 'flex', justifyContent: 'center', marginTop: 40 }}>
<Spin />
</div>
)}
{invitations && invitations.length > 0 && notificationType === NOTIFICATION_OPTION_UNREAD ? (
<div className="notification-list mt-3">
{invitations.map(invitation => (
<InvitationItem
key={invitation.id}
item={invitation}
isUnreadNotifications={notificationType === NOTIFICATION_OPTION_UNREAD}
t={t}
/>
))}
</div>
) : null}
{notifications && notifications.length > 0 ? (
<div className="notification-list mt-3">
{notifications.map(notification => (
<NotificationItem
key={notification.id}
notification={notification}
isUnreadNotifications={notificationType === NOTIFICATION_OPTION_UNREAD}
markNotificationAsRead={id => Promise.resolve(markNotificationAsRead(id))}
goToUrl={goToUrl}
/>
))}
</div>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={t('notificationsDrawer.noNotifications')}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
marginBlockStart: 32,
}}
/>
)}
</Drawer>
);
};
export default NotificationDrawer;

View File

@@ -0,0 +1,41 @@
import { BellOutlined } from '@ant-design/icons';
import { Badge, Button, Tooltip } from 'antd';
import { toggleDrawer } from '@features/navbar/notificationSlice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useTranslation } from 'react-i18next';
import { useAppSelector } from '@/hooks/useAppSelector';
const NotificationButton = () => {
const dispatch = useAppDispatch();
const { notifications, invitations } = useAppSelector(state => state.notificationReducer);
const { t } = useTranslation('navbar');
const hasNotifications = () => {
return notifications.length > 0 || invitations.length > 0;
};
const notificationCount = () => {
return notifications.length + invitations.length;
};
return (
<Tooltip title={t('notificationTooltip')} trigger={'hover'}>
<Button
style={{ height: '62px', width: '60px' }}
type="text"
icon={
hasNotifications() ? (
<Badge count={notificationCount()}>
<BellOutlined style={{ fontSize: 20 }} />
</Badge>
) : (
<BellOutlined style={{ fontSize: 20 }} />
)
}
onClick={() => dispatch(toggleDrawer())}
/>
</Tooltip>
);
};
export default NotificationButton;

View File

@@ -0,0 +1,398 @@
.ant-notification {
box-sizing: border-box;
margin: 0;
padding: 0;
color: rgba(0, 0, 0, 0.85);
font-size: 14px;
font-variant: tabular-nums;
line-height: 1.5715;
list-style: none;
font-feature-settings: "tnum";
position: fixed;
z-index: 1010;
margin-right: 24px;
}
.ant-notification-topLeft,
.ant-notification-bottomLeft {
margin-right: 0;
margin-left: 24px;
}
.ant-notification-topLeft .ant-notification-fade-enter.ant-notification-fade-enter-active,
.ant-notification-bottomLeft .ant-notification-fade-enter.ant-notification-fade-enter-active,
.ant-notification-topLeft .ant-notification-fade-appear.ant-notification-fade-appear-active,
.ant-notification-bottomLeft .ant-notification-fade-appear.ant-notification-fade-appear-active {
-webkit-animation-name: NotificationLeftFadeIn;
animation-name: NotificationLeftFadeIn;
}
.ant-notification-close-icon {
font-size: 14px;
cursor: pointer;
}
.ant-notification-hook-holder {
position: relative;
}
.ant-notification-notice {
position: relative;
width: 384px;
max-width: calc(100vw - 24px * 2);
/* margin-bottom: 16px; */
margin-left: auto;
padding: 16px 24px;
overflow: hidden;
line-height: 1.5715;
word-wrap: break-word;
background: #fff;
border-radius: 2px;
box-shadow:
0 3px 6px -4px rgba(0, 0, 0, 0.12),
0 6px 16px 0 rgba(0, 0, 0, 0.08),
0 9px 28px 8px rgba(0, 0, 0, 0.05);
}
.ant-notification-topLeft .ant-notification-notice,
.ant-notification-bottomLeft .ant-notification-notice {
margin-right: auto;
margin-left: 0;
}
.ant-notification-notice-message {
margin-bottom: 8px;
font-size: 16px;
line-height: 24px;
}
.ant-notification-notice-message-single-line-auto-margin {
display: block;
width: calc(384px - 24px * 2 - 24px - 48px - 100%);
max-width: 4px;
background-color: transparent;
pointer-events: none;
}
.ant-notification-notice-message-single-line-auto-margin::before {
display: block;
content: "";
}
.ant-notification-notice-description {
font-size: 14px;
}
.ant-notification-notice-closable .ant-notification-notice-message {
padding-right: 24px;
}
.ant-notification-notice-with-icon .ant-notification-notice-message {
margin-bottom: 4px;
margin-left: 48px;
font-size: 16px;
}
.ant-notification-notice-with-icon .ant-notification-notice-description {
margin-left: 48px;
font-size: 14px;
}
.ant-notification-notice-icon {
position: absolute;
margin-left: 4px;
font-size: 24px;
line-height: 24px;
}
.anticon.ant-notification-notice-icon-success {
color: #52c41a;
}
.anticon.ant-notification-notice-icon-info {
color: #1890ff;
}
.anticon.ant-notification-notice-icon-warning {
color: #faad14;
}
.anticon.ant-notification-notice-icon-error {
color: #ff4d4f;
}
.ant-notification-notice-close {
position: absolute;
top: 16px;
right: 22px;
color: rgba(0, 0, 0, 0.45);
outline: none;
}
.ant-notification-notice-close:hover {
color: rgba(0, 0, 0, 0.67);
}
.ant-notification-notice-btn {
float: right;
margin-top: 16px;
}
.ant-notification .notification-fade-effect {
-webkit-animation-duration: 0.24s;
animation-duration: 0.24s;
-webkit-animation-timing-function: cubic-bezier(0.645, 0.045, 0.355, 1);
animation-timing-function: cubic-bezier(0.645, 0.045, 0.355, 1);
-webkit-animation-fill-mode: both;
animation-fill-mode: both;
}
.ant-notification-fade-enter,
.ant-notification-fade-appear {
-webkit-animation-duration: 0.24s;
animation-duration: 0.24s;
-webkit-animation-timing-function: cubic-bezier(0.645, 0.045, 0.355, 1);
animation-timing-function: cubic-bezier(0.645, 0.045, 0.355, 1);
-webkit-animation-fill-mode: both;
animation-fill-mode: both;
opacity: 0;
-webkit-animation-play-state: paused;
animation-play-state: paused;
}
.ant-notification-fade-leave {
-webkit-animation-duration: 0.24s;
animation-duration: 0.24s;
-webkit-animation-timing-function: cubic-bezier(0.645, 0.045, 0.355, 1);
animation-timing-function: cubic-bezier(0.645, 0.045, 0.355, 1);
-webkit-animation-fill-mode: both;
animation-fill-mode: both;
-webkit-animation-duration: 0.2s;
animation-duration: 0.2s;
-webkit-animation-play-state: paused;
animation-play-state: paused;
}
.ant-notification-fade-enter.ant-notification-fade-enter-active,
.ant-notification-fade-appear.ant-notification-fade-appear-active {
-webkit-animation-name: NotificationFadeIn;
animation-name: NotificationFadeIn;
-webkit-animation-play-state: running;
animation-play-state: running;
}
.ant-notification-fade-leave.ant-notification-fade-leave-active {
-webkit-animation-name: NotificationFadeOut;
animation-name: NotificationFadeOut;
-webkit-animation-play-state: running;
animation-play-state: running;
}
@-webkit-keyframes NotificationFadeIn {
0% {
left: 384px;
opacity: 0;
}
100% {
left: 0;
opacity: 1;
}
}
@keyframes NotificationFadeIn {
0% {
left: 384px;
opacity: 0;
}
100% {
left: 0;
opacity: 1;
}
}
@-webkit-keyframes NotificationLeftFadeIn {
0% {
right: 384px;
opacity: 0;
}
100% {
right: 0;
opacity: 1;
}
}
@keyframes NotificationLeftFadeIn {
0% {
right: 384px;
opacity: 0;
}
100% {
right: 0;
opacity: 1;
}
}
@-webkit-keyframes NotificationFadeOut {
0% {
max-height: 150px;
margin-bottom: 16px;
opacity: 1;
}
100% {
max-height: 0;
margin-bottom: 0;
padding-top: 0;
padding-bottom: 0;
opacity: 0;
}
}
@keyframes NotificationFadeOut {
0% {
max-height: 150px;
margin-bottom: 16px;
opacity: 1;
}
100% {
max-height: 0;
margin-bottom: 0;
padding-top: 0;
padding-bottom: 0;
opacity: 0;
}
}
.notification-item {
border: 1px solid var(--border-color, #f0f0f0);
border-radius: 4px;
padding: 16px;
margin-bottom: 12px;
position: relative;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
background-color: var(--bg-color, #fff);
}
.notification-item:hover {
background-color: var(--hover-bg-color, #fafafa);
}
.notification-item .ant-notification-notice-message {
margin-bottom: 8px;
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
color: var(--text-color, rgba(0, 0, 0, 0.85));
}
.notification-item .ant-notification-notice-description {
color: var(--description-color, rgba(0, 0, 0, 0.65));
}
/* Light mode (default) */
:root {
--border-color: #f0f0f0;
--bg-color: #fff;
--hover-bg-color: #fafafa;
--text-color: rgba(0, 0, 0, 0.85);
--description-color: rgba(0, 0, 0, 0.65);
}
/* Dark mode */
[data-theme="dark"] .notification-item,
.dark-theme .notification-item,
.ant-layout-dark .notification-item {
--border-color: #303030;
--bg-color: #141414;
--hover-bg-color: #1f1f1f;
--text-color: rgba(255, 255, 255, 0.85);
--description-color: rgba(255, 255, 255, 0.65);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
/* Ensure the close button is visible in both themes */
[data-theme="dark"] .notification-item .ant-btn,
.dark-theme .notification-item .ant-btn,
.ant-layout-dark .notification-item .ant-btn {
color: rgba(255, 255, 255, 0.65);
}
[data-theme="dark"] .notification-item .ant-btn:hover,
.dark-theme .notification-item .ant-btn:hover,
.ant-layout-dark .notification-item .ant-btn:hover {
color: rgba(255, 255, 255, 0.85);
}
/* Add these new styles at the end of the file */
.worklenz-notification {
padding: 12px;
margin-bottom: 12px;
transition: all 0.3s ease;
background-color: var(--background-color);
}
/* Light mode styles */
[data-theme="light"] .worklenz-notification {
--background-color: #fff;
--text-color: rgba(0, 0, 0, 0.85);
--secondary-text-color: rgba(0, 0, 0, 0.45);
--hover-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
/* Dark mode styles */
[data-theme="dark"] .worklenz-notification {
--background-color: #1f1f1f;
--text-color: rgba(255, 255, 255, 0.85);
--secondary-text-color: rgba(255, 255, 255, 0.45);
--hover-shadow: 0 2px 8px rgba(0, 0, 0, 0.45);
}
.worklenz-notification:hover {
box-shadow: var(--hover-shadow);
}
.worklenz-notification .ant-notification-notice-description {
color: var(--text-color);
}
.worklenz-notification .ant-typography-secondary {
color: var(--secondary-text-color) !important;
}
.rounded-4 {
border-radius: 4px;
}
.cursor-pointer {
cursor: pointer;
}
.mb-1 {
margin-bottom: 8px;
}
.mt-1 {
margin-top: 8px;
}
.d-flex {
display: flex;
}
.align-items-baseline {
align-items: baseline;
}
.justify-content-between {
justify-content: space-between;
}
.p-0 {
padding: 0;
}
.small {
font-size: 12px;
}

View File

@@ -0,0 +1,127 @@
import { IWorklenzNotification } from '@/types/notifications/notifications.types';
import { BankOutlined } from '@ant-design/icons';
import { Button, Tag, Typography, theme } from 'antd';
import DOMPurify from 'dompurify';
import React, { useState } from 'react';
import { fromNow } from '@/utils/dateUtils';
import './notification-item.css';
const { Text } = Typography;
interface NotificationItemProps {
notification: IWorklenzNotification;
isUnreadNotifications?: boolean;
markNotificationAsRead?: (id: string) => Promise<void>;
goToUrl?: (e: React.MouseEvent, notification: IWorklenzNotification) => Promise<void>;
}
const NotificationItem = ({
notification,
isUnreadNotifications = true,
markNotificationAsRead,
goToUrl,
}: NotificationItemProps) => {
const { token } = theme.useToken();
const [loading, setLoading] = useState(false);
const isDarkMode =
token.colorBgContainer === '#141414' ||
token.colorBgContainer.includes('dark') ||
document.documentElement.getAttribute('data-theme') === 'dark';
const handleNotificationClick = async (e: React.MouseEvent) => {
await goToUrl?.(e, notification);
await markNotificationAsRead?.(notification.id);
};
const handleMarkAsRead = async (e: React.MouseEvent) => {
e.stopPropagation();
if (!notification.id) return;
setLoading(true);
try {
await markNotificationAsRead?.(notification.id);
} finally {
setLoading(false);
}
};
const createSafeHtml = (html: string) => {
return { __html: DOMPurify.sanitize(html) };
};
const getTagBackground = (color?: string) => {
if (!color) return {};
// Create a more transparent version of the color for the background
// This is equivalent to the color + '4d' in the Angular template
const bgColor = `${color}4d`;
// For dark mode, we might need to adjust the text color for better contrast
if (isDarkMode) {
return {
backgroundColor: bgColor,
color: '#ffffff',
borderColor: 'transparent',
};
}
return {
backgroundColor: bgColor,
borderColor: 'transparent',
};
};
return (
<div
style={{
width: 'auto',
border: notification.color ? `2px solid ${notification.color}4d` : undefined,
cursor: notification.url ? 'pointer' : 'default',
}}
onClick={handleNotificationClick}
className="ant-notification-notice worklenz-notification rounded-4"
>
<div className="ant-notification-notice-content">
<div className="ant-notification-notice-description">
{/* Team name */}
<div className="mb-1">
<Text type="secondary">
<BankOutlined /> {notification.team}
</Text>
</div>
{/* Message with HTML content */}
<div className="mb-1" dangerouslySetInnerHTML={createSafeHtml(notification.message)} />
{/* Project tag */}
{notification.project && (
<div>
<Tag style={getTagBackground(notification.color)}>{notification.project}</Tag>
</div>
)}
</div>
{/* Footer with mark as read button and timestamp */}
<div className="d-flex align-items-baseline justify-content-between mt-1">
{isUnreadNotifications && markNotificationAsRead && (
<Button
loading={loading}
type="link"
size="small"
shape="round"
className="p-0"
onClick={e => handleMarkAsRead(e)}
>
<u>Mark as read</u>
</Button>
)}
<Text type="secondary" className="small">
{notification.created_at ? fromNow(notification.created_at) : ''}
</Text>
</div>
</div>
</div>
);
};
export default NotificationItem;

View File

@@ -0,0 +1,95 @@
import { Button, Typography, Tag } from 'antd';
import { BankOutlined } from '@ant-design/icons';
import { IWorklenzNotification } from '@/types/notifications/notifications.types';
import { useNavigate } from 'react-router-dom';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { toggleDrawer } from '../../../../../features/navbar/notificationSlice';
import { teamsApiService } from '@/api/teams/teams.api.service';
import { formatDistanceToNow } from 'date-fns';
import { tagBackground } from '@/utils/colorUtils';
interface NotificationTemplateProps {
item: IWorklenzNotification;
isUnreadNotifications: boolean;
markNotificationAsRead: (id: string) => Promise<void>;
loadersMap: Record<string, boolean>;
}
const NotificationTemplate: React.FC<NotificationTemplateProps> = ({
item,
isUnreadNotifications,
markNotificationAsRead,
loadersMap,
}) => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const goToUrl = async (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
console.log('goToUrl triggered', { url: item.url, teamId: item.team_id });
if (item.url) {
dispatch(toggleDrawer());
if (item.team_id) {
await teamsApiService.setActiveTeam(item.team_id);
}
navigate(item.url, {
state: item.params || null,
});
}
};
const formatDate = (dateString?: string) => {
if (!dateString) return '';
return formatDistanceToNow(new Date(dateString), { addSuffix: true });
};
const handleMarkAsRead = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
markNotificationAsRead(item.id);
};
return (
<div
style={{ width: 'auto', border: `2px solid ${item.color}4d` }}
onClick={goToUrl}
className={`ant-notification-notice worklenz-notification rounded-4 ${item.url ? 'cursor-pointer' : ''}`}
>
<div className="ant-notification-notice-content">
<div className="ant-notification-notice-description">
<Typography.Text type="secondary" className="mb-1">
<BankOutlined /> {item.team}
</Typography.Text>
<div className="mb-1" dangerouslySetInnerHTML={{ __html: item.message }} />
{item.project && item.color && (
<Tag style={{ backgroundColor: tagBackground(item.color) }}>{item.project}</Tag>
)}
</div>
<div className="d-flex align-items-baseline justify-content-between mt-1">
{isUnreadNotifications && (
<Button
type="link"
shape="round"
size="small"
loading={loadersMap[item.id]}
onClick={handleMarkAsRead}
>
<u>Mark as read</u>
</Button>
)}
<Typography.Text type="secondary" className="small">
{formatDate(item.created_at)}
</Typography.Text>
</div>
</div>
</div>
);
};
export default NotificationTemplate;

View File

@@ -0,0 +1,7 @@
.notification-content.clickable {
transition: background-color 0.2s ease;
}
.notification-content.clickable:hover {
background-color: rgba(0, 0, 0, 0.02);
}

View File

@@ -0,0 +1,99 @@
import { notification } from 'antd';
import { IWorklenzNotification } from '@/types/notifications/notifications.types';
import { teamsApiService } from '@/api/teams/teams.api.service';
import { toQueryString } from '@/utils/toQueryString';
import { BankOutlined } from '@ant-design/icons';
import './push-notification-template.css';
const PushNotificationTemplate = ({ notification: notificationData }: { notification: IWorklenzNotification }) => {
const handleClick = async () => {
if (notificationData.url) {
let url = notificationData.url;
if (notificationData.params && Object.keys(notificationData.params).length) {
const q = toQueryString(notificationData.params);
url += q;
}
if (notificationData.team_id) {
await teamsApiService.setActiveTeam(notificationData.team_id);
}
window.location.href = url;
}
};
return (
<div
onClick={handleClick}
className={`notification-content ${notificationData.url ? 'clickable' : ''}`}
style={{
cursor: notificationData.url ? 'pointer' : 'default',
padding: '8px 0',
borderRadius: '8px'
}}
>
<div style={{
display: 'flex',
alignItems: 'center',
marginBottom: '8px',
color: '#262626',
fontSize: '14px',
fontWeight: 500
}}>
{notificationData.team && (
<>
<BankOutlined style={{ marginRight: '8px', color: '#1890ff' }} />
{notificationData.team}
</>
)}
{!notificationData.team && 'Worklenz'}
</div>
<div
style={{
color: '#595959',
fontSize: '13px',
lineHeight: '1.5',
marginTop: '4px'
}}
dangerouslySetInnerHTML={{ __html: notificationData.message }}
/>
</div>
);
};
let notificationQueue: IWorklenzNotification[] = [];
let isProcessing = false;
const processNotificationQueue = () => {
if (isProcessing || notificationQueue.length === 0) return;
isProcessing = true;
const notificationData = notificationQueue.shift();
if (notificationData) {
notification.info({
message: null,
description: <PushNotificationTemplate notification={notificationData} />,
placement: 'topRight',
duration: 5,
style: {
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
padding: '12px 16px',
minWidth: '300px',
maxWidth: '400px'
},
onClose: () => {
isProcessing = false;
processNotificationQueue();
}
});
} else {
isProcessing = false;
}
};
export const showNotification = (notificationData: IWorklenzNotification) => {
notificationQueue.push(notificationData);
processNotificationQueue();
};