ritesh19180's picture
Upload folder using huggingface_hub
d3ec6da verified
Raw
History Blame Contribute Delete
18.4 kB
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { Send, User, ShieldCheck, Bot, MessageSquare, Circle, Loader2 } from 'lucide-react';
import { supabase } from "../../lib/supabaseClient";
import useAuthStore from "../../store/authStore";
const TicketChat = ({ ticketId, currentUserRole = 'user' }) => {
const [messages, setMessages] = useState([]);
const [inputValue, setInputValue] = useState('');
const [isTyping, setIsTyping] = useState(false);
const [loading, setLoading] = useState(true);
// eslint-disable-next-line no-unused-vars
const [unreadCount, setUnreadCount] = useState(0);
const [isAtBottom, setIsAtBottom] = useState(true);
const [isInternal, setIsInternal] = useState(false);
const [isStaff, setIsStaff] = useState(false);
const { user, profile } = useAuthStore();
const messagesEndRef = useRef(null);
const scrollContainerRef = useRef(null);
const inputRef = useRef(null);
const channelRef = useRef(null);
// ─── Fetch Messages ──────────────────────────────────────────────────
const fetchMessages = async () => {
if (!ticketId) return;
setLoading(true);
try {
// 1. Fetch Public Messages
const { data: publicMsgs, error: pubErr } = await supabase
.from('ticket_messages')
.select('*')
.eq('ticket_id', ticketId)
.order('created_at', { ascending: true });
if (pubErr) throw pubErr;
// 2. Fetch Internal Notes if staff
let internalMsgs = [];
const userRole = profile?.role || 'user';
const isStaffUser = ['admin', 'super_admin', 'agent', 'master_admin'].includes(userRole);
setIsStaff(isStaffUser);
if (isStaffUser) {
const { data: privateMsgs, error: privErr } = await supabase
.from('internal_notes')
.select('*, profiles:agent_id(full_name)')
.eq('ticket_id', ticketId)
.order('created_at', { ascending: true });
if (!privErr && privateMsgs) {
internalMsgs = privateMsgs.map(m => ({
...m,
message: m.content,
sender_name: m.profiles?.full_name || 'Agent',
sender_role: 'admin',
is_internal: true
}));
}
}
// 3. Combine and Sort
const combined = [...(publicMsgs || []), ...internalMsgs].sort(
(a, b) => new Date(a.created_at) - new Date(b.created_at)
);
setMessages(combined);
} catch (err) {
console.error("Error fetching messages:", err);
} finally {
setLoading(false);
setTimeout(() => scrollToBottom(false), 50);
}
};
// ─── Realtime Subscription ───────────────────────────────────────────
useEffect(() => {
fetchMessages();
const channel = supabase.channel(`ticket_chat_${ticketId}`);
channelRef.current = channel;
// Subscribe to changes
channel
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'ticket_messages',
filter: `ticket_id=eq.${ticketId}`
},
(payload) => {
const newMessage = payload.new;
setMessages((prev) => {
// Avoid duplicates if we already added it locally
if (prev.find(m => m.id === newMessage.id)) return prev;
// Remove optimistic duplicates based on content and time
const filtered = prev.filter(m => !(String(m.id).startsWith('temp-') && m.message === newMessage.message && m.sender_id === newMessage.sender_id));
return [...filtered, newMessage];
});
// Handle notification logic
if (newMessage.sender_id !== user?.id) {
if (!isAtBottom) {
setUnreadCount(prev => prev + 1);
} else {
setTimeout(() => scrollToBottom(), 50);
}
}
}
)
.on(
'broadcast',
{ event: 'typing' },
(payload) => {
if (payload.payload.user_id !== user?.id) {
setIsTyping(true);
clearTimeout(window.typingTimeout);
window.typingTimeout = setTimeout(() => {
setIsTyping(false);
}, 3000);
setTimeout(() => scrollToBottom(), 50);
}
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [ticketId]);
const handleInputChange = (e) => {
setInputValue(e.target.value);
if (channelRef.current && e.target.value.trim().length > 0) {
channelRef.current.send({
type: 'broadcast',
event: 'typing',
payload: { user_id: user?.id }
}).catch(() => { });
}
};
// ─── Auto-scroll ─────────────────────────────────────────────────────
const scrollToBottom = useCallback((smooth = true) => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({
top: scrollContainerRef.current.scrollHeight,
behavior: smooth ? 'smooth' : 'instant'
});
}
}, []);
const handleScroll = () => {
const el = scrollContainerRef.current;
if (!el) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
setIsAtBottom(atBottom);
if (atBottom) setUnreadCount(0);
};
// ─── Send message ────────────────────────────────────────────────────
const handleSend = async (e) => {
e.preventDefault();
if (!inputValue.trim() || !user) return;
const content = inputValue.trim();
const currentIsInternal = isInternal;
// Optimistic UI update
const tempMessage = {
id: `temp-${Date.now()}`,
ticket_id: ticketId,
sender_id: user.id,
sender_name: profile?.full_name || user.email,
sender_role: profile?.role || 'user',
message: content,
is_internal: currentIsInternal,
created_at: new Date().toISOString()
};
setMessages(prev => [...prev, tempMessage]);
setInputValue('');
setTimeout(() => scrollToBottom(), 50);
try {
if (currentIsInternal) {
const { error } = await supabase
.from('internal_notes')
.insert([{
ticket_id: ticketId,
agent_id: user.id,
content: content
}]);
if (error) throw error;
} else {
const { error } = await supabase
.from('ticket_messages')
.insert([{
ticket_id: ticketId,
sender_id: user.id,
sender_name: profile?.full_name || user.email,
sender_role: profile?.role || 'user',
message: content
}]);
if (error) throw error;
}
} catch (err) {
console.error("Error sending message:", err);
setMessages(prev => prev.filter(m => m.id !== tempMessage.id));
}
};
// ─── Helpers ─────────────────────────────────────────────────────────
const formatTime = (iso) => {
try {
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} catch { return ''; }
};
const formatDate = (iso) => {
try {
const d = new Date(iso);
const today = new Date();
if (d.toDateString() === today.toDateString()) return 'Today';
const yesterday = new Date(today);
yesterday.setDate(today.getDate() - 1);
if (d.toDateString() === yesterday.toDateString()) return 'Yesterday';
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
} catch { return ''; }
};
const grouped = [];
let lastDate = null;
messages.forEach((msg) => {
const date = formatDate(msg.created_at);
if (date !== lastDate) {
grouped.push({ type: 'divider', label: date });
lastDate = date;
}
grouped.push({ type: 'message', data: msg });
});
return (
<div style={{ background: '#ffffff', borderRadius: '20px', border: '1px solid #f0fdf4', height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* Header */}
<div style={{ padding: '12px 20px', borderBottom: '1px solid #f0fdf4', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<h2 style={{ fontSize: '11px', color: '#9ca3af', letterSpacing: '0.1em', fontWeight: 600, textTransform: 'uppercase', margin: 0 }}>
AI ASSISTANT
</h2>
</div>
<div className="flex items-center gap-4">
{isStaff && (
<div style={{ display: 'flex', alignItems: 'center', background: 'transparent', gap: '4px' }}>
<button
onClick={() => setIsInternal(false)}
style={{ padding: '4px 12px', fontSize: '10px', fontWeight: 700, borderRadius: '8px', cursor: 'pointer', border: 'none', transition: 'all 0.2s', ...( !isInternal ? { background: '#0f1f12', color: '#ffffff' } : { background: 'transparent', color: '#6b7280' } ) }}
>
PUBLIC
</button>
<button
onClick={() => setIsInternal(true)}
style={{ padding: '4px 12px', fontSize: '10px', fontWeight: 700, borderRadius: '8px', cursor: 'pointer', border: 'none', transition: 'all 0.2s', ...( isInternal ? { background: '#0f1f12', color: '#ffffff' } : { background: 'transparent', color: '#6b7280' } ) }}
>
INTERNAL
</button>
</div>
)}
</div>
</div>
{/* Messages */}
<div
ref={scrollContainerRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto px-5 py-4 space-y-1 bg-white"
>
{loading ? (
<div className="h-full flex flex-col items-center justify-center">
<Loader2 className="w-6 h-6 text-emerald-500 animate-spin" />
</div>
) : grouped.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-center py-16">
<Bot className="w-10 h-10 text-slate-200 mb-3" />
<p className="text-sm font-medium text-slate-400 italic">No messages yet.</p>
</div>
) : (
grouped.map((item, i) => {
if (item.type === 'divider') {
return (
<div key={`div-${i}`} className="flex items-center gap-3 py-2">
<div className="flex-1 h-px bg-slate-100" />
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{item.label}</span>
<div className="flex-1 h-px bg-slate-100" />
</div>
);
}
const msg = item.data;
const isMe = msg.sender_id === user?.id;
const isAdmin = msg.sender_role === 'admin' || msg.sender_role === 'super_admin';
return (
<div key={msg.id || i} className={`flex gap-2.5 ${isMe ? 'justify-end' : 'justify-start'} group py-1`}>
{!isMe && (
<div className={`w-7 h-7 rounded-full flex items-center justify-center shrink-0 mt-auto mb-1 shadow-sm
${isAdmin ? 'bg-indigo-600' : 'bg-slate-200 border border-slate-300'}`}>
{isAdmin ? <ShieldCheck size={13} className="text-white" /> : <User size={13} className="text-slate-500" />}
</div>
)}
<div className={`max-w-[80%] flex flex-col gap-1 ${isMe ? 'items-end' : 'items-start'}`}>
<div className={`flex items-center gap-2 px-1`}>
<span className={`text-[9px] font-black uppercase tracking-widest ${msg.is_internal ? 'text-amber-600' : 'text-slate-400'}`}>
{msg.is_internal ? '🔒 Internal Note' : (isMe ? 'You' : msg.sender_name || (isAdmin ? 'Support ' : 'User'))}
</span>
<span className="text-[8px] font-bold text-slate-300">
{formatTime(msg.created_at)}
</span>
</div>
<div style={{
padding: '14px 18px', fontSize: '13px', lineHeight: 1.5,
...(!isMe && !msg.is_internal ? {
background: '#f0fdf4', color: '#0f1f12', borderRadius: '14px', border: '1px solid #d1fae5'
} : msg.is_internal ? {
background: '#fef3c7', color: '#92400e', borderRadius: '14px', border: '1px solid #fde68a'
} : {
background: '#0f1f12', color: '#ffffff', borderRadius: '14px'
})
}}>
{msg.message}
</div>
</div>
{isMe && (
<div className={`w-7 h-7 rounded-full flex items-center justify-center shrink-0 mt-auto mb-1 shadow-sm
${isAdmin ? 'bg-indigo-600' : 'bg-emerald-100 border border-emerald-200'}`}>
{isAdmin ? <ShieldCheck size={13} className="text-white" /> : <User size={13} className="text-emerald-700" />}
</div>
)}
</div>
);
})
)}
{isTyping && (
<div className="flex items-center gap-2 text-slate-400 text-[10px] font-black uppercase tracking-widest italic py-1 px-2 animate-pulse">
<Loader2 className="w-3 h-3 animate-spin" /> Someone is typing...
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div style={{ padding: '16px 20px', borderTop: '1px solid #f0fdf4', background: '#ffffff' }}>
<form onSubmit={handleSend} className="flex gap-3">
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={handleInputChange}
placeholder="Type your message..."
style={{ flex: 1, background: '#f9fafb', border: '1.5px solid #e5e7eb', borderRadius: '12px', padding: '10px 16px', fontSize: '13px', outline: 'none' }}
className="focus:border-emerald-500 transition-colors"
/>
<button
type="submit"
disabled={!inputValue.trim() || !user}
style={{ padding: '10px 20px', background: '#16a34a', color: '#ffffff', borderRadius: '10px', fontWeight: 600, fontSize: '12px', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '8px' }}
className="active:scale-95 transition-transform disabled:opacity-50"
>
{isInternal ? <ShieldCheck size={14} /> : <Send size={14} />}
<span className="hidden sm:inline">{isInternal ? 'Note' : 'Send'}</span>
</button>
</form>
</div>
</div>
);
};
export default TicketChat;