- {/* 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 (
-
- )
-}
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/cv/components/voice-button.tsx b/frontend/src/pages/disability/cv/components/voice-button.tsx
index ea1e077..0043c3f 100644
--- a/frontend/src/pages/disability/cv/components/voice-button.tsx
+++ b/frontend/src/pages/disability/cv/components/voice-button.tsx
@@ -1,131 +1,18 @@
-import { startTransition, useCallback, useEffect, useRef, useState } from 'react'
-
import { Loader2, Mic } from 'lucide-react'
-import toastifyCommon from '@/core/lib/toastify-common'
+import { useSpeechInput } from '@/core/hooks/use-speech-input'
import { cn } from '@/core/lib/utils'
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()
-}
-
-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
- }
-}
-
-export const VoiceButton = ({ label, onResult, compact = false }: { label: string; onResult: (value: string) => void; compact?: boolean }) => {
+export const VoiceButton = ({
+ label,
+ onResult,
+ compact = false
+}: {
+ label: string
+ onResult: (value: string) => void
+ compact?: boolean
+}) => {
const { isListening, startListening } = useSpeechInput(onResult, label)
return (
@@ -167,7 +54,11 @@ export const VoiceListeningPopup = () => {
Đang lắng nghe...
- Hãy đọc thông tin cho trường "{activeVoiceLabel || 'Văn bản'}". Hệ thống sẽ tự động chuyển đổi thành văn bản.
+ Hãy đọc thông tin cho trường{' '}
+
+ "{activeVoiceLabel || 'Văn bản'}"
+
+ . Hệ thống sẽ tự động chuyển đổi thành văn bản.