Files
clube67_newwhats.local/clube67/newwhats.local/frontend/pages/inbox/index.tsx
T

455 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import {
Search,
MoreVertical,
User,
MessageSquare,
ChevronDown,
RefreshCw,
MessageSquarePlus,
RotateCw,
ArrowLeft,
} from 'lucide-react';
import { useRouter } from 'next/router';
import { motion, AnimatePresence } from 'framer-motion';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import type { NewWhatsChat } from '../../types/inboxTypes';
import { getAvatarProxyUrl, formatPhone } from '../../utils/inboxUtils';
import { useNewInboxBridge } from '../../hooks/inbox/useNewInboxBridge';
import { useInstanceSelector } from '../../hooks/inbox/useInstanceSelector';
import { useChatFilter } from '../../hooks/inbox/useChatFilter';
import { useChatWindow } from '../../hooks/inbox/useChatWindow';
import { useMessageInput } from '../../hooks/inbox/useMessageInput';
import { messageApi } from '../../services/chatApiService';
import { useProtocolPanel } from '../../hooks/useProtocolPanel';
import ChatSidebar from '../../components/ChatSidebar';
import ThinSidebar, { TabType } from '../../components/ThinSidebar';
import InboxHeader from '../../components/InboxHeader';
import MessageArea from '../../components/MessageArea';
import InputBar from '../../components/InputBar';
import NewConversationModal from '../../components/NewConversationModal';
import ContactProfile from '../../components/ContactProfile';
import ProtocolPanel from '../../components/ProtocolPanel';
type Chat = NewWhatsChat;
export default function WhatsAppInboxPage() {
const router = useRouter()
// ── Seleção de instância (hook dedicado) ─────────────────────────────────
const {
instances,
activeInstance,
mappedActiveInstance,
connectedInstances,
isConnected,
dropdownRef,
selectInstance,
refreshInstances,
handleConnect: handleConnectInstance,
} = useInstanceSelector()
// ── Inbox bridge (chats + mensagens) ──────────────────────────────────────
const {
chats: chatsStore,
selectedChat,
messages,
loadingChats: loading,
loadingMessages,
presenceByJid,
handleSelectChat: handleSelectChatBridge,
handleSendText,
handleLoadMoreHistory,
handleMenuAction,
} = useNewInboxBridge()
// ── Filtros de chat (hook dedicado) ───────────────────────────────────────
const {
searchTerm,
activeFilter,
chatScope,
chatsByScope,
isFavoriteChat,
setSearchTerm,
setActiveFilter,
setChatScope,
toggleFavorite,
} = useChatFilter(chatsStore)
// ── Chat window (hook dedicado) ───────────────────────────────────────────
const {
scrollRef,
isTyping,
replyingTo,
reactionPickerFor,
hasMoreHistory,
loadingMore,
isProfileOpen,
isSyncingAvatar,
isChatMenuOpen,
setReplyingTo,
setIsProfileOpen,
setIsChatMenuOpen,
handleTyping,
handleScroll,
handleSelectChat: onChatWindowSelect,
handleReactToMessage,
handleSendMedia,
handleSendAudio,
handleSyncAvatar,
toggleReactionPicker,
} = useChatWindow({
selectedChat,
activeInstanceId: activeInstance?.instance ?? null,
onLoadMoreHistory: handleLoadMoreHistory,
})
// ── Mensagem de entrada (hook dedicado) ───────────────────────────────────
const { newMessage, setNewMessage, mentions, setMentions, handleSendMessage } = useMessageInput({
selectedChat,
replyingTo,
onClearReply: () => setReplyingTo(null),
onSendText: handleSendText,
})
// ── Painel de Protocolo ───────────────────────────────────────────────────
const [isProtocolOpen, setIsProtocolOpen] = useState(false)
const protocolPanel = useProtocolPanel(selectedChat ? String(selectedChat.id) : null)
// ── Estado local de UI ────────────────────────────────────────────────────
const [activeTab, setActiveTab] = useState<TabType>('chats')
const [isInstanceDropdownOpen, setIsInstanceDropdownOpen] = useState(false)
const [isOptionsMenuOpen, setIsOptionsMenuOpen] = useState(false)
const [drafts] = useState<Record<string, string>>({})
const [isMobile, setIsMobile] = useState(false)
const [isNewConvoModalOpen, setIsNewConvoModalOpen] = useState(false)
// Preloader: começa visível e some após o primeiro ciclo loading true→false
const [pageReady, setPageReady] = useState(false)
const prevLoadingRef = useRef(false)
useEffect(() => {
if (prevLoadingRef.current && !loading) setPageReady(true)
prevLoadingRef.current = loading
}, [loading])
const optionsRef = useRef<HTMLDivElement>(null)
const initialized = useRef(false)
// ── Setup mobile + click-outside ─────────────────────────────────────────
useEffect(() => {
if (initialized.current) return
initialized.current = true
const resize = () => setIsMobile(window.innerWidth < 768)
window.addEventListener('resize', resize)
resize()
const click = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) setIsInstanceDropdownOpen(false)
if (optionsRef.current && !optionsRef.current.contains(e.target as Node)) setIsOptionsMenuOpen(false)
}
document.addEventListener('mousedown', click)
return () => { window.removeEventListener('resize', resize); document.removeEventListener('mousedown', click) }
}, [])
// ── Handlers ─────────────────────────────────────────────────────────────
const handleSelectChat = useCallback((chat: Chat | null) => {
handleSelectChatBridge(chat)
onChatWindowSelect()
setIsProtocolOpen(false)
}, [handleSelectChatBridge, onChatWindowSelect])
const handleChatMenuAction = useCallback((action: string, chat: Chat) => {
handleMenuAction(action, chat, toggleFavorite)
}, [handleMenuAction, toggleFavorite])
const handleFetchChats = useCallback(async () => {
await refreshInstances()
setIsOptionsMenuOpen(false)
}, [refreshInstances])
// Voltar: se há chat aberto, fecha-o; senão, página anterior
const handleBack = useCallback(() => {
if (selectedChat) {
handleSelectChat(null)
} else {
router.back()
}
}, [selectedChat, handleSelectChat, router])
// ESC fecha chat ou vai para página anterior
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') handleBack()
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [handleBack])
// ── Render ────────────────────────────────────────────────────────────────
return (
<div className="flex h-screen w-full bg-[#f0f2f5] overflow-hidden select-none font-sans text-[#111b21]">
{/* Preloader global visível até o primeiro carregamento de chats completar */}
<AnimatePresence>
{!pageReady && (
<motion.div
key="inbox-preloader"
exit={{ opacity: 0 }}
transition={{ duration: 0.4 }}
className="fixed inset-0 z-[9999] flex items-center justify-center bg-[#070b14]"
>
<div className="flex flex-col items-center gap-5">
<motion.div
animate={{ opacity: [1, 0.2, 1] }}
transition={{ duration: 1.6, repeat: Infinity, ease: 'easeInOut' }}
>
<svg viewBox="0 0 175.216 175.552" xmlns="http://www.w3.org/2000/svg" className="w-20 h-20">
<defs>
<linearGradient id="wpp-grad" x1="85.915" y1="173.376" x2="85.915" y2="5.765" gradientUnits="userSpaceOnUse">
<stop offset="0" stopColor="#20b038"/>
<stop offset="1" stopColor="#60d66a"/>
</linearGradient>
</defs>
<path d="M87.184 25.227c-33.733 0-61.166 27.423-61.178 61.13a60.98 60.98 0 0 0 8.232 30.673L26 149.344l33.79-8.854a61.22 61.22 0 0 0 29.376 7.483h.023c33.73 0 61.17-27.423 61.18-61.13a60.75 60.75 0 0 0-17.91-43.263 60.77 60.77 0 0 0-43.27-17.353z" fill="url(#wpp-grad)"/>
<path d="M122.08 106.07c-1.96-1.02-11.6-5.72-13.4-6.38-1.8-.66-3.11-.98-4.42.98-1.31 1.96-5.06 6.38-6.21 7.69s-2.29 1.47-4.25.49c-11.6-5.8-19.21-10.36-26.83-23.5-2.03-3.49.43-3.25 1.16-5.42.6-1.77.3-3.27-.17-4.25-.49-.98-4.42-10.65-6.05-14.58-1.59-3.83-3.22-3.3-4.42-3.36-1.14-.06-2.45-.07-3.76-.07s-3.44.49-5.24 2.45c-1.8 1.96-6.87 6.71-6.87 16.37s7.04 18.99 8.02 20.3c.98 1.31 13.85 21.14 33.57 29.67 12.47 5.38 17.35 5.83 23.59 4.91 3.79-.57 11.6-4.74 13.24-9.33 1.63-4.59 1.63-8.52 1.14-9.33-.47-.81-1.78-1.31-3.74-2.31z" fill="#fff"/>
</svg>
</motion.div>
<span className="text-slate-500 text-sm">Carregando conversas</span>
</div>
</motion.div>
)}
</AnimatePresence>
<div className="flex flex-1 overflow-hidden h-full">
<ThinSidebar activeTab={activeTab} setActiveTab={setActiveTab} activeInstance={activeInstance} />
{activeTab === 'chats' && (
<div className={`${isMobile ? (selectedChat ? 'hidden' : 'w-full') : 'w-[400px]'} flex flex-col bg-white border-r border-[#d1d7db] shrink-0 z-20`}>
{/* Header da sidebar */}
<div className="h-[60px] px-4 flex items-center justify-between bg-[#f0f2f5] border-b border-[#d1d7db] shrink-0">
<div className="flex items-center gap-1">
<button
onClick={handleBack}
title={selectedChat ? 'Voltar aos chats' : 'Voltar'}
className="p-2 hover:bg-black/5 rounded-full text-[#54656f] shrink-0"
>
<ArrowLeft className="w-5 h-5" />
</button>
<div className="relative" ref={dropdownRef}>
<div onClick={() => setIsInstanceDropdownOpen(!isInstanceDropdownOpen)} className="flex items-center gap-3 cursor-pointer p-1">
<div className="w-10 h-10 rounded-full bg-white border border-black/5 overflow-hidden flex items-center justify-center">
{activeInstance?.instance
? <img
src={getAvatarProxyUrl({
instance_name: activeInstance.instance,
remote_jid: activeInstance.phone ? `${activeInstance.phone}@s.whatsapp.net` : null,
}) || activeInstance.avatar || ''}
className="w-full h-full object-cover" alt=""
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none' }}
/>
: <User className="text-gray-400" />}
</div>
<span className="font-bold text-sm truncate max-w-[120px]">{activeInstance?.nome || 'Instância'}</span>
<ChevronDown className={`w-4 h-4 transition-all ${isInstanceDropdownOpen ? 'rotate-180' : ''}`} />
</div>
<AnimatePresence>
{isInstanceDropdownOpen && (
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }}
className="absolute left-0 top-full bg-white shadow-2xl rounded-xl border border-gray-100 w-72 z-50 py-2">
{instances.map(i => (
<button key={i.instance} onClick={() => { selectInstance(i); setIsInstanceDropdownOpen(false) }}
className="w-full p-3 hover:bg-gray-50 flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-gray-100 overflow-hidden">
<img
src={getAvatarProxyUrl({
instance_name: i.instance,
remote_jid: i.phone ? `${i.phone}@s.whatsapp.net` : null,
}) || i.avatar || ''}
alt="" className="w-full h-full object-cover"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none' }}
/>
</div>
<div className="text-left">
<span className="text-sm font-medium block">{i.nome}</span>
<span className={`text-xs ${i.status === 'connected' ? 'text-green-500' : 'text-gray-400'}`}>{i.status}</span>
</div>
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
</div>{/* end left flex */}
<div className="flex items-center gap-1">
<div className="relative" ref={optionsRef}>
<button onClick={() => setIsOptionsMenuOpen(!isOptionsMenuOpen)} className="p-2 hover:bg-black/5 rounded-full">
<MoreVertical className="w-5 h-5 text-[#54656f]" />
</button>
<AnimatePresence>
{isOptionsMenuOpen && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="absolute right-0 top-full mt-1 bg-white shadow-2xl rounded-xl border border-gray-100 w-56 z-50 py-2"
>
<button
onClick={handleFetchChats}
disabled={loading}
className="w-full flex items-center gap-3 px-4 py-2.5 text-[14px] text-[#111b21] hover:bg-[#f5f6f6] transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
Atualizar chats
</button>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
{/* Busca */}
<div className="px-4 py-2 flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-2.5 w-4 h-4 text-gray-400" />
<input type="text" placeholder="Pesquisar..."
className="w-full bg-[#f0f2f5] h-[35px] pl-10 rounded-lg text-sm border-none focus:ring-0"
value={searchTerm} onChange={e => setSearchTerm(e.target.value)} />
</div>
<button onClick={() => setIsNewConvoModalOpen(true)}
className="w-9 h-9 flex items-center justify-center rounded-full text-gray-500 hover:bg-black/5">
<MessageSquarePlus className="w-4 h-4" />
</button>
</div>
{/* Lista de chats */}
<ChatSidebar
chats={chatsByScope}
loading={loading}
selectedChat={selectedChat}
searchTerm={searchTerm}
onSelectChat={handleSelectChat}
onMenuAction={handleChatMenuAction}
activeFilter={activeFilter}
selectedLabelFilter={null}
chatScope={chatScope}
activeCustomListName={null}
presenceByJid={presenceByJid}
isFavoriteChat={isFavoriteChat}
drafts={drafts}
/>
</div>
)}
{/* Área principal */}
<div
className={`${isMobile ? (selectedChat ? 'w-full' : 'hidden') : 'flex-1'} flex h-full relative overflow-hidden`}
style={{ backgroundImage: "url('/chat-bg.png')", backgroundRepeat: 'repeat', backgroundColor: '#b5bdb5' }}
>
{/* Overlay branqueador sobre o padrão */}
<div className="absolute inset-0 bg-white/75 pointer-events-none z-0" />
{selectedChat ? (
<>
<div className="flex-1 flex flex-col h-full relative z-[1] overflow-hidden border-r border-[#d1d7db]">
<InboxHeader
selectedChat={selectedChat}
isMobile={isMobile}
isTyping={isTyping}
onAvatarClick={() => { setIsProfileOpen(!isProfileOpen); setIsProtocolOpen(false) }}
onBack={() => handleSelectChat(null)}
onSyncAvatar={handleSyncAvatar}
isChatMenuOpen={isChatMenuOpen}
isSyncingAvatar={isSyncingAvatar}
isProtocolOpen={isProtocolOpen}
hasActiveProtocol={protocolPanel.hasActive}
onSearchClick={() => {}}
onToggleMenu={() => setIsChatMenuOpen(!isChatMenuOpen)}
onProtocolClick={() => { setIsProtocolOpen(!isProtocolOpen); setIsProfileOpen(false) }}
/>
<MessageArea
messages={messages as any}
selectedChat={selectedChat}
scrollRef={scrollRef}
msgLoading={loadingMessages}
onScroll={handleScroll}
historyLoadingMore={loadingMore}
hasMoreHistory={hasMoreHistory}
reactionPickerFor={reactionPickerFor}
onReply={setReplyingTo}
onToggleReactionPicker={toggleReactionPicker}
onReact={handleReactToMessage}
onForward={() => {}}
onDelete={() => {}}
onEdit={() => {}}
quickReactions={['👍', '❤️', '😂', '😮', '😢', '🙏']}
getGroupSenderLabel={(msg: any) => {
if (msg.direction === 'out') return 'Você'
if (msg.participant) return msg.participant
if (msg.senderJid) {
if (msg.senderJid.endsWith('@lid')) return msg.participant || ''
const phone = msg.senderJid.split('@')[0].split(':')[0]
return formatPhone(phone, msg.senderJid)
}
return ''
}}
/>
<InputBar
newMessage={newMessage}
onNewMessageChange={setNewMessage}
onSend={handleSendMessage}
onPresence={handleTyping}
onCancelReply={() => setReplyingTo(null)}
replyingTo={replyingTo}
selectedChat={selectedChat}
isMobile={isMobile}
onSendMedia={handleSendMedia}
onSendAudio={handleSendAudio}
instanceId={activeInstance?.instance ?? null}
mentions={mentions}
onMentionsChange={setMentions}
/>
</div>
<ContactProfile
isOpen={isProfileOpen && !isProtocolOpen && !isMobile}
chat={selectedChat}
messages={messages as any}
onClose={() => setIsProfileOpen(false)}
/>
<ProtocolPanel
isOpen={isProtocolOpen && !isMobile}
chatId={selectedChat ? String(selectedChat.id) : null}
onClose={() => setIsProtocolOpen(false)}
{...protocolPanel}
/>
</>
) : (
<div className="flex-1 flex flex-col items-center justify-center p-10 bg-[#f0f2f5]">
<MessageSquare className="w-20 h-20 opacity-10 mb-8" />
<h1 className="text-2xl font-light text-[#41525d] mb-4">NewWhats</h1>
<p className="text-sm text-[#667781]">Selecione uma conversa para começar.</p>
</div>
)}
</div>
</div>
<NewConversationModal
isOpen={isNewConvoModalOpen}
onClose={() => setIsNewConvoModalOpen(false)}
activeInstance={mappedActiveInstance as any}
connectedInstances={connectedInstances as any}
sendMessage={async (instanceId: string, phone: string, text: string) => {
try {
const res = await messageApi.send(instanceId, `${phone}@s.whatsapp.net`, text)
return { ok: true, messageId: res?.messageId }
} catch {
return { ok: false }
}
}}
/>
</div>
)
}