From c485561d622ab378f7fc8045bbec7a7a3bce2eeb Mon Sep 17 00:00:00 2001 From: pradeepkumar Date: Thu, 5 Mar 2026 06:37:44 +0530 Subject: [PATCH] feat: implement live support chat functionality with a dedicated service and UI page. --- src/app/faq/page.tsx | 15 +- src/app/support/chat/page.tsx | 457 +++++++++++++++++++++++++++ src/services/index.ts | 4 + src/services/support-chat.service.ts | 72 +++++ 4 files changed, 544 insertions(+), 4 deletions(-) create mode 100644 src/app/support/chat/page.tsx create mode 100644 src/services/support-chat.service.ts diff --git a/src/app/faq/page.tsx b/src/app/faq/page.tsx index ec9f1c3..a0cc0f3 100644 --- a/src/app/faq/page.tsx +++ b/src/app/faq/page.tsx @@ -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() {
- - +
diff --git a/src/app/support/chat/page.tsx b/src/app/support/chat/page.tsx new file mode 100644 index 0000000..69ef148 --- /dev/null +++ b/src/app/support/chat/page.tsx @@ -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(null); + const [messages, setMessages] = useState([]); + 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(null); + const messagesContainerRef = useRef(null); + const typingTimeoutRef = useRef(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) => { + 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 ( +
+
+
+ ); + } + + if (!session) return null; + + return ( +
+ {/* Header */} +
+ +
+ + {/* Chat Area */} +
+ {/* Chat Header */} +
+ +
+

+ Support Chat +

+

+ {chat?.status === 'CLOSED' ? 'This chat has been closed' : 'We typically reply within a few minutes'} +

+
+
+ + {error ? ( +
+
+

{error}

+ +
+
+ ) : ( + <> + {/* Messages Area */} +
+ {/* Load More */} + {hasMore && ( +
+ +
+ )} + + {messages.length === 0 ? ( +
+
+ +

+ Welcome to Support Chat +

+

+ Send a message to start chatting with our support team +

+
+
+ ) : ( + groupedMessages.map((group) => ( +
+ {/* Date Separator */} +
+
+ {group.date} +
+
+ + {group.messages.map((msg) => { + const isOwn = msg.senderId === currentUserId; + return ( +
+
+ {!isOwn && ( +

+ Support Team +

+ )} +

+ {msg.content} +

+

+ {formatTime(msg.createdAt)} +

+
+
+ ); + })} +
+ )) + )} + + {/* Typing Indicator */} + {isTyping && ( +
+
+
+ + + +
+
+
+ )} + +
+
+ + {/* Input Area */} +
+ {chat?.status === 'CLOSED' ? ( +
+

+ This chat has been closed. Start a new chat from the FAQ page. +

+
+ ) : ( +
+ { + 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} + /> + +
+ )} +
+ + )} +
+ + {/* Footer */} +
+
+ ); +} diff --git a/src/services/index.ts b/src/services/index.ts index 94e35e4..1132900 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -52,6 +52,10 @@ export type { export { socketService } from './socket.service'; export { messagesService } from './messages.service'; +// Support Chat Service +export { supportChatService } from './support-chat.service'; +export type { SupportChat, SupportMessage } from './support-chat.service'; + // CMS Service export { cmsService } from './cms.service'; diff --git a/src/services/support-chat.service.ts b/src/services/support-chat.service.ts new file mode 100644 index 0000000..e9082dc --- /dev/null +++ b/src/services/support-chat.service.ts @@ -0,0 +1,72 @@ +import api from './api'; + +interface ApiResponse { + success: boolean; + data: T; + timestamp: string; +} + +export interface SupportChat { + id: string; + userId: string; + status: 'OPEN' | 'CLOSED'; + lastMessageAt: string | null; + lastMessageText: string | null; + userUnreadCount: number; + adminUnreadCount: number; + createdAt: string; + updatedAt: string; +} + +export interface SupportMessage { + id: string; + chatId: string; + senderId: string; + senderRole: string; + content: string; + createdAt: string; +} + +interface SupportMessagesResponse { + messages: SupportMessage[]; + pagination: { + page: number; + limit: number; + total: number; + pages: number; + }; +} + +class SupportChatService { + async getOrCreateChat(): Promise { + const response = await api.post>('/support-chat'); + return response.data.data; + } + + async getMyChat(): Promise { + const response = await api.get>('/support-chat/my'); + return response.data.data; + } + + async getMessages(chatId: string, page = 1): Promise { + const response = await api.get>( + `/support-chat/${chatId}/messages`, + { params: { page, limit: 50 } } + ); + return response.data.data; + } + + async sendMessage(chatId: string, content: string): Promise { + const response = await api.post>( + `/support-chat/${chatId}/messages`, + { content } + ); + return response.data.data; + } + + async markAsRead(chatId: string): Promise { + await api.patch(`/support-chat/${chatId}/read`); + } +} + +export const supportChatService = new SupportChatService();