refactor Notifications rc

This commit is contained in:
alma 2026-01-16 01:59:57 +01:00
parent ab1ba65a9f
commit f865f2b95c
6 changed files with 405 additions and 113 deletions

View File

@ -176,6 +176,7 @@ export function Email() {
// Trigger notification if count changed // Trigger notification if count changed
if (currentUnreadCount !== lastUnreadCountRef.current) { if (currentUnreadCount !== lastUnreadCountRef.current) {
const previousCount = lastUnreadCountRef.current;
lastUnreadCountRef.current = currentUnreadCount; lastUnreadCountRef.current = currentUnreadCount;
// Prepare notification items (unread emails only, max 10) // Prepare notification items (unread emails only, max 10)
@ -197,12 +198,28 @@ export function Email() {
}; };
}); });
// Trigger notification update // Trigger notification update (for badge)
await triggerNotification({ await triggerNotification({
source: 'email', source: 'email',
count: currentUnreadCount, count: currentUnreadCount,
items: notificationItems, items: notificationItems,
}); });
// Dispatch event for Outlook-style notifications (only for new emails)
if (previousCount >= 0 && currentUnreadCount > previousCount) {
const newEmails = transformedEmails
.filter(e => !e.read)
.slice(0, currentUnreadCount - previousCount); // Only the new ones
if (newEmails.length > 0) {
window.dispatchEvent(new CustomEvent('new-emails-detected', {
detail: {
emails: transformedEmails,
accountMap: accountMap,
}
}));
}
}
} }
// Show error only if all accounts failed // Show error only if all accounts failed

View File

@ -1,9 +1,9 @@
"use client"; "use client";
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Phone, PhoneOff, X } from 'lucide-react'; import { Phone, PhoneOff } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { OutlookNotification, OutlookNotificationData } from '@/components/outlook-notification';
export interface IncomingCall { export interface IncomingCall {
from: { from: {
@ -30,7 +30,7 @@ export function IncomingCallNotification({
onReject, onReject,
}: IncomingCallNotificationProps) { }: IncomingCallNotificationProps) {
const router = useRouter(); const router = useRouter();
const [isVisible, setIsVisible] = useState(false); const [notificationData, setNotificationData] = useState<OutlookNotificationData | null>(null);
useEffect(() => { useEffect(() => {
if (call) { if (call) {
@ -38,98 +38,62 @@ export function IncomingCallNotification({
from: call.from.name || call.from.username, from: call.from.name || call.from.username,
roomId: call.roomId, roomId: call.roomId,
}); });
setIsVisible(true);
const callerName = call.from.name || call.from.username || 'Inconnu';
// Auto-dismiss after 30 seconds if user doesn't interact const notification: OutlookNotificationData = {
const autoDismissTimer = setTimeout(() => { id: `call-${call.roomId}-${Date.now()}`,
console.log('[IncomingCallNotification] ⏰ Auto-dismissing after 30 seconds'); source: 'call',
setIsVisible(false); title: 'Parole',
onDismiss(); subtitle: 'Appel entrant',
}, 30000); // 30 seconds message: `Vous avez un appel de ${callerName}${call.roomName && call.roomName !== callerName ? ` dans ${call.roomName}` : ''}`,
icon: Phone,
return () => { iconColor: 'text-blue-600',
clearTimeout(autoDismissTimer); iconBgColor: 'bg-blue-100',
borderColor: 'border-blue-500',
timestamp: call.timestamp,
autoDismiss: 30000, // 30 seconds
actions: [
{
label: 'Accepter',
onClick: () => {
onAccept(call.roomId);
router.push(`/parole?room=${call.roomId}`);
setNotificationData(null);
},
variant: 'default',
className: 'bg-green-600 hover:bg-green-700 text-white',
icon: Phone,
},
{
label: 'Raccrocher',
onClick: () => {
onReject();
setNotificationData(null);
},
variant: 'destructive',
icon: PhoneOff,
},
],
}; };
} else {
setIsVisible(false);
}
}, [call, onDismiss]);
if (!call || !isVisible) { setNotificationData(notification);
} else {
setNotificationData(null);
}
}, [call, router, onAccept, onReject]);
if (!notificationData) {
return null; return null;
} }
const handleAccept = () => {
onAccept(call.roomId);
// Navigate to parole page with room ID
router.push(`/parole?room=${call.roomId}`);
setIsVisible(false);
};
const handleReject = () => {
onReject();
setIsVisible(false);
};
const handleDismiss = () => {
onDismiss();
setIsVisible(false);
};
const callerName = call.from.name || call.from.username || 'Inconnu';
return ( return (
<div className="fixed top-4 right-4 z-[9999] animate-in slide-in-from-top-5 duration-300"> <OutlookNotification
<div className="bg-white rounded-lg shadow-2xl border-2 border-blue-500 p-5 min-w-[340px] max-w-[420px]"> notification={notificationData}
{/* Header with Outlook-like style */} onDismiss={() => {
<div className="flex items-start justify-between mb-4 pb-3 border-b border-gray-200"> onDismiss();
<div className="flex items-center gap-3"> setNotificationData(null);
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center flex-shrink-0"> }}
<Phone className="w-5 h-5 text-blue-600" /> />
</div>
<div>
<h3 className="font-semibold text-base text-gray-900 leading-tight">Parole</h3>
<p className="text-xs text-gray-500 mt-0.5">Appel entrant</p>
</div>
</div>
<button
onClick={handleDismiss}
className="text-gray-400 hover:text-gray-600 transition-colors p-1 -mt-1 -mr-1"
aria-label="Fermer"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Call Info - Outlook style message */}
<div className="mb-5">
<p className="text-gray-800 text-sm leading-relaxed">
Vous avez un appel de <span className="font-semibold text-gray-900">{callerName}</span>
</p>
{call.roomName && call.roomName !== callerName && (
<p className="text-xs text-gray-500 mt-1.5">Dans {call.roomName}</p>
)}
</div>
{/* Actions - Outlook style buttons */}
<div className="flex gap-2.5">
<Button
onClick={handleAccept}
className="flex-1 bg-green-600 hover:bg-green-700 text-white font-medium py-2.5 text-sm shadow-sm"
>
<Phone className="w-4 h-4 mr-2" />
Accepter
</Button>
<Button
onClick={handleReject}
variant="destructive"
className="flex-1 font-medium py-2.5 text-sm shadow-sm"
>
<PhoneOff className="w-4 h-4 mr-2" />
Raccrocher
</Button>
</div>
</div>
</div>
); );
} }

