/opt/canhelp/apps/web/src/app/chat
Edit: /opt/canhelp/apps/web/src/app/chat/page.tsx (24237B)
'use client'
import { useState, useEffect, useRef, useCallback, Suspense } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import Link from 'next/link'
import { useSession } from '@/lib/auth'
import { useLocale } from '@/context/locale'
import { getChatRooms, getMessages, getDailyUsage } from '@/lib/api'
import { formatShortName } from '@/lib/formatName'
import { io, Socket } from 'socket.io-client'
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'
interface RoomEntry {
room: {
id: string
taskId: string | null
customerId: string
specialistId: string
createdAt: string
}
task: { id: string; title: string } | null
otherUser: { id: string; name: string; firstName?: string; lastName?: string; image?: string } | null
lastMessage?: { body: string; createdAt: string; translations?: Record
| null; imageUrl?: string | null } | null
}
interface MessageEntry {
message: {
id: string
roomId: string
senderId: string
body: string
imageUrl?: string | null
isRead: boolean
createdAt: string
translations?: Record | null
}
sender: { id: string; name: string; firstName?: string | null; lastName?: string | null; image?: string } | null
}
function ChatPageInner() {
const router = useRouter()
const searchParams = useSearchParams()
const { data: session, isPending } = useSession()
const { t, locale } = useLocale()
const [rooms, setRooms] = useState([])
const [loadingRooms, setLoadingRooms] = useState(true)
const [activeRoomId, setActiveRoomId] = useState(null)
const [messages, setMessages] = useState([])
const [loadingMessages, setLoadingMessages] = useState(false)
const [input, setInput] = useState('')
const [connected, setConnected] = useState(false)
const [msgLimit, setMsgLimit] = useState<{ left: number | null; sent: number } | null>(null)
const [uploadingImage, setUploadingImage] = useState(false)
const [lightboxUrl, setLightboxUrl] = useState(null)
const socketRef = useRef(null)
const messagesEndRef = useRef(null)
const inputRef = useRef(null)
const fileInputRef = useRef(null)
useEffect(() => {
if (!isPending && !session) router.push('/login')
}, [session, isPending])
// Load chat rooms
useEffect(() => {
if (!session) return
getChatRooms()
.then((data) => {
setRooms(data)
const roomParam = searchParams.get('room')
if (roomParam) setActiveRoomId(roomParam)
})
.catch((err) => console.error('[chat] getChatRooms error:', err))
.finally(() => setLoadingRooms(false))
getDailyUsage()
.then((u) => setMsgLimit({ left: u.messagesLeft, sent: u.messagesSent }))
.catch((err) => console.error('[chat] getDailyUsage error:', err))
}, [session])
// Setup Socket.IO
useEffect(() => {
if (!session) return
const socket = io(API_URL, {
withCredentials: true,
transports: ['websocket', 'polling'],
})
socketRef.current = socket
socket.on('connect', () => setConnected(true))
socket.on('disconnect', () => setConnected(false))
socket.on('new_message', (msg: MessageEntry['message']) => {
setMessages((prev) => {
// avoid duplicate if we get it back from our own send
if (prev.some((m) => m.message.id === msg.id)) return prev
// sender info: only known for current user; others show placeholder (? avatar)
const sender = session.user && session.user.id === msg.senderId
? { id: session.user.id, name: session.user.name ?? '', image: session.user.image ?? undefined }
: null
return [
...prev,
{ message: msg, sender },
]
})
// Update lastMessage in rooms list and bubble room to top
setRooms((prev) => {
const updated = prev.map((r) =>
r.room.id === msg.roomId
? { ...r, lastMessage: { body: msg.body, createdAt: msg.createdAt, translations: msg.translations ?? null, imageUrl: msg.imageUrl ?? null } }
: r
)
return updated.sort((a, b) => {
const aTime = a.lastMessage?.createdAt ? new Date(a.lastMessage.createdAt).getTime() : new Date(a.room.createdAt).getTime()
const bTime = b.lastMessage?.createdAt ? new Date(b.lastMessage.createdAt).getTime() : new Date(b.room.createdAt).getTime()
return bTime - aTime
})
})
})
socket.on('message_translated', ({ id, roomId, translations }: { id: string; roomId?: string; translations: Record }) => {
setMessages((prev) =>
prev.map((m) =>
m.message.id === id ? { ...m, message: { ...m.message, translations } } : m,
),
)
// Also update lastMessage in sidebar if this is the last message of that room
if (roomId) {
setRooms((prev) =>
prev.map((r) =>
r.room.id === roomId && r.lastMessage
? { ...r, lastMessage: { ...r.lastMessage, translations } }
: r,
),
)
}
})
return () => {
socket.disconnect()
socketRef.current = null
}
}, [session])
// Join room and load messages when activeRoomId changes
useEffect(() => {
if (!activeRoomId) return
setLoadingMessages(true)
setMessages([])
getMessages(activeRoomId)
.then(setMessages)
.catch((err) => console.error('[chat] getMessages error:', err))
.finally(() => setLoadingMessages(false))
socketRef.current?.emit('join_room', activeRoomId)
socketRef.current?.emit('mark_read', activeRoomId)
}, [activeRoomId])
// Focus input when active room changes
useEffect(() => {
if (!activeRoomId) return
const t = setTimeout(() => inputRef.current?.focus(), 50)
return () => clearTimeout(t)
}, [activeRoomId])
// Auto-scroll on new messages (within container only)
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}, [messages])
// Close lightbox on Escape
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setLightboxUrl(null) }
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [])
const sendMessage = useCallback(() => {
const body = input.trim()
if (!body || !activeRoomId || !socketRef.current) return
if (msgLimit && msgLimit.left !== null && msgLimit.left <= 0) return
socketRef.current.emit('send_message', { roomId: activeRoomId, body, locale })
setInput('')
// decrement local counter
setMsgLimit((prev) => prev && prev.left !== null ? { ...prev, left: prev.left - 1, sent: prev.sent + 1 } : prev)
}, [input, activeRoomId, msgLimit])
const uploadAndSendImage = useCallback(async (file: File) => {
if (!activeRoomId || !socketRef.current) return
setUploadingImage(true)
try {
const formData = new FormData()
formData.append('file', file)
const res = await fetch(`${API_URL}/api/uploads`, {
method: 'POST',
credentials: 'include',
body: formData,
})
if (!res.ok) throw new Error('Upload failed')
const { url } = await res.json() as { url: string }
socketRef.current.emit('send_message', { roomId: activeRoomId, body: '', imageUrl: url, locale })
} catch (_) {
// silent
} finally {
setUploadingImage(false)
if (fileInputRef.current) fileInputRef.current.value = ''
}
}, [activeRoomId, locale])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendMessage()
}
}
const LOCALE_TAG: Record = { ru: 'ru-RU', en: 'en-US', el: 'el-GR' }
const localeTag = LOCALE_TAG[locale] ?? 'el-GR'
function fmtMsgTime(iso: string): string {
const d = new Date(iso)
const now = new Date()
const diffMin = Math.floor((now.getTime() - d.getTime()) / 60000)
if (diffMin < 1) return t('chat.just_now')
if (diffMin < 60) return `${diffMin}${t('chat.min_ago')}`
if (d.toDateString() === now.toDateString()) return d.toLocaleTimeString(localeTag, { hour: '2-digit', minute: '2-digit' })
return d.toLocaleDateString(localeTag, { day: '2-digit', month: '2-digit' })
}
if (isPending || loadingRooms) {
return {t('common.loading')}
}
const activeRoom = rooms.find((r) => r.room.id === activeRoomId)
const currentUserId = session?.user?.id
return (
{/* Rooms sidebar */}
{t('nav.chat')}
{connected ? t('chat.connected') : t('chat.disconnected')}
{rooms.length === 0 ? (
{t('chat.no_rooms')}
) : (
rooms.map(({ room, task, otherUser, lastMessage }) => {
const roomLabel = task?.title ?? (formatShortName(otherUser?.firstName, otherUser?.lastName, otherUser?.name) || t('chat.direct_chat'))
const roomSub = task ? (formatShortName(otherUser?.firstName, otherUser?.lastName, otherUser?.name) || '') : t('chat.direct_chat')
return (
)
})
)}
{/* Messages panel */}
{!activeRoomId ? (
{t('chat.select')}
) : (
<>
{/* Header */}
{activeRoom?.task?.title ?? (formatShortName(activeRoom?.otherUser?.firstName, activeRoom?.otherUser?.lastName, activeRoom?.otherUser?.name) || t('chat.direct_chat'))}
{/* Task banner */}
{activeRoom?.task && (
{t('chat.linked_task')}
{activeRoom.task.title}
)}
{/* Messages */}
{loadingMessages ? (
{t('common.loading')}
) : messages.length === 0 ? (
{t('chat.no_messages')}
) : (
messages.map(({ message, sender }) => {
const isMe = message.senderId === currentUserId
return (
{/* Avatar */}
{sender?.image ? (

) : (
sender?.name?.[0]?.toUpperCase() ?? '?'
)}
{/* Bubble */}
{!isMe && (
{formatShortName(sender?.firstName, sender?.lastName, sender?.name)}
)}
{message.imageUrl ? (
) : (
{message.translations?.[locale] ?? message.body}
)}
{new Date(message.createdAt).toLocaleTimeString(localeTag, {
hour: '2-digit',
minute: '2-digit',
})}
)
})
)}
{/* Lightbox */}
{lightboxUrl && (
setLightboxUrl(null)}
>
e.stopPropagation()}>
{/* eslint-disable-next-line @next/next/no-img-element */}
e.stopPropagation()}
>
)}
{/* Input */}
{/* Hidden file input */}
{
const file = e.target.files?.[0]
if (file) uploadAndSendImage(file)
}}
/> {/* Limit banners */}
{msgLimit && msgLimit.left !== null && msgLimit.left <= 0 && (
{t('chat.limit_reached', 'Daily message limit reached')}
{t('dashboard.plan.upgrade', 'Upgrade plan')}
)}
{msgLimit && msgLimit.left !== null && msgLimit.left > 0 && msgLimit.left <= Math.ceil((msgLimit.left + msgLimit.sent) * 0.2) && (
{t('chat.limit_warning', '{n} messages left today').replace('{n}', String(msgLimit.left))}
{t('dashboard.plan.upgrade', 'Upgrade plan')}
)}
{/* Image upload button */}
>
)}
)
}
export default function ChatPage() {
return (
... }>