357 lines
13 KiB
TypeScript
357 lines
13 KiB
TypeScript
/**
|
|
* Notification Panel Component
|
|
*
|
|
* Sidebar panel that displays all notifications with detailed view
|
|
*/
|
|
|
|
'use client';
|
|
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { useState } from 'react';
|
|
import { useTranslations, useLocale } from 'next-intl';
|
|
import { useRouter } from 'next/navigation';
|
|
import {
|
|
listNotifications,
|
|
markNotificationAsRead,
|
|
markAllNotificationsAsRead,
|
|
deleteNotification,
|
|
} from '@/lib/api';
|
|
import type { NotificationResponse } from '@/types/api';
|
|
import {
|
|
X,
|
|
Trash2,
|
|
CheckCheck,
|
|
Filter,
|
|
Bell,
|
|
Package,
|
|
RefreshCw,
|
|
XCircle,
|
|
CheckCircle,
|
|
Mail,
|
|
Timer,
|
|
FileText,
|
|
Megaphone,
|
|
User,
|
|
Building2,
|
|
} from 'lucide-react';
|
|
import { useConfirm } from '@/components/ui/use-confirm';
|
|
|
|
interface NotificationPanelProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export default function NotificationPanel({ isOpen, onClose }: NotificationPanelProps) {
|
|
const confirm = useConfirm();
|
|
const t = useTranslations('components.notificationPanel');
|
|
const tTime = useTranslations('components.notificationDropdown.time');
|
|
const locale = useLocale();
|
|
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
|
|
const [selectedFilter, setSelectedFilter] = useState<'all' | 'unread' | 'read'>('all');
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const queryClient = useQueryClient();
|
|
const router = useRouter();
|
|
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: ['notifications', 'panel', selectedFilter, currentPage],
|
|
queryFn: () =>
|
|
listNotifications({
|
|
page: currentPage,
|
|
limit: 20,
|
|
isRead: selectedFilter === 'all' ? undefined : selectedFilter === 'read',
|
|
}),
|
|
enabled: isOpen,
|
|
});
|
|
|
|
const notifications = data?.notifications || [];
|
|
const total = data?.total || 0;
|
|
const totalPages = Math.ceil(total / 20);
|
|
|
|
const markAsReadMutation = useMutation({
|
|
mutationFn: markNotificationAsRead,
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
|
},
|
|
});
|
|
|
|
const markAllAsReadMutation = useMutation({
|
|
mutationFn: markAllNotificationsAsRead,
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
|
},
|
|
});
|
|
|
|
const deleteNotificationMutation = useMutation({
|
|
mutationFn: deleteNotification,
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
|
},
|
|
});
|
|
|
|
const handleNotificationClick = (notification: NotificationResponse) => {
|
|
if (!notification.read) {
|
|
markAsReadMutation.mutate(notification.id);
|
|
}
|
|
|
|
if (notification.actionUrl) {
|
|
onClose();
|
|
router.push(notification.actionUrl);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (e: React.MouseEvent, notificationId: string) => {
|
|
e.stopPropagation();
|
|
if (await confirm({ title: t('deleteConfirm'), destructive: true })) {
|
|
deleteNotificationMutation.mutate(notificationId);
|
|
}
|
|
};
|
|
|
|
const getPriorityColor = (priority: string) => {
|
|
const colors = {
|
|
urgent: 'border-l-4 border-red-500 bg-red-50',
|
|
high: 'border-l-4 border-orange-500 bg-orange-50',
|
|
medium: 'border-l-4 border-yellow-500 bg-yellow-50',
|
|
low: 'border-l-4 border-brand-turquoise bg-brand-turquoise/5',
|
|
};
|
|
return colors[priority as keyof typeof colors] || 'border-l-4 border-neutral-300';
|
|
};
|
|
|
|
const getNotificationIconComponent = (type: string) => {
|
|
const icons: Record<string, typeof Bell> = {
|
|
booking_created: Package,
|
|
booking_updated: RefreshCw,
|
|
booking_cancelled: XCircle,
|
|
booking_confirmed: CheckCircle,
|
|
csv_booking_accepted: CheckCircle,
|
|
csv_booking_rejected: XCircle,
|
|
csv_booking_request_sent: Mail,
|
|
rate_quote_expiring: Timer,
|
|
document_uploaded: FileText,
|
|
system_announcement: Megaphone,
|
|
user_invited: User,
|
|
organization_update: Building2,
|
|
};
|
|
return icons[type.toLowerCase()] || Bell;
|
|
};
|
|
|
|
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 tTime('now');
|
|
if (diffMins < 60) return tTime('minutes', { minutes: diffMins });
|
|
if (diffHours < 24) return tTime('hours', { hours: diffHours });
|
|
if (diffDays < 7) return tTime('days', { days: diffDays });
|
|
return date.toLocaleDateString(dateLocale, {
|
|
day: 'numeric',
|
|
month: 'short',
|
|
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
|
|
});
|
|
};
|
|
|
|
if (!isOpen) return null;
|
|
|
|
return (
|
|
<>
|
|
{/* Backdrop */}
|
|
<div className="fixed inset-0 bg-black/30 z-40 transition-opacity" onClick={onClose} />
|
|
|
|
{/* Panel */}
|
|
<div className="fixed right-0 top-0 bottom-0 w-full max-w-2xl bg-white shadow-2xl z-50 flex flex-col animate-slide-in-right">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-6 py-4 border-b bg-white">
|
|
<div>
|
|
<h2 className="text-xl font-bold text-brand-navy">{t('title')}</h2>
|
|
<p className="text-sm text-neutral-600 mt-1">{t('totalCount', { count: total })}</p>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors text-neutral-600"
|
|
aria-label={t('closeAria')}
|
|
>
|
|
<X className="w-6 h-6" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Filter Bar */}
|
|
<div className="flex items-center justify-between px-6 py-3 border-b bg-neutral-50">
|
|
<div className="flex items-center space-x-2">
|
|
<Filter className="w-4 h-4 text-neutral-500" />
|
|
<div className="flex space-x-1">
|
|
{(['all', 'unread', 'read'] as const).map(filter => (
|
|
<button
|
|
key={filter}
|
|
onClick={() => {
|
|
setSelectedFilter(filter);
|
|
setCurrentPage(1);
|
|
}}
|
|
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
|
selectedFilter === filter
|
|
? 'bg-brand-turquoise text-brand-navy'
|
|
: 'bg-white text-neutral-700 hover:bg-neutral-100 border border-neutral-300'
|
|
}`}
|
|
>
|
|
{t(`filters.${filter}` as any)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
{selectedFilter === 'unread' && notifications.length > 0 && (
|
|
<button
|
|
onClick={() => markAllAsReadMutation.mutate()}
|
|
disabled={markAllAsReadMutation.isPending}
|
|
className="flex items-center space-x-1 text-sm text-brand-navy hover:text-brand-turquoise font-medium disabled:opacity-50"
|
|
>
|
|
<CheckCheck className="w-4 h-4" />
|
|
<span>{t('markAllRead')}</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Notifications List */}
|
|
<div className="flex-1 overflow-y-auto">
|
|
{isLoading ? (
|
|
<div className="flex items-center justify-center h-64">
|
|
<div className="text-center">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-turquoise mx-auto mb-4" />
|
|
<p className="text-sm text-neutral-500">{t('loading')}</p>
|
|
</div>
|
|
</div>
|
|
) : notifications.length === 0 ? (
|
|
<div className="flex items-center justify-center h-64">
|
|
<div className="text-center">
|
|
<Bell className="h-16 w-16 text-neutral-300 mx-auto mb-4" />
|
|
<h3 className="text-lg font-semibold text-brand-navy mb-2">{t('emptyTitle')}</h3>
|
|
<p className="text-sm text-neutral-500">
|
|
{selectedFilter === 'unread' ? t('emptyUnread') : t('emptyAll')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="divide-y">
|
|
{notifications.map((notification: NotificationResponse) => (
|
|
<div
|
|
key={notification.id}
|
|
onClick={() => handleNotificationClick(notification)}
|
|
className={`p-6 hover:bg-neutral-50 transition-all cursor-pointer group ${
|
|
!notification.read ? 'bg-brand-turquoise/5/50' : ''
|
|
} ${getPriorityColor(notification.priority || 'low')}`}
|
|
>
|
|
<div className="flex items-start space-x-4">
|
|
{/* Icon */}
|
|
<div className="flex-shrink-0">
|
|
{(() => {
|
|
const Icon = getNotificationIconComponent(notification.type);
|
|
return <Icon className="h-6 w-6 text-neutral-500" />;
|
|
})()}
|
|
</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-2">
|
|
<h3 className="text-base font-semibold text-brand-navy">
|
|
{notification.title}
|
|
</h3>
|
|
{!notification.read && (
|
|
<span className="w-2.5 h-2.5 bg-brand-turquoise rounded-full" />
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={e => handleDelete(e, notification.id)}
|
|
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-red-100 rounded"
|
|
title={t('deleteTitle')}
|
|
>
|
|
<Trash2 className="w-4 h-4 text-red-600" />
|
|
</button>
|
|
</div>
|
|
|
|
<p className="text-sm text-neutral-700 mb-3 leading-relaxed">
|
|
{notification.message}
|
|
</p>
|
|
|
|
{/* Metadata */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center space-x-4 text-xs text-neutral-500">
|
|
<span className="font-medium">{formatTime(notification.createdAt)}</span>
|
|
<span className="px-2 py-0.5 bg-neutral-200 rounded-full text-neutral-700 font-medium">
|
|
{notification.type.replace(/_/g, ' ').toUpperCase()}
|
|
</span>
|
|
{notification.priority && (
|
|
<span
|
|
className={`px-2 py-0.5 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-brand-turquoise/10 text-brand-navy'
|
|
}`}
|
|
>
|
|
{notification.priority.toUpperCase()}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{notification.actionUrl && (
|
|
<span className="text-xs text-brand-navy font-medium group-hover:underline">
|
|
{t('viewDetails')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
{totalPages > 1 && (
|
|
<div className="flex items-center justify-between px-6 py-4 border-t bg-neutral-50">
|
|
<div className="text-sm text-neutral-600">
|
|
{t('pagination.page', { current: currentPage, total: totalPages })}
|
|
</div>
|
|
<div className="flex space-x-2">
|
|
<button
|
|
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
|
disabled={currentPage === 1}
|
|
className="px-3 py-1.5 text-sm font-medium bg-white border border-neutral-300 rounded-md hover:bg-neutral-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{t('pagination.previous')}
|
|
</button>
|
|
<button
|
|
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
|
disabled={currentPage === totalPages}
|
|
className="px-3 py-1.5 text-sm font-medium bg-white border border-neutral-300 rounded-md hover:bg-neutral-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{t('pagination.next')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<style jsx>{`
|
|
@keyframes slide-in-right {
|
|
from {
|
|
transform: translateX(100%);
|
|
}
|
|
to {
|
|
transform: translateX(0);
|
|
}
|
|
}
|
|
|
|
.animate-slide-in-right {
|
|
animation: slide-in-right 0.3s ease-out;
|
|
}
|
|
`}</style>
|
|
</>
|
|
);
|
|
}
|