feat: implement live support chat functionality with a dedicated service and UI page.
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
import Link from 'next/link';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { CommonHeader } from '@/components/layout/CommonHeader';
|
import { CommonHeader } from '@/components/layout/CommonHeader';
|
||||||
import { Footer } from '@/components/layout/Footer';
|
import { Footer } from '@/components/layout/Footer';
|
||||||
@@ -248,7 +249,10 @@ export default function FAQPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<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
|
<Image
|
||||||
src="/assets/icons/chat-icon.svg"
|
src="/assets/icons/chat-icon.svg"
|
||||||
alt=""
|
alt=""
|
||||||
@@ -256,8 +260,11 @@ export default function FAQPage() {
|
|||||||
height={20}
|
height={20}
|
||||||
/>
|
/>
|
||||||
Start Live Chat
|
Start Live Chat
|
||||||
</button>
|
</Link>
|
||||||
<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">
|
<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
|
<Image
|
||||||
src="/assets/icons/email-icon.svg"
|
src="/assets/icons/email-icon.svg"
|
||||||
alt=""
|
alt=""
|
||||||
@@ -265,7 +272,7 @@ export default function FAQPage() {
|
|||||||
height={20}
|
height={20}
|
||||||
/>
|
/>
|
||||||
Email Support
|
Email Support
|
||||||
</button>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
457
src/app/support/chat/page.tsx
Normal file
457
src/app/support/chat/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -52,6 +52,10 @@ export type {
|
|||||||
export { socketService } from './socket.service';
|
export { socketService } from './socket.service';
|
||||||
export { messagesService } from './messages.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
|
// CMS Service
|
||||||
export { cmsService } from './cms.service';
|
export { cmsService } from './cms.service';
|
||||||
|
|
||||||
|
|||||||
72
src/services/support-chat.service.ts
Normal file
72
src/services/support-chat.service.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import api from './api';
|
||||||
|
|
||||||
|
interface ApiResponse<T> {
|
||||||
|
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<SupportChat> {
|
||||||
|
const response = await api.post<ApiResponse<SupportChat>>('/support-chat');
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMyChat(): Promise<SupportChat> {
|
||||||
|
const response = await api.get<ApiResponse<SupportChat>>('/support-chat/my');
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMessages(chatId: string, page = 1): Promise<SupportMessagesResponse> {
|
||||||
|
const response = await api.get<ApiResponse<SupportMessagesResponse>>(
|
||||||
|
`/support-chat/${chatId}/messages`,
|
||||||
|
{ params: { page, limit: 50 } }
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage(chatId: string, content: string): Promise<SupportMessage> {
|
||||||
|
const response = await api.post<ApiResponse<SupportMessage>>(
|
||||||
|
`/support-chat/${chatId}/messages`,
|
||||||
|
{ content }
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAsRead(chatId: string): Promise<void> {
|
||||||
|
await api.patch(`/support-chat/${chatId}/read`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const supportChatService = new SupportChatService();
|
||||||
Reference in New Issue
Block a user