/
opt
/
canhelp
/
apps
/
web
/
src
/
app
/
chat
/
/opt/canhelp/apps/web/src/app/chat
mkdir
upload
Name
Size
Mode
Actions
page.tsx
24237
0644
edit
dl
rm
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<string, string> | 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<string, string> | 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<RoomEntry[]>([]) const [loadingRooms, setLoadingRooms] = useState(true) const [activeRoomId, setActiveRoomId] = useState<string | null>(null) const [messages, setMessages] = useState<MessageEntry[]>([]) 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<string | null>(null) const socketRef = useRef<Socket | null>(null) const messagesEndRef = useRef<HTMLDivElement>(null) const inputRef = useRef<HTMLTextAreaElement>(null) const fileInputRef = useRef<HTMLInputElement>(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<string, string> }) => { 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<string, string> = { 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 <div className="text-center py-20 text-white">{t('common.loading')}</div> } const activeRoom = rooms.find((r) => r.room.id === activeRoomId) const currentUserId = session?.user?.id return ( <div className="max-w-5xl mx-auto px-4 py-6 h-[calc(100vh-80px)] flex gap-4"> {/* Rooms sidebar */} <div className="w-72 flex-shrink-0 bg-white rounded-xl border border-gray-200 overflow-hidden flex flex-col"> <div className="p-4 border-b border-gray-100"> <h1 className="font-bold text-gray-900 text-lg">{t('nav.chat')}</h1> <div className={`flex items-center gap-1.5 mt-1`}> <span className={`w-2 h-2 rounded-full ${connected ? 'bg-green-400' : 'bg-gray-300'}`} /> <span className="text-xs text-gray-400">{connected ? t('chat.connected') : t('chat.disconnected')}</span> </div> </div> <div className="flex-1 overflow-y-auto"> {rooms.length === 0 ? ( <div className="p-6 text-center text-gray-400 text-sm"> {t('chat.no_rooms')} </div> ) : ( 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 ( <button key={room.id} onClick={() => { setActiveRoomId(room.id) setTimeout(() => inputRef.current?.focus(), 50) }} className={`w-full text-left px-4 py-3 border-b border-gray-50 hover:bg-gray-50 transition ${ activeRoomId === room.id ? 'bg-green-50 border-l-2 border-l-green-500' : '' }`} > <div className="flex items-start justify-between gap-1"> <p className="text-sm font-medium text-gray-800 line-clamp-1 flex-1"> {roomLabel} </p> {lastMessage && ( <span className="text-[10px] text-gray-400 shrink-0 mt-0.5">{fmtMsgTime(lastMessage.createdAt)}</span> )} </div> {lastMessage ? ( <p className="text-xs text-gray-400 mt-0.5 line-clamp-1"> {lastMessage.imageUrl && !lastMessage.body ? '📷' : lastMessage.translations?.[locale] ?? lastMessage.body} </p> ) : task ? ( <p className="text-xs text-gray-400 mt-0.5 line-clamp-1">{roomSub}</p> ) : null} </button> ) }) )} </div> </div> {/* Messages panel */} <div className="flex-1 bg-white rounded-xl border border-gray-200 flex flex-col overflow-hidden"> {!activeRoomId ? ( <div className="flex-1 flex items-center justify-center text-gray-400 text-sm"> {t('chat.select')} </div> ) : ( <> {/* Header */} <div className="px-5 py-4 border-b border-gray-100"> <p className="font-semibold text-gray-800 text-sm"> {activeRoom?.task?.title ?? (formatShortName(activeRoom?.otherUser?.firstName, activeRoom?.otherUser?.lastName, activeRoom?.otherUser?.name) || t('chat.direct_chat'))} </p> </div> {/* Task banner */} {activeRoom?.task && ( <Link href={`/tasks/${activeRoom.task.id}`} className="flex items-center gap-3 mx-5 mt-4 px-4 py-3 bg-green-50 border border-green-200 rounded-xl hover:bg-green-100 transition group" > <div className="w-8 h-8 rounded-lg bg-green-600 flex items-center justify-center flex-shrink-0"> <svg className="w-4 h-4 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"> <path d="M16 4h2a2 2 0 012 2v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6a2 2 0 012-2h2" /> <rect x="8" y="2" width="8" height="4" rx="1" ry="1" /> </svg> </div> <div className="flex-1 min-w-0"> <p className="text-xs text-gray-500 font-medium">{t('chat.linked_task')}</p> <p className="text-sm font-semibold text-gray-800 truncate group-hover:underline">{activeRoom.task.title}</p> </div> <svg className="w-4 h-4 text-gray-400 flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"> <polyline points="9 18 15 12 9 6" /> </svg> </Link> )} {/* Messages */} <div className="flex-1 overflow-y-auto px-5 py-4 space-y-3"> {loadingMessages ? ( <div className="text-center text-gray-400 text-sm py-10">{t('common.loading')}</div> ) : messages.length === 0 ? ( <div className="text-center text-gray-400 text-sm py-10">{t('chat.no_messages')}</div> ) : ( messages.map(({ message, sender }) => { const isMe = message.senderId === currentUserId return ( <div key={message.id} className={`flex gap-2.5 ${isMe ? 'flex-row-reverse' : ''}`}> {/* Avatar */} <div className="w-8 h-8 rounded-full bg-green-100 flex-shrink-0 flex items-center justify-center text-green-700 text-xs font-bold overflow-hidden"> {sender?.image ? ( <img src={sender.image} alt={sender.name} className="w-full h-full object-cover" /> ) : ( sender?.name?.[0]?.toUpperCase() ?? '?' )} </div> {/* Bubble */} <div className={`max-w-[70%] ${isMe ? 'items-end' : 'items-start'} flex flex-col gap-1`}> {!isMe && ( <span className="text-xs text-gray-400 px-1">{formatShortName(sender?.firstName, sender?.lastName, sender?.name)}</span> )} {message.imageUrl ? ( <button type="button" onClick={() => setLightboxUrl(`${API_URL}${message.imageUrl}`)} className="block focus:outline-none" > {/* eslint-disable-next-line @next/next/no-img-element */} <img src={`${API_URL}${message.imageUrl}`} alt="photo" className="rounded-2xl max-w-[240px] max-h-[320px] object-cover cursor-pointer hover:opacity-90 transition" /> </button> ) : ( <div className={`px-4 py-2 rounded-2xl text-sm leading-relaxed ${ isMe ? 'bg-green-600 text-white rounded-tr-sm' : 'bg-gray-100 text-gray-800 rounded-tl-sm' }`} > {message.translations?.[locale] ?? message.body} </div> )} <span className="text-xs text-gray-300 px-1"> {new Date(message.createdAt).toLocaleTimeString(localeTag, { hour: '2-digit', minute: '2-digit', })} </span> </div> </div> ) }) )} <div ref={messagesEndRef} /> </div> {/* Lightbox */} {lightboxUrl && ( <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm" onClick={() => setLightboxUrl(null)} > <div className="relative max-w-[90vw] max-h-[90vh]" onClick={(e) => e.stopPropagation()}> {/* eslint-disable-next-line @next/next/no-img-element */} <img src={lightboxUrl} alt="photo" className="max-w-[90vw] max-h-[90vh] rounded-2xl object-contain shadow-2xl" /> <button onClick={() => setLightboxUrl(null)} className="absolute -top-3 -right-3 w-8 h-8 bg-white rounded-full flex items-center justify-center shadow-lg hover:bg-gray-100 transition" aria-label="Close" > <svg className="w-4 h-4 text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}> <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /> </svg> </button> <a href={lightboxUrl} download target="_blank" rel="noreferrer" className="absolute -bottom-3 -right-3 w-8 h-8 bg-white rounded-full flex items-center justify-center shadow-lg hover:bg-gray-100 transition" aria-label="Download" onClick={(e) => e.stopPropagation()} > <svg className="w-4 h-4 text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <path strokeLinecap="round" strokeLinejoin="round" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2M7 10l5 5 5-5M12 15V3" /> </svg> </a> </div> </div> )} {/* Input */} <div className="px-4 py-3 border-t border-gray-100"> {/* Hidden file input */} <input ref={fileInputRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp" className="hidden" onChange={(e) => { const file = e.target.files?.[0] if (file) uploadAndSendImage(file) }} /> {/* Limit banners */} {msgLimit && msgLimit.left !== null && msgLimit.left <= 0 && ( <div className="mb-2 flex items-center gap-2 bg-red-50 border border-red-200 rounded-xl px-3 py-2"> <svg className="w-4 h-4 text-red-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" /> </svg> <span className="text-xs text-red-700 flex-1">{t('chat.limit_reached', 'Daily message limit reached')}</span> <Link href="/pricing" className="text-xs font-medium text-red-700 underline">{t('dashboard.plan.upgrade', 'Upgrade plan')}</Link> </div> )} {msgLimit && msgLimit.left !== null && msgLimit.left > 0 && msgLimit.left <= Math.ceil((msgLimit.left + msgLimit.sent) * 0.2) && ( <div className="mb-2 flex items-center gap-2 bg-amber-50 border border-amber-200 rounded-xl px-3 py-2"> <svg className="w-4 h-4 text-amber-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" /> </svg> <span className="text-xs text-amber-700 flex-1">{t('chat.limit_warning', '{n} messages left today').replace('{n}', String(msgLimit.left))}</span> <Link href="/pricing" className="text-xs font-medium text-amber-700 underline">{t('dashboard.plan.upgrade', 'Upgrade plan')}</Link> </div> )} <div className="flex gap-2 items-end"> {/* Image upload button */} <button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingImage || !connected || !!(msgLimit && msgLimit.left !== null && msgLimit.left <= 0)} className="h-10 w-10 text-gray-400 hover:text-gray-500 disabled:opacity-40 disabled:cursor-not-allowed flex-shrink-0 flex items-center justify-center rounded-xl hover:bg-gray-50 transition" title="Send photo" > {uploadingImage ? ( <svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24"> <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /> <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" /> </svg> ) : ( <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}> <rect x="3" y="3" width="18" height="18" rx="3" ry="3" /> <circle cx="8.5" cy="8.5" r="1.5" /> <polyline points="21 15 16 10 5 21" /> </svg> )} </button> <textarea ref={inputRef} value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={handleKeyDown} rows={1} placeholder={t('chat.placeholder')} disabled={!!(msgLimit && msgLimit.left !== null && msgLimit.left <= 0)} className="flex-1 border border-gray-300 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 resize-none max-h-32 disabled:bg-gray-50 disabled:text-gray-400" style={{ minHeight: '40px' }} /> <button onClick={sendMessage} disabled={!input.trim() || !connected || !!(msgLimit && msgLimit.left !== null && msgLimit.left <= 0)} className="h-10 w-10 bg-green-600 text-white rounded-xl hover:bg-green-700 disabled:opacity-40 disabled:cursor-not-allowed flex-shrink-0 flex items-center justify-center" > <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.2}> <path strokeLinecap="round" strokeLinejoin="round" d="M5 12h14M12 5l7 7-7 7" /> </svg> </button> </div> </div> </> )} </div> </div> ) } export default function ChatPage() { return ( <Suspense fallback={<div className="text-center py-20 text-white">...</div>}> <ChatPageInner /> </Suspense> ) }
Save
cmd:
run