From bb45d0cf8b7e78f3d27b949f485e73dfd9a842df Mon Sep 17 00:00:00 2001 From: Christian Iradukunda <99505626+iChris-tian@users.noreply.github.com> Date: Wed, 23 Oct 2024 14:10:31 +0200 Subject: [PATCH] applicant notifications (#196) * ft working on applicant notifications * ft-applicant-notifications * fixing codeclimate * fixing codeclimate * fixing codeclimate * fixing codeclimate * fixing codeclimate * fixing codeclimate * fixing codeclimate * fixing codeclimate * fix codeclimate * fix missing close tag route.tsx * ft-applicant-notifications * fix: route.tsx * fix notifications.tsx * ft-applicant-notifications --------- Co-authored-by: chris --- package.json | 6 +- src/App.tsx | 11 +- src/components/form/SignInForm.tsx | 7 +- src/components/sidebar/navHeader.tsx | 129 ++++++----- src/components/sidebar/sidebarItems.tsx | 10 +- .../AppNotification.tsx | 200 ++++++++++++++++++ src/routes/routes.tsx | 45 +++- src/utils/Notifications.tsx | 98 +++++++++ .../NotificationService.ts | 47 ++++ src/utils/applicantNotifications/pusher.ts | 23 ++ src/utils/applicantNotifications/types.ts | 6 + .../useFetchNotifications.ts | 21 ++ src/utils/applicantNotifications/usePusher.ts | 22 ++ src/utils/toast.tsx | 24 ++- src/utils/utils.tsx | 1 + 15 files changed, 571 insertions(+), 79 deletions(-) create mode 100644 src/pages/ApplicantNotifications/AppNotification.tsx create mode 100644 src/utils/Notifications.tsx create mode 100644 src/utils/applicantNotifications/NotificationService.ts create mode 100644 src/utils/applicantNotifications/pusher.ts create mode 100644 src/utils/applicantNotifications/types.ts create mode 100644 src/utils/applicantNotifications/useFetchNotifications.ts create mode 100644 src/utils/applicantNotifications/usePusher.ts diff --git a/package.json b/package.json index f14e538fc..6ae4d9b6c 100755 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ "webpack-dev-server": "^4.11.1" }, "dependencies": { - "@apollo/client": "^3.8.1", + "@apollo/client": "^3.11.8", "@emotion/react": "^11.10.4", "@emotion/styled": "^11.10.4", "@fortawesome/fontawesome-svg-core": "^6.4.2", @@ -115,6 +115,8 @@ "patch-package": "^6.4.7", "path": "^0.12.7", "popper.js": "^1.16.1", + "pusher": "^5.2.0", + "pusher-js": "^8.4.0-rc2", "react-datepicker": "^4.8.0", "react-hook-form": "^7.45.4", "react-hot-toast": "^2.4.1", @@ -136,8 +138,10 @@ "redux-devtools-extension": "^2.13.9", "redux-state-sync": "^3.1.4", "redux-thunk": "^2.4.1", + "socket.io-client": "^4.8.0", "stream": "^0.0.2", "stream-http": "^3.2.0", + "subscriptions-transport-ws": "^0.11.0", "ts-jest": "^29.0.3", "with-click-outside": "^1.0.1", "yup": "^1.2.0", diff --git a/src/App.tsx b/src/App.tsx index a4285556b..d0248855e 100755 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,8 +1,13 @@ -import React from 'react'; -import AppRoutes from './routes/index'; +import React from "react"; +import AppRoutes from "./routes/index"; +import { NotificationProvider } from "./utils/Notifications"; function App() { - return ; + return ( + + + + ); } export default App; diff --git a/src/components/form/SignInForm.tsx b/src/components/form/SignInForm.tsx index 6d461429a..592dd4f6d 100644 --- a/src/components/form/SignInForm.tsx +++ b/src/components/form/SignInForm.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useContext } from "react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faEye, faEyeSlash } from "@fortawesome/free-regular-svg-icons"; import { loginSchema } from "../validation/login"; @@ -14,6 +14,7 @@ import { loginAction } from "../../redux/actions/login"; import { Token } from '../../utils/utils'; import { getUserbyFilter } from "../../redux/actions/users"; import jwtDecode from "jwt-decode"; +import { useNotifications } from "../../utils/Notifications"; const googleIcn: string = require("../../assets/assets/googleIcon.jpg").default; @@ -44,6 +45,7 @@ const LoginForm = () => { const [isNormalLogin, setIsNormalLogin] = useState(false); const navigate = useNavigate(); const location = useLocation(); + const { setUserId } = useNotifications(); const { register, @@ -93,6 +95,7 @@ const LoginForm = () => { localStorage.setItem("access_token", token); if (userId) { localStorage.setItem("userId", userId); + setUserId(userId); } await redirectAfterLogin(); } else { @@ -253,4 +256,4 @@ const LoginForm = () => { ); }; -export default LoginForm; \ No newline at end of file +export default LoginForm; diff --git a/src/components/sidebar/navHeader.tsx b/src/components/sidebar/navHeader.tsx index 3baacc6e9..578098cf4 100644 --- a/src/components/sidebar/navHeader.tsx +++ b/src/components/sidebar/navHeader.tsx @@ -1,5 +1,5 @@ import React, { useState } from "react"; -import { Link } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import Sidebar from "./sidebar"; import { SunIcon } from "@heroicons/react/outline"; import { MoonIcon } from "@heroicons/react/solid"; @@ -8,48 +8,89 @@ import { IoClose } from "react-icons/io5"; import * as icon from "react-icons/hi2"; import { AiOutlineBell } from "react-icons/ai"; import { useTheme } from "../../hooks/darkmode"; +import jwtDecode from "jwt-decode"; +import { useNotifications } from "../../utils/Notifications"; + const logo: string = require("../../assets/logo.svg").default; const profile: string = require("../../assets/avatar.png").default; const LogoWhite: string = require("../../assets/logoWhite.svg").default; -import jwtDecode from "jwt-decode"; import {destination} from '../../utils/utils' import SearchBar from "../../components/SearchBar"; const placeholderImage = profile; const onImageError = (e) => { - e.target.src = placeholderImage -} + e.target.src = placeholderImage; +}; + +const ThemeToggle = ({ theme, handleToggleTheme }) => ( +
+ {theme ? ( + + ) : ( + + )} +
+); + +const NotificationBell = ({ unreadCount, handleShowNotification }) => ( + + + {unreadCount > 0 && ( + + {unreadCount} + + )} + +); + +const ProfileSection = ({ user, onImageError, handleShowProfileDropdown }) => ( + + profile + +); function NavBar() { const userDestination = destination(); const access_token = localStorage.getItem("access_token"); //@ts-ignore const user = access_token ? jwtDecode(access_token).picture : profile; - const [showNotification, setShowNotification] = useState(false); - const [showProfileDropdown, setShowprofileDropdown] = useState(false); - const { theme, setTheme } = useTheme(); - function handleToggleTheme() { - setTheme(!theme); - } + const [nav, setNav] = useState(false); - const handleClick = () => setNav(!nav); - const handleShowNotification = () => setShowNotification(!showNotification); + const [showProfileDropdown, setShowProfileDropdown] = useState(false); + + const { theme, setTheme } = useTheme(); + const handleToggleTheme = () => setTheme(!theme); + + const navigate = useNavigate(); + const handleShowNotification = () => navigate("/applicant/notifications"); const handleShowProfileDropdown = () => - setShowprofileDropdown(!showProfileDropdown); + setShowProfileDropdown(!showProfileDropdown); + + const { unreadCount } = useNotifications(); + + const handleClick = () => setNav(!nav); return ( - -
+
{showProfileDropdown && ( )} -
-
+
+
{theme ? ( - + ) : ( logoWhite )} -

+

PULSE

@@ -92,38 +129,18 @@ function NavBar() { {/* End of Search Bar */}
- - {" "} - {/* */} - - -
- {theme ? ( - - ) : ( - - )} -
- - profile - + + +
- {/*
    - -
-
{}
*/}
); } diff --git a/src/components/sidebar/sidebarItems.tsx b/src/components/sidebar/sidebarItems.tsx index c85018f6d..2a8737b0d 100644 --- a/src/components/sidebar/sidebarItems.tsx +++ b/src/components/sidebar/sidebarItems.tsx @@ -79,11 +79,11 @@ export const applicantSidebarItems = [ ), title: "Schedule Interview", }, - { - path: "notifications", - icon: , - title: "Notifications", - }, + // { + // path: "notifications", + // icon: , + // title: "Notifications", + // }, { path: "calendar", icon: , diff --git a/src/pages/ApplicantNotifications/AppNotification.tsx b/src/pages/ApplicantNotifications/AppNotification.tsx new file mode 100644 index 000000000..60ed249c8 --- /dev/null +++ b/src/pages/ApplicantNotifications/AppNotification.tsx @@ -0,0 +1,200 @@ +import React, { useState } from "react"; +import { IoMdMailOpen, IoMdMailUnread } from "react-icons/io"; +import { useNotifications } from "../../utils/Notifications"; + +interface Notification { + id: string; + message: string; + read: boolean; + createdAt: string; +} + +function ApplicantNotifications() { + const [activeTab, setActiveTab] = useState("All"); + const [sortOrder, setSortOrder] = useState<"new" | "old">("new"); + const [searchQuery, setSearchQuery] = useState(""); + + const { notifications, markAsRead, markAsUnread, unreadCount } = + useNotifications(); + + const handleToggleRead = (notification: Notification) => + notification.read + ? markAsUnread(notification.id) + : markAsRead(notification.id); + + const handleSearchChange = (e: React.ChangeEvent) => + setSearchQuery(e.target.value); + + const filteredNotifications = filterNotifications( + notifications, + activeTab, + searchQuery + ); + const sortedNotifications = sortNotifications( + filteredNotifications, + sortOrder + ); + + return ( +
+
+ +
+ ); +} + +const filterNotifications = ( + notifications: Notification[], + activeTab: string, + searchQuery: string +) => + notifications.filter( + (notification) => + (activeTab === "Unread" ? !notification.read : true) && + notification.message.toLowerCase().includes(searchQuery.toLowerCase()) + ); + +const sortNotifications = ( + notifications: Notification[], + sortOrder: "new" | "old" +) => + [...notifications].sort((a, b) => + sortOrder === "new" + ? new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + : new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + ); + +const formatDate = (dateString: string) => + new Date(dateString).toLocaleString("en-US", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + +const Header = ({ + activeTab, + unreadCount, + onTabChange, + sortOrder, + onSortOrderChange, + searchQuery, + onSearchChange, +}: { + activeTab: string; + unreadCount: number; + onTabChange: (tab: string) => void; + sortOrder: "new" | "old"; + onSortOrderChange: (order: "new" | "old") => void; + searchQuery: string; + onSearchChange: (e: React.ChangeEvent) => void; +}) => ( +
+
+
+ + +
+
+ Order By: + +
+
+
+ +
+
+); + +const NotificationList = ({ + notifications, + onToggleRead, + formatDate, +}: { + notifications: Notification[]; + onToggleRead: (notification: Notification) => void; + formatDate: (dateString: string) => string; +}) => ( +
+ {notifications.length > 0 ? ( + notifications.map((notification) => ( +
+
+
+ {notification.message || "No message content available."} +
+
+
+
+ {formatDate(notification.createdAt) || "Date not available"} +
+ +
+
+ )) + ) : ( +
+ No notifications available. +
+ )} +
+); + +export default ApplicantNotifications; diff --git a/src/routes/routes.tsx b/src/routes/routes.tsx index bbf541989..0006edc91 100644 --- a/src/routes/routes.tsx +++ b/src/routes/routes.tsx @@ -49,6 +49,7 @@ import Dashboard from "../pages/Dashboard"; import ApplicantLayout from "../pages/Applicant/ApplicantLayout"; import AdminLayout from "../components/Layout/Admins/AdminLayout"; import GoogleSignup from "./../components/form/GoogleSignup"; +import ApplicantNotifications from "../pages/ApplicantNotifications/AppNotification"; import ApplicantDashboard from "../pages/Applicant/ApplicantDashboard"; import UpdateJobPost from "../pages/JobPost/updateJobPost"; import VerifyEmail from "../pages/verifyEmail"; @@ -68,10 +69,18 @@ function Navigation() { } /> }/> } /> - : - roleName === 'Applicant' ? : - } /> + + ) : roleName === 'Applicant' ? ( + + ) : ( + + ) + } + /> {/* Admin Routes (Protected) */} - + {/* Applicant Routes (Protected) */} + + + + } + /> + {/* + + + } + /> */} }> } /> - + } /> - + {/* Catch-All Route */} + } @@ -374,4 +399,4 @@ function Navigation() { ); } -export default Navigation; \ No newline at end of file +export default Navigation; diff --git a/src/utils/Notifications.tsx b/src/utils/Notifications.tsx new file mode 100644 index 000000000..c511e61fa --- /dev/null +++ b/src/utils/Notifications.tsx @@ -0,0 +1,98 @@ +import React, { createContext, useState, useContext } from "react"; +import { toast, ToastContainer } from "react-toastify"; +import "react-toastify/dist/ReactToastify.css"; +import { toastOptions } from "./toast"; +import { useFetchNotifications } from "./applicantNotifications/useFetchNotifications"; +import { usePusherNotifications } from "./applicantNotifications/usePusher"; +import { updateNotificationStatus } from "./applicantNotifications/NotificationService"; +import { Notification } from "./applicantNotifications/types"; + +interface NotificationContextProps { + notifications: Notification[]; + unreadCount: number; + markAsRead: (id: string) => void; + markAsUnread: (id: string) => void; + userId: string | null; + setUserId: (userId: string | null) => void; +} + +const NotificationContext = createContext( + undefined +); + +export const useNotifications = () => { + const context = useContext(NotificationContext); + if (!context) { + throw new Error( + "useNotifications must be used within a NotificationProvider" + ); + } + return context; +}; + +export const NotificationProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const { notifications, unreadCount, markAsRead, markAsUnread,userId, setUserId} = + useNotificationsState(); + + return ( + + {children} + + + ); +}; + +const useNotificationsState = () => { + const [notifications, setNotifications] = useState([]); + const [unreadCount, setUnreadCount] = useState(0); + const [userId, setUserId] = useState( + localStorage.getItem("userId") + ); + + + const addNotification = (notification: Notification) => { + setNotifications((prev) => [notification, ...prev]); + setUnreadCount((prev) => prev + 1); + toast.info(`New notification: ${notification.message}`, toastOptions); + }; + + useFetchNotifications(userId, setNotifications, setUnreadCount); + usePusherNotifications(userId, addNotification); + + const updateNotificationReadStatus = (id: string, read: boolean) => { + setNotifications((prev) => + prev.map((n) => (n.id === id ? { ...n, read } : n)) + ); + setUnreadCount((prev) => prev + (read ? -1 : 1)); + }; + + const markAsRead = async (id: string) => { + await updateNotificationStatus(id, true); + updateNotificationReadStatus(id, true); + }; + + const markAsUnread = async (id: string) => { + await updateNotificationStatus(id, false); + updateNotificationReadStatus(id, false); + }; + + return { + notifications, + unreadCount, + markAsRead, + markAsUnread, + userId, + setUserId + }; +}; diff --git a/src/utils/applicantNotifications/NotificationService.ts b/src/utils/applicantNotifications/NotificationService.ts new file mode 100644 index 000000000..2e3e57905 --- /dev/null +++ b/src/utils/applicantNotifications/NotificationService.ts @@ -0,0 +1,47 @@ +import { Notification } from "./types"; + +export const fetchNotifications = async ( + userId: string +): Promise => { + const response = await fetch(`${process.env.BACKEND_URL}/`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + query: `query GetNotifications($userId: ID!) { + getNotifications(userId: $userId) { + id + message + read + createdAt + } + }`, + variables: { userId }, + }), + }); + + const result = await response.json(); + return result.data?.getNotifications || []; +}; + +export const updateNotificationStatus = async (id: string, read: boolean) => { + const response = await fetch(`${process.env.BACKEND_URL}/`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + query: `mutation MarkNotificationAsRead($id: ID!) { + markNotificationAsRead(id: $id) { + id + read + } + }`, + variables: { id }, + }), + }); + + const result = await response.json(); + return result.data?.markNotificationAsRead.read; +}; diff --git a/src/utils/applicantNotifications/pusher.ts b/src/utils/applicantNotifications/pusher.ts new file mode 100644 index 000000000..b5a7723e5 --- /dev/null +++ b/src/utils/applicantNotifications/pusher.ts @@ -0,0 +1,23 @@ +import Pusher, { Channel } from "pusher-js"; +import { Notification } from "./types"; + +export const initializePusher = ( + userId: string, + onNewNotification: (notification: Notification) => void +): Channel => { + const pusher = new Pusher(process.env.PUSHER_KEY!, { + cluster: process.env.PUSHER_CLUSTER!, + }); + + const channel = pusher.subscribe(`notifications-${userId}`); + channel.bind("new-notification", onNewNotification); + + return channel; +}; + +export const unsubscribePusher = (channel: Channel | null) => { + if (channel) { + channel.unbind_all(); + channel.unsubscribe(); + } +}; diff --git a/src/utils/applicantNotifications/types.ts b/src/utils/applicantNotifications/types.ts new file mode 100644 index 000000000..f9184864f --- /dev/null +++ b/src/utils/applicantNotifications/types.ts @@ -0,0 +1,6 @@ +export interface Notification { + id: string; + message: string; + read: boolean; + createdAt: string; +} diff --git a/src/utils/applicantNotifications/useFetchNotifications.ts b/src/utils/applicantNotifications/useFetchNotifications.ts new file mode 100644 index 000000000..91965adae --- /dev/null +++ b/src/utils/applicantNotifications/useFetchNotifications.ts @@ -0,0 +1,21 @@ +import { useEffect } from "react"; +import { Notification } from "./types"; +import { fetchNotifications } from "./NotificationService"; + +export const useFetchNotifications = ( + userId: string | null, + setNotifications: (notifications: Notification[]) => void, + setUnreadCount: (count: number) => void +) => { + useEffect(() => { + if (userId) { + fetchAndSetNotifications(userId); + } + }, [userId]); + + const fetchAndSetNotifications = async (userId: string) => { + const fetchedNotifications = await fetchNotifications(userId); + setNotifications(fetchedNotifications); + setUnreadCount(fetchedNotifications.filter((n) => !n.read).length); + }; +}; diff --git a/src/utils/applicantNotifications/usePusher.ts b/src/utils/applicantNotifications/usePusher.ts new file mode 100644 index 000000000..31b2edb62 --- /dev/null +++ b/src/utils/applicantNotifications/usePusher.ts @@ -0,0 +1,22 @@ +import { useEffect, useState } from "react"; +import { initializePusher, unsubscribePusher } from "./pusher"; +import { Notification } from "./types"; +import { Channel } from "pusher-js"; + +export const usePusherNotifications = ( + userId: string | null, + addNotification: (notification: Notification) => void +) => { + const [channel, setChannel] = useState(null); + + useEffect(() => { + if (userId) { + const pusherChannel = initializePusher(userId, addNotification); + setChannel(pusherChannel); + } + + return () => unsubscribePusher(channel); + }, [userId]); + + return channel; +}; diff --git a/src/utils/toast.tsx b/src/utils/toast.tsx index be34ea852..57db9e74a 100644 --- a/src/utils/toast.tsx +++ b/src/utils/toast.tsx @@ -1,4 +1,4 @@ -import { toast } from 'react-toastify'; +import { toast, ToastOptions } from 'react-toastify'; export const showSuccessToast = (message: string) => { toast.success(message); @@ -6,4 +6,24 @@ export const showSuccessToast = (message: string) => { export const showErrorToast = (message: string) => { toast.error(message); -}; \ No newline at end of file +}; + +export const toastOptions: ToastOptions = { + position: "bottom-right", + autoClose: 5000, + hideProgressBar: true, + closeOnClick: true, + pauseOnHover: true, + draggable: true, + progress: undefined, + className: "small-toast", + bodyClassName: "small-toast-body", + style: { + background: "#01acf0", + color: "#ffffff", + borderRadius: "10px", + padding: "10px", + fontSize: "0.875rem", + boxShadow: "0px 4px 10px rgba(0, 0, 0, 0.1)", + }, +}; diff --git a/src/utils/utils.tsx b/src/utils/utils.tsx index f0d2a5031..c497d4b5b 100755 --- a/src/utils/utils.tsx +++ b/src/utils/utils.tsx @@ -31,6 +31,7 @@ export const Token = () => { if (data) { const roleName = data.checkUserRole?.roleName; localStorage.setItem('roleName', roleName); + localStorage.setItem('userId', decoded.data.userId); } }); return decoded;