Implementing Live Chat Support
Live chat lets users communicate with support operators in real time. Architecture: WebSocket server (Socket.IO or Laravel Reverb), database for history, operator panel with dialog queue.
Architecture
[Visitor widget] ←──WebSocket──→ [Chat Server]
[Operator panel] ←──WebSocket──→ ↕
[Database: chats, messages]
↕
[Queue: offline messages → email]
Backend: Node.js + Socket.IO
import { Server } from 'socket.io';
import { createServer } from 'http';
const io = new Server(createServer(), {
cors: { origin: process.env.FRONTEND_URL, credentials: true },
});
// Authorize operators
io.use(async (socket, next) => {
const token = socket.handshake.auth.token;
if (token) {
const user = await verifyToken(token);
if (user?.role === 'operator') {
socket.data.user = user;
socket.data.isOperator = true;
}
}
next();
});
io.on('connection', (socket) => {
// Visitor starts chat
socket.on('chat:start', async (data) => {
const chat = await Chat.create({
visitor_name: data.name,
visitor_email: data.email,
page_url: data.pageUrl,
status: 'waiting',
});
socket.join(`chat:${chat.id}`);
socket.data.chatId = chat.id;
// Notify all operators
io.to('operators').emit('chat:new', {
id: chat.id,
visitor_name: chat.visitor_name,
page_url: chat.page_url,
started_at: chat.created_at,
});
socket.emit('chat:started', { chatId: chat.id });
});
// Operator accepts chat
socket.on('chat:accept', async ({ chatId }) => {
const chat = await Chat.findByPk(chatId);
if (!chat || chat.status !== 'waiting') return;
await chat.update({ operator_id: socket.data.user.id, status: 'active', accepted_at: new Date() });
socket.join(`chat:${chatId}`);
// Notify visitor
io.to(`chat:${chatId}`).emit('chat:accepted', {
operator: { name: socket.data.user.name, avatar: socket.data.user.avatar },
});
});
// Message
socket.on('chat:message', async ({ chatId, text }) => {
const message = await Message.create({
chat_id: chatId,
sender_type: socket.data.isOperator ? 'operator' : 'visitor',
sender_id: socket.data.user?.id ?? null,
text: sanitize(text),
});
io.to(`chat:${chatId}`).emit('chat:message', {
id: message.id,
text: message.text,
sender_type: message.sender_type,
sent_at: message.created_at,
});
await chat.update({ last_message_at: new Date() });
});
// Typing indicator
socket.on('chat:typing', ({ chatId }) => {
socket.to(`chat:${chatId}`).emit('chat:typing', {
sender_type: socket.data.isOperator ? 'operator' : 'visitor',
});
});
// Close chat
socket.on('chat:close', async ({ chatId }) => {
await Chat.update({ status: 'closed', closed_at: new Date() }, { where: { id: chatId } });
io.to(`chat:${chatId}`).emit('chat:closed');
const chat = await Chat.findByPk(chatId, { include: [Message] });
if (chat.visitor_email) {
await emailService.sendTranscript(chat);
}
});
if (socket.data.isOperator) {
socket.join('operators');
io.to('operators').emit('operator:online', { id: socket.data.user.id });
}
socket.on('disconnect', async () => {
if (socket.data.isOperator) {
io.to('operators').emit('operator:offline', { id: socket.data.user?.id });
} else if (socket.data.chatId) {
io.to(`chat:${socket.data.chatId}`).emit('visitor:left');
}
});
});
React: Visitor Widget
export function ChatWidget() {
const [isOpen, setIsOpen] = useState(false);
const [status, setStatus] = useState<'idle' | 'starting' | 'waiting' | 'active' | 'closed'>('idle');
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [chatId, setChatId] = useState<string | null>(null);
const [operator, setOperator] = useState<{ name: string } | null>(null);
const socketRef = useRef<Socket | null>(null);
useEffect(() => {
socketRef.current = io(import.meta.env.VITE_CHAT_SERVER);
const socket = socketRef.current;
socket.on('chat:started', ({ chatId }) => {
setChatId(chatId);
setStatus('waiting');
});
socket.on('chat:accepted', ({ operator }) => {
setOperator(operator);
setStatus('active');
});
socket.on('chat:message', (message) => {
setMessages(prev => [...prev, message]);
});
socket.on('chat:closed', () => setStatus('closed'));
return () => { socket.disconnect(); };
}, []);
const startChat = () => {
setStatus('starting');
socketRef.current?.emit('chat:start', {
name: 'Visitor',
pageUrl: window.location.href,
});
};
const sendMessage = () => {
if (!input.trim() || !chatId) return;
socketRef.current?.emit('chat:message', { chatId, text: input });
setInput('');
};
if (!isOpen) {
return (
<button className="chat-trigger" onClick={() => setIsOpen(true)} aria-label="Open chat">
💬
</button>
);
}
return (
<div className="chat-widget" role="dialog" aria-label="Support chat">
<header>
<h2>{operator ? `Chat with ${operator.name}` : 'Support chat'}</h2>
<button onClick={() => setIsOpen(false)} aria-label="Close">×</button>
</header>
<div className="messages" aria-live="polite">
{status === 'idle' && (
<div className="start-screen">
<p>Hi! How can we help?</p>
<button onClick={startChat}>Start chat</button>
</div>
)}
{status === 'waiting' && <p>Looking for available operator...</p>}
{messages.map(msg => (
<div key={msg.id} className={`message message--${msg.sender_type}`}>
<p>{msg.text}</p>
</div>
))}
</div>
{status === 'active' && (
<footer>
<input
value={input}
onChange={e => {
setInput(e.target.value);
socketRef.current?.emit('chat:typing', { chatId });
}}
onKeyDown={e => e.key === 'Enter' && !e.shiftKey && (e.preventDefault(), sendMessage())}
placeholder="Type message..."
/>
<button onClick={sendMessage} disabled={!input.trim()}>Send</button>
</footer>
)}
</div>
);
}
Timeline
| Task | Timeline |
|---|---|
| Socket.IO server + basic events | 2–3 days |
| Visitor widget (React) | 2–3 days |
| Operator panel | 3–4 days |
| Chat history + email transcripts | +1–2 days |
| Full system with queue and monitoring | 8–12 days |







