feat: implement live support chat functionality with a dedicated service and UI page.

This commit is contained in:
pradeepkumar
2026-03-05 06:37:44 +05:30
parent 724ce6a735
commit c485561d62
4 changed files with 544 additions and 4 deletions

View File

@@ -1,6 +1,7 @@
'use client';
import Image from 'next/image';
import Link from 'next/link';
import { useState, useEffect } from 'react';
import { CommonHeader } from '@/components/layout/CommonHeader';
import { Footer } from '@/components/layout/Footer';
@@ -248,7 +249,10 @@ export default function FAQPage() {
</div>
<div className="flex flex-col gap-3">
<button className="flex items-center justify-center gap-2 w-[174px] h-[51px] border border-[#00293d] rounded-[7px] font-fractul text-[16px] text-[#00293d] hover:bg-gray-50 transition-colors">
<Link
href="/support/chat"
className="flex items-center justify-center gap-2 w-[174px] h-[51px] border border-[#00293d] rounded-[7px] font-fractul text-[16px] text-[#00293d] hover:bg-gray-50 transition-colors"
>
<Image
src="/assets/icons/chat-icon.svg"
alt=""
@@ -256,8 +260,11 @@ export default function FAQPage() {
height={20}
/>
Start Live Chat
</button>
<button className="flex items-center justify-center gap-2 w-[174px] h-[51px] border border-[#00293d] rounded-[7px] font-fractul text-[16px] text-[#00293d] hover:bg-gray-50 transition-colors">
</Link>
<a
href="mailto:support@requesn.com"
className="flex items-center justify-center gap-2 w-[174px] h-[51px] border border-[#00293d] rounded-[7px] font-fractul text-[16px] text-[#00293d] hover:bg-gray-50 transition-colors"
>
<Image
src="/assets/icons/email-icon.svg"
alt=""
@@ -265,7 +272,7 @@ export default function FAQPage() {
height={20}
/>
Email Support
</button>
</a>
</div>
</div>
</div>

View File

@@ -0,0 +1,457 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import Image from 'next/image';
import { CommonHeader } from '@/components/layout/CommonHeader';
import { Footer } from '@/components/layout/Footer';
import { supportChatService } from '@/services/support-chat.service';
import { socketService } from '@/services/socket.service';
import type { SupportChat, SupportMessage } from '@/services/support-chat.service';
export default function SupportChatPage() {
const { data: session, status } = useSession();
const router = useRouter();
const [chat, setChat] = useState<SupportChat | null>(null);
const [messages, setMessages] = useState<SupportMessage[]>([]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [isSending, setIsSending] = useState(false);
const [hasMore, setHasMore] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [isTyping, setIsTyping] = useState(false);
const [error, setError] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const currentUserId = (session?.user as any)?.id;
// Redirect to login if not authenticated
useEffect(() => {
if (status === 'loading') return;
if (!session) {
const tokensExist = typeof window !== 'undefined' && !!localStorage.getItem('accessToken');
if (!tokensExist) {
router.replace('/login');
}
}
}, [session, status, router]);
// Initialize chat
useEffect(() => {
if (!session) return;
const initChat = async () => {
try {
const supportChat = await supportChatService.getOrCreateChat();
setChat(supportChat);
// Load messages
const { messages: loadedMessages, pagination } = await supportChatService.getMessages(supportChat.id);
setMessages(loadedMessages);
setHasMore(pagination.page < pagination.pages);
// Mark as read
await supportChatService.markAsRead(supportChat.id);
} catch (err) {
console.error('Failed to initialize support chat:', err);
setError('Failed to load support chat. Please try again.');
} finally {
setIsLoading(false);
}
};
initChat();
}, [session]);
// Socket connection and event listeners
useEffect(() => {
if (!chat) return;
const token = typeof window !== 'undefined' ? localStorage.getItem('accessToken') : null;
if (!token) return;
// Connect socket if not connected
const connectSocket = async () => {
if (!socketService.isConnected()) {
try {
await socketService.connect(token);
} catch (err) {
console.error('Socket connection failed:', err);
}
}
// Join support chat room
if (socketService.isConnected()) {
const socket = (socketService as any).socket;
if (socket) {
socket.emit('support_join', { chatId: chat.id });
}
}
};
connectSocket();
// Listen for new support messages
const handleNewMessage = (message: SupportMessage) => {
if (message.chatId === chat.id) {
setMessages(prev => {
if (prev.some(m => m.id === message.id)) return prev;
return [...prev, message];
});
// Mark as read since we're viewing
supportChatService.markAsRead(chat.id).catch(() => {});
}
};
const handleTypingStart = (data: { chatId: string; userId: string }) => {
if (data.chatId === chat.id && data.userId !== currentUserId) {
setIsTyping(true);
}
};
const handleTypingStop = (data: { chatId: string; userId: string }) => {
if (data.chatId === chat.id && data.userId !== currentUserId) {
setIsTyping(false);
}
};
// Register socket event handlers
const socket = (socketService as any).socket;
if (socket) {
socket.on('support_new_message', handleNewMessage);
socket.on('support_typing_start', handleTypingStart);
socket.on('support_typing_stop', handleTypingStop);
}
return () => {
if (socket) {
socket.off('support_new_message', handleNewMessage);
socket.off('support_typing_start', handleTypingStart);
socket.off('support_typing_stop', handleTypingStop);
socket.emit('support_leave', { chatId: chat.id });
}
};
}, [chat, currentUserId]);
// Auto-scroll to bottom on new messages
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// Send message
const handleSend = async () => {
if (!input.trim() || !chat || isSending) return;
const content = input.trim();
setInput('');
setIsSending(true);
// Stop typing indicator
const socket = (socketService as any).socket;
if (socket) {
socket.emit('support_typing_stop', { chatId: chat.id });
}
try {
// Try socket first
if (socket?.connected) {
const result = await new Promise<{ success: boolean; message?: SupportMessage; error?: string }>((resolve) => {
const timeout = setTimeout(() => resolve({ success: false, error: 'timeout' }), 10000);
socket.emit('support_send_message', { chatId: chat.id, content }, (response: any) => {
clearTimeout(timeout);
resolve(response);
});
});
if (result.success && result.message) {
setMessages(prev => {
if (prev.some(m => m.id === result.message!.id)) return prev;
return [...prev, result.message!];
});
} else {
// Fallback to REST
const message = await supportChatService.sendMessage(chat.id, content);
setMessages(prev => [...prev, message]);
}
} else {
// REST fallback
const message = await supportChatService.sendMessage(chat.id, content);
setMessages(prev => [...prev, message]);
}
} catch (err) {
console.error('Failed to send message:', err);
setInput(content); // Restore message
} finally {
setIsSending(false);
}
};
// Handle typing indicator
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInput(e.target.value);
if (!chat) return;
const socket = (socketService as any).socket;
if (!socket?.connected) return;
socket.emit('support_typing_start', { chatId: chat.id });
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
typingTimeoutRef.current = setTimeout(() => {
socket.emit('support_typing_stop', { chatId: chat.id });
}, 3000);
};
// Load older messages
const loadMore = useCallback(async () => {
if (!chat || !hasMore || isLoadingMore) return;
setIsLoadingMore(true);
try {
const nextPage = currentPage + 1;
const { messages: olderMessages, pagination } = await supportChatService.getMessages(chat.id, nextPage);
setMessages(prev => [...olderMessages, ...prev]);
setCurrentPage(nextPage);
setHasMore(pagination.page < pagination.pages);
} catch (err) {
console.error('Failed to load more messages:', err);
} finally {
setIsLoadingMore(false);
}
}, [chat, hasMore, isLoadingMore, currentPage]);
// Format time
const formatTime = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
if (date.toDateString() === today.toDateString()) return 'Today';
if (date.toDateString() === yesterday.toDateString()) return 'Yesterday';
return date.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' });
};
// Group messages by date
const groupedMessages = messages.reduce<{ date: string; messages: SupportMessage[] }[]>((groups, msg) => {
const dateStr = formatDate(msg.createdAt);
const lastGroup = groups[groups.length - 1];
if (lastGroup && lastGroup.date === dateStr) {
lastGroup.messages.push(msg);
} else {
groups.push({ date: dateStr, messages: [msg] });
}
return groups;
}, []);
if (status === 'loading' || (isLoading && session)) {
return (
<div className="min-h-screen bg-white flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#00293d]"></div>
</div>
);
}
if (!session) return null;
return (
<div className="min-h-screen bg-white flex flex-col">
{/* Header */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 w-full">
<CommonHeader />
</div>
{/* Chat Area */}
<main className="flex-1 max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 pb-8 w-full flex flex-col">
{/* Chat Header */}
<div className="border border-[#00293d]/10 rounded-t-[15px] px-6 py-4 flex items-center gap-3 bg-[#00293d]">
<Image
src="/assets/icons/chat-icon.svg"
alt=""
width={24}
height={24}
className="brightness-0 invert"
/>
<div>
<h1 className="font-fractul font-bold text-[18px] text-white">
Support Chat
</h1>
<p className="font-serif text-[13px] text-white/70">
{chat?.status === 'CLOSED' ? 'This chat has been closed' : 'We typically reply within a few minutes'}
</p>
</div>
</div>
{error ? (
<div className="flex-1 border-x border-[#00293d]/10 flex items-center justify-center">
<div className="text-center">
<p className="font-serif text-[16px] text-red-500 mb-4">{error}</p>
<button
onClick={() => window.location.reload()}
className="font-fractul text-[14px] text-[#e58625] hover:underline"
>
Try Again
</button>
</div>
</div>
) : (
<>
{/* Messages Area */}
<div
ref={messagesContainerRef}
className="flex-1 border-x border-[#00293d]/10 overflow-y-auto px-6 py-4 min-h-[400px] max-h-[500px] bg-[#f9f9f9]"
>
{/* Load More */}
{hasMore && (
<div className="text-center mb-4">
<button
onClick={loadMore}
disabled={isLoadingMore}
className="font-serif text-[13px] text-[#5ba4a4] hover:underline disabled:opacity-50"
>
{isLoadingMore ? 'Loading...' : 'Load older messages'}
</button>
</div>
)}
{messages.length === 0 ? (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<Image
src="/assets/icons/chat-icon.svg"
alt=""
width={48}
height={48}
className="mx-auto mb-4 opacity-30"
/>
<p className="font-fractul font-semibold text-[16px] text-[#00293d]/60 mb-2">
Welcome to Support Chat
</p>
<p className="font-serif text-[14px] text-[#00293d]/40">
Send a message to start chatting with our support team
</p>
</div>
</div>
) : (
groupedMessages.map((group) => (
<div key={group.date}>
{/* Date Separator */}
<div className="flex items-center gap-3 my-4">
<div className="flex-1 h-px bg-[#00293d]/10"></div>
<span className="font-serif text-[12px] text-[#00293d]/40">{group.date}</span>
<div className="flex-1 h-px bg-[#00293d]/10"></div>
</div>
{group.messages.map((msg) => {
const isOwn = msg.senderId === currentUserId;
return (
<div
key={msg.id}
className={`flex mb-3 ${isOwn ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[75%] rounded-[15px] px-4 py-3 ${
isOwn
? 'bg-[#00293d] text-white rounded-br-[4px]'
: 'bg-white text-[#00293d] border border-[#00293d]/10 rounded-bl-[4px]'
}`}
>
{!isOwn && (
<p className="font-fractul font-semibold text-[12px] text-[#e58625] mb-1">
Support Team
</p>
)}
<p className="font-serif text-[14px] leading-relaxed whitespace-pre-wrap break-words">
{msg.content}
</p>
<p
className={`font-serif text-[11px] mt-1 ${
isOwn ? 'text-white/50' : 'text-[#00293d]/40'
}`}
>
{formatTime(msg.createdAt)}
</p>
</div>
</div>
);
})}
</div>
))
)}
{/* Typing Indicator */}
{isTyping && (
<div className="flex justify-start mb-3">
<div className="bg-white border border-[#00293d]/10 rounded-[15px] rounded-bl-[4px] px-4 py-3">
<div className="flex gap-1">
<span className="w-2 h-2 bg-[#00293d]/30 rounded-full animate-bounce" style={{ animationDelay: '0ms' }}></span>
<span className="w-2 h-2 bg-[#00293d]/30 rounded-full animate-bounce" style={{ animationDelay: '150ms' }}></span>
<span className="w-2 h-2 bg-[#00293d]/30 rounded-full animate-bounce" style={{ animationDelay: '300ms' }}></span>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input Area */}
<div className="border border-[#00293d]/10 rounded-b-[15px] px-4 py-3 bg-white">
{chat?.status === 'CLOSED' ? (
<div className="text-center py-2">
<p className="font-serif text-[14px] text-[#00293d]/50">
This chat has been closed. Start a new chat from the FAQ page.
</p>
</div>
) : (
<div className="flex items-center gap-3">
<input
type="text"
value={input}
onChange={handleInputChange}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
placeholder="Type your message..."
className="flex-1 font-serif text-[14px] text-[#00293d] bg-[#f5f5f5] rounded-[10px] px-4 py-3 outline-none focus:ring-2 focus:ring-[#5ba4a4]/30 placeholder:text-[#00293d]/40"
disabled={isSending}
/>
<button
onClick={handleSend}
disabled={!input.trim() || isSending}
className="w-[44px] h-[44px] flex items-center justify-center bg-[#e58625] rounded-[10px] hover:bg-[#d47a1f] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSending ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
) : (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M22 2L11 13" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M22 2L15 22L11 13L2 9L22 2Z" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
</button>
</div>
)}
</div>
</>
)}
</main>
{/* Footer */}
<Footer />
</div>
);
}