Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions frontend/app/(dashboard)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import { ReactNode } from "react";
import { useAuthStore } from "../../stores/auth.store";
import { useShipmentSocket } from "../../hooks/useShipmentSocket";
import { NotificationBell } from "../../components/notifications/notification-bell";

interface DashboardLayoutProps {
children: ReactNode;
}

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

interface DashboardLayoutProps {
children: ReactNode;
}

export default function DashboardLayout({ children }: DashboardLayoutProps) {
const pathname = usePathname();
const user = useAuthStore((state) => state.user);

// Initialize the WebSocket connection for real-time shipment updates
useShipmentSocket();

// Determine nav items based on user role
const getNavItems = () => {
if (!user) return SHIPPER_NAV;
if (user.role === 'admin') return ADMIN_NAV;
if (user.role === 'carrier') return CARRIER_NAV;
return SHIPPER_NAV;
};

const navItems = getNavItems();

return (
<div className="flex h-screen">
{/* Sidebar */}
<aside className="w-64 border-r bg-card flex flex-col">
<div className="h-16 flex items-center gap-2 px-6 border-b">
<div className="h-7 w-7 rounded-lg bg-primary flex items-center justify-center">
<div className="h-16 flex items-center gap-2 px-4 border-b">
<div className="h-7 w-7 rounded-lg bg-primary flex items-center justify-center flex-shrink-0">
<span className="text-primary-foreground font-bold text-xs">FF</span>
</div>
<span className="font-bold text-foreground">FreightFlow</span>
<span className="font-bold text-foreground flex-1">FreightFlow</span>
<NotificationBell />
</div>

<nav className="flex-1 p-4 space-y-1">
Expand Down
153 changes: 153 additions & 0 deletions frontend/components/notifications/notification-bell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
'use client';

import { useState } from 'react';
import { useNotificationStore, type ShipmentNotification } from '../../stores/notification.store';
import { cn } from '@/lib/utils';
import { ShipmentStatus } from '@/types/shipment.types';

// Human-readable status labels
const getStatusLabel = (status: ShipmentStatus): string => {
const labels: Record<ShipmentStatus, string> = {
[ShipmentStatus.PENDING]: 'Pending',
[ShipmentStatus.ACCEPTED]: 'Accepted',
[ShipmentStatus.IN_TRANSIT]: 'In Transit',
[ShipmentStatus.DELIVERED]: 'Delivered',
[ShipmentStatus.COMPLETED]: 'Completed',
[ShipmentStatus.CANCELLED]: 'Cancelled',
[ShipmentStatus.DISPUTED]: 'Disputed',
};
return labels[status] || status;
};

// Format relative time
const formatRelativeTime = (dateString: string): string => {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);

if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
};

export function NotificationBell() {
const [isOpen, setIsOpen] = useState(false);
const { notifications, unreadCount, markAllAsRead } = useNotificationStore();

const handleToggle = () => {
if (!isOpen && unreadCount > 0) {
markAllAsRead();
}
setIsOpen(!isOpen);
};

const displayCount = unreadCount > 9 ? '9+' : unreadCount.toString();

return (
<div className="relative">
{/* Bell Icon Button */}
<button
onClick={handleToggle}
className="relative p-2 rounded-md hover:bg-accent transition-colors"
aria-label="Notifications"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-foreground"
>
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
</svg>
{unreadCount > 0 && (
<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">
{displayCount}
</span>
)}
</button>

{/* Dropdown */}
{isOpen && (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-40"
onClick={() => setIsOpen(false)}
/>

{/* Dropdown Panel */}
<div className="absolute right-0 top-full mt-2 w-80 bg-background border rounded-lg shadow-lg z-50 overflow-hidden">
<div className="p-3 border-b">
<h3 className="font-semibold text-sm">Notifications</h3>
</div>

<div className="max-h-96 overflow-y-auto">
{notifications.length === 0 ? (
<div className="p-4 text-center text-muted-foreground text-sm">
No notifications yet
</div>
) : (
<ul className="divide-y">
{notifications.slice(0, 20).map((notification) => (
<NotificationItem
key={notification.id}
notification={notification}
/>
))}
</ul>
)}
</div>
</div>
</>
)}
</div>
);
}

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 (
<li className={cn('p-3 hover:bg-accent/50 transition-colors', !notification.read && 'bg-primary/5')}>
<div className="flex items-start gap-3">
<div className={cn('w-2 h-2 rounded-full mt-2 flex-shrink-0', statusColor)} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{getStatusLabel(notification.status)}
</p>
<p className="text-xs text-muted-foreground truncate">
{notification.origin} → {notification.destination}
</p>
<div className="flex items-center justify-between mt-1">
<span className="text-xs text-muted-foreground">
{notification.trackingNumber}
</span>
<span className="text-xs text-muted-foreground">
{formatRelativeTime(notification.updatedAt)}
</span>
</div>
</div>
</div>
</li>
);
}
101 changes: 101 additions & 0 deletions frontend/hooks/useShipmentSocket.ts
Original file line number Diff line number Diff line change
@@ -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, string> = {
[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<ReturnType<typeof connectSocket> | 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<ShipmentNotification, 'id' | 'read'> = {
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;
};
}, []);
}
69 changes: 69 additions & 0 deletions frontend/stores/notification.store.ts
Original file line number Diff line number Diff line change
@@ -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<ShipmentNotification, 'id' | 'read'>) => 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<NotificationState>((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 }),
}));
Loading