268 lines
8.8 KiB
TypeScript

'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { Send, Bot, Phone, ArrowLeft } from 'lucide-react';
import { cn, formatPhone } from '@/lib/utils';
import { MessageBubble, hashColor, getInitials } from './message-bubble';
interface Message {
id: string;
contact_id: string;
direction: 'inbound' | 'outbound';
source: string;
body: string;
created_at: string;
}
interface Contact {
id: string;
phone: string;
name: string | null;
status: string;
email: string | null;
message_count: number;
first_contact: string;
last_contact: string;
}
interface ChatThreadProps {
contactId: string;
onBack?: () => void;
}
export function ChatThread({ contactId, onBack }: ChatThreadProps) {
const [contact, setContact] = useState<Contact | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const [loading, setLoading] = useState(true);
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const scrollToBottom = useCallback(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, []);
// Fetch conversation data
const fetchData = useCallback(async () => {
try {
const res = await fetch(`/api/conversations/${contactId}`);
if (res.ok) {
const data = await res.json();
setContact(data.contact);
setMessages(data.messages || []);
}
} catch (err) {
console.error('Failed to load conversation:', err);
} finally {
setLoading(false);
}
}, [contactId]);
useEffect(() => {
setLoading(true);
fetchData();
const interval = setInterval(fetchData, 5000);
return () => clearInterval(interval);
}, [fetchData]);
// Auto-scroll on new messages
useEffect(() => {
scrollToBottom();
}, [messages, scrollToBottom]);
// Focus input when conversation loads
useEffect(() => {
if (!loading) inputRef.current?.focus();
}, [loading, contactId]);
const handleSend = async () => {
const text = input.trim();
if (!text || sending) return;
setSending(true);
setInput('');
// Optimistic update
const optimistic: Message = {
id: `tmp-${Date.now()}`,
contact_id: contactId,
direction: 'outbound',
source: 'manual',
body: text,
created_at: new Date().toISOString(),
};
setMessages((prev) => [...prev, optimistic]);
try {
const res = await fetch(`/api/conversations/${contactId}/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: text }),
});
if (!res.ok) {
// Remove optimistic message on failure
setMessages((prev) => prev.filter((m) => m.id !== optimistic.id));
console.error('Failed to send message');
} else {
// Replace optimistic with real message
const data = await res.json();
setMessages((prev) =>
prev.map((m) => (m.id === optimistic.id ? { ...optimistic, id: data.messageId || optimistic.id } : m))
);
}
} catch {
setMessages((prev) => prev.filter((m) => m.id !== optimistic.id));
} finally {
setSending(false);
inputRef.current?.focus();
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
if (loading) {
return (
<div className="flex-1 flex items-center justify-center bg-[#0f1729]">
<div className="w-8 h-8 border-2 border-cyan-400/30 border-t-cyan-400 rounded-full animate-spin" />
</div>
);
}
if (!contact) {
return (
<div className="flex-1 flex items-center justify-center bg-[#0f1729]">
<p className="text-slate-500 text-sm">Contact not found</p>
</div>
);
}
const displayName = contact.name || formatPhone(contact.phone);
const avatarColor = hashColor(displayName);
const initials = getInitials(contact.name || contact.phone.slice(-4));
return (
<div className="flex-1 flex flex-col h-full bg-[#0f1729]">
{/* Header */}
<div className="flex-shrink-0 flex items-center justify-between px-5 py-3.5 border-b border-[#334155] bg-[#0f1729]/80 backdrop-blur-sm">
<div className="flex items-center gap-3">
{/* Mobile back button */}
{onBack && (
<button onClick={onBack} className="lg:hidden p-1.5 -ml-1.5 text-slate-400 hover:text-slate-200 transition-colors">
<ArrowLeft className="w-5 h-5" />
</button>
)}
{/* Avatar */}
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-white text-sm font-bold"
style={{ backgroundColor: avatarColor }}
>
{initials}
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-bold text-slate-100 text-[15px]">{contact.name || 'Unknown'}</h3>
<span className="inline-flex items-center gap-1 bg-cyan-500/15 text-cyan-400 px-2 py-0.5 rounded-full text-[10px] font-medium border border-cyan-500/20">
<Bot className="w-3 h-3" />
CloseBot SMS AI
</span>
</div>
<div className="flex items-center gap-2 mt-0.5">
<Phone className="w-3 h-3 text-slate-500" />
<span className="text-xs text-slate-400">{formatPhone(contact.phone)}</span>
</div>
</div>
</div>
{/* Status */}
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-green-400 rounded-full animate-pulse" />
<span className="text-xs text-green-400 font-medium">Active</span>
</div>
</div>
{/* Messages area */}
<div ref={scrollRef} className="flex-1 overflow-y-auto px-5 py-4 flex flex-col">
{messages.length === 0 ? (
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-slate-500">
<Bot className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p className="text-sm font-medium">No messages yet</p>
<p className="text-xs mt-1 opacity-70">Send a message to start the conversation</p>
</div>
</div>
) : (
<>
{/* Date separator for first message */}
<div className="flex items-center gap-3 mb-4 mt-2">
<div className="flex-1 h-px bg-[#334155]" />
<span className="text-[11px] text-slate-500 font-medium">
{new Date(messages[0].created_at).toLocaleDateString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
})}
</span>
<div className="flex-1 h-px bg-[#334155]" />
</div>
{messages.map((msg, i) => {
// Show avatar only for first message in a sequence from same direction
const prevMsg = i > 0 ? messages[i - 1] : null;
const showAvatar = !prevMsg || prevMsg.direction !== msg.direction;
return (
<MessageBubble
key={msg.id}
body={msg.body}
direction={msg.direction}
source={msg.source}
createdAt={msg.created_at}
contactName={contact.name}
showAvatar={showAvatar}
/>
);
})}
</>
)}
</div>
{/* Message input */}
<div className="flex-shrink-0 px-5 py-4 border-t border-[#334155]">
<div className="flex items-center gap-3 bg-[#1a2332] border border-[#334155] rounded-xl px-4 py-2 focus-within:border-cyan-500/50 focus-within:ring-1 focus-within:ring-cyan-500/20 transition-all">
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
className="flex-1 bg-transparent text-sm text-slate-100 placeholder:text-slate-500 focus:outline-none"
disabled={sending}
/>
<button
onClick={handleSend}
disabled={!input.trim() || sending}
className={cn(
'p-2 rounded-lg transition-all duration-200',
input.trim() && !sending
? 'bg-cyan-500 text-white hover:bg-cyan-400 shadow-lg shadow-cyan-500/25'
: 'bg-[#334155] text-slate-500 cursor-not-allowed'
)}
>
<Send className="w-4 h-4" />
</button>
</div>
</div>
</div>
);
}