392 lines
16 KiB
TypeScript
392 lines
16 KiB
TypeScript
/**
|
|
* Notifications Page
|
|
*
|
|
* Full page view for managing all user notifications
|
|
*/
|
|
|
|
'use client';
|
|
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { useState } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import {
|
|
listNotifications,
|
|
markNotificationAsRead,
|
|
markAllNotificationsAsRead,
|
|
deleteNotification,
|
|
} from '@/lib/api';
|
|
import type { NotificationResponse } from '@/types/api';
|
|
import { Trash2, CheckCheck, Filter, Bell, ChevronLeft, ChevronRight } from 'lucide-react';
|
|
|
|
export default function NotificationsPage() {
|
|
const [selectedFilter, setSelectedFilter] = useState<'all' | 'unread' | 'read'>('all');
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const queryClient = useQueryClient();
|
|
const router = useRouter();
|
|
|
|
// Fetch notifications with pagination
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: ['notifications', 'page', selectedFilter, currentPage],
|
|
queryFn: () =>
|
|
listNotifications({
|
|
page: currentPage,
|
|
limit: 20,
|
|
isRead: selectedFilter === 'all' ? undefined : selectedFilter === 'read',
|
|
}),
|
|
});
|
|
|
|
const notifications = data?.notifications || [];
|
|
const total = data?.total || 0;
|
|
const totalPages = Math.ceil(total / 20);
|
|
const unreadCount = notifications.filter((n: NotificationResponse) => !n.read).length;
|
|
|
|
// Mark single notification as read
|
|
const markAsReadMutation = useMutation({
|
|
mutationFn: markNotificationAsRead,
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
|
},
|
|
});
|
|
|
|
// Mark all as read
|
|
const markAllAsReadMutation = useMutation({
|
|
mutationFn: markAllNotificationsAsRead,
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
|
},
|
|
});
|
|
|
|
// Delete notification
|
|
const deleteNotificationMutation = useMutation({
|
|
mutationFn: deleteNotification,
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
|
},
|
|
});
|
|
|
|
const handleNotificationClick = (notification: NotificationResponse) => {
|
|
if (!notification.read) {
|
|
markAsReadMutation.mutate(notification.id);
|
|
}
|
|
|
|
// Navigate to actionUrl if available
|
|
if (notification.actionUrl) {
|
|
router.push(notification.actionUrl);
|
|
}
|
|
};
|
|
|
|
const handleDelete = (e: React.MouseEvent, notificationId: string) => {
|
|
e.stopPropagation();
|
|
if (confirm('Are you sure you want to delete this notification?')) {
|
|
deleteNotificationMutation.mutate(notificationId);
|
|
}
|
|
};
|
|
|
|
const getPriorityColor = (priority: string) => {
|
|
const colors = {
|
|
urgent: 'border-l-4 border-red-500 bg-red-50 hover:bg-red-100',
|
|
high: 'border-l-4 border-orange-500 bg-orange-50 hover:bg-orange-100',
|
|
medium: 'border-l-4 border-yellow-500 bg-yellow-50 hover:bg-yellow-100',
|
|
low: 'border-l-4 border-blue-500 bg-blue-50 hover:bg-blue-100',
|
|
};
|
|
return colors[priority as keyof typeof colors] || 'border-l-4 border-gray-300 hover:bg-gray-100';
|
|
};
|
|
|
|
const getNotificationIcon = (type: string) => {
|
|
const icons: Record<string, string> = {
|
|
booking_created: '📦',
|
|
booking_updated: '🔄',
|
|
booking_cancelled: '❌',
|
|
booking_confirmed: '✅',
|
|
csv_booking_accepted: '✅',
|
|
csv_booking_rejected: '❌',
|
|
csv_booking_request_sent: '📧',
|
|
rate_quote_expiring: '⏰',
|
|
document_uploaded: '📄',
|
|
system_announcement: '📢',
|
|
user_invited: '👤',
|
|
organization_update: '🏢',
|
|
};
|
|
return icons[type.toLowerCase()] || '🔔';
|
|
};
|
|
|
|
const formatTime = (dateString: 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('en-US', {
|
|
month: 'long',
|
|
day: 'numeric',
|
|
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50">
|
|
{/* Header */}
|
|
<div className="bg-white border-b shadow-sm">
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center space-x-4">
|
|
<div className="p-3 bg-blue-100 rounded-lg">
|
|
<Bell className="w-8 h-8 text-blue-600" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-3xl font-bold text-gray-900">Notifications</h1>
|
|
<p className="text-sm text-gray-600 mt-1">
|
|
{total} notification{total !== 1 ? 's' : ''} total
|
|
{unreadCount > 0 && ` • ${unreadCount} unread`}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{unreadCount > 0 && (
|
|
<button
|
|
onClick={() => markAllAsReadMutation.mutate()}
|
|
disabled={markAllAsReadMutation.isPending}
|
|
className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
|
>
|
|
<CheckCheck className="w-5 h-5" />
|
|
<span>Mark all as read</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main Content */}
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
{/* Filter Bar */}
|
|
<div className="bg-white rounded-lg shadow-sm border p-4 mb-6">
|
|
<div className="flex items-center space-x-3">
|
|
<Filter className="w-5 h-5 text-gray-500" />
|
|
<span className="text-sm font-medium text-gray-700">Filter:</span>
|
|
<div className="flex space-x-2">
|
|
{(['all', 'unread', 'read'] as const).map((filter) => (
|
|
<button
|
|
key={filter}
|
|
onClick={() => {
|
|
setSelectedFilter(filter);
|
|
setCurrentPage(1);
|
|
}}
|
|
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
|
selectedFilter === filter
|
|
? 'bg-blue-600 text-white shadow-sm'
|
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
|
}`}
|
|
>
|
|
{filter.charAt(0).toUpperCase() + filter.slice(1)}
|
|
{filter === 'unread' && unreadCount > 0 && (
|
|
<span className="ml-2 px-2 py-0.5 bg-white/20 rounded-full text-xs">
|
|
{unreadCount}
|
|
</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Notifications List */}
|
|
<div className="bg-white rounded-lg shadow-sm border">
|
|
{isLoading ? (
|
|
<div className="flex items-center justify-center py-20">
|
|
<div className="text-center">
|
|
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-blue-600 mx-auto mb-4" />
|
|
<p className="text-gray-500">Loading notifications...</p>
|
|
</div>
|
|
</div>
|
|
) : notifications.length === 0 ? (
|
|
<div className="flex items-center justify-center py-20">
|
|
<div className="text-center">
|
|
<div className="text-7xl mb-4">🔔</div>
|
|
<h3 className="text-xl font-semibold text-gray-900 mb-2">No notifications</h3>
|
|
<p className="text-gray-500">
|
|
{selectedFilter === 'unread'
|
|
? "You're all caught up! Great job!"
|
|
: 'No notifications to display'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="divide-y">
|
|
{notifications.map((notification: NotificationResponse) => (
|
|
<div
|
|
key={notification.id}
|
|
onClick={() => handleNotificationClick(notification)}
|
|
className={`p-6 transition-all cursor-pointer group ${
|
|
!notification.read ? 'bg-blue-50/50' : ''
|
|
} ${getPriorityColor(notification.priority || 'low')}`}
|
|
>
|
|
<div className="flex items-start space-x-4">
|
|
{/* Icon */}
|
|
<div className="flex-shrink-0 text-4xl">
|
|
{getNotificationIcon(notification.type)}
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-start justify-between mb-2">
|
|
<div className="flex items-center space-x-3">
|
|
<h3 className="text-lg font-semibold text-gray-900">
|
|
{notification.title}
|
|
</h3>
|
|
{!notification.read && (
|
|
<span className="flex items-center space-x-1 px-2 py-1 bg-blue-600 text-white text-xs font-medium rounded-full">
|
|
<span className="w-2 h-2 bg-white rounded-full animate-pulse" />
|
|
<span>NEW</span>
|
|
</span>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={(e) => handleDelete(e, notification.id)}
|
|
className="opacity-0 group-hover:opacity-100 transition-opacity p-2 hover:bg-red-100 rounded-lg"
|
|
title="Delete notification"
|
|
>
|
|
<Trash2 className="w-5 h-5 text-red-600" />
|
|
</button>
|
|
</div>
|
|
|
|
<p className="text-base text-gray-700 mb-4 leading-relaxed">
|
|
{notification.message}
|
|
</p>
|
|
|
|
{/* Metadata */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center flex-wrap gap-3 text-xs">
|
|
<span className="flex items-center space-x-1 text-gray-600">
|
|
<svg
|
|
className="w-4 h-4"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
/>
|
|
</svg>
|
|
<span className="font-medium">{formatTime(notification.createdAt)}</span>
|
|
</span>
|
|
<span className="px-3 py-1 bg-gray-100 rounded-full text-gray-700 font-medium">
|
|
{notification.type.replace(/_/g, ' ').toUpperCase()}
|
|
</span>
|
|
{notification.priority && (
|
|
<span
|
|
className={`px-3 py-1 rounded-full font-medium ${
|
|
notification.priority === 'urgent'
|
|
? 'bg-red-100 text-red-700'
|
|
: notification.priority === 'high'
|
|
? 'bg-orange-100 text-orange-700'
|
|
: notification.priority === 'medium'
|
|
? 'bg-yellow-100 text-yellow-700'
|
|
: 'bg-blue-100 text-blue-700'
|
|
}`}
|
|
>
|
|
{notification.priority.toUpperCase()}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{notification.actionUrl && (
|
|
<span className="text-sm text-blue-600 font-medium group-hover:underline flex items-center space-x-1">
|
|
<span>View details</span>
|
|
<svg
|
|
className="w-4 h-4"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M9 5l7 7-7 7"
|
|
/>
|
|
</svg>
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
{totalPages > 1 && (
|
|
<div className="mt-6 bg-white rounded-lg shadow-sm border p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="text-sm text-gray-600">
|
|
Showing page <span className="font-semibold">{currentPage}</span> of{' '}
|
|
<span className="font-semibold">{totalPages}</span>
|
|
{' • '}
|
|
<span className="font-semibold">{total}</span> total notification
|
|
{total !== 1 ? 's' : ''}
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<button
|
|
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
|
disabled={currentPage === 1}
|
|
className="flex items-center space-x-1 px-4 py-2 text-sm font-medium bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
<ChevronLeft className="w-4 h-4" />
|
|
<span>Previous</span>
|
|
</button>
|
|
<div className="flex items-center space-x-1">
|
|
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
|
let pageNum;
|
|
if (totalPages <= 5) {
|
|
pageNum = i + 1;
|
|
} else if (currentPage <= 3) {
|
|
pageNum = i + 1;
|
|
} else if (currentPage >= totalPages - 2) {
|
|
pageNum = totalPages - 4 + i;
|
|
} else {
|
|
pageNum = currentPage - 2 + i;
|
|
}
|
|
|
|
return (
|
|
<button
|
|
key={pageNum}
|
|
onClick={() => setCurrentPage(pageNum)}
|
|
className={`w-10 h-10 text-sm font-medium rounded-lg transition-colors ${
|
|
currentPage === pageNum
|
|
? 'bg-blue-600 text-white'
|
|
: 'bg-white text-gray-700 hover:bg-gray-100 border border-gray-300'
|
|
}`}
|
|
>
|
|
{pageNum}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
<button
|
|
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
|
disabled={currentPage === totalPages}
|
|
className="flex items-center space-x-1 px-4 py-2 text-sm font-medium bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
<span>Next</span>
|
|
<ChevronRight className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|