From d431612a8df5e4be8bc2950bb07afb348ca62ccd Mon Sep 17 00:00:00 2001 From: duyaivy Date: Thu, 18 Jun 2026 23:32:40 +0700 Subject: [PATCH 1/4] fix(chat): resolve build issue with unuse const and improve chat interactions --- .gitignore | 3 +- frontend/src/core/hooks/use-speech-input.ts | 123 ++++++++++++++++ frontend/src/core/shared/auth.ts | 35 ++++- .../communication/chat/ChatPageRefactored.tsx | 64 ++++++-- .../pages/communication/chat/MessageRoom.tsx | 94 +++++++++--- .../chat/components/ChatWindow.tsx | 14 +- .../chat/components/MessageInputNew.tsx | 37 +++-- .../chat/components/MessageListNew.tsx | 3 +- .../disability/cv/components/voice-button.tsx | 139 ++---------------- 9 files changed, 325 insertions(+), 187 deletions(-) create mode 100644 frontend/src/core/hooks/use-speech-input.ts diff --git a/.gitignore b/.gitignore index 600e365..1eb2866 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -**/node_modules \ No newline at end of file +**/node_modules +.history/ \ No newline at end of file diff --git a/frontend/src/core/hooks/use-speech-input.ts b/frontend/src/core/hooks/use-speech-input.ts new file mode 100644 index 0000000..4b76574 --- /dev/null +++ b/frontend/src/core/hooks/use-speech-input.ts @@ -0,0 +1,123 @@ +import { startTransition, useCallback, useEffect, useRef, useState } from 'react' + +import toastifyCommon from '@/core/lib/toastify-common' +import { useSpeechStore } from '@/core/store/features/speech/speechStore' + +interface SpeechAlternativeLike { + transcript: string +} + +interface SpeechResultLike { + readonly length: number + readonly [index: number]: SpeechAlternativeLike | undefined +} + +interface SpeechResultListLike { + readonly length: number + readonly [index: number]: SpeechResultLike | undefined +} + +interface SpeechRecognitionEventLike extends Event { + readonly results: SpeechResultListLike +} + +interface SpeechRecognitionLike { + continuous: boolean + interimResults: boolean + lang: string + start: () => void + abort: () => void + onresult: ((event: SpeechRecognitionEventLike) => void) | null + onerror: (() => void) | null + onend: (() => void) | null +} + +type SpeechRecognitionConstructor = new () => SpeechRecognitionLike + +const collectSpeechTranscript = (results: SpeechResultListLike) => { + const chunks: string[] = [] + + for (let index = 0; index < results.length; index += 1) { + const transcript = results[index]?.[0]?.transcript + + if (transcript) { + chunks.push(transcript.trim()) + } + } + + return chunks.join(' ').trim() +} + +export const useSpeechInput = (onResult: (value: string) => void, label: string = '') => { + const recognitionRef = useRef(null) + const [localIsListening, setLocalIsListening] = useState(false) + const { setIsListening } = useSpeechStore() + + const startListening = useCallback(() => { + const speechWindow = window as Window & { + SpeechRecognition?: SpeechRecognitionConstructor + webkitSpeechRecognition?: SpeechRecognitionConstructor + } + const Recognition = speechWindow.SpeechRecognition || speechWindow.webkitSpeechRecognition + + if (!Recognition) { + toastifyCommon.info('Trình duyệt chưa hỗ trợ nhập bằng giọng nói') + return + } + + const recognition = new Recognition() + recognition.continuous = false + recognition.interimResults = false + recognition.lang = 'vi-VN' + + const abortFn = () => { + recognition.abort() + setLocalIsListening(false) + setIsListening(false, '', null) + } + + recognition.onresult = (event) => { + const transcript = collectSpeechTranscript(event.results) + + if (transcript) { + startTransition(() => { + onResult(transcript) + }) + } + setLocalIsListening(false) + setIsListening(false, '', null) + } + recognition.onerror = () => { + setLocalIsListening(false) + setIsListening(false, '', null) + toastifyCommon.error('Không thể hoàn tất nhập giọng nói') + } + recognition.onend = () => { + setLocalIsListening(false) + setIsListening(false, '', null) + } + + recognitionRef.current = recognition + setLocalIsListening(true) + setIsListening(true, label, abortFn) + + try { + recognition.start() + } catch { + setLocalIsListening(false) + setIsListening(false, '', null) + toastifyCommon.error('Không thể bắt đầu nhập giọng nói') + } + }, [onResult, label, setIsListening]) + + useEffect(() => { + return () => { + recognitionRef.current?.abort() + } + }, []) + + return { + isListening: localIsListening, + startListening + } +} diff --git a/frontend/src/core/shared/auth.ts b/frontend/src/core/shared/auth.ts index ba2e4ad..20c1561 100644 --- a/frontend/src/core/shared/auth.ts +++ b/frontend/src/core/shared/auth.ts @@ -1,13 +1,34 @@ -import { getAccessTokenFromLS, getRefreshTokenFromLS, getUserFromLocalStorage } from '@/core/shared/storage' +import { USER_LOCAL_STORAGE_KEY } from '@/core/helpers/common' import { type AuthState } from '@/core/store/features/auth/types' +import { type LoginResponse } from '@/models/interface/auth.interfaces' + +export const getCurrentUser = (): LoginResponse['user'] | null => { + if (typeof window === 'undefined') { + return null + } + + const rawUser = localStorage.getItem(USER_LOCAL_STORAGE_KEY) + + if (!rawUser) { + return null + } + + try { + return JSON.parse(rawUser) as LoginResponse['user'] + } catch { + return null + } +} export const getPersistedAuth = (): Partial => { - const access_token = getAccessTokenFromLS() - const refresh_token = getRefreshTokenFromLS() - const user = getUserFromLocalStorage() + const user = getCurrentUser() - return access_token ? { access_token, refresh_token, user, isAuthenticated: true } : {} + return user + ? { + user, + isAuthenticated: true + } + : {} } -export const isAuthenticated = (): boolean => !!getPersistedAuth().access_token -export const getCurrentUser = () => getPersistedAuth().user +export const isAuthenticated = (): boolean => Boolean(getCurrentUser()) diff --git a/frontend/src/pages/communication/chat/ChatPageRefactored.tsx b/frontend/src/pages/communication/chat/ChatPageRefactored.tsx index 7bff9f9..4dfce0c 100644 --- a/frontend/src/pages/communication/chat/ChatPageRefactored.tsx +++ b/frontend/src/pages/communication/chat/ChatPageRefactored.tsx @@ -1,13 +1,15 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useState } from 'react' + import { getCurrentUser } from '@/core/shared/auth' import { useAuthStore } from '@/core/store/features/auth/authStore' + import ChatSidebar, { type ConversationItem } from './components/ChatSidebar' import ChatWindow from './components/ChatWindow' -import TopNavigation from './components/TopNavigation' -import { useSocketMessages } from './useSocketMessages' -import { useAutoScroll } from './useAutoScroll' import { type Message } from './components/MessageListNew' +import TopNavigation from './components/TopNavigation' import { type SocketMessage } from './socket.types' +import { useAutoScroll } from './useAutoScroll' +import { useSocketMessages } from './useSocketMessages' import './chat.css' // Mock data - Replace with real data from your backend @@ -65,6 +67,50 @@ const MOCK_MESSAGES: Message[] = [ } ] +const MOCK_MESSAGES_BY_CONVERSATION: Record = { + '1': MOCK_MESSAGES, + '2': [ + { + id: '2-1', + content: 'Chào anh, em đã gửi portfolio và CV của mình qua email. Anh có thể xem giúp em được không?', + sender: 'other', + timestamp: '9:15 AM' + }, + { + id: '2-2', + content: 'Anh xem giúp em phần kinh nghiệm về dự án trước khi em gửi cho nhà tuyển dụng được không ạ?', + sender: 'other', + timestamp: '9:17 AM' + }, + { + id: '2-3', + content: 'Anh nhận được rồi, anh sẽ phản hồi sau khi review em nhé.', + sender: 'user', + timestamp: '9:21 AM' + } + ], + '3': [ + { + id: '3-1', + content: 'Vâng anh, em đã thực hành rồi những vẫn còn chưa ổn lắm', + sender: 'other', + timestamp: '8:45 AM' + }, + { + id: '3-2', + content: 'Phần form đăng nhập em bị lỗi validation.', + sender: 'other', + timestamp: '8:47 AM' + }, + { + id: '3-3', + content: 'Buổi tiếp theo anh sẽ hướng dẫn cách debug và fix lỗi validation cho em và các bạn học viên.', + sender: 'user', + timestamp: '8:52 AM' + } + ] +} + export default function ChatPageRefactored() { const storeUser = useAuthStore((state) => state.user) const currentUserId = storeUser?.id || getCurrentUser()?.id || '' @@ -73,9 +119,7 @@ export default function ChatPageRefactored() { const [conversations, setConversations] = useState(MOCK_CONVERSATIONS) const [activeConversationId, setActiveConversationId] = useState('1') const [messages, setMessages] = useState(MOCK_MESSAGES) - const [isLoadingMessages, setIsLoadingMessages] = useState(false) const [socketError, setSocketError] = useState(null) - const [searchQuery, setSearchQuery] = useState('') // Auto-scroll useAutoScroll(messages.length) @@ -113,7 +157,7 @@ export default function ChatPageRefactored() { ) // Initialize socket - const { sendMessage: socketSendMessage, isConnected } = useSocketMessages({ + const { sendMessage: socketSendMessage } = useSocketMessages({ conversationId: activeConversationId || '', currentUserId, onMessageReceived: handleMessageReceived, @@ -156,8 +200,6 @@ export default function ChatPageRefactored() { * Handle search */ const handleSearch = useCallback((query: string) => { - setSearchQuery(query) - if (!query.trim()) { setConversations(MOCK_CONVERSATIONS) return @@ -177,7 +219,7 @@ export default function ChatPageRefactored() { */ const handleSelectConversation = useCallback((id: string) => { setActiveConversationId(id) - setMessages(MOCK_MESSAGES) // Reset messages for demo + setMessages(MOCK_MESSAGES_BY_CONVERSATION[id] ?? []) setSocketError(null) }, []) @@ -210,7 +252,7 @@ export default function ChatPageRefactored() { conversationTitle={activeConversation.name} messages={messages} isOnline={activeConversation.isOnline} - isLoading={isLoadingMessages} + isLoading={false} onSendMessage={handleSendMessage} onCall={() => console.log('Call initiated')} onVideoCall={() => console.log('Video call initiated')} diff --git a/frontend/src/pages/communication/chat/MessageRoom.tsx b/frontend/src/pages/communication/chat/MessageRoom.tsx index 4b033e3..e086456 100644 --- a/frontend/src/pages/communication/chat/MessageRoom.tsx +++ b/frontend/src/pages/communication/chat/MessageRoom.tsx @@ -3,14 +3,16 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { getCurrentUser } from '@/core/shared/auth' import { useAuthStore } from '@/core/store/features/auth/authStore' -import { type ChatMessage } from './types' -import { useAutoScroll } from './useAutoScroll' -import { useSocketMessages } from './useSocketMessages' +import AudioControlBar from './components/AudioControlBar' +import ChatHeader from './components/ChatHeader' import MessageInput from './components/MessageInput' import MessageList from './components/MessageList' -import ChatHeader from './components/ChatHeader' -import AudioControlBar from './components/AudioControlBar' +import { getRoleLabel } from './role-labels' import { type SocketMessage } from './socket.types' +import { canUseSpeechSynthesis } from './speech' +import { type AudioDirection, type ChatMessage, type SenderRole } from './types' +import { useAutoScroll } from './useAutoScroll' +import { useSocketMessages } from './useSocketMessages' interface MessageRoomProps { conversationId: string @@ -66,12 +68,11 @@ export default function MessageRoom({ id: socketMessage.id, conversationId: socketMessage.conversationId, senderId: socketMessage.senderId, - content: socketMessage.content, text: socketMessage.content, timestamp: socketMessage.createdAt, createdAt: socketMessage.createdAt, isMine: socketMessage.isMine, - sender: (participantRole as any) || 'candidate' + sender: (participantRole as SenderRole) || 'candidate' } // Append message to state @@ -139,13 +140,16 @@ export default function MessageRoom({ return } - if (!window.speechSynthesis) { + if (!canUseSpeechSynthesis()) { setError('Speech synthesis is not supported in this browser.') return } window.speechSynthesis.cancel() const utterance = new SpeechSynthesisUtterance(draft) + utterance.onstart = () => setIsPlaying(true) + utterance.onend = () => setIsPlaying(false) + utterance.onerror = () => setIsPlaying(false) window.speechSynthesis.speak(utterance) }, [draft]) @@ -157,13 +161,19 @@ export default function MessageRoom({ return } - if (!window.speechSynthesis) { + if (!canUseSpeechSynthesis()) { setError('Speech synthesis is not supported in this browser.') return } window.speechSynthesis.cancel() const utterance = new SpeechSynthesisUtterance(message.text) + utterance.onstart = () => { + setIsPlaying(true) + setActiveMessageId(message.id) + } + utterance.onend = () => setIsPlaying(false) + utterance.onerror = () => setIsPlaying(false) window.speechSynthesis.speak(utterance) }, []) @@ -182,10 +192,62 @@ export default function MessageRoom({ [activeMessageId, messages] ) + const togglePlayback = () => { + if (!activeMessage || !canUseSpeechSynthesis()) { + return + } + + if (isPlaying) { + window.speechSynthesis.pause() + setIsPlaying(false) + return + } + + if (window.speechSynthesis.paused) { + window.speechSynthesis.resume() + setIsPlaying(true) + return + } + + handleReadMessage(activeMessage) + } + + const repeatActiveMessage = () => { + if (activeMessage) { + handleReadMessage(activeMessage) + } + } + + const readAdjacentMessage = (direction: AudioDirection) => { + if (!activeMessageId) { + return + } + + const currentIndex = messages.findIndex((message) => message.id === activeMessageId) + const nextIndex = direction === 'previous' ? currentIndex - 1 : currentIndex + 1 + const nextMessage = messages[nextIndex] + + if (nextMessage) { + handleReadMessage(nextMessage) + } + } + + const participantLabel = getRoleLabel(participantRole as SenderRole) + return (
{/* Chat Header */} - + handleReadMessage({ + id: 'conversation-title', + sender: 'candidate', + text: `D-SHIFTIFY chat. ${conversationTitle}. ${participantLabel}.`, + timestamp: '' + })} + /> {/* Error Messages */} {(error || socketError) && ( @@ -226,15 +288,13 @@ export default function MessageRoom({ {/* Message List */} -
+
diff --git a/frontend/src/pages/communication/chat/components/MessageInputNew.tsx b/frontend/src/pages/communication/chat/components/MessageInputNew.tsx index e3691b3..9f0fea2 100644 --- a/frontend/src/pages/communication/chat/components/MessageInputNew.tsx +++ b/frontend/src/pages/communication/chat/components/MessageInputNew.tsx @@ -1,23 +1,22 @@ -import { FormEvent, useRef, useState, useEffect, useCallback } from 'react' +import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react' + import { Mic, Paperclip, Send, Volume2 } from 'lucide-react' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { useSpeechInput } from '@/core/hooks/use-speech-input' + import EmojiPickerButton from './EmojiPickerButton' interface MessageInputProps { onSendMessage: (text: string) => void onReadDraft?: (text: string) => void isLoading?: boolean - isListening?: boolean - onStartListening?: () => void } export default function MessageInputNew({ onSendMessage, onReadDraft, - isLoading = false, - isListening = false, - onStartListening + isLoading = false }: MessageInputProps) { const [message, setMessage] = useState('') const [sendAnimation, setSendAnimation] = useState(false) @@ -71,6 +70,13 @@ export default function MessageInputNew({ textareaRef.current?.focus() } + const appendSpeechText = useCallback((text: string) => { + setMessage((prev) => (prev.trim() ? `${prev.trim()} ${text}` : text)) + textareaRef.current?.focus() + }, []) + + const { isListening, startListening } = useSpeechInput(appendSpeechText, 'tin nhắn') + return (
{/* Left action buttons */} -
+
{/* Emoji Picker */} @@ -90,7 +96,7 @@ export default function MessageInputNew({ type='button' aria-label='Đính kèm tệp' disabled={isLoading} - className='p-2 text-gray-500 hover:text-brand-primary hover:bg-brand-primary/5 rounded-lg + className='flex size-9 shrink-0 items-center justify-center rounded-lg p-2 text-gray-500 hover:text-brand-primary hover:bg-brand-primary/5 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-brand-primary/20' > @@ -108,7 +114,7 @@ export default function MessageInputNew({ onClick={handleReadDraft} aria-label='Đọc bản nháp' disabled={!hasContent} - className='p-2 text-gray-500 hover:text-violet-500 hover:bg-violet-50 rounded-lg + className='flex size-9 shrink-0 items-center justify-center rounded-lg p-2 text-gray-500 hover:text-violet-500 hover:bg-violet-50 transition-all duration-200 disabled:opacity-30 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-brand-primary/20' > @@ -120,7 +126,7 @@ export default function MessageInputNew({
{/* Textarea */} -
+
@@ -143,16 +149,17 @@ export default function MessageInputNew({
{/* Right action buttons */} -
+
{/* Microphone */}
diff --git a/frontend/src/pages/communication/chat/components/ChatWindow.tsx b/frontend/src/components/chat/ChatWindow.tsx similarity index 90% rename from frontend/src/pages/communication/chat/components/ChatWindow.tsx rename to frontend/src/components/chat/ChatWindow.tsx index 9252638..5911385 100644 --- a/frontend/src/pages/communication/chat/components/ChatWindow.tsx +++ b/frontend/src/components/chat/ChatWindow.tsx @@ -1,8 +1,10 @@ import { useEffect, useState } from 'react' -import ChatHeaderNew from './ChatHeaderNew' -import MessageInputNew from './MessageInputNew' -import MessageListNew, { type Message } from './MessageListNew' +import { type Message } from '@/models/interface/chat.interfaces' + +import ChatHeader from './ChatHeader' +import MessageInput from './MessageInput' +import MessageList from './MessageList' import TypingIndicator from './TypingIndicator' interface ChatWindowProps { @@ -63,7 +65,7 @@ export default function ChatWindow({
{/* Header */}
- - onReadMessage?.(msg.content)} @@ -89,7 +91,7 @@ export default function ChatWindow({ {/* Message Input */}
- word[0]) + .join('') + .toUpperCase() + .slice(0, 2) +} + +export default function MessageList({ messages, isLoading = false, onReadMessage, @@ -35,7 +37,7 @@ export default function MessageListNew({ endOfMessagesRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }) }, [messages.length]) - const contactInitial = contactName ? contactName.charAt(0).toUpperCase() : 'A' + const contactInitials = contactName ? getInitials(contactName) : 'A' return (
- {contactInitial} + {contactInitials} )} diff --git a/frontend/src/pages/communication/chat/components/TopNavigation.tsx b/frontend/src/components/chat/TopNavigation.tsx similarity index 99% rename from frontend/src/pages/communication/chat/components/TopNavigation.tsx rename to frontend/src/components/chat/TopNavigation.tsx index 3f3fbc9..08ba6e1 100644 --- a/frontend/src/pages/communication/chat/components/TopNavigation.tsx +++ b/frontend/src/components/chat/TopNavigation.tsx @@ -1,5 +1,6 @@ import { LogOut, User } from 'lucide-react' import { useNavigate } from 'react-router-dom' + import Logo from '@/components/logo/logo' import { ThemeToggle } from '@/components/theme/theme-toogle' import { useAuthStore } from '@/core/store/features/auth/authStore' diff --git a/frontend/src/pages/communication/chat/components/TypingIndicator.tsx b/frontend/src/components/chat/TypingIndicator.tsx similarity index 100% rename from frontend/src/pages/communication/chat/components/TypingIndicator.tsx rename to frontend/src/components/chat/TypingIndicator.tsx diff --git a/frontend/src/core/services/axios-client.ts b/frontend/src/core/services/axios-client.ts index b01055d..a274e8f 100644 --- a/frontend/src/core/services/axios-client.ts +++ b/frontend/src/core/services/axios-client.ts @@ -10,7 +10,6 @@ import { removeRefreshTokenFromLS, setAccessTokenToLS } from '@/core/shared/storage' -import { type LoginResponse } from '@/models/interface/auth.interfaces' const controllers = new Map() let isRefreshing = false diff --git a/frontend/src/pages/communication/chat/message.service.ts b/frontend/src/core/services/chat.service.ts similarity index 84% rename from frontend/src/pages/communication/chat/message.service.ts rename to frontend/src/core/services/chat.service.ts index 0337a94..fe7e495 100644 --- a/frontend/src/pages/communication/chat/message.service.ts +++ b/frontend/src/core/services/chat.service.ts @@ -1,6 +1,5 @@ import axiosClient from '@/core/services/axios-client' - -import { type GetConversationMessagesParams } from './types' +import { type GetConversationMessagesParams } from '@/models/interface/chat.interfaces' export const CHAT_MESSAGE_LIMIT = 30 export const USE_MOCK_CHAT_MESSAGES = import.meta.env.VITE_USE_MOCK_CHAT === 'true' diff --git a/frontend/src/pages/communication/chat/useAutoScroll.ts b/frontend/src/hooks/chat/use-auto-scroll.ts similarity index 100% rename from frontend/src/pages/communication/chat/useAutoScroll.ts rename to frontend/src/hooks/chat/use-auto-scroll.ts diff --git a/frontend/src/pages/communication/chat/useSocketMessages.ts b/frontend/src/hooks/chat/use-socket-messages.ts similarity index 88% rename from frontend/src/pages/communication/chat/useSocketMessages.ts rename to frontend/src/hooks/chat/use-socket-messages.ts index 7769e2b..6cc5018 100644 --- a/frontend/src/pages/communication/chat/useSocketMessages.ts +++ b/frontend/src/hooks/chat/use-socket-messages.ts @@ -1,8 +1,11 @@ import { useCallback, useEffect, useRef } from 'react' import { useSocketContext } from '@/contexts/useSocketContext' - -import { type ReceiveMessagePayload, type SendMessagePayload, type SocketMessage } from './socket.types' +import { + type ReceiveMessagePayload, + type SendMessagePayload, + type SocketMessage +} from '@/models/interface/chat.interfaces' interface UseSocketMessagesOptions { conversationId: string @@ -29,7 +32,6 @@ export const useSocketMessages = ({ */ const handleReceiveMessage = useCallback( (payload: ReceiveMessagePayload) => { - console.log('%c🟣 [Socket] Message received: ' + payload.content, 'color: #a855f7; font-weight: bold') // Only process messages for the current conversation if (payload.conversationId !== conversationId) { return @@ -101,7 +103,7 @@ export const useSocketMessages = ({ } eventListenersRef.current.forEach(({ event, handler }) => { - socket.off(event, handler as any) + socket.off(event, handler) }) eventListenersRef.current = [] @@ -131,11 +133,8 @@ export const useSocketMessages = ({ } try { - console.log('%c🔵 [Socket] Message sent: ' + content, 'color: #3b82f6; font-weight: bold') socket.emit('send_message', payload, (acknowledgment?: unknown) => { - if (import.meta.env.DEV) { - console.log('Message sent successfully:', acknowledgment) - } + void acknowledgment }) return true } catch (error) { diff --git a/frontend/src/hooks/routes/use-router-element.tsx b/frontend/src/hooks/routes/use-router-element.tsx index f040c09..3bb219f 100644 --- a/frontend/src/hooks/routes/use-router-element.tsx +++ b/frontend/src/hooks/routes/use-router-element.tsx @@ -2,7 +2,6 @@ import { lazy } from 'react' import { Navigate, Route, Routes, useLocation } from 'react-router-dom' -import DisabilityLayout from '@/app/layout/disability-layout' import LayoutClient from '@/app/layout/layout-client' import LayoutMain from '@/app/layout/layout-main' import SuspenseProvider from '@/app/providers/suspense-provider' @@ -16,7 +15,7 @@ const Login = lazy(() => import('@/pages/auth/login')) const Register = lazy(() => import('@/pages/auth/register')) const VerifyAccountEmail = lazy(() => import('@/pages/auth/verify-account-email')) const AccountSettingsPage = lazy(() => import('@/pages/account/settings')) -const ChatPage = lazy(() => import('@/pages/communication/chat/ChatPageRefactored')) +const ChatPage = lazy(() => import('@/pages/communication/chat')) const CallPage = lazy(() => import('@/pages/communication/call')) const VideoCallPage = lazy(() => import('@/pages/communication/video-call')) const DisabilityDashboardPage = lazy(() => import('@/pages/disability/dashboard')) @@ -33,7 +32,6 @@ const DisabilityProfilePage = lazy(() => import('@/pages/disability/profile')) const DisabilityProfileUpdatePage = lazy(() => import('@/pages/disability/profile/update')) const DisabilityCvPage = lazy(() => import('@/pages/disability/cv')) const DisabilityCvEditPage = lazy(() => import('@/pages/disability/cv/edit')) -const DisabilityCvPreviewPage = lazy(() => import('@/pages/disability/cv/preview')) const BusinessDashboardPage = lazy(() => import('@/pages/business/dashboard')) const BusinessMessagesPage = lazy(() => import('@/pages/business/messages')) const BusinessCandidatesPage = lazy(() => import('@/pages/business/candidates')) diff --git a/frontend/src/models/interface/chat.interfaces.ts b/frontend/src/models/interface/chat.interfaces.ts new file mode 100644 index 0000000..0b40ae6 --- /dev/null +++ b/frontend/src/models/interface/chat.interfaces.ts @@ -0,0 +1,55 @@ +export type ChatSender = 'user' | 'other' + +export type UserRole = 'candidate' | 'business' | 'educator' + +export interface ConversationItem { + id: string + name: string + lastMessage: string + timestamp: string + isOnline?: boolean + avatar?: string + unreadCount?: number +} + +export interface Message { + id: string + content: string + sender: ChatSender + timestamp: string + avatar?: string + status?: 'sending' | 'sent' | 'error' +} + +export interface SocketMessage { + id: string + conversationId: string + senderId: string + content: string + createdAt: string + isMine: boolean +} + +export interface SendMessagePayload { + conversationId: string + content: string + timestamp?: string +} + +export interface ReceiveMessagePayload { + id: string + conversationId: string + senderId: string + content: string + createdAt: string +} + +export interface SocketError { + code: string + message: string +} + +export interface GetConversationMessagesParams { + cursor?: string | null + limit?: number +} diff --git a/frontend/src/pages/auth/login.tsx b/frontend/src/pages/auth/login.tsx index 9ba026e..e604dd1 100644 --- a/frontend/src/pages/auth/login.tsx +++ b/frontend/src/pages/auth/login.tsx @@ -60,7 +60,7 @@ export default function Login() { loginStart() mutationLogin(data, { onSuccess: (response) => { - loginSuccess(response.data) + loginSuccess(response) }, onError: (error) => { loginFailure(error.message) diff --git a/frontend/src/pages/business/messages/index.tsx b/frontend/src/pages/business/messages/index.tsx index 2eeed3b..bc9b834 100644 --- a/frontend/src/pages/business/messages/index.tsx +++ b/frontend/src/pages/business/messages/index.tsx @@ -2,42 +2,8 @@ import { useCallback, useState } from 'react' import { getCurrentUser } from '@/core/shared/auth' import { useAuthStore } from '@/core/store/features/auth/authStore' +import { commonChatContacts } from '@/pages/communication/chat/chat.mock' import SharedChatLayout from '@/pages/communication/chat/SharedChatLayout' -import { type ConversationItem } from '@/pages/communication/chat/components/ChatSidebar' - -/** - * Mock data for recruiters messaging with candidates - */ -const MOCK_CANDIDATES: ConversationItem[] = [ - { - id: 'candidate-1', - name: 'Nguyễn Văn A', - lastMessage: 'Tôi sẵn sàng cho buổi phỏng vấn...', - timestamp: '10:30 AM', - isOnline: true - }, - { - id: 'candidate-2', - name: 'Trần Thị B', - lastMessage: 'Cảm ơn vì cơ hội...', - timestamp: '9:15 AM', - isOnline: true - }, - { - id: 'candidate-3', - name: 'Lê Văn C', - lastMessage: 'Tôi đã hoàn thành bài tập...', - timestamp: '8:45 AM', - isOnline: false - }, - { - id: 'candidate-4', - name: 'Phạm Thị D', - lastMessage: 'Khi nào có kết quả phỏng vấn?', - timestamp: '7:30 AM', - isOnline: true - } -] /** * BusinessMessagesPage - Messages page for Recruiters/Companies @@ -53,26 +19,18 @@ export default function BusinessMessagesPage() { setActiveContactId(id) }, []) - const handleSearch = useCallback((query: string) => { - console.log('Search candidates:', query) - }, []) + const handleSearch = useCallback(() => {}, []) - const handleSendMessage = useCallback((contactId: string, text: string) => { - console.log(`Message sent to candidate ${contactId}: ${text}`) - }, []) + const handleSendMessage = useCallback(() => {}, []) - const handleCall = useCallback((contactId: string) => { - console.log(`Voice call initiated with candidate ${contactId}`) - }, []) + const handleCall = useCallback(() => {}, []) - const handleVideoCall = useCallback((contactId: string) => { - console.log(`Video call initiated with candidate ${contactId}`) - }, []) + const handleVideoCall = useCallback(() => {}, []) return ( void -} - -export default function ChatContainer({ conversation, onBackToList }: ChatContainerProps) { - const storeUser = useAuthStore((state) => state.user) - const currentUserId = storeUser?.id || getCurrentUser()?.id || '' - const [messages, setMessages] = useState([]) - const [draft, setDraft] = useState('') - const [isLoading, setIsLoading] = useState(false) - const [isLoadingMore, setIsLoadingMore] = useState(false) - const [error, setError] = useState(null) - const [nextCursor, setNextCursor] = useState(null) - const [activeMessageId, setActiveMessageId] = useState(null) - const [isPlaying, setIsPlaying] = useState(false) - const [isListening, setIsListening] = useState(false) - const messageListRef = useRef(null) - const recognitionRef = useRef(null) - const shouldScrollToLatestRef = useRef(false) - const restoreScrollRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null) - - const mergeMessages = useCallback( - (currentMessages: ChatMessage[], incomingMessages: ChatMessage[]) => - sortMessagesByCreatedAt(dedupeMessagesById([...currentMessages, ...incomingMessages])), - [] - ) - - const getFallbackMessages = useCallback( - () => sortMessagesByCreatedAt(dedupeMessagesById(conversation.messages)), - [conversation.messages] - ) - - const activeMessage = useMemo( - () => messages.find((message) => message.id === activeMessageId) || null, - [activeMessageId, messages] - ) - - useEffect(() => { - let isRequestActive = true - - const loadMessages = async () => { - setMessages([]) - setNextCursor(null) - setError(null) - setActiveMessageId(null) - - if (!conversation.id) { - return - } - - setIsLoading(true) - shouldScrollToLatestRef.current = true - - try { - const response = await getConversationMessages(conversation.id) - const normalizedResponse = normalizeMessagesResponse(response, currentUserId, conversation.id) - const chatMessages = normalizedResponse.data.map((message) => toChatMessage(message, conversation.participantRole)) - - if (!isRequestActive) { - return - } - - setMessages(sortMessagesByCreatedAt(dedupeMessagesById(chatMessages))) - setNextCursor(normalizedResponse.nextCursor) - } catch (apiError) { - if (import.meta.env.DEV) { - console.warn('Unable to load conversation messages', apiError) - } - - if (!isRequestActive) { - return - } - - setError('Unable to load messages') - setMessages(USE_MOCK_CHAT_MESSAGES ? getFallbackMessages() : []) - } finally { - if (isRequestActive) { - setIsLoading(false) - } - } - } - - void loadMessages() - - return () => { - isRequestActive = false - } - }, [conversation.id, conversation.participantRole, currentUserId, getFallbackMessages]) - - useLayoutEffect(() => { - const messageList = messageListRef.current - - if (!messageList) { - return - } - - if (restoreScrollRef.current) { - const { scrollHeight, scrollTop } = restoreScrollRef.current - messageList.scrollTop = messageList.scrollHeight - scrollHeight + scrollTop - restoreScrollRef.current = null - return - } - - if (shouldScrollToLatestRef.current && !isLoading) { - messageList.scrollTop = messageList.scrollHeight - shouldScrollToLatestRef.current = false - } - }, [isLoading, messages.length]) - - const loadOlderMessages = async () => { - if (!conversation.id || !nextCursor || isLoadingMore) { - return - } - - const messageList = messageListRef.current - restoreScrollRef.current = messageList - ? { scrollHeight: messageList.scrollHeight, scrollTop: messageList.scrollTop } - : null - - setIsLoadingMore(true) - setError(null) - - try { - const response = await getConversationMessages(conversation.id, { cursor: nextCursor }) - const normalizedResponse = normalizeMessagesResponse(response, currentUserId, conversation.id) - const olderMessages = normalizedResponse.data.map((message) => toChatMessage(message, conversation.participantRole)) - - setMessages((currentMessages) => mergeMessages(currentMessages, olderMessages)) - setNextCursor(normalizedResponse.nextCursor) - } catch (apiError) { - if (import.meta.env.DEV) { - console.warn('Unable to load older conversation messages', apiError) - } - - restoreScrollRef.current = null - setError('Unable to load older messages') - } finally { - setIsLoadingMore(false) - } - } - - const readText = (text: string, messageId?: string) => { - const cleanText = text.trim() - - if (!cleanText || !canUseSpeechSynthesis()) { - return - } - - window.speechSynthesis.cancel() - - const utterance = new SpeechSynthesisUtterance(cleanText) - utterance.rate = 0.9 - utterance.pitch = 1 - - utterance.onstart = () => { - setIsPlaying(true) - if (messageId) { - setActiveMessageId(messageId) - } - } - - utterance.onend = () => setIsPlaying(false) - utterance.onerror = () => setIsPlaying(false) - - window.speechSynthesis.speak(utterance) - } - - const handleReadMessage = (message: ChatMessage) => { - readText(message.text, message.id) - } - - const togglePlayback = () => { - if (!activeMessage || !canUseSpeechSynthesis()) { - return - } - - if (isPlaying) { - window.speechSynthesis.pause() - setIsPlaying(false) - return - } - - if (window.speechSynthesis.paused) { - window.speechSynthesis.resume() - setIsPlaying(true) - return - } - - readText(activeMessage.text, activeMessage.id) - } - - const repeatActiveMessage = () => { - if (activeMessage) { - readText(activeMessage.text, activeMessage.id) - } - } - - const readAdjacentMessage = (direction: AudioDirection) => { - if (!activeMessageId) { - return - } - - const currentIndex = messages.findIndex((message) => message.id === activeMessageId) - const nextIndex = direction === 'previous' ? currentIndex - 1 : currentIndex + 1 - const nextMessage = messages[nextIndex] - - if (nextMessage) { - readText(nextMessage.text, nextMessage.id) - } - } - - const handleSpeechInput = () => { - const Recognition = getSpeechRecognition() - - if (!Recognition) { - readText('Speech input is not available in this browser.') - return - } - - if (isListening && recognitionRef.current) { - recognitionRef.current.stop() - setIsListening(false) - return - } - - const recognition = new Recognition() - recognition.continuous = false - recognition.interimResults = false - recognition.lang = 'en-US' - - recognition.onresult = (event) => { - const transcript = Array.from({ length: event.results.length }) - .map((_, index) => event.results[index][0].transcript) - .join(' ') - .trim() - - if (transcript) { - setDraft((currentDraft) => (currentDraft ? `${currentDraft} ${transcript}` : transcript)) - } - } - - recognition.onerror = () => setIsListening(false) - recognition.onend = () => setIsListening(false) - - recognitionRef.current = recognition - setIsListening(true) - recognition.start() - } - - const sendMessage = () => { - const cleanDraft = draft.trim() - - if (!cleanDraft) { - return - } - - const sentMessage: ChatMessage = { - id: `message-${Date.now()}`, - conversationId: conversation.id, - senderId: currentUserId, - createdAt: new Date().toISOString(), - voiceUrl: null, - isMine: true, - sender: 'candidate', - text: cleanDraft, - timestamp: new Intl.DateTimeFormat('en-US', { - hour: '2-digit', - minute: '2-digit' - }).format(new Date()), - hasAudio: true - } - - setMessages((currentMessages) => [...currentMessages, sentMessage]) - setDraft('') - } - - const participantLabel = getRoleLabel(conversation.participantRole) - - return ( -
-
- readText(`D-SHIFTIFY chat. ${conversation.title}. ${participantLabel}.`)} - /> - - - - {activeMessage && ( - readAdjacentMessage('previous')} - onTogglePlayback={togglePlayback} - onNext={() => readAdjacentMessage('next')} - onRepeat={repeatActiveMessage} - /> - )} - - readText(draft)} - /> -
-
- ) -} diff --git a/frontend/src/pages/communication/chat/ChatPageRefactored.tsx b/frontend/src/pages/communication/chat/ChatPageRefactored.tsx deleted file mode 100644 index 4dfce0c..0000000 --- a/frontend/src/pages/communication/chat/ChatPageRefactored.tsx +++ /dev/null @@ -1,269 +0,0 @@ -import { useCallback, useState } from 'react' - -import { getCurrentUser } from '@/core/shared/auth' -import { useAuthStore } from '@/core/store/features/auth/authStore' - -import ChatSidebar, { type ConversationItem } from './components/ChatSidebar' -import ChatWindow from './components/ChatWindow' -import { type Message } from './components/MessageListNew' -import TopNavigation from './components/TopNavigation' -import { type SocketMessage } from './socket.types' -import { useAutoScroll } from './useAutoScroll' -import { useSocketMessages } from './useSocketMessages' -import './chat.css' - -// Mock data - Replace with real data from your backend -const MOCK_CONVERSATIONS: ConversationItem[] = [ - { - id: '1', - name: 'Tập đoàn công nghệ X', - lastMessage: 'Chào bạn, chúng tôi đã xem hồ sơ của bạn...', - timestamp: '10:30 AM', - isOnline: true, - unreadCount: 2 - }, - { - id: '2', - name: 'Ứng viên 2', - lastMessage: 'Chào anh, em đã gửi portfolio...', - timestamp: '9:15 AM', - isOnline: false, - unreadCount: 1 - }, - { - id: '3', - name: 'Học viên 3', - lastMessage: 'Vâng a, em đã thực hành rồi...', - timestamp: '8:45 AM', - isOnline: true - } -] - -const MOCK_MESSAGES: Message[] = [ - { - id: '1', - content: 'Chào bạn, chúng tôi đã xem hồ sơ của bạn để sẵn sàng cho nhà tuyển dụng', - sender: 'other', - timestamp: '10:30 AM' - }, - { - id: '2', - content: - 'Chúng tôi có thể trả 50 thiệm vào 9:30 sáng mai. Liên hệ gia sư được chi tiêu trong email của bạn cần cập nhật', - sender: 'other', - timestamp: '10:32 AM' - }, - { - id: '3', - content: 'Tuyệt vời, tôi sẽ đặt lịch vào 9:30 sáng mai. Liên hệ gia sư được ghi nhận', - sender: 'user', - timestamp: '10:35 AM' - }, - { - id: '4', - content: 'Cảm ơn anh!', - sender: 'user', - timestamp: '10:36 AM' - } -] - -const MOCK_MESSAGES_BY_CONVERSATION: Record = { - '1': MOCK_MESSAGES, - '2': [ - { - id: '2-1', - content: 'Chào anh, em đã gửi portfolio và CV của mình qua email. Anh có thể xem giúp em được không?', - sender: 'other', - timestamp: '9:15 AM' - }, - { - id: '2-2', - content: 'Anh xem giúp em phần kinh nghiệm về dự án trước khi em gửi cho nhà tuyển dụng được không ạ?', - sender: 'other', - timestamp: '9:17 AM' - }, - { - id: '2-3', - content: 'Anh nhận được rồi, anh sẽ phản hồi sau khi review em nhé.', - sender: 'user', - timestamp: '9:21 AM' - } - ], - '3': [ - { - id: '3-1', - content: 'Vâng anh, em đã thực hành rồi những vẫn còn chưa ổn lắm', - sender: 'other', - timestamp: '8:45 AM' - }, - { - id: '3-2', - content: 'Phần form đăng nhập em bị lỗi validation.', - sender: 'other', - timestamp: '8:47 AM' - }, - { - id: '3-3', - content: 'Buổi tiếp theo anh sẽ hướng dẫn cách debug và fix lỗi validation cho em và các bạn học viên.', - sender: 'user', - timestamp: '8:52 AM' - } - ] -} - -export default function ChatPageRefactored() { - const storeUser = useAuthStore((state) => state.user) - const currentUserId = storeUser?.id || getCurrentUser()?.id || '' - - // State - const [conversations, setConversations] = useState(MOCK_CONVERSATIONS) - const [activeConversationId, setActiveConversationId] = useState('1') - const [messages, setMessages] = useState(MOCK_MESSAGES) - const [socketError, setSocketError] = useState(null) - - // Auto-scroll - useAutoScroll(messages.length) - - // Get active conversation - const activeConversation = conversations.find((c) => c.id === activeConversationId) - - /** - * Handle incoming messages from socket - */ - const handleMessageReceived = useCallback( - (socketMessage: SocketMessage) => { - if (socketMessage.conversationId !== activeConversationId) { - return - } - - const message: Message = { - id: socketMessage.id, - content: socketMessage.content, - sender: socketMessage.isMine ? 'user' : 'other', - timestamp: new Date(socketMessage.createdAt).toLocaleTimeString('vi-VN', { - hour: '2-digit', - minute: '2-digit' - }) - } - - setMessages((prev) => { - if (prev.some((m) => m.id === message.id)) { - return prev - } - return [...prev, message] - }) - }, - [activeConversationId] - ) - - // Initialize socket - const { sendMessage: socketSendMessage } = useSocketMessages({ - conversationId: activeConversationId || '', - currentUserId, - onMessageReceived: handleMessageReceived, - onError: (error) => setSocketError(error.message) - }) - - /** - * Handle sending a message - */ - const handleSendMessage = useCallback( - (text: string) => { - if (!activeConversationId) { - return - } - - const success = socketSendMessage(text) - - if (!success) { - setSocketError('Không thể gửi tin nhắn. Vui lòng thử lại.') - } - }, - [activeConversationId, socketSendMessage] - ) - - /** - * Handle reading message aloud - */ - const handleReadMessage = useCallback((text: string) => { - if (!window.speechSynthesis) { - return - } - - window.speechSynthesis.cancel() - const utterance = new SpeechSynthesisUtterance(text) - utterance.lang = 'vi-VN' - window.speechSynthesis.speak(utterance) - }, []) - - /** - * Handle search - */ - const handleSearch = useCallback((query: string) => { - if (!query.trim()) { - setConversations(MOCK_CONVERSATIONS) - return - } - - const filtered = MOCK_CONVERSATIONS.filter( - (conv) => - conv.name.toLowerCase().includes(query.toLowerCase()) || - conv.lastMessage.toLowerCase().includes(query.toLowerCase()) - ) - - setConversations(filtered) - }, []) - - /** - * Handle selecting a conversation - */ - const handleSelectConversation = useCallback((id: string) => { - setActiveConversationId(id) - setMessages(MOCK_MESSAGES_BY_CONVERSATION[id] ?? []) - setSocketError(null) - }, []) - - return ( -
- {/* Top Navigation */} - - - {/* Error Alert */} - {socketError && ( -
- {socketError} -
- )} - - {/* Main Content: 2-Column Layout */} -
- {/* Left Sidebar */} - - - {/* Right Chat Window */} - {activeConversation ? ( - console.log('Call initiated')} - onVideoCall={() => console.log('Video call initiated')} - onReadMessage={handleReadMessage} - /> - ) : ( -
-

Chọn một cuộc hội thoại để bắt đầu

-
- )} -
-
- ) -} diff --git a/frontend/src/pages/communication/chat/MessageRoom.tsx b/frontend/src/pages/communication/chat/MessageRoom.tsx deleted file mode 100644 index e086456..0000000 --- a/frontend/src/pages/communication/chat/MessageRoom.tsx +++ /dev/null @@ -1,312 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' - -import { getCurrentUser } from '@/core/shared/auth' -import { useAuthStore } from '@/core/store/features/auth/authStore' - -import AudioControlBar from './components/AudioControlBar' -import ChatHeader from './components/ChatHeader' -import MessageInput from './components/MessageInput' -import MessageList from './components/MessageList' -import { getRoleLabel } from './role-labels' -import { type SocketMessage } from './socket.types' -import { canUseSpeechSynthesis } from './speech' -import { type AudioDirection, type ChatMessage, type SenderRole } from './types' -import { useAutoScroll } from './useAutoScroll' -import { useSocketMessages } from './useSocketMessages' - -interface MessageRoomProps { - conversationId: string - conversationTitle: string - participantRole: string - onBackToList: () => void - initialMessages: ChatMessage[] - isLoading?: boolean -} - -/** - * MessageRoom Component - Handles real-time message display and sending via Socket.IO - * - * Features: - * - Real-time message receiving via socket - * - Message sending with socket emission - * - Auto-scroll to newest message - * - Accessibility support (aria-labels, semantic HTML) - * - Error handling and connection state management - */ -export default function MessageRoom({ - conversationId, - conversationTitle, - participantRole, - onBackToList, - initialMessages, - isLoading = false -}: MessageRoomProps) { - const storeUser = useAuthStore((state) => state.user) - const currentUserId = storeUser?.id || getCurrentUser()?.id || '' - - // Message state - const [messages, setMessages] = useState(initialMessages) - const [draft, setDraft] = useState('') - const [error, setError] = useState(null) - const [socketError, setSocketError] = useState(null) - - // Speech recognition state - const [isListening, setIsListening] = useState(false) - const [isPlaying, setIsPlaying] = useState(false) - const [activeMessageId, setActiveMessageId] = useState(null) - - // Auto-scroll ref - const messagesEndRef = useAutoScroll(messages.length) - - /** - * Handle incoming messages from socket - */ - const handleMessageReceived = useCallback( - (socketMessage: SocketMessage) => { - // Convert socket message to ChatMessage format - const chatMessage: ChatMessage = { - id: socketMessage.id, - conversationId: socketMessage.conversationId, - senderId: socketMessage.senderId, - text: socketMessage.content, - timestamp: socketMessage.createdAt, - createdAt: socketMessage.createdAt, - isMine: socketMessage.isMine, - sender: (participantRole as SenderRole) || 'candidate' - } - - // Append message to state - setMessages((prevMessages) => { - // Prevent duplicate messages - if (prevMessages.some((m) => m.id === chatMessage.id)) { - return prevMessages - } - return [...prevMessages, chatMessage] - }) - - setSocketError(null) - }, - [participantRole] - ) - - /** - * Handle socket errors - */ - const handleSocketError = useCallback((err: Error) => { - setSocketError(err.message) - if (import.meta.env.DEV) { - console.error('Socket error:', err) - } - }, []) - - // Initialize socket message handler - const { sendMessage, isConnected } = useSocketMessages({ - conversationId, - currentUserId, - onMessageReceived: handleMessageReceived, - onError: handleSocketError - }) - - /** - * Handle sending a message - */ - const handleSendMessage = useCallback(() => { - if (!draft.trim()) { - return - } - - const success = sendMessage(draft) - - if (success) { - setDraft('') - setError(null) - } else { - setError('Failed to send message. Please try again.') - } - }, [draft, sendMessage]) - - /** - * Update initial messages when they change - */ - useEffect(() => { - setMessages(initialMessages) - }, [initialMessages]) - - /** - * Read draft text aloud - */ - const handleReadDraft = useCallback(() => { - if (!draft.trim()) { - return - } - - if (!canUseSpeechSynthesis()) { - setError('Speech synthesis is not supported in this browser.') - return - } - - window.speechSynthesis.cancel() - const utterance = new SpeechSynthesisUtterance(draft) - utterance.onstart = () => setIsPlaying(true) - utterance.onend = () => setIsPlaying(false) - utterance.onerror = () => setIsPlaying(false) - window.speechSynthesis.speak(utterance) - }, [draft]) - - /** - * Read message aloud - */ - const handleReadMessage = useCallback((message: ChatMessage) => { - if (!message.text.trim()) { - return - } - - if (!canUseSpeechSynthesis()) { - setError('Speech synthesis is not supported in this browser.') - return - } - - window.speechSynthesis.cancel() - const utterance = new SpeechSynthesisUtterance(message.text) - utterance.onstart = () => { - setIsPlaying(true) - setActiveMessageId(message.id) - } - utterance.onend = () => setIsPlaying(false) - utterance.onerror = () => setIsPlaying(false) - window.speechSynthesis.speak(utterance) - }, []) - - /** - * Handle speech input (simplified) - */ - const handleStartSpeechInput = useCallback(() => { - setIsListening(!isListening) - // Implement speech-to-text logic here if needed - }, [isListening]) - - const hasAudioControls = isPlaying || isListening - - const activeMessage = useMemo( - () => messages.find((m) => m.id === activeMessageId) || null, - [activeMessageId, messages] - ) - - const togglePlayback = () => { - if (!activeMessage || !canUseSpeechSynthesis()) { - return - } - - if (isPlaying) { - window.speechSynthesis.pause() - setIsPlaying(false) - return - } - - if (window.speechSynthesis.paused) { - window.speechSynthesis.resume() - setIsPlaying(true) - return - } - - handleReadMessage(activeMessage) - } - - const repeatActiveMessage = () => { - if (activeMessage) { - handleReadMessage(activeMessage) - } - } - - const readAdjacentMessage = (direction: AudioDirection) => { - if (!activeMessageId) { - return - } - - const currentIndex = messages.findIndex((message) => message.id === activeMessageId) - const nextIndex = direction === 'previous' ? currentIndex - 1 : currentIndex + 1 - const nextMessage = messages[nextIndex] - - if (nextMessage) { - handleReadMessage(nextMessage) - } - } - - const participantLabel = getRoleLabel(participantRole as SenderRole) - - return ( -
- {/* Chat Header */} - handleReadMessage({ - id: 'conversation-title', - sender: 'candidate', - text: `D-SHIFTIFY chat. ${conversationTitle}. ${participantLabel}.`, - timestamp: '' - })} - /> - - {/* Error Messages */} - {(error || socketError) && ( -
- {error || socketError} -
- )} - - {/* Connection Status */} - {!isConnected && ( -
- Connecting to chat server... -
- )} - - {/* Message List */} - {}} - onReadMessage={handleReadMessage} - /> - - {/* Auto-scroll anchor */} - - ) -} diff --git a/frontend/src/pages/communication/chat/SharedChatLayout.tsx b/frontend/src/pages/communication/chat/SharedChatLayout.tsx index 453ff3c..221f118 100644 --- a/frontend/src/pages/communication/chat/SharedChatLayout.tsx +++ b/frontend/src/pages/communication/chat/SharedChatLayout.tsx @@ -1,19 +1,17 @@ import { useCallback, useEffect, useMemo, useState } from 'react' +import ChatSidebar from '@/components/chat/ChatSidebar' +import ChatWindow from '@/components/chat/ChatWindow' import config from '@/core/configs/env' import { getAccessTokenFromLS } from '@/core/shared/storage' import { useAuthStore } from '@/core/store/features/auth/authStore' +import { useAutoScroll } from '@/hooks/chat/use-auto-scroll' +import { useSocketMessages } from '@/hooks/chat/use-socket-messages' +import { type ConversationItem, type Message, type SocketMessage, type UserRole } from '@/models/interface/chat.interfaces' -import ChatSidebar, { type ConversationItem } from './components/ChatSidebar' -import ChatWindow from './components/ChatWindow' -import { type Message } from './components/MessageListNew' -import { type SocketMessage } from './socket.types' -import { useAutoScroll } from './useAutoScroll' -import { useSocketMessages } from './useSocketMessages' +import { getMockMessagesForContact } from './chat.mock' import './chat.css' -export type UserRole = 'candidate' | 'business' | 'educator' - interface SharedChatLayoutProps { sidebarTitle: string contactList: ConversationItem[] @@ -47,7 +45,6 @@ export default function SharedChatLayout({ isLoading = false, pageTitle }: SharedChatLayoutProps) { - // State const [messages, setMessages] = useState(initialMessages) const [socketError, setSocketError] = useState(null) const isMockChat = config.useMockChat @@ -55,15 +52,13 @@ export default function SharedChatLayout({ const token = storeToken || getAccessTokenFromLS() const isSocketEnabled = Boolean(token) && !isMockChat - // Auto-scroll useAutoScroll(messages.length) - // Get active contact - const activeContact = useMemo(() => contactList.find((c) => c.id === activeContactId), [contactList, activeContactId]) + const activeContact = useMemo( + () => contactList.find((contact) => contact.id === activeContactId), + [activeContactId, contactList] + ) - /** - * Handle incoming messages from socket - */ const handleMessageReceived = useCallback( (socketMessage: SocketMessage) => { if (socketMessage.conversationId !== activeContactId) { @@ -80,17 +75,16 @@ export default function SharedChatLayout({ }) } - setMessages((prev) => { - if (prev.some((m) => m.id === message.id)) { - return prev + setMessages((currentMessages) => { + if (currentMessages.some((currentMessage) => currentMessage.id === message.id)) { + return currentMessages } - return [...prev, message] + return [...currentMessages, message] }) }, [activeContactId] ) - // Socket integration const { sendMessage: socketSendMessage, isConnected, @@ -102,23 +96,20 @@ export default function SharedChatLayout({ onError: (error) => setSocketError(error.message) }) - // Load mock messages or initial messages when active contact changes useEffect(() => { - if (activeContactId) { - if (!isConnected || isMockChat) { - const mockHist = getMockMessagesForContact(activeContactId, activeContact?.name || '', currentUserRole) - setMessages(mockHist) - } else { - setMessages(initialMessages) - } - } else { + if (!activeContactId) { setMessages([]) + return } - }, [activeContactId, activeContact?.name, currentUserRole, initialMessages, isConnected, isMockChat]) - /** - * Handle sending a message - */ + if (!isConnected || isMockChat) { + setMessages(getMockMessagesForContact(activeContactId, activeContact?.name || '', currentUserRole)) + return + } + + setMessages(initialMessages) + }, [activeContact?.name, activeContactId, currentUserRole, initialMessages, isConnected, isMockChat]) + const handleSendMessage = useCallback( (text: string) => { if (!activeContactId) { @@ -126,7 +117,7 @@ export default function SharedChatLayout({ } const tempId = `msg-local-${Date.now()}` - const userMsg: Message = { + const userMessage: Message = { id: tempId, content: text, sender: 'user', @@ -137,50 +128,54 @@ export default function SharedChatLayout({ status: 'sending' } - setMessages((prev) => { - if (prev.some((m) => m.id === tempId)) return prev - return [...prev, userMsg] - }) + setMessages((currentMessages) => [...currentMessages, userMessage]) const success = socketSendMessage(text) if (success || isMockChat) { - setMessages((prev) => prev.map((m) => (m.id === tempId ? { ...m, status: 'sent' } : m))) + setMessages((currentMessages) => + currentMessages.map((message) => (message.id === tempId ? { ...message, status: 'sent' } : message)) + ) onSendMessage?.(activeContactId, text) setSocketError(null) - } else { - setMessages((prev) => prev.map((m) => (m.id === tempId ? { ...m, status: 'error' } : m))) - setSocketError('Không thể gửi tin nhắn. Vui lòng kiểm tra kết nối mạng.') + return } + + setMessages((currentMessages) => + currentMessages.map((message) => (message.id === tempId ? { ...message, status: 'error' } : message)) + ) + setSocketError('Không thể gửi tin nhắn. Vui lòng kiểm tra kết nối mạng.') }, - [activeContactId, socketSendMessage, onSendMessage, isMockChat] + [activeContactId, isMockChat, onSendMessage, socketSendMessage] ) - /** - * Handle retrying a failed message - */ const handleRetryMessage = useCallback( (message: Message) => { - if (!activeContactId) return + if (!activeContactId) { + return + } - setMessages((prev) => prev.map((m) => (m.id === message.id ? { ...m, status: 'sending' } : m))) + setMessages((currentMessages) => + currentMessages.map((currentMessage) => + currentMessage.id === message.id ? { ...currentMessage, status: 'sending' } : currentMessage + ) + ) const success = socketSendMessage(message.content) - if (success || isMockChat) { - setMessages((prev) => prev.map((m) => (m.id === message.id ? { ...m, status: 'sent' } : m))) - setSocketError(null) - } else { - setMessages((prev) => prev.map((m) => (m.id === message.id ? { ...m, status: 'error' } : m))) - setSocketError('Không thể gửi tin nhắn. Vui lòng kiểm tra kết nối mạng.') - } + setMessages((currentMessages) => + currentMessages.map((currentMessage) => + currentMessage.id === message.id + ? { ...currentMessage, status: success || isMockChat ? 'sent' : 'error' } + : currentMessage + ) + ) + + setSocketError(success || isMockChat ? null : 'Không thể gửi tin nhắn. Vui lòng kiểm tra kết nối mạng.') }, - [activeContactId, socketSendMessage, isMockChat] + [activeContactId, isMockChat, socketSendMessage] ) - /** - * Handle reading message aloud - */ const handleReadMessage = useCallback((text: string) => { if (!window.speechSynthesis) { return @@ -192,19 +187,6 @@ export default function SharedChatLayout({ window.speechSynthesis.speak(utterance) }, []) - /** - * Handle search - */ - const handleSearch = useCallback( - (query: string) => { - onSearch?.(query) - }, - [onSearch] - ) - - /** - * Handle contact selection - */ const handleSelectContact = useCallback( (id: string) => { onSelectContact(id) @@ -213,18 +195,12 @@ export default function SharedChatLayout({ [onSelectContact] ) - /** - * Handle call - */ const handleCall = useCallback(() => { if (activeContactId) { onCall?.(activeContactId) } }, [activeContactId, onCall]) - /** - * Handle video call - */ const handleVideoCall = useCallback(() => { if (activeContactId) { onVideoCall?.(activeContactId) @@ -233,27 +209,22 @@ export default function SharedChatLayout({ return (
- {/* Page Header (Optional) */} {pageTitle && (

{pageTitle}

)} - {/* Main Content: 2-Column Layout */}
- {/* Left Sidebar */} - {/* Right Chat Column */}
- {/* Connection Warning Banner */} {isSocketEnabled && !isConnecting && !isConnected && (
({socketError}) : null}
)} - {/* Right Chat Window Container */} {activeContact ? (
- {/* Chat illustration */}
- +

Chào mừng bạn!

-

- Chọn một cuộc hội thoại để bắt đầu nhắn tin -

+

Chọn một cuộc hội thoại để bắt đầu nhắn tin

)} @@ -327,219 +290,3 @@ export default function SharedChatLayout({
) } - -/** - * Mock data generator for testing UI offline - */ -function getMockMessagesForContact(contactId: string, contactName: string, role: UserRole): Message[] { - // If contact is candidate (meaning user is business) - if (role === 'business') { - if (contactId === 'candidate-1') { - return [ - { - id: 'mock-1-1', - content: - 'Chào anh/chị, em rất quan tâm đến vị trí lập trình viên React bên công ty mình. Vị trí này có hỗ trợ làm việc từ xa không ạ?', - sender: 'other', - timestamp: '10:15 AM' - }, - { - id: 'mock-1-2', - content: - 'Chào Văn A, bên mình có hỗ trợ Hybrid và Remote cho các bạn thiết bị hỗ trợ đặc biệt nhé. CV của em rất ấn tượng!', - sender: 'user', - timestamp: '10:20 AM' - }, - { - id: 'mock-1-3', - content: 'Dạ thế thì tốt quá ạ. Em sẵn sàng làm bài test kỹ năng hoặc phỏng vấn bất cứ lúc nào.', - sender: 'other', - timestamp: '10:22 AM' - } - ] - } - if (contactId === 'candidate-2') { - return [ - { - id: 'mock-2-1', - content: - 'Chào anh/chị, em gửi portfolio dự án thiết kế UI/UX tiếp cận (accessibility) để bên mình xem thêm ạ.', - sender: 'other', - timestamp: '09:05 AM' - }, - { - id: 'mock-2-2', - content: 'Chào B, cảm ơn em. Anh đã nhận được file và gửi cho bộ phận thiết kế đánh giá rồi nhé.', - sender: 'user', - timestamp: '09:12 AM' - }, - { - id: 'mock-2-3', - content: 'Dạ vâng, em cảm ơn và rất mong nhận được phản hồi từ công ty.', - sender: 'other', - timestamp: '09:15 AM' - } - ] - } - if (contactId === 'candidate-3') { - return [ - { - id: 'mock-3-1', - content: - 'Chào anh, em đã hoàn thành bài tập kỹ năng HTML/CSS chuẩn WCAG mà trung tâm giới thiệu rồi ạ. Em gửi link github ở đây nhé.', - sender: 'other', - timestamp: '08:30 AM' - }, - { - id: 'mock-3-2', - content: 'Cảm ơn C, anh sẽ báo lại team kỹ thuật review. Rất nhanh chóng!', - sender: 'user', - timestamp: '08:40 AM' - } - ] - } - if (contactId === 'candidate-4') { - return [ - { - id: 'mock-4-1', - content: 'Dạ chào anh/chị, không biết kết quả vòng phỏng vấn kỹ thuật tuần trước của em thế nào rồi ạ?', - sender: 'other', - timestamp: '07:15 AM' - }, - { - id: 'mock-4-2', - content: - 'Chào D, bộ phận nhân sự đang tổng hợp kết quả. Dự kiến ngày mai sẽ gửi thư thông báo chính thức cho em nhé.', - sender: 'user', - timestamp: '07:20 AM' - }, - { - id: 'mock-4-3', - content: 'Dạ em cảm ơn anh/chị nhiều ạ. Chúc anh/chị ngày làm việc vui vẻ!', - sender: 'other', - timestamp: '07:25 AM' - } - ] - } - } - - // If contact is company or training center (meaning user is candidate) - if (role === 'candidate') { - if (contactId === 'company-1') { - return [ - { - id: 'mock-c1-1', - content: - 'Chào bạn, chúng tôi đã xem hồ sơ của bạn trên D-SHIFTIFY và muốn hẹn lịch phỏng vấn online vào 10h sáng mai.', - sender: 'other', - timestamp: '10:00 AM' - }, - { - id: 'mock-c1-2', - content: 'Dạ em chào anh/chị. Em rất sẵn lòng tham gia ạ. Lịch đó hoàn toàn phù hợp với em.', - sender: 'user', - timestamp: '10:10 AM' - }, - { - id: 'mock-c1-3', - content: 'Được rồi, link phỏng vấn trực tuyến Zoom/Meet sẽ được gửi trước buổi phỏng vấn 15 phút nhé.', - sender: 'other', - timestamp: '10:15 AM' - } - ] - } - if (contactId === 'company-2') { - return [ - { - id: 'mock-c2-1', - content: - 'Chào bạn, chúng tôi nhận được đơn ứng tuyển của bạn. Vị trí nhập liệu này hỗ trợ làm việc remote 100% phù hợp với nhu cầu của bạn.', - sender: 'other', - timestamp: '09:00 AM' - }, - { - id: 'mock-c2-2', - content: - 'Dạ em cảm ơn anh/chị, cho em hỏi công cụ làm việc của công ty có hỗ trợ tốt cho trình đọc màn hình (screen reader) không ạ?', - sender: 'user', - timestamp: '09:05 AM' - }, - { - id: 'mock-c2-3', - content: - 'Hệ thống quản lý công việc và cổng chat nội bộ của chúng tôi đều đạt chuẩn accessibility và hỗ trợ tốt cho JAWS/NVDA bạn nhé.', - sender: 'other', - timestamp: '09:15 AM' - } - ] - } - if (contactId === 'company-3') { - return [ - { - id: 'mock-c3-1', - content: - 'Cảm ơn bạn đã nộp hồ sơ quan tâm vị trí của chúng tôi. Ban nhân sự sẽ liên hệ lại sau khi duyệt hồ sơ ứng viên.', - sender: 'other', - timestamp: '08:45 AM' - } - ] - } - if (contactId === 'center-1') { - return [ - { - id: 'mock-ct1-1', - content: - 'Chào bạn, khóa học Tin học Văn phòng chuẩn tiếp cận cho người khiếm thị sẽ khai giảng vào thứ 2 tuần sau.', - sender: 'other', - timestamp: '10:50 AM' - }, - { - id: 'mock-ct1-2', - content: 'Dạ cho em hỏi học phí và tài liệu học tập có được hỗ trợ miễn phí không ạ?', - sender: 'user', - timestamp: '10:55 AM' - }, - { - id: 'mock-ct1-3', - content: - 'Học phí được tài trợ 100% bởi quỹ D-SHIFTIFY. Tài liệu dạng audio và chữ nổi cũng sẵn sàng hỗ trợ gửi đến tận nhà bạn.', - sender: 'other', - timestamp: '11:00 AM' - } - ] - } - if (contactId === 'center-2') { - return [ - { - id: 'mock-ct2-1', - content: - 'Chào bạn, chứng chỉ hoàn thành khóa học kỹ năng mềm chuyên biệt của bạn đã được duyệt và gửi về địa chỉ đăng ký.', - sender: 'other', - timestamp: '09:50 AM' - }, - { - id: 'mock-ct2-2', - content: 'Dạ em cảm ơn trung tâm và các thầy cô rất nhiều ạ! Khóa học đã giúp ích cho em rất nhiều.', - sender: 'user', - timestamp: '10:00 AM' - } - ] - } - } - - // Fallback generic conversation - return [ - { - id: `mock-gen-1`, - content: `Chào bạn, mình là đại diện của ${contactName}. Rất vui được kết nối với bạn trên D-SHIFTIFY!`, - sender: 'other', - timestamp: '09:00 AM' - }, - { - id: `mock-gen-2`, - content: 'Chào bạn, mình cũng rất vui được kết nối. Bạn cần mình cung cấp thông tin gì thêm không?', - sender: 'user', - timestamp: '09:05 AM' - } - ] -} diff --git a/frontend/src/pages/communication/chat/chat.css b/frontend/src/pages/communication/chat/chat.css index 98298ed..69c38c0 100644 --- a/frontend/src/pages/communication/chat/chat.css +++ b/frontend/src/pages/communication/chat/chat.css @@ -1,7 +1,3 @@ -/* ═══════════════════════════════════════════ - Chat UI — Animations & Utilities - ═══════════════════════════════════════════ */ - @keyframes chatFadeInUp { from { opacity: 0; @@ -25,7 +21,9 @@ } @keyframes chatPulseDot { - 0%, 60%, 100% { + 0%, + 60%, + 100% { opacity: 0.3; transform: scale(0.8); } @@ -56,7 +54,8 @@ } @keyframes chatOnlinePulse { - 0%, 100% { + 0%, + 100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.5); } 50% { @@ -65,7 +64,8 @@ } @keyframes chatSendBounce { - 0%, 100% { + 0%, + 100% { transform: scale(1); } 50% { diff --git a/frontend/src/pages/communication/chat/chat.mock.ts b/frontend/src/pages/communication/chat/chat.mock.ts new file mode 100644 index 0000000..b980f30 --- /dev/null +++ b/frontend/src/pages/communication/chat/chat.mock.ts @@ -0,0 +1,262 @@ +import { type ConversationItem, type Message, type UserRole } from '@/models/interface/chat.interfaces' + +export const commonChatContacts: ConversationItem[] = [ + { + id: 'candidate-1', + name: 'Nguyễn Minh Khang', + lastMessage: 'Tôi sẵn sàng cho buổi phỏng vấn...', + timestamp: '10:30 AM', + isOnline: true, + unreadCount: 2 + }, + { + id: 'candidate-2', + name: 'Trần Thu Hà', + lastMessage: 'Cảm ơn vì cơ hội...', + timestamp: '9:15 AM', + isOnline: true, + unreadCount: 1 + }, + { + id: 'candidate-3', + name: 'Lê Hoàng Phúc', + lastMessage: 'Tôi đã hoàn thành bài tập...', + timestamp: '8:45 AM', + isOnline: false + }, + { + id: 'candidate-4', + name: 'Phạm Gia Linh', + lastMessage: 'Khi nào có kết quả phỏng vấn?', + timestamp: '7:30 AM', + isOnline: true + } +] + +function syncBusinessContactPreviews() { + commonChatContacts.forEach((contact) => { + const [firstMessage] = getMockMessagesForContact(contact.id, contact.name, 'business') + + if (firstMessage) { + contact.lastMessage = firstMessage.content + } + }) +} + +/** + * Mock data generator for testing UI offline + */ +export function getMockMessagesForContact(contactId: string, contactName: string, role: UserRole): Message[] { + // If contact is candidate (meaning user is business) + if (role === 'business') { + if (contactId === 'candidate-1') { + return [ + { + id: 'mock-1-1', + content: + 'Chào anh/chị, em rất quan tâm đến vị trí lập trình viên React bên công ty mình. Vị trí này có hỗ trợ làm việc từ xa không ạ?', + sender: 'other', + timestamp: '10:15 AM' + }, + { + id: 'mock-1-2', + content: + 'Chào Khang, bên mình có hỗ trợ Hybrid và Remote cho các bạn thiết bị hỗ trợ đặc biệt nhé. CV của em rất ấn tượng!', + sender: 'user', + timestamp: '10:20 AM' + }, + { + id: 'mock-1-3', + content: 'Dạ thế thì tốt quá ạ. Em sẵn sàng làm bài test kỹ năng hoặc phỏng vấn bất cứ lúc nào.', + sender: 'other', + timestamp: '10:22 AM' + } + ] + } + if (contactId === 'candidate-2') { + return [ + { + id: 'mock-2-1', + content: + 'Chào anh/chị, em gửi portfolio dự án thiết kế UI/UX tiếp cận (accessibility) để bên mình xem thêm ạ.', + sender: 'other', + timestamp: '09:05 AM' + }, + { + id: 'mock-2-2', + content: 'Chào Hà, cảm ơn em. Anh đã nhận được file và gửi cho bộ phận thiết kế đánh giá rồi nhé.', + sender: 'user', + timestamp: '09:12 AM' + }, + { + id: 'mock-2-3', + content: 'Dạ vâng, em cảm ơn và rất mong nhận được phản hồi từ công ty.', + sender: 'other', + timestamp: '09:15 AM' + } + ] + } + if (contactId === 'candidate-3') { + return [ + { + id: 'mock-3-1', + content: + 'Chào anh, em đã hoàn thành bài tập kỹ năng HTML/CSS chuẩn WCAG mà trung tâm giới thiệu rồi ạ. Em gửi link github ở đây nhé.', + sender: 'other', + timestamp: '08:30 AM' + }, + { + id: 'mock-3-2', + content: 'Cảm ơn Phúc, anh sẽ báo lại team kỹ thuật review. Rất nhanh chóng!', + sender: 'user', + timestamp: '08:40 AM' + } + ] + } + if (contactId === 'candidate-4') { + return [ + { + id: 'mock-4-1', + content: 'Dạ chào anh/chị, không biết kết quả vòng phỏng vấn kỹ thuật tuần trước của em thế nào rồi ạ?', + sender: 'other', + timestamp: '07:15 AM' + }, + { + id: 'mock-4-2', + content: + 'Chào Linh, bộ phận nhân sự đang tổng hợp kết quả. Dự kiến ngày mai sẽ gửi thư thông báo chính thức cho em nhé.', + sender: 'user', + timestamp: '07:20 AM' + }, + { + id: 'mock-4-3', + content: 'Dạ em cảm ơn anh/chị nhiều ạ. Chúc anh/chị ngày làm việc vui vẻ!', + sender: 'other', + timestamp: '07:25 AM' + } + ] + } + } + + // If contact is company or training center (meaning user is candidate) + if (role === 'candidate') { + if (contactId === 'company-1') { + return [ + { + id: 'mock-c1-1', + content: + 'Chào bạn, chúng tôi đã xem hồ sơ của bạn trên D-SHIFTIFY và muốn hẹn lịch phỏng vấn online vào 10h sáng mai.', + sender: 'other', + timestamp: '10:00 AM' + }, + { + id: 'mock-c1-2', + content: 'Dạ em chào anh/chị. Em rất sẵn lòng tham gia ạ. Lịch đó hoàn toàn phù hợp với em.', + sender: 'user', + timestamp: '10:10 AM' + }, + { + id: 'mock-c1-3', + content: 'Được rồi, link phỏng vấn trực tuyến Zoom/Meet sẽ được gửi trước buổi phỏng vấn 15 phút nhé.', + sender: 'other', + timestamp: '10:15 AM' + } + ] + } + if (contactId === 'company-2') { + return [ + { + id: 'mock-c2-1', + content: + 'Chào bạn, chúng tôi nhận được đơn ứng tuyển của bạn. Vị trí nhập liệu này hỗ trợ làm việc remote 100% phù hợp với nhu cầu của bạn.', + sender: 'other', + timestamp: '09:00 AM' + }, + { + id: 'mock-c2-2', + content: + 'Dạ em cảm ơn anh/chị, cho em hỏi công cụ làm việc của công ty có hỗ trợ tốt cho trình đọc màn hình (screen reader) không ạ?', + sender: 'user', + timestamp: '09:05 AM' + }, + { + id: 'mock-c2-3', + content: + 'Hệ thống quản lý công việc và cổng chat nội bộ của chúng tôi đều đạt chuẩn accessibility và hỗ trợ tốt cho JAWS/NVDA bạn nhé.', + sender: 'other', + timestamp: '09:15 AM' + } + ] + } + if (contactId === 'company-3') { + return [ + { + id: 'mock-c3-1', + content: + 'Cảm ơn bạn đã nộp hồ sơ quan tâm vị trí của chúng tôi. Ban nhân sự sẽ liên hệ lại sau khi duyệt hồ sơ ứng viên.', + sender: 'other', + timestamp: '08:45 AM' + } + ] + } + if (contactId === 'center-1') { + return [ + { + id: 'mock-ct1-1', + content: + 'Chào bạn, khóa học Tin học Văn phòng chuẩn tiếp cận cho người khiếm thị sẽ khai giảng vào thứ 2 tuần sau.', + sender: 'other', + timestamp: '10:50 AM' + }, + { + id: 'mock-ct1-2', + content: 'Dạ cho em hỏi học phí và tài liệu học tập có được hỗ trợ miễn phí không ạ?', + sender: 'user', + timestamp: '10:55 AM' + }, + { + id: 'mock-ct1-3', + content: + 'Học phí được tài trợ 100% bởi quỹ D-SHIFTIFY. Tài liệu dạng audio và chữ nổi cũng sẵn sàng hỗ trợ gửi đến tận nhà bạn.', + sender: 'other', + timestamp: '11:00 AM' + } + ] + } + if (contactId === 'center-2') { + return [ + { + id: 'mock-ct2-1', + content: + 'Chào bạn, chứng chỉ hoàn thành khóa học kỹ năng mềm chuyên biệt của bạn đã được duyệt và gửi về địa chỉ đăng ký.', + sender: 'other', + timestamp: '09:50 AM' + }, + { + id: 'mock-ct2-2', + content: 'Dạ em cảm ơn trung tâm và các thầy cô rất nhiều ạ! Khóa học đã giúp ích cho em rất nhiều.', + sender: 'user', + timestamp: '10:00 AM' + } + ] + } + } + + // Fallback generic conversation + return [ + { + id: `mock-gen-1`, + content: `Chào bạn, mình là đại diện của ${contactName}. Rất vui được kết nối với bạn trên D-SHIFTIFY!`, + sender: 'other', + timestamp: '09:00 AM' + }, + { + id: `mock-gen-2`, + content: 'Chào bạn, mình cũng rất vui được kết nối. Bạn cần mình cung cấp thông tin gì thêm không?', + sender: 'user', + timestamp: '09:05 AM' + } + ] +} + +syncBusinessContactPreviews() diff --git a/frontend/src/pages/communication/chat/components/AudioControlBar.tsx b/frontend/src/pages/communication/chat/components/AudioControlBar.tsx deleted file mode 100644 index ba990e6..0000000 --- a/frontend/src/pages/communication/chat/components/AudioControlBar.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { Pause, Play, Repeat, SkipBack, SkipForward } from 'lucide-react' - -interface AudioControlBarProps { - isPlaying: boolean - onPrevious: () => void - onTogglePlayback: () => void - onNext: () => void - onRepeat: () => void -} - -export default function AudioControlBar({ - isPlaying, - onPrevious, - onTogglePlayback, - onNext, - onRepeat -}: AudioControlBarProps) { - return ( -
-
- - - - - - - -
-
- ) -} diff --git a/frontend/src/pages/communication/chat/components/ChatHeader.tsx b/frontend/src/pages/communication/chat/components/ChatHeader.tsx deleted file mode 100644 index e0d7f73..0000000 --- a/frontend/src/pages/communication/chat/components/ChatHeader.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { ArrowLeft, Volume2 } from 'lucide-react' - -interface ChatHeaderProps { - title: string - participantLabel: string - onBack: () => void - onReadTitle: () => void -} - -export default function ChatHeader({ title, participantLabel, onBack, onReadTitle }: ChatHeaderProps) { - return ( -
-
- - -
-

D-SHIFTIFY

-

{title}

-

{participantLabel}

-
- - -
-
- ) -} diff --git a/frontend/src/pages/communication/chat/components/ChatList.tsx b/frontend/src/pages/communication/chat/components/ChatList.tsx deleted file mode 100644 index 5219d01..0000000 --- a/frontend/src/pages/communication/chat/components/ChatList.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { Volume2 } from 'lucide-react' - -import { getRoleLabel } from '../role-labels' -import { type MockConversation } from '../types' - -interface ChatListProps { - conversations: MockConversation[] - onSelectConversation: (conversationId: string) => void - onReadListTitle: () => void -} - -const getLastMessage = (conversation: MockConversation) => conversation.messages[conversation.messages.length - 1] - -export default function ChatList({ conversations, onSelectConversation, onReadListTitle }: ChatListProps) { - return ( -
-
-
-
-
-

D-SHIFTIFY

-

Chat List

-
- - -
-
- - -
-
- ) -} diff --git a/frontend/src/pages/communication/chat/components/MessageBubble.tsx b/frontend/src/pages/communication/chat/components/MessageBubble.tsx deleted file mode 100644 index 01404d8..0000000 --- a/frontend/src/pages/communication/chat/components/MessageBubble.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { Volume2 } from 'lucide-react' - -import { getRoleLabel } from '../role-labels' -import { type ChatMessage } from '../types' - -interface MessageBubbleProps { - message: ChatMessage - onReadMessage: (message: ChatMessage) => void -} - -export default function MessageBubble({ message, onReadMessage }: MessageBubbleProps) { - const isCandidate = message.sender === 'candidate' - const senderLabel = getRoleLabel(message.sender) - - return ( -
-
-
-
-

{senderLabel}

-

{message.text}

-
- - {message.hasAudio && ( - - )} -
- - -
-
- ) -} diff --git a/frontend/src/pages/communication/chat/components/MessageInput.tsx b/frontend/src/pages/communication/chat/components/MessageInput.tsx deleted file mode 100644 index 9ac9c79..0000000 --- a/frontend/src/pages/communication/chat/components/MessageInput.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { type FormEvent } from 'react' - -import { Mic, Volume2 } from 'lucide-react' - -interface MessageInputProps { - value: string - isListening: boolean - onChange: (value: string) => void - onSubmit: () => void - onStartSpeechInput: () => void - onReadDraft: () => void -} - -export default function MessageInput({ - value, - isListening, - onChange, - onSubmit, - onStartSpeechInput, - onReadDraft -}: MessageInputProps) { - const handleSubmit = (event: FormEvent) => { - event.preventDefault() - onSubmit() - } - - return ( - - - -
- onChange(event.target.value)} - aria-label='Message input' - placeholder='Type message' - className='min-h-12 min-w-0 flex-1 border-2 border-black bg-white px-4 text-lg font-bold text-black placeholder:text-black focus:outline-none focus-visible:ring-4 focus-visible:ring-black' - /> - - - - -
- - - - ) -} diff --git a/frontend/src/pages/communication/chat/components/MessageList.tsx b/frontend/src/pages/communication/chat/components/MessageList.tsx deleted file mode 100644 index 0939208..0000000 --- a/frontend/src/pages/communication/chat/components/MessageList.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { type RefObject } from 'react' - -import { type ChatMessage } from '../types' - -import MessageBubble from './MessageBubble' - -interface MessageListProps { - messages: ChatMessage[] - hasAudioControls: boolean - isLoading: boolean - isLoadingMore: boolean - hasMore: boolean - error: string | null - listRef: RefObject - onLoadOlder: () => void - onReadMessage: (message: ChatMessage) => void -} - -export default function MessageList({ - messages, - hasAudioControls, - isLoading, - isLoadingMore, - hasMore, - error, - listRef, - onLoadOlder, - onReadMessage -}: MessageListProps) { - return ( -
- {hasMore && ( - - )} - - {isLoading &&

Loading messages...

} - - {!isLoading && error &&

Messages are unavailable.

} - - {!isLoading && !messages.length && ( -

No messages yet.

- )} - - {messages.map((message) => ( - - ))} -
- ) -} diff --git a/frontend/src/pages/communication/chat/index.tsx b/frontend/src/pages/communication/chat/index.tsx index 570f7dc..17deada 100644 --- a/frontend/src/pages/communication/chat/index.tsx +++ b/frontend/src/pages/communication/chat/index.tsx @@ -1,42 +1,31 @@ -import { useState } from 'react' +import { useCallback, useState } from 'react' -import ChatContainer from './ChatContainer' -import ChatList from './components/ChatList' -import { mockConversations } from './mock-data' +import TopNavigation from '@/components/chat/TopNavigation' +import { getCurrentUser } from '@/core/shared/auth' +import { useAuthStore } from '@/core/store/features/auth/authStore' -export default function ChatPage() { - const [activeChatId, setActiveChatId] = useState(null) - const activeConversation = mockConversations.find((conversation) => conversation.id === activeChatId) - - const readText = (text: string) => { - const cleanText = text.trim() +import { commonChatContacts } from './chat.mock' +import SharedChatLayout from './SharedChatLayout' - if (!cleanText || typeof window === 'undefined' || !('speechSynthesis' in window)) { - return - } - - window.speechSynthesis.cancel() - window.speechSynthesis.speak(new SpeechSynthesisUtterance(cleanText)) - } +export default function ChatPage() { + const storeUser = useAuthStore((state) => state.user) + const currentUserId = storeUser?.id || getCurrentUser()?.id || '' + const [activeContactId, setActiveContactId] = useState(commonChatContacts[0]?.id ?? null) - if (activeConversation) { - return ( -
- setActiveChatId(null)} - /> -
- ) - } + const handleSelectContact = useCallback((id: string) => { + setActiveContactId(id) + }, []) return ( -
- readText('D-SHIFTIFY chat list')} +
+ +
) diff --git a/frontend/src/pages/communication/chat/message.adapter.ts b/frontend/src/pages/communication/chat/message.adapter.ts deleted file mode 100644 index 325dbb9..0000000 --- a/frontend/src/pages/communication/chat/message.adapter.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { - type ChatMessage, - type ConversationRole, - type NormalizedMessage, - type NormalizedMessagesResponse, - type RawConversationMessage -} from './types' - -const getStringValue = (value: unknown): string => (typeof value === 'string' ? value : '') - -const formatMessageTime = (createdAt: string) => { - const date = new Date(createdAt) - - if (Number.isNaN(date.getTime())) { - return '' - } - - return new Intl.DateTimeFormat('en-US', { - hour: '2-digit', - minute: '2-digit' - }).format(date) -} - -export const normalizeMessage = ( - rawMessage: RawConversationMessage, - currentUserId: string, - fallbackConversationId = '' -): NormalizedMessage => { - const senderId = getStringValue(rawMessage.sender_id ?? rawMessage.senderId) - const createdAt = getStringValue(rawMessage.created_at ?? rawMessage.createdAt) - const content = getStringValue(rawMessage.content) - const conversationId = getStringValue(rawMessage.conversation_id ?? rawMessage.conversationId) || fallbackConversationId - const voiceUrl = getStringValue(rawMessage.voice_url ?? rawMessage.voiceUrl) || null - - const providedId = getStringValue(rawMessage.message_id ?? rawMessage.id) - // Temporary fallback while the backend contract is still settling. Replace this once message_id is guaranteed. - const fallbackId = [senderId || 'unknown-sender', createdAt || 'unknown-time', content || 'empty-message'].join('-') - - return { - id: providedId || fallbackId, - conversationId, - content, - voiceUrl, - senderId, - createdAt, - isMine: Boolean(currentUserId && senderId && senderId === currentUserId) - } -} - -const normalizeRawMessages = (response: unknown): RawConversationMessage[] => { - if (Array.isArray(response)) { - return response as RawConversationMessage[] - } - - if (!response || typeof response !== 'object') { - return [] - } - - const responseRecord = response as Record - - if (Array.isArray(responseRecord.data)) { - return responseRecord.data as RawConversationMessage[] - } - - if (responseRecord.data && typeof responseRecord.data === 'object') { - const nestedData = (responseRecord.data as Record).data - - if (Array.isArray(nestedData)) { - return nestedData as RawConversationMessage[] - } - } - - // Backend response is not matching the expected contract yet; keep the UI stable. - return [] -} - -const normalizeNextCursor = (response: unknown): string | null => { - if (!response || typeof response !== 'object') { - return null - } - - const responseRecord = response as Record - const directCursor = responseRecord.next_cursor ?? responseRecord.nextCursor - - if (typeof directCursor === 'string') { - return directCursor - } - - if (responseRecord.data && typeof responseRecord.data === 'object') { - const nestedCursor = (responseRecord.data as Record).next_cursor - - if (typeof nestedCursor === 'string') { - return nestedCursor - } - } - - return null -} - -export const normalizeMessagesResponse = ( - response: unknown, - currentUserId: string, - conversationId: string -): NormalizedMessagesResponse => ({ - nextCursor: normalizeNextCursor(response), - data: normalizeRawMessages(response).map((message) => normalizeMessage(message, currentUserId, conversationId)) -}) - -export const sortMessagesByCreatedAt = (messages: T[]): T[] => - [...messages].sort((firstMessage, secondMessage) => { - const firstTime = new Date(firstMessage.createdAt || firstMessage.timestamp || '').getTime() - const secondTime = new Date(secondMessage.createdAt || secondMessage.timestamp || '').getTime() - - return (Number.isNaN(firstTime) ? 0 : firstTime) - (Number.isNaN(secondTime) ? 0 : secondTime) - }) - -export const dedupeMessagesById = (messages: T[]): T[] => { - const seenMessageIds = new Set() - - return messages.filter((message) => { - if (seenMessageIds.has(message.id)) { - return false - } - - seenMessageIds.add(message.id) - return true - }) -} - -export const toChatMessage = (message: NormalizedMessage, participantRole: ConversationRole): ChatMessage => ({ - id: message.id, - conversationId: message.conversationId, - senderId: message.senderId, - createdAt: message.createdAt, - voiceUrl: message.voiceUrl, - isMine: message.isMine, - sender: message.isMine ? 'candidate' : participantRole, - text: message.content, - timestamp: formatMessageTime(message.createdAt), - hasAudio: true -}) diff --git a/frontend/src/pages/communication/chat/mock-data.ts b/frontend/src/pages/communication/chat/mock-data.ts deleted file mode 100644 index 718bc91..0000000 --- a/frontend/src/pages/communication/chat/mock-data.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { type MockConversation } from './types' - -export const mockConversations: MockConversation[] = [ - { - id: 'conversation-training-1', - contactName: 'Bright Future Training Center', - title: 'Digital Skills Class', - participantRole: 'training_facility', - messages: [ - { - id: 'training-message-1', - sender: 'candidate', - text: 'Hello, I am visually impaired and interested in your digital skills course. Do you have evening classes?', - timestamp: '08:30', - hasAudio: true - }, - { - id: 'training-message-2', - sender: 'training_facility', - text: 'Hello. Yes, our screen reader friendly class runs every Tuesday and Thursday from 6 PM to 8 PM.', - timestamp: '08:34', - hasAudio: true - }, - { - id: 'training-message-3', - sender: 'candidate', - text: 'That schedule works for me. Can I join remotely, and will the learning materials support audio reading?', - timestamp: '08:38', - hasAudio: true - }, - { - id: 'training-message-4', - sender: 'training_facility', - text: 'You can join remotely. We provide accessible documents, audio summaries, and keyboard navigation practice.', - timestamp: '08:42', - hasAudio: true - } - ] - }, - { - id: 'conversation-recruiter-1', - contactName: 'Linh Tran', - title: 'Remote Support Role', - participantRole: 'recruiter', - messages: [ - { - id: 'recruiter-message-1', - sender: 'recruiter', - text: 'Hello, this is Linh from D-SHIFTIFY. I reviewed your profile and would like to discuss a remote customer support role.', - timestamp: '09:15', - hasAudio: true - }, - { - id: 'recruiter-message-2', - sender: 'candidate', - text: 'Hello Linh. Thank you for contacting me. I am interested in learning more about the position.', - timestamp: '09:17', - hasAudio: true - }, - { - id: 'recruiter-message-3', - sender: 'recruiter', - text: 'The role uses screen reader friendly tools. The schedule is Monday to Friday with flexible start times.', - timestamp: '09:20', - hasAudio: true - }, - { - id: 'recruiter-message-4', - sender: 'candidate', - text: 'That sounds suitable for me. Could you please share the interview time and required documents?', - timestamp: '09:23', - hasAudio: true - } - ] - }, - { - id: 'conversation-admin-1', - contactName: 'D-SHIFTIFY Support', - title: 'Profile Verification', - participantRole: 'admin', - messages: [ - { - id: 'admin-message-1', - sender: 'admin', - text: 'Your profile verification is almost complete. Please confirm that your preferred contact method is phone call.', - timestamp: '10:05', - hasAudio: true - }, - { - id: 'admin-message-2', - sender: 'candidate', - text: 'Yes, phone call is my preferred contact method. Please also keep email notifications enabled.', - timestamp: '10:08', - hasAudio: true - } - ] - }, - { - id: 'conversation-candidate-1', - contactName: 'Nguyen Minh', - title: 'Peer Interview Practice', - participantRole: 'candidate', - messages: [ - { - id: 'candidate-message-1', - sender: 'candidate', - text: 'Hi, I am also preparing for a recruiter interview. Would you like to practice common questions together?', - timestamp: '11:12', - hasAudio: true - }, - { - id: 'candidate-message-2', - sender: 'candidate', - text: 'Yes, that would help. I want to practice explaining my screen reader workflow clearly.', - timestamp: '11:16', - hasAudio: true - } - ] - } -] diff --git a/frontend/src/pages/communication/chat/role-labels.ts b/frontend/src/pages/communication/chat/role-labels.ts deleted file mode 100644 index f820bd5..0000000 --- a/frontend/src/pages/communication/chat/role-labels.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { type ConversationRole, type SenderRole } from './types' - -const roleLabels: Record = { - candidate: 'Candidate', - recruiter: 'Recruiter', - admin: 'Admin', - training_facility: 'Training Facility' -} - -export const getRoleLabel = (role: ConversationRole | SenderRole) => roleLabels[role] diff --git a/frontend/src/pages/communication/chat/socket.types.ts b/frontend/src/pages/communication/chat/socket.types.ts deleted file mode 100644 index 5d0d267..0000000 --- a/frontend/src/pages/communication/chat/socket.types.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Socket event types for chat messaging - */ - -export interface SocketMessage { - id: string - conversationId: string - senderId: string - content: string - createdAt: string - isMine: boolean -} - -export interface SendMessagePayload { - conversationId: string - content: string - timestamp?: string -} - -export interface ReceiveMessagePayload { - id: string - conversationId: string - senderId: string - content: string - createdAt: string -} - -export interface SocketError { - code: string - message: string -} diff --git a/frontend/src/pages/communication/chat/speech.ts b/frontend/src/pages/communication/chat/speech.ts deleted file mode 100644 index eb66518..0000000 --- a/frontend/src/pages/communication/chat/speech.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { type SpeechRecognitionConstructor } from './types' - -export const getSpeechRecognition = () => { - if (typeof window === 'undefined') { - return null - } - - const speechWindow = window as Window & { - SpeechRecognition?: SpeechRecognitionConstructor - webkitSpeechRecognition?: SpeechRecognitionConstructor - } - - return speechWindow.SpeechRecognition || speechWindow.webkitSpeechRecognition || null -} - -export const canUseSpeechSynthesis = () => typeof window !== 'undefined' && 'speechSynthesis' in window diff --git a/frontend/src/pages/communication/chat/types.ts b/frontend/src/pages/communication/chat/types.ts deleted file mode 100644 index 0c21d86..0000000 --- a/frontend/src/pages/communication/chat/types.ts +++ /dev/null @@ -1,86 +0,0 @@ -export type SenderRole = 'candidate' | 'recruiter' | 'admin' | 'training_facility' - -export type ConversationRole = SenderRole - -export type MockConversation = { - id: string - contactName: string - title: string - participantRole: ConversationRole - messages: ChatMessage[] -} - -export type ChatMessage = { - id: string - conversationId?: string - senderId?: string - createdAt?: string - voiceUrl?: string | null - isMine?: boolean - sender: SenderRole - text: string - timestamp: string - hasAudio?: boolean -} - -export type NormalizedMessage = { - id: string - conversationId: string - content: string - voiceUrl: string | null - senderId: string - createdAt: string - isMine: boolean -} - -export type RawConversationMessage = { - message_id?: string | null - id?: string | null - conversation_id?: string | null - conversationId?: string | null - content?: string | null - voice_url?: string | null - voiceUrl?: string | null - sender_id?: string | null - senderId?: string | null - created_at?: string | null - createdAt?: string | null - [key: string]: unknown -} - -export type GetConversationMessagesParams = { - cursor?: string | null - limit?: number -} - -export type NormalizedMessagesResponse = { - nextCursor: string | null - data: NormalizedMessage[] -} - -export type AudioDirection = 'previous' | 'next' - -export type SpeechRecognitionConstructor = new () => SpeechRecognitionInstance - -export type SpeechRecognitionInstance = { - continuous: boolean - interimResults: boolean - lang: string - onresult: ((event: SpeechRecognitionResultEvent) => void) | null - onerror: (() => void) | null - onend: (() => void) | null - start: () => void - stop: () => void -} - -export type SpeechRecognitionResultEvent = { - results: { - length: number - [index: number]: { - isFinal: boolean - [index: number]: { - transcript: string - } - } - } -} diff --git a/frontend/src/pages/disability/messages/DisabilityBusinessChat.tsx b/frontend/src/pages/disability/messages/DisabilityBusinessChat.tsx index cca3a24..823e75b 100644 --- a/frontend/src/pages/disability/messages/DisabilityBusinessChat.tsx +++ b/frontend/src/pages/disability/messages/DisabilityBusinessChat.tsx @@ -2,8 +2,8 @@ import { useCallback, useState } from 'react' import { getCurrentUser } from '@/core/shared/auth' import { useAuthStore } from '@/core/store/features/auth/authStore' +import { type ConversationItem } from '@/models/interface/chat.interfaces' import SharedChatLayout from '@/pages/communication/chat/SharedChatLayout' -import { type ConversationItem } from '@/pages/communication/chat/components/ChatSidebar' const MOCK_COMPANIES: ConversationItem[] = [ { @@ -41,21 +41,13 @@ export default function DisabilityBusinessChat() { setActiveContactId(id) }, []) - const handleSearch = useCallback((query: string) => { - console.log('Search query:', query) - }, []) + const handleSearch = useCallback(() => {}, []) - const handleSendMessage = useCallback((contactId: string, text: string) => { - console.log(`Message sent to ${contactId}: ${text}`) - }, []) + const handleSendMessage = useCallback(() => {}, []) - const handleCall = useCallback((contactId: string) => { - console.log(`Voice call initiated with ${contactId}`) - }, []) + const handleCall = useCallback(() => {}, []) - const handleVideoCall = useCallback((contactId: string) => { - console.log(`Video call initiated with ${contactId}`) - }, []) + const handleVideoCall = useCallback(() => {}, []) return ( { - console.log('Search query:', query) - }, []) + const handleSearch = useCallback(() => {}, []) - const handleSendMessage = useCallback((contactId: string, text: string) => { - console.log(`Message sent to ${contactId}: ${text}`) - }, []) + const handleSendMessage = useCallback(() => {}, []) - const handleCall = useCallback((contactId: string) => { - console.log(`Voice call initiated with ${contactId}`) - }, []) + const handleCall = useCallback(() => {}, []) - const handleVideoCall = useCallback((contactId: string) => { - console.log(`Video call initiated with ${contactId}`) - }, []) + const handleVideoCall = useCallback(() => {}, []) return ( { - console.log('Search query:', query) - }, []) + const handleSearch = useCallback(() => {}, []) - const handleSendMessage = useCallback((contactId: string, text: string) => { - console.log(`Message sent to ${contactId}: ${text}`) - }, []) + const handleSendMessage = useCallback(() => {}, []) - const handleCall = useCallback((contactId: string) => { - console.log(`Voice call initiated with ${contactId}`) - }, []) + const handleCall = useCallback(() => {}, []) - const handleVideoCall = useCallback((contactId: string) => { - console.log(`Video call initiated with ${contactId}`) - }, []) + const handleVideoCall = useCallback(() => {}, []) const switchTab = (tab: 'companies' | 'training') => { setActiveTab(tab) diff --git a/frontend/src/pages/educator/messages/index.tsx b/frontend/src/pages/educator/messages/index.tsx index c31632b..199a634 100644 --- a/frontend/src/pages/educator/messages/index.tsx +++ b/frontend/src/pages/educator/messages/index.tsx @@ -2,8 +2,8 @@ import { useCallback, useState } from 'react' import { getCurrentUser } from '@/core/shared/auth' import { useAuthStore } from '@/core/store/features/auth/authStore' +import { type ConversationItem } from '@/models/interface/chat.interfaces' import SharedChatLayout from '@/pages/communication/chat/SharedChatLayout' -import { type ConversationItem } from '@/pages/communication/chat/components/ChatSidebar' /** * Mock data for educators messaging with students @@ -60,21 +60,13 @@ export default function EducatorMessagesPage() { setActiveContactId(id) }, []) - const handleSearch = useCallback((query: string) => { - console.log('Search students:', query) - }, []) + const handleSearch = useCallback(() => {}, []) - const handleSendMessage = useCallback((contactId: string, text: string) => { - console.log(`Message sent to student ${contactId}: ${text}`) - }, []) + const handleSendMessage = useCallback(() => {}, []) - const handleCall = useCallback((contactId: string) => { - console.log(`Voice call initiated with student ${contactId}`) - }, []) + const handleCall = useCallback(() => {}, []) - const handleVideoCall = useCallback((contactId: string) => { - console.log(`Video call initiated with student ${contactId}`) - }, []) + const handleVideoCall = useCallback(() => {}, []) return ( Date: Fri, 19 Jun 2026 00:10:49 +0700 Subject: [PATCH 3/4] fix(package): add missing agentation dependency --- frontend/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/package.json b/frontend/package.json index bada143..3a5ea05 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,6 +30,7 @@ "@radix-ui/react-tooltip": "^1.1.8", "@tanstack/react-query": "^5.66.0", "@tanstack/react-query-devtools": "^5.75.7", + "agentation": "^3.0.2", "antd": "^5.24.0", "axios": "^1.7.9", "class-variance-authority": "^0.7.1", From 5851e2851278b33d7cf05d6b41d99060206c0034 Mon Sep 17 00:00:00 2001 From: duyaivy Date: Fri, 19 Jun 2026 00:15:40 +0700 Subject: [PATCH 4/4] chore(vercel): add vercel configuration --- frontend/vercel.json | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 frontend/vercel.json diff --git a/frontend/vercel.json b/frontend/vercel.json new file mode 100644 index 0000000..1323cda --- /dev/null +++ b/frontend/vercel.json @@ -0,0 +1,8 @@ +{ + "rewrites": [ + { + "source": "/(.*)", + "destination": "/index.html" + } + ] +}