init
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
Reference in New Issue
Block a user