refactor Notifications rc
This commit is contained in:
parent
ab1ba65a9f
commit
f865f2b95c
@ -176,6 +176,7 @@ export function Email() {
|
||||
|
||||
// Trigger notification if count changed
|
||||
if (currentUnreadCount !== lastUnreadCountRef.current) {
|
||||
const previousCount = lastUnreadCountRef.current;
|
||||
lastUnreadCountRef.current = currentUnreadCount;
|
||||
|
||||
// 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({
|
||||
source: 'email',
|
||||
count: currentUnreadCount,
|
||||
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
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Phone, PhoneOff, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Phone, PhoneOff } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { OutlookNotification, OutlookNotificationData } from '@/components/outlook-notification';
|
||||
|
||||
export interface IncomingCall {
|
||||
from: {
|
||||
@ -30,7 +30,7 @@ export function IncomingCallNotification({
|
||||
onReject,
|
||||
}: IncomingCallNotificationProps) {
|
||||
const router = useRouter();
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [notificationData, setNotificationData] = useState<OutlookNotificationData | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (call) {
|
||||
@ -38,98 +38,62 @@ export function IncomingCallNotification({
|
||||
from: call.from.name || call.from.username,
|
||||
roomId: call.roomId,
|
||||
});
|
||||
setIsVisible(true);
|
||||
|
||||
// Auto-dismiss after 30 seconds if user doesn't interact
|
||||
const autoDismissTimer = setTimeout(() => {
|
||||
console.log('[IncomingCallNotification] ⏰ Auto-dismissing after 30 seconds');
|
||||
setIsVisible(false);
|
||||
onDismiss();
|
||||
}, 30000); // 30 seconds
|
||||
|
||||
return () => {
|
||||
clearTimeout(autoDismissTimer);
|
||||
};
|
||||
} else {
|
||||
setIsVisible(false);
|
||||
}
|
||||
}, [call, onDismiss]);
|
||||
|
||||
if (!call || !isVisible) {
|
||||
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';
|
||||
|
||||
const notification: OutlookNotificationData = {
|
||||
id: `call-${call.roomId}-${Date.now()}`,
|
||||
source: 'call',
|
||||
title: 'Parole',
|
||||
subtitle: 'Appel entrant',
|
||||
message: `Vous avez un appel de ${callerName}${call.roomName && call.roomName !== callerName ? ` dans ${call.roomName}` : ''}`,
|
||||
icon: Phone,
|
||||
iconColor: 'text-blue-600',
|
||||
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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
setNotificationData(notification);
|
||||
} else {
|
||||
setNotificationData(null);
|
||||
}
|
||||
}, [call, router, onAccept, onReject]);
|
||||
|
||||
if (!notificationData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-[9999] animate-in slide-in-from-top-5 duration-300">
|
||||
<div className="bg-white rounded-lg shadow-2xl border-2 border-blue-500 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 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>
|
||||
<OutlookNotification
|
||||
notification={notificationData}
|
||||
onDismiss={() => {
|
||||
onDismiss();
|
||||
setNotificationData(null);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -10,6 +10,9 @@ import { useBackgroundImage } from "@/components/background-switcher";
|
||||
import { clearAuthCookies, clearKeycloakCookies } from "@/lib/session";
|
||||
import { useRocketChatCalls } from "@/hooks/use-rocketchat-calls";
|
||||
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 {
|
||||
children: React.ReactNode;
|
||||
@ -208,15 +211,10 @@ export function LayoutWrapper({ children, isSignInPage, isAuthenticated }: Layou
|
||||
{!isSignInPage && isAuthenticated && <Footer />}
|
||||
<Toaster />
|
||||
|
||||
{/* Incoming call notification */}
|
||||
{/* Notifications stack (calls and emails) */}
|
||||
{!isSignInPage && isAuthenticated && (
|
||||
<>
|
||||
<NotificationStack>
|
||||
{incomingCall && (
|
||||
<div style={{ position: 'fixed', top: 0, right: 0, zIndex: 9999 }}>
|
||||
{/* Debug: Show if incomingCall exists */}
|
||||
{console.log('[LayoutWrapper] Rendering IncomingCallNotification', { incomingCall })}
|
||||
</div>
|
||||
)}
|
||||
<IncomingCallNotification
|
||||
call={incomingCall}
|
||||
onDismiss={() => {
|
||||
@ -233,7 +231,17 @@ export function LayoutWrapper({ children, isSignInPage, isAuthenticated }: Layou
|
||||
// TODO: Send reject signal to RocketChat if needed
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{emailNotification && (
|
||||
<OutlookNotification
|
||||
notification={emailNotification}
|
||||
onDismiss={() => {
|
||||
console.log('[LayoutWrapper] Email notification dismissed');
|
||||
setEmailNotification(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</NotificationStack>
|
||||
)}
|
||||
</AuthCheck>
|
||||
);
|
||||
|
||||
34
components/notification-stack.tsx
Normal file
34
components/notification-stack.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
161
components/outlook-notification.tsx
Normal file
161
components/outlook-notification.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
hooks/use-email-notifications.ts
Normal file
108
hooks/use-email-notifications.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user