Skip to content

Commit 56c2b67

Browse files
committed
feat: add NotificationBell component to display user notifications in the header, enhancing user experience with real-time updates
1 parent 02b3ccb commit 56c2b67

File tree

2 files changed

+142
-0
lines changed

2 files changed

+142
-0
lines changed
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { Bell } from 'lucide-react';
2+
import {
3+
DropdownMenu,
4+
DropdownMenuContent,
5+
DropdownMenuLabel,
6+
DropdownMenuSeparator,
7+
DropdownMenuTrigger,
8+
} from '@shared/components/ui/dropdown-menu';
9+
import { Button } from '@shared/components/ui/button';
10+
import { Separator } from '@shared/components/ui/separator';
11+
import { useNotifications } from '../hooks/useNotifications';
12+
import type { NotificationItem } from '@shared/types/notification.types';
13+
14+
const formatTimestamp = (timestamp: number) => {
15+
try {
16+
return new Intl.DateTimeFormat(undefined, {
17+
month: 'short',
18+
day: 'numeric',
19+
hour: '2-digit',
20+
minute: '2-digit',
21+
}).format(new Date(timestamp));
22+
} catch (err) {
23+
return '';
24+
}
25+
};
26+
27+
const NotificationListItem = ({
28+
notification,
29+
onClick,
30+
}: {
31+
notification: NotificationItem;
32+
onClick: (notification: NotificationItem) => void;
33+
}) => {
34+
const isUnread = !notification.read;
35+
36+
return (
37+
<button
38+
type="button"
39+
onClick={() => onClick(notification)}
40+
className={`flex w-full gap-3 px-4 py-3 text-left transition-colors hover:bg-muted ${
41+
isUnread ? 'bg-muted/60' : ''
42+
}`}
43+
>
44+
<span
45+
className={`mt-1 h-2 w-2 rounded-full ${isUnread ? 'bg-red-500' : 'bg-transparent'}`}
46+
aria-hidden
47+
/>
48+
<div className="flex-1 min-w-0">
49+
<div className="flex items-center justify-between gap-2">
50+
<span className="font-medium line-clamp-1">{notification.title}</span>
51+
<span className="text-xs text-muted-foreground whitespace-nowrap">
52+
{formatTimestamp(Number(notification.timestamp))}
53+
</span>
54+
</div>
55+
<p className="text-sm text-muted-foreground line-clamp-2">{notification.message}</p>
56+
</div>
57+
</button>
58+
);
59+
};
60+
61+
export const NotificationBell = () => {
62+
const {
63+
notifications,
64+
hasUnread,
65+
unreadCount,
66+
isOpen,
67+
setIsOpen,
68+
isLoading,
69+
handleNotificationClick,
70+
} = useNotifications();
71+
72+
const renderList = () => {
73+
if (isLoading) {
74+
return <div className="p-4 text-sm text-muted-foreground">Loading notifications...</div>;
75+
}
76+
77+
if (notifications.length === 0) {
78+
return (
79+
<div className="p-4 text-sm text-muted-foreground">
80+
You&apos;re all caught up. No notifications yet.
81+
</div>
82+
);
83+
}
84+
85+
return (
86+
<div className="max-h-96 overflow-y-auto">
87+
{notifications.map((notification) => (
88+
<NotificationListItem
89+
key={`${notification.id ?? 'broadcast'}-${notification.timestamp}-${notification.title}`}
90+
notification={notification}
91+
onClick={handleNotificationClick}
92+
/>
93+
))}
94+
</div>
95+
);
96+
};
97+
98+
return (
99+
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
100+
<DropdownMenuTrigger asChild>
101+
<Button
102+
variant="ghost"
103+
size="icon"
104+
className="relative"
105+
aria-label="Notifications"
106+
aria-haspopup="menu"
107+
>
108+
<Bell className="h-5 w-5" />
109+
{hasUnread && (
110+
<span className="absolute -top-0.5 -right-0.5 h-2.5 w-2.5 rounded-full bg-red-500" />
111+
)}
112+
</Button>
113+
</DropdownMenuTrigger>
114+
<DropdownMenuContent align="end" className="w-80 p-0">
115+
<DropdownMenuLabel className="flex items-center justify-between px-3 py-2">
116+
<span className="font-semibold">Notifications</span>
117+
{hasUnread ? (
118+
<span className="text-xs text-destructive">{unreadCount} unread</span>
119+
) : (
120+
<span className="text-xs text-muted-foreground">Up to date</span>
121+
)}
122+
</DropdownMenuLabel>
123+
<DropdownMenuSeparator />
124+
{renderList()}
125+
<Separator />
126+
<div className="px-3 py-2 text-xs text-muted-foreground">
127+
Newest notifications appear first.
128+
</div>
129+
</DropdownMenuContent>
130+
</DropdownMenu>
131+
);
132+
};
133+
134+
export default NotificationBell;
135+

apps/jobboard-frontend/src/modules/shared/components/layout/Header.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { ThemeToggle } from '../common/ThemeToggle';
2121
import { LanguageSwitcher } from '../common/LanguageSwitcher';
2222
import { useAuth, useAuthActions } from '@shared/stores/authStore';
2323
import { Avatar, AvatarFallback, AvatarImage } from '@shared/components/ui/avatar';
24+
import NotificationBell from '@modules/notifications/components/NotificationBell';
2425

2526
type Role = 'ROLE_EMPLOYER' | 'ROLE_JOBSEEKER';
2627

@@ -175,6 +176,7 @@ export default function Header() {
175176

176177
{/* Desktop actions */}
177178
<div className="hidden md:flex items-center gap-2">
179+
{isAuthenticated && <NotificationBell />}
178180
<DropdownMenu>
179181
<DropdownMenuTrigger asChild>
180182
<Button variant="ghost" size="icon" aria-label={t('layout.header.nav.settings', 'Settings')}>
@@ -241,6 +243,11 @@ export default function Header() {
241243
</SheetHeader>
242244

243245
<div className="mt-4 flex flex-col gap-3" role="navigation" aria-label="Mobile navigation">
246+
{isAuthenticated && (
247+
<div className="flex items-center justify-start">
248+
<NotificationBell />
249+
</div>
250+
)}
244251
{filteredSections.map((section) => {
245252
const isOpen = openMobileSection === section.key;
246253
return (

0 commit comments

Comments
 (0)