Skip to content

Commit e40bbf3

Browse files
authored
Merge pull request #665 from Divineifed1/main
notifications
2 parents dff24d9 + a4cfb8c commit e40bbf3

File tree

4 files changed

+352
-3
lines changed

4 files changed

+352
-3
lines changed

frontend/app/(dashboard)/layout.tsx

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ import Link from "next/link";
44
import { usePathname } from "next/navigation";
55
import { cn } from "@/lib/utils";
66
import { ReactNode } from "react";
7+
import { useAuthStore } from "../../stores/auth.store";
8+
import { useShipmentSocket } from "../../hooks/useShipmentSocket";
9+
import { NotificationBell } from "../../components/notifications/notification-bell";
10+
11+
interface DashboardLayoutProps {
12+
children: ReactNode;
13+
}
714

815
const SHIPPER_NAV = [
916
{ href: '/dashboard', label: 'Dashboard' },
@@ -26,18 +33,37 @@ const ADMIN_NAV = [
2633
{ href: '/admin/shipments', label: 'Shipment Oversight' },
2734
];
2835

36+
interface DashboardLayoutProps {
37+
children: ReactNode;
38+
}
39+
2940
export default function DashboardLayout({ children }: DashboardLayoutProps) {
3041
const pathname = usePathname();
42+
const user = useAuthStore((state) => state.user);
43+
44+
// Initialize the WebSocket connection for real-time shipment updates
45+
useShipmentSocket();
46+
47+
// Determine nav items based on user role
48+
const getNavItems = () => {
49+
if (!user) return SHIPPER_NAV;
50+
if (user.role === 'admin') return ADMIN_NAV;
51+
if (user.role === 'carrier') return CARRIER_NAV;
52+
return SHIPPER_NAV;
53+
};
54+
55+
const navItems = getNavItems();
3156

3257
return (
3358
<div className="flex h-screen">
3459
{/* Sidebar */}
3560
<aside className="w-64 border-r bg-card flex flex-col">
36-
<div className="h-16 flex items-center gap-2 px-6 border-b">
37-
<div className="h-7 w-7 rounded-lg bg-primary flex items-center justify-center">
61+
<div className="h-16 flex items-center gap-2 px-4 border-b">
62+
<div className="h-7 w-7 rounded-lg bg-primary flex items-center justify-center flex-shrink-0">
3863
<span className="text-primary-foreground font-bold text-xs">FF</span>
3964
</div>
40-
<span className="font-bold text-foreground">FreightFlow</span>
65+
<span className="font-bold text-foreground flex-1">FreightFlow</span>
66+
<NotificationBell />
4167
</div>
4268

4369
<nav className="flex-1 p-4 space-y-1">
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import { useNotificationStore, type ShipmentNotification } from '../../stores/notification.store';
5+
import { cn } from '@/lib/utils';
6+
import { ShipmentStatus } from '@/types/shipment.types';
7+
8+
// Human-readable status labels
9+
const getStatusLabel = (status: ShipmentStatus): string => {
10+
const labels: Record<ShipmentStatus, string> = {
11+
[ShipmentStatus.PENDING]: 'Pending',
12+
[ShipmentStatus.ACCEPTED]: 'Accepted',
13+
[ShipmentStatus.IN_TRANSIT]: 'In Transit',
14+
[ShipmentStatus.DELIVERED]: 'Delivered',
15+
[ShipmentStatus.COMPLETED]: 'Completed',
16+
[ShipmentStatus.CANCELLED]: 'Cancelled',
17+
[ShipmentStatus.DISPUTED]: 'Disputed',
18+
};
19+
return labels[status] || status;
20+
};
21+
22+
// Format relative time
23+
const formatRelativeTime = (dateString: string): string => {
24+
const date = new Date(dateString);
25+
const now = new Date();
26+
const diffMs = now.getTime() - date.getTime();
27+
const diffMins = Math.floor(diffMs / 60000);
28+
const diffHours = Math.floor(diffMs / 3600000);
29+
const diffDays = Math.floor(diffMs / 86400000);
30+
31+
if (diffMins < 1) return 'Just now';
32+
if (diffMins < 60) return `${diffMins}m ago`;
33+
if (diffHours < 24) return `${diffHours}h ago`;
34+
if (diffDays < 7) return `${diffDays}d ago`;
35+
return date.toLocaleDateString();
36+
};
37+
38+
export function NotificationBell() {
39+
const [isOpen, setIsOpen] = useState(false);
40+
const { notifications, unreadCount, markAllAsRead } = useNotificationStore();
41+
42+
const handleToggle = () => {
43+
if (!isOpen && unreadCount > 0) {
44+
markAllAsRead();
45+
}
46+
setIsOpen(!isOpen);
47+
};
48+
49+
const displayCount = unreadCount > 9 ? '9+' : unreadCount.toString();
50+
51+
return (
52+
<div className="relative">
53+
{/* Bell Icon Button */}
54+
<button
55+
onClick={handleToggle}
56+
className="relative p-2 rounded-md hover:bg-accent transition-colors"
57+
aria-label="Notifications"
58+
>
59+
<svg
60+
xmlns="http://www.w3.org/2000/svg"
61+
width="20"
62+
height="20"
63+
viewBox="0 0 24 24"
64+
fill="none"
65+
stroke="currentColor"
66+
strokeWidth="2"
67+
strokeLinecap="round"
68+
strokeLinejoin="round"
69+
className="text-foreground"
70+
>
71+
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
72+
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
73+
</svg>
74+
{unreadCount > 0 && (
75+
<span className="absolute -top-1 -right-1 min-w-[18px] h-[18px] flex items-center justify-center bg-red-500 text-white text-xs font-bold rounded-full px-1">
76+
{displayCount}
77+
</span>
78+
)}
79+
</button>
80+
81+
{/* Dropdown */}
82+
{isOpen && (
83+
<>
84+
{/* Backdrop */}
85+
<div
86+
className="fixed inset-0 z-40"
87+
onClick={() => setIsOpen(false)}
88+
/>
89+
90+
{/* Dropdown Panel */}
91+
<div className="absolute right-0 top-full mt-2 w-80 bg-background border rounded-lg shadow-lg z-50 overflow-hidden">
92+
<div className="p-3 border-b">
93+
<h3 className="font-semibold text-sm">Notifications</h3>
94+
</div>
95+
96+
<div className="max-h-96 overflow-y-auto">
97+
{notifications.length === 0 ? (
98+
<div className="p-4 text-center text-muted-foreground text-sm">
99+
No notifications yet
100+
</div>
101+
) : (
102+
<ul className="divide-y">
103+
{notifications.slice(0, 20).map((notification) => (
104+
<NotificationItem
105+
key={notification.id}
106+
notification={notification}
107+
/>
108+
))}
109+
</ul>
110+
)}
111+
</div>
112+
</div>
113+
</>
114+
)}
115+
</div>
116+
);
117+
}
118+
119+
function NotificationItem({ notification }: { notification: ShipmentNotification }) {
120+
const statusColor = {
121+
[ShipmentStatus.PENDING]: 'bg-yellow-500',
122+
[ShipmentStatus.ACCEPTED]: 'bg-blue-500',
123+
[ShipmentStatus.IN_TRANSIT]: 'bg-orange-500',
124+
[ShipmentStatus.DELIVERED]: 'bg-green-500',
125+
[ShipmentStatus.COMPLETED]: 'bg-green-700',
126+
[ShipmentStatus.CANCELLED]: 'bg-red-500',
127+
[ShipmentStatus.DISPUTED]: 'bg-red-700',
128+
}[notification.status] || 'bg-gray-500';
129+
130+
return (
131+
<li className={cn('p-3 hover:bg-accent/50 transition-colors', !notification.read && 'bg-primary/5')}>
132+
<div className="flex items-start gap-3">
133+
<div className={cn('w-2 h-2 rounded-full mt-2 flex-shrink-0', statusColor)} />
134+
<div className="flex-1 min-w-0">
135+
<p className="text-sm font-medium truncate">
136+
{getStatusLabel(notification.status)}
137+
</p>
138+
<p className="text-xs text-muted-foreground truncate">
139+
{notification.origin}{notification.destination}
140+
</p>
141+
<div className="flex items-center justify-between mt-1">
142+
<span className="text-xs text-muted-foreground">
143+
{notification.trackingNumber}
144+
</span>
145+
<span className="text-xs text-muted-foreground">
146+
{formatRelativeTime(notification.updatedAt)}
147+
</span>
148+
</div>
149+
</div>
150+
</div>
151+
</li>
152+
);
153+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
'use client';
2+
3+
import { useEffect, useRef } from 'react';
4+
import { useAuthStore } from '../stores/auth.store';
5+
import { useNotificationStore, type ShipmentNotification } from '../stores/notification.store';
6+
import { connectSocket, disconnectSocket } from '../lib/socket';
7+
import { toast } from 'sonner';
8+
import { ShipmentStatus } from '../types/shipment.types';
9+
10+
// Standard status transitions that warrant a toast notification
11+
const STANDARD_STATUSES: ShipmentStatus[] = [
12+
ShipmentStatus.PENDING,
13+
ShipmentStatus.ACCEPTED,
14+
ShipmentStatus.IN_TRANSIT,
15+
ShipmentStatus.DELIVERED,
16+
];
17+
18+
interface ShipmentUpdatedPayload {
19+
event: string;
20+
shipmentId: string;
21+
trackingNumber: string;
22+
status: ShipmentStatus;
23+
origin: string;
24+
destination: string;
25+
updatedAt: string;
26+
}
27+
28+
// Helper to get human-readable status message
29+
const getStatusMessage = (status: ShipmentStatus): string => {
30+
const messages: Record<ShipmentStatus, string> = {
31+
[ShipmentStatus.PENDING]: 'Shipment created - awaiting carrier',
32+
[ShipmentStatus.ACCEPTED]: 'Shipment accepted by carrier',
33+
[ShipmentStatus.IN_TRANSIT]: 'Shipment is in transit',
34+
[ShipmentStatus.DELIVERED]: 'Shipment delivered',
35+
[ShipmentStatus.COMPLETED]: 'Shipment completed',
36+
[ShipmentStatus.CANCELLED]: 'Shipment cancelled',
37+
[ShipmentStatus.DISPUTED]: 'Shipment has a dispute',
38+
};
39+
return messages[status] || `Status updated to ${status}`;
40+
};
41+
42+
export function useShipmentSocket() {
43+
const user = useAuthStore((state) => state.user);
44+
const addNotification = useNotificationStore((state) => state.addNotification);
45+
const socketRef = useRef<ReturnType<typeof connectSocket> | null>(null);
46+
const isConnectedRef = useRef(false);
47+
48+
useEffect(() => {
49+
// When user is set, connect the socket
50+
if (user && user.accessToken) {
51+
if (!isConnectedRef.current) {
52+
const socket = connectSocket(user.accessToken);
53+
socketRef.current = socket;
54+
isConnectedRef.current = true;
55+
56+
// Attach the shipment:updated listener
57+
socket.on('shipment:updated', (payload: ShipmentUpdatedPayload) => {
58+
// Add to notification store
59+
const notification: Omit<ShipmentNotification, 'id' | 'read'> = {
60+
event: payload.event,
61+
shipmentId: payload.shipmentId,
62+
trackingNumber: payload.trackingNumber,
63+
status: payload.status,
64+
origin: payload.origin,
65+
destination: payload.destination,
66+
updatedAt: payload.updatedAt,
67+
};
68+
addNotification(notification);
69+
70+
// Show toast for standard transitions
71+
if (STANDARD_STATUSES.includes(payload.status)) {
72+
toast.info(getStatusMessage(payload.status), {
73+
description: `Tracking: ${payload.trackingNumber}`,
74+
duration: 5000,
75+
});
76+
}
77+
});
78+
}
79+
} else if (!user && isConnectedRef.current) {
80+
// When user becomes null (logout), disconnect
81+
if (socketRef.current) {
82+
socketRef.current.off('shipment:updated');
83+
socketRef.current = null;
84+
}
85+
disconnectSocket();
86+
isConnectedRef.current = false;
87+
}
88+
}, [user, addNotification]);
89+
90+
// Cleanup on unmount
91+
useEffect(() => {
92+
return () => {
93+
if (socketRef.current) {
94+
socketRef.current.off('shipment:updated');
95+
socketRef.current = null;
96+
}
97+
disconnectSocket();
98+
isConnectedRef.current = false;
99+
};
100+
}, []);
101+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
'use client';
2+
3+
import { create } from 'zustand';
4+
import type { ShipmentStatus } from '../types/shipment.types';
5+
6+
export interface ShipmentNotification {
7+
id: string;
8+
event: string;
9+
shipmentId: string;
10+
trackingNumber: string;
11+
status: ShipmentStatus;
12+
origin: string;
13+
destination: string;
14+
updatedAt: string;
15+
read: boolean;
16+
}
17+
18+
interface NotificationState {
19+
notifications: ShipmentNotification[];
20+
unreadCount: number;
21+
addNotification: (notification: Omit<ShipmentNotification, 'id' | 'read'>) => void;
22+
markAllAsRead: () => void;
23+
markAsRead: (id: string) => void;
24+
clearNotifications: () => void;
25+
}
26+
27+
const generateId = (): string => {
28+
return `notif-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
29+
};
30+
31+
export const useNotificationStore = create<NotificationState>((set) => ({
32+
notifications: [],
33+
unreadCount: 0,
34+
35+
addNotification: (notification) =>
36+
set((state) => {
37+
const newNotification: ShipmentNotification = {
38+
...notification,
39+
id: generateId(),
40+
read: false,
41+
};
42+
// Keep only the last 20 notifications
43+
const updatedNotifications = [newNotification, ...state.notifications].slice(0, 20);
44+
return {
45+
notifications: updatedNotifications,
46+
unreadCount: state.unreadCount + 1,
47+
};
48+
}),
49+
50+
markAllAsRead: () =>
51+
set((state) => ({
52+
notifications: state.notifications.map((n) => ({ ...n, read: true })),
53+
unreadCount: 0,
54+
})),
55+
56+
markAsRead: (id) =>
57+
set((state) => {
58+
const notification = state.notifications.find((n) => n.id === id);
59+
if (!notification || notification.read) return state;
60+
return {
61+
notifications: state.notifications.map((n) =>
62+
n.id === id ? { ...n, read: true } : n
63+
),
64+
unreadCount: Math.max(0, state.unreadCount - 1),
65+
};
66+
}),
67+
68+
clearNotifications: () => set({ notifications: [], unreadCount: 0 }),
69+
}));

0 commit comments

Comments
 (0)