268 lines
8.8 KiB
TypeScript
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>
|
|
);
|
|
}
|