View File

@ -10,6 +10,9 @@ import { useBackgroundImage } from "@/components/background-switcher";
import { clearAuthCookies, clearKeycloakCookies } from "@/lib/session"; import { clearAuthCookies, clearKeycloakCookies } from "@/lib/session";
import { useRocketChatCalls } from "@/hooks/use-rocketchat-calls"; import { useRocketChatCalls } from "@/hooks/use-rocketchat-calls";
import { IncomingCallNotification } from "@/components/incoming-call-notification"; import { IncomingCallNotification } from "@/components/incoming-call-notification";
import { useEmailNotifications } from "@/hooks/use-email-notifications";
import { OutlookNotification } from "@/components/outlook-notification";
import { NotificationStack } from "@/components/notification-stack";
interface LayoutWrapperProps { interface LayoutWrapperProps {
children: React.ReactNode; children: React.ReactNode;
@ -208,32 +211,37 @@ export function LayoutWrapper({ children, isSignInPage, isAuthenticated }: Layou
{!isSignInPage && isAuthenticated && <Footer />} {!isSignInPage && isAuthenticated && <Footer />}
<Toaster /> <Toaster />
{/* Incoming call notification */} {/* Notifications stack (calls and emails) */}
{!isSignInPage && isAuthenticated && ( {!isSignInPage && isAuthenticated && (
<> <NotificationStack>
{incomingCall && ( {incomingCall && (
<div style={{ position: 'fixed', top: 0, right: 0, zIndex: 9999 }}> <IncomingCallNotification
{/* Debug: Show if incomingCall exists */} call={incomingCall}
{console.log('[LayoutWrapper] Rendering IncomingCallNotification', { incomingCall })} onDismiss={() => {
</div> console.log('[LayoutWrapper] Call dismissed');
setIncomingCall(null);
}}
onAccept={(roomId) => {
console.log('[LayoutWrapper] Call accepted, navigating to room:', roomId);
setIncomingCall(null);
}}
onReject={() => {
console.log('[LayoutWrapper] Call rejected');
setIncomingCall(null);
// TODO: Send reject signal to RocketChat if needed
}}
/>
)} )}
<IncomingCallNotification {emailNotification && (
call={incomingCall} <OutlookNotification
onDismiss={() => { notification={emailNotification}
console.log('[LayoutWrapper] Call dismissed'); onDismiss={() => {
setIncomingCall(null); console.log('[LayoutWrapper] Email notification dismissed');
}} setEmailNotification(null);
onAccept={(roomId) => { }}
console.log('[LayoutWrapper] Call accepted, navigating to room:', roomId); />
setIncomingCall(null); )}
}} </NotificationStack>
onReject={() => {
console.log('[LayoutWrapper] Call rejected');
setIncomingCall(null);
// TODO: Send reject signal to RocketChat if needed
}}
/>
</>
)} )}
</AuthCheck> </AuthCheck>
); );

View File

@ -0,0 +1,34 @@
"use client";
import { ReactNode } from 'react';
interface NotificationStackProps {
children: ReactNode[];
}
/**
* Container component to stack multiple notifications vertically
*/
export function NotificationStack({ children }: NotificationStackProps) {
const notifications = Array.isArray(children) ? children.filter(Boolean) : (children ? [children] : []);
if (notifications.length === 0) {
return null;
}
return (
<div className="fixed top-4 right-4 z-[9999] flex flex-col gap-3">
{notifications.map((notification, index) => (
<div
key={index}
className="animate-in slide-in-from-top-5 duration-300"
style={{
animationDelay: `${index * 100}ms`,
}}
>
{notification}
</div>
))}
</div>
);
}

View File

@ -0,0 +1,161 @@
"use client";
import { useState, useEffect, ReactNode } from 'react';
import { X, LucideIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useRouter } from 'next/navigation';
export interface OutlookNotificationAction {
label: string;
onClick: () => void;
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
className?: string;
icon?: LucideIcon;
}
export interface OutlookNotificationData {
id: string;
source: 'email' | 'rocketchat' | 'call' | 'leantime' | 'calendar';
title: string;
subtitle?: string;
message: string;
icon: LucideIcon;
iconColor?: string;
iconBgColor?: string;
borderColor?: string;
link?: string;
timestamp?: Date;
actions?: OutlookNotificationAction[];
autoDismiss?: number; // milliseconds, default 30s
}
interface OutlookNotificationProps {
notification: OutlookNotificationData | null;
onDismiss: () => void;
}
export function OutlookNotification({
notification,
onDismiss,
}: OutlookNotificationProps) {
const router = useRouter();
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
if (notification) {
console.log('[OutlookNotification] 📬 Showing notification', {
source: notification.source,
title: notification.title,
});
setIsVisible(true);
// Auto-dismiss after specified time (default 30 seconds)
const autoDismissTime = notification.autoDismiss || 30000;
const autoDismissTimer = setTimeout(() => {
console.log('[OutlookNotification] ⏰ Auto-dismissing after', autoDismissTime, 'ms');
setIsVisible(false);
onDismiss();
}, autoDismissTime);
return () => {
clearTimeout(autoDismissTimer);
};
} else {
setIsVisible(false);
}
}, [notification, onDismiss]);
if (!notification || !isVisible) {
return null;
}
const handleDismiss = () => {
onDismiss();
setIsVisible(false);
};
const handleLinkClick = () => {
if (notification.link) {
router.push(notification.link);
setIsVisible(false);
onDismiss();
}
};
const Icon = notification.icon;
const borderColor = notification.borderColor || 'border-blue-500';
const iconBgColor = notification.iconBgColor || 'bg-blue-100';
const iconColor = notification.iconColor || 'text-blue-600';
return (
<div className={`bg-white rounded-lg shadow-2xl border-2 ${borderColor} p-5 min-w-[340px] max-w-[420px]`}>
{/* Header with Outlook-like style */}
<div className="flex items-start justify-between mb-4 pb-3 border-b border-gray-200">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-full ${iconBgColor} flex items-center justify-center flex-shrink-0`}>
<Icon className={`w-5 h-5 ${iconColor}`} />
</div>
<div>
<h3 className="font-semibold text-base text-gray-900 leading-tight">{notification.title}</h3>
{notification.subtitle && (
<p className="text-xs text-gray-500 mt-0.5">{notification.subtitle}</p>
)}
</div>
</div>
<button
onClick={handleDismiss}
className="text-gray-400 hover:text-gray-600 transition-colors p-1 -mt-1 -mr-1"
aria-label="Fermer"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Message - Outlook style */}
<div className="mb-5">
<p className="text-gray-800 text-sm leading-relaxed">
{notification.message}
</p>
{notification.timestamp && (
<p className="text-xs text-gray-500 mt-1.5">
{new Date(notification.timestamp).toLocaleTimeString('fr-FR', {
hour: '2-digit',
minute: '2-digit'
})}
</p>
)}
</div>
{/* Actions - Outlook style buttons */}
{notification.actions && notification.actions.length > 0 && (
<div className="flex gap-2.5">
{notification.actions.map((action, index) => {
const ActionIcon = action.icon;
return (
<Button
key={index}
onClick={action.onClick}
variant={action.variant || 'default'}
className={`flex-1 font-medium py-2.5 text-sm shadow-sm ${action.className || ''}`}
>
{ActionIcon && <ActionIcon className="w-4 h-4 mr-2" />}
{action.label}
</Button>
);
})}
</div>
)}
{/* If no actions but has link, make the whole card clickable */}
{!notification.actions && notification.link && (
<Button
onClick={handleLinkClick}
className="w-full font-medium py-2.5 text-sm shadow-sm"
>
Ouvrir
</Button>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,108 @@
import { useState, useEffect, useRef } from 'react';
import { Mail } from 'lucide-react';
import { OutlookNotificationData } from '@/components/outlook-notification';
/**
* Hook to manage email notifications and show Outlook-style notifications
*/
export function useEmailNotifications() {
const [emailNotification, setEmailNotification] = useState<OutlookNotificationData | null>(null);
const lastEmailIdsRef = useRef<Set<string>>(new Set());
const notificationQueueRef = useRef<OutlookNotificationData[]>([]);
const isShowingRef = useRef(false);
useEffect(() => {
// Listen for new emails via custom event
const handleNewEmails = (event: CustomEvent) => {
const emails = event.detail?.emails || [];
const accountMap = event.detail?.accountMap || new Map();
if (!emails || emails.length === 0) return;
// Find new emails (not in lastEmailIdsRef)
const newEmails = emails.filter((email: any) => {
const emailId = email.id;
if (!lastEmailIdsRef.current.has(emailId) && !email.read) {
lastEmailIdsRef.current.add(emailId);
return true;
}
return false;
});
// Update lastEmailIdsRef with all current emails
lastEmailIdsRef.current = new Set(emails.map((e: any) => e.id));
// If there are new unread emails, queue them for notification
if (newEmails.length > 0) {
newEmails.forEach((email: any) => {
const account = accountMap.get(email.accountId);
const notification: OutlookNotificationData = {
id: `email-${email.id}-${Date.now()}`,
source: 'email',
title: 'Courrier',
subtitle: 'Nouvel email',
message: `${email.subject || 'Sans objet'} - De ${email.fromName || email.from.split('@')[0]}`,
icon: Mail,
iconColor: 'text-green-600',
iconBgColor: 'bg-green-100',
borderColor: 'border-green-500',
link: '/courrier',
timestamp: new Date(email.date),
autoDismiss: 10000, // 10 seconds for emails
actions: [
{
label: 'Ouvrir',
onClick: () => {
window.location.href = '/courrier';
},
variant: 'default',
className: 'bg-green-600 hover:bg-green-700 text-white',
},
],
};
notificationQueueRef.current.push(notification);
});
// Show the first notification if none is currently showing
if (!isShowingRef.current && notificationQueueRef.current.length > 0) {
showNextNotification();
}
}
};
window.addEventListener('new-emails-detected', handleNewEmails as EventListener);
return () => {
window.removeEventListener('new-emails-detected', handleNewEmails as EventListener);
};
}, []);
const showNextNotification = () => {
if (notificationQueueRef.current.length === 0) {
isShowingRef.current = false;
return;
}
const nextNotification = notificationQueueRef.current.shift();
if (nextNotification) {
isShowingRef.current = true;
setEmailNotification(nextNotification);
}
};
const handleDismiss = () => {
setEmailNotification(null);
isShowingRef.current = false;
// Show next notification after a short delay
setTimeout(() => {
showNextNotification();
}, 500);
};
return {
emailNotification,
setEmailNotification: handleDismiss,
};
}