Files
afrov2/components/dashboard/DashboardMessages.tsx
streaper2 887030ee47 synchronisation du contenu avec la DB et amélioration du CMS
- Migration du blog et des interviews des données mockées vers PostgreSQL (Prisma).
- Refonte des pages publiques en Server Components pour de meilleures performances/SEO.
- Intégration d'un éditeur de texte riche (React Quill) avec titres H1-H6 et séparateurs.
- Correction des erreurs de validation Prisma liées aux paramètres asynchrones (Next.js 15+).
- Correction des problèmes d'affichage des modales de suspension via React Portals.
- Installation de @tailwindcss/typography et correction du débordement de texte (break-words).
- Implémentation des actions de modération (suspension/révocation) pour les utilisateurs et boutiques.
2026-04-12 18:59:20 +02:00

422 lines
21 KiB
TypeScript

"use client";
import React, { useState, useEffect, useRef } from 'react';
import { Send, User as UserIcon, Building2, ChevronLeft, Loader2, MessageSquare, Archive, Inbox, ArchiveRestore, Flag } from 'lucide-react';
import { useUser } from '../UserProvider';
import { toast } from 'react-hot-toast';
interface Message {
id: string;
content: string;
senderId: string;
createdAt: string;
sender: {
name: string;
avatar?: string;
};
}
interface Conversation {
id: string;
business: {
id: string;
name: string;
logoUrl: string;
ownerId: string;
};
participants: {
user: {
id: string;
name: string;
avatar?: string;
};
}[];
messages: Message[];
}
interface DashboardMessagesProps {
onMessagesRead?: () => void;
initialConversationId?: string | null;
}
const DashboardMessages = ({ onMessagesRead, initialConversationId }: DashboardMessagesProps) => {
const { user } = useUser();
const [conversations, setConversations] = useState<Conversation[]>([]);
const [selectedConversation, setSelectedConversation] = useState<Conversation | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [newMessage, setNewMessage] = useState('');
const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false);
const [isMobileListVisible, setIsMobileListVisible] = useState(true);
const [showArchived, setShowArchived] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
fetchConversations();
}, []);
useEffect(() => {
if (selectedConversation) {
fetchMessages(selectedConversation.id);
}
}, [selectedConversation]);
useEffect(() => {
scrollToBottom();
}, [messages]);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
const fetchConversations = async () => {
try {
const res = await fetch('/api/conversations', {
headers: { 'x-user-id': user?.id || '' }
});
const data = await res.json();
if (!data.error) {
setConversations(data);
// Handle initial auto-selection
if (initialConversationId) {
const found = data.find((c: Conversation) => c.id === initialConversationId);
if (found) {
setSelectedConversation(found);
setIsMobileListVisible(false);
}
}
}
} catch (error) {
console.error('Error fetching conversations:', error);
} finally {
setLoading(false);
}
};
const fetchMessages = async (id: string) => {
try {
const res = await fetch(`/api/conversations/${id}`, {
headers: { 'x-user-id': user?.id || '' }
});
const data = await res.json();
if (!data.error) {
setMessages(data);
// After fetching messages (which marks them as read in the backend),
// notify the parent to refresh the unread count badge
if (onMessagesRead) onMessagesRead();
}
} catch (error) {
console.error('Error fetching messages:', error);
}
};
const handleSendMessage = async (e: React.FormEvent) => {
e.preventDefault();
if (!newMessage.trim() || !selectedConversation || sending) return;
setSending(true);
try {
const res = await fetch('/api/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-user-id': user?.id || ''
},
body: JSON.stringify({
conversationId: selectedConversation.id,
content: newMessage
})
});
const data = await res.json();
if (!data.error) {
setMessages([...messages, {
...data,
sender: { name: user?.name || '' }
}]);
setNewMessage('');
// Refresh conversations list to update last message/order
fetchConversations();
} else {
toast.error(data.error);
}
} catch (error) {
toast.error('Erreur lors de l\'envoi');
} finally {
setSending(false);
}
};
const handleToggleArchive = async (conversation: Conversation) => {
const isCurrentlyArchived = conversation.participants.find(p => p.user.id === user?.id)?.isArchived;
try {
const res = await fetch(`/api/conversations/${conversation.id}/archive`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'x-user-id': user?.id || ''
},
body: JSON.stringify({ archived: !isCurrentlyArchived })
});
if (res.ok) {
toast.success(isCurrentlyArchived ? 'Conversation désarchivée' : 'Conversation archivée');
fetchConversations();
// If we archived the currently selected conversation, close it
if (!isCurrentlyArchived && selectedConversation?.id === conversation.id) {
setSelectedConversation(null);
}
}
} catch (error) {
toast.error('Erreur lors de l\'archivage');
}
};
const handleReportMessage = async (messageId: string) => {
if (!window.confirm('Voulez-vous vraiment signaler ce message aux administrateurs ?')) return;
try {
const res = await fetch(`/api/messages/${messageId}/report`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-user-id': user?.id || ''
},
body: JSON.stringify({ reason: "Signalement rapide" })
});
const data = await res.json();
if (res.ok) {
toast.success('Message signalé avec succès');
} else {
toast.error(data.error || 'Erreur lors du signalement');
}
} catch (error) {
toast.error('Erreur réseau lors du signalement');
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-full min-h-[400px]">
<Loader2 className="w-8 h-8 animate-spin text-brand-600" />
</div>
);
}
return (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden h-[600px] flex">
{/* 1. Conversations Sidebar */}
<div className={`${isMobileListVisible ? 'flex' : 'hidden'} md:flex flex-col w-full md:w-80 border-r border-gray-200 h-full`}>
<div className="p-4 border-b border-gray-200 bg-gray-50">
<div className="flex items-center justify-between mb-4">
<h3 className="font-bold text-gray-900 flex items-center gap-2">
<MessageSquare className="w-4 h-4 text-brand-600" />
Messages
</h3>
</div>
<div className="flex bg-gray-200/50 p-1 rounded-lg">
<button
onClick={() => setShowArchived(false)}
className={`flex-1 flex items-center justify-center gap-2 py-1.5 text-xs font-bold rounded-md transition-all ${!showArchived ? 'bg-white text-brand-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
>
<Inbox className="w-3.5 h-3.5" />
Actifs
</button>
<button
onClick={() => setShowArchived(true)}
className={`flex-1 flex items-center justify-center gap-2 py-1.5 text-xs font-bold rounded-md transition-all ${showArchived ? 'bg-white text-brand-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
>
<Archive className="w-3.5 h-3.5" />
Archivés
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{(() => {
const filtered = conversations.filter(conv => {
const myParticipant = conv.participants.find(p => p.user.id === user?.id);
return !!myParticipant?.isArchived === showArchived;
});
if (filtered.length === 0) {
return (
<div className="p-8 text-center text-gray-500 text-sm">
<p>{showArchived ? 'Aucune conversation archivée.' : 'Aucune conversation pour le moment.'}</p>
</div>
);
}
return filtered.map((conv) => {
const lastMsg = conv.messages[0];
const isSelected = selectedConversation?.id === conv.id;
// Determine display name: If current user is business owner, show customer name
const isOwner = user?.id === conv.business.ownerId;
const partner = conv.participants.find(p => p.user.id !== user?.id)?.user;
const displayName = isOwner ? partner?.name : conv.business.name;
const displayAvatar = isOwner ? partner?.avatar : conv.business.logoUrl;
return (
<button
key={conv.id}
onClick={() => {
setSelectedConversation(conv);
setIsMobileListVisible(false);
}}
className={`w-full p-4 flex gap-3 hover:bg-gray-50 transition-colors text-left border-b border-gray-100 ${isSelected ? 'bg-brand-50' : ''}`}
>
<div className="w-12 h-12 rounded-lg bg-gray-100 overflow-hidden flex-shrink-0 border border-gray-200">
<img src={displayAvatar || conv.business.logoUrl} alt={displayName} className="w-full h-full object-cover" />
</div>
<div className="flex-1 min-w-0">
<div className="flex justify-between items-start">
<h4 className="font-bold text-gray-900 truncate text-sm">
{displayName}
{isOwner && <span className="ml-2 text-[9px] bg-blue-100 text-blue-600 px-1 rounded">Client</span>}
</h4>
<span className="text-[10px] text-gray-400">
{lastMsg ? new Date(lastMsg.createdAt).toLocaleDateString() : ''}
</span>
</div>
<p className="text-xs text-gray-500 truncate mt-1">
{lastMsg ? lastMsg.content : 'Pas de message'}
</p>
</div>
</button>
);
});
})()}
</div>
</div>
{/* 2. Chat Window */}
<div className={`${!isMobileListVisible ? 'flex' : 'hidden'} md:flex flex-col flex-1 h-full bg-slate-50 relative`}>
{selectedConversation ? (
<>
{/* Chat Header */}
<div className="p-4 bg-white border-b border-gray-200 flex items-center gap-3">
<button onClick={() => setIsMobileListVisible(true)} className="md:hidden p-2 -ml-2 text-gray-500">
<ChevronLeft className="w-5 h-5" />
</button>
{/* Determine header display */}
{(() => {
const isOwner = user?.id === selectedConversation.business.ownerId;
const partner = selectedConversation.participants.find(p => p.user.id !== user?.id)?.user;
const displayName = isOwner ? partner?.name : selectedConversation.business.name;
const displayAvatar = isOwner ? partner?.avatar : selectedConversation.business.logoUrl;
return (
<>
<div className="w-10 h-10 rounded-lg bg-gray-100 overflow-hidden border border-gray-100">
<img src={displayAvatar || selectedConversation.business.logoUrl} alt="" className="w-full h-full object-cover" />
</div>
<div>
<h4 className="font-bold text-gray-900 text-sm">{displayName} {isOwner && "(Client)"}</h4>
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-full bg-green-500"></div>
<span className="text-[11px] text-gray-500 italic">En ligne</span>
</div>
</div>
<div className="ml-auto">
<button
onClick={() => handleToggleArchive(selectedConversation)}
className="p-2 text-gray-400 hover:text-brand-600 hover:bg-brand-50 rounded-full transition-all title-archive"
title={selectedConversation.participants.find(p => p.user.id === user?.id)?.isArchived ? "Désarchiver" : "Archiver"}
>
{selectedConversation.participants.find(p => p.user.id === user?.id)?.isArchived
? <ArchiveRestore className="w-5 h-5" />
: <Archive className="w-5 h-5" />
}
</button>
</div>
</>
);
})()}
</div>
{/* Messages Area */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((msg) => {
// Triple-check identity: ID, Email, or Name
const isMe = user && (
msg.senderId === user.id ||
(msg.sender?.email && msg.sender.email === user.email) ||
(msg.sender?.name && msg.sender.name === user.name)
);
return (
<div key={msg.id} className={`flex flex-col ${isMe ? 'items-end' : 'items-start'}`}>
<span className={`text-[10px] mb-1 px-1 font-bold ${isMe ? 'text-brand-600' : 'text-gray-500'}`}>
{isMe ? 'Vous' : (user?.id === selectedConversation.business.ownerId
? (selectedConversation.participants.find(p => p.user.id !== user?.id)?.user.name || 'Client')
: selectedConversation.business.name
)}
</span>
<div className={`max-w-[80%] rounded-2xl p-3 text-sm shadow-xl border-2 transition-all ${
isMe
? 'bg-brand-600 text-white border-brand-500 rounded-tr-none ring-2 ring-brand-100'
: 'bg-white text-gray-800 border-gray-100 rounded-tl-none ring-2 ring-gray-50 shadow-gray-200/50'
}`}>
<p className="leading-relaxed font-medium">{msg.content}</p>
<div className={`flex items-center gap-1 mt-1 ${isMe ? 'justify-end text-brand-100' : 'justify-start text-gray-400'}`}>
<span className="text-[9px] font-bold">
{new Date(msg.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
{!isMe && (
<button
onClick={() => handleReportMessage(msg.id)}
className="ml-2 hover:text-red-500 transition-colors p-0.5 rounded"
title="Signaler ce message"
>
<Flag className="w-2.5 h-2.5" />
</button>
)}
</div>
</div>
</div>
);
})}
<div ref={messagesEndRef} />
</div>
{/* Input Area */}
<div className="p-4 bg-white border-t border-gray-200">
<form onSubmit={handleSendMessage} className="flex gap-2">
<input
type="text"
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
placeholder="Écrivez votre message..."
className="flex-1 bg-gray-100 border-none rounded-full px-4 py-2 text-sm focus:ring-2 focus:ring-brand-500 outline-none"
/>
<button
type="submit"
disabled={!newMessage.trim() || sending}
className="w-10 h-10 rounded-full bg-brand-600 text-white flex items-center justify-center hover:bg-brand-700 transition-colors disabled:opacity-50"
>
{sending ? <Loader2 className="w-5 h-5 animate-spin" /> : <Send className="w-5 h-5" />}
</button>
</form>
</div>
</>
) : (
<div className="flex-1 flex flex-col items-center justify-center text-gray-400 p-8 text-center">
<div className="w-16 h-16 bg-white rounded-full flex items-center justify-center shadow-sm mb-4">
<MessageSquare className="w-8 h-8 text-gray-200" />
</div>
<h4 className="font-bold text-gray-900 mb-2">Votre messagerie</h4>
<p className="text-sm max-w-xs">Sélectionnez une conversation pour commencer à échanger avec vos entrepreneurs préférés.</p>
</div>
)}
</div>
</div>
);
};
export default DashboardMessages;