+ {/* Bell Icon Button */}
+
+
+ {/* Dropdown */}
+ {isOpen && (
+ <>
+ {/* Backdrop */}
+
setIsOpen(false)}
+ />
+
+ {/* Dropdown Panel */}
+
+
+
Notifications
+
+
+
+ {notifications.length === 0 ? (
+
+ No notifications yet
+
+ ) : (
+
+ {notifications.slice(0, 20).map((notification) => (
+
+ ))}
+
+ )}
+
+
+ >
+ )}
+
+ );
+}
+
+function NotificationItem({ notification }: { notification: ShipmentNotification }) {
+ const statusColor = {
+ [ShipmentStatus.PENDING]: 'bg-yellow-500',
+ [ShipmentStatus.ACCEPTED]: 'bg-blue-500',
+ [ShipmentStatus.IN_TRANSIT]: 'bg-orange-500',
+ [ShipmentStatus.DELIVERED]: 'bg-green-500',
+ [ShipmentStatus.COMPLETED]: 'bg-green-700',
+ [ShipmentStatus.CANCELLED]: 'bg-red-500',
+ [ShipmentStatus.DISPUTED]: 'bg-red-700',
+ }[notification.status] || 'bg-gray-500';
+
+ return (
+
+
+
+
+
+ {getStatusLabel(notification.status)}
+
+
+ {notification.origin} → {notification.destination}
+
+
+
+ {notification.trackingNumber}
+
+
+ {formatRelativeTime(notification.updatedAt)}
+
+
+
+
+
+ );
+}
diff --git a/frontend/hooks/useShipmentSocket.ts b/frontend/hooks/useShipmentSocket.ts
new file mode 100644
index 00000000..8e3b2b5f
--- /dev/null
+++ b/frontend/hooks/useShipmentSocket.ts
@@ -0,0 +1,101 @@
+'use client';
+
+import { useEffect, useRef } from 'react';
+import { useAuthStore } from '../stores/auth.store';
+import { useNotificationStore, type ShipmentNotification } from '../stores/notification.store';
+import { connectSocket, disconnectSocket } from '../lib/socket';
+import { toast } from 'sonner';
+import { ShipmentStatus } from '../types/shipment.types';
+
+// Standard status transitions that warrant a toast notification
+const STANDARD_STATUSES: ShipmentStatus[] = [
+ ShipmentStatus.PENDING,
+ ShipmentStatus.ACCEPTED,
+ ShipmentStatus.IN_TRANSIT,
+ ShipmentStatus.DELIVERED,
+];
+
+interface ShipmentUpdatedPayload {
+ event: string;
+ shipmentId: string;
+ trackingNumber: string;
+ status: ShipmentStatus;
+ origin: string;
+ destination: string;
+ updatedAt: string;
+}
+
+// Helper to get human-readable status message
+const getStatusMessage = (status: ShipmentStatus): string => {
+ const messages: Record
= {
+ [ShipmentStatus.PENDING]: 'Shipment created - awaiting carrier',
+ [ShipmentStatus.ACCEPTED]: 'Shipment accepted by carrier',
+ [ShipmentStatus.IN_TRANSIT]: 'Shipment is in transit',
+ [ShipmentStatus.DELIVERED]: 'Shipment delivered',
+ [ShipmentStatus.COMPLETED]: 'Shipment completed',
+ [ShipmentStatus.CANCELLED]: 'Shipment cancelled',
+ [ShipmentStatus.DISPUTED]: 'Shipment has a dispute',
+ };
+ return messages[status] || `Status updated to ${status}`;
+};
+
+export function useShipmentSocket() {
+ const user = useAuthStore((state) => state.user);
+ const addNotification = useNotificationStore((state) => state.addNotification);
+ const socketRef = useRef | null>(null);
+ const isConnectedRef = useRef(false);
+
+ useEffect(() => {
+ // When user is set, connect the socket
+ if (user && user.accessToken) {
+ if (!isConnectedRef.current) {
+ const socket = connectSocket(user.accessToken);
+ socketRef.current = socket;
+ isConnectedRef.current = true;
+
+ // Attach the shipment:updated listener
+ socket.on('shipment:updated', (payload: ShipmentUpdatedPayload) => {
+ // Add to notification store
+ const notification: Omit = {
+ event: payload.event,
+ shipmentId: payload.shipmentId,
+ trackingNumber: payload.trackingNumber,
+ status: payload.status,
+ origin: payload.origin,
+ destination: payload.destination,
+ updatedAt: payload.updatedAt,
+ };
+ addNotification(notification);
+
+ // Show toast for standard transitions
+ if (STANDARD_STATUSES.includes(payload.status)) {
+ toast.info(getStatusMessage(payload.status), {
+ description: `Tracking: ${payload.trackingNumber}`,
+ duration: 5000,
+ });
+ }
+ });
+ }
+ } else if (!user && isConnectedRef.current) {
+ // When user becomes null (logout), disconnect
+ if (socketRef.current) {
+ socketRef.current.off('shipment:updated');
+ socketRef.current = null;
+ }
+ disconnectSocket();
+ isConnectedRef.current = false;
+ }
+ }, [user, addNotification]);
+
+ // Cleanup on unmount
+ useEffect(() => {
+ return () => {
+ if (socketRef.current) {
+ socketRef.current.off('shipment:updated');
+ socketRef.current = null;
+ }
+ disconnectSocket();
+ isConnectedRef.current = false;
+ };
+ }, []);
+}
diff --git a/frontend/stores/notification.store.ts b/frontend/stores/notification.store.ts
new file mode 100644
index 00000000..79cdde76
--- /dev/null
+++ b/frontend/stores/notification.store.ts
@@ -0,0 +1,69 @@
+'use client';
+
+import { create } from 'zustand';
+import type { ShipmentStatus } from '../types/shipment.types';
+
+export interface ShipmentNotification {
+ id: string;
+ event: string;
+ shipmentId: string;
+ trackingNumber: string;
+ status: ShipmentStatus;
+ origin: string;
+ destination: string;
+ updatedAt: string;
+ read: boolean;
+}
+
+interface NotificationState {
+ notifications: ShipmentNotification[];
+ unreadCount: number;
+ addNotification: (notification: Omit) => void;
+ markAllAsRead: () => void;
+ markAsRead: (id: string) => void;
+ clearNotifications: () => void;
+}
+
+const generateId = (): string => {
+ return `notif-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
+};
+
+export const useNotificationStore = create((set) => ({
+ notifications: [],
+ unreadCount: 0,
+
+ addNotification: (notification) =>
+ set((state) => {
+ const newNotification: ShipmentNotification = {
+ ...notification,
+ id: generateId(),
+ read: false,
+ };
+ // Keep only the last 20 notifications
+ const updatedNotifications = [newNotification, ...state.notifications].slice(0, 20);
+ return {
+ notifications: updatedNotifications,
+ unreadCount: state.unreadCount + 1,
+ };
+ }),
+
+ markAllAsRead: () =>
+ set((state) => ({
+ notifications: state.notifications.map((n) => ({ ...n, read: true })),
+ unreadCount: 0,
+ })),
+
+ markAsRead: (id) =>
+ set((state) => {
+ const notification = state.notifications.find((n) => n.id === id);
+ if (!notification || notification.read) return state;
+ return {
+ notifications: state.notifications.map((n) =>
+ n.id === id ? { ...n, read: true } : n
+ ),
+ unreadCount: Math.max(0, state.unreadCount - 1),
+ };
+ }),
+
+ clearNotifications: () => set({ notifications: [], unreadCount: 0 }),
+}));