Conversations.jsx
1 import { useState } from 'react'; 2 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; 3 import { fetchConversations } from '../api'; 4 import MetricCard from '../components/MetricCard'; 5 import { PieChart } from '../components/charts'; 6 7 const INTENT_COLORS = { 8 interested: 'text-emerald-400', 9 not_interested: 'text-red-400', 10 question: 'text-sky-400', 11 unsubscribe: 'text-amber-400', 12 neutral: 'text-slate-400', 13 autoresponder: 'text-slate-500', 14 }; 15 16 const SENTIMENT_COLORS = { 17 positive: '#34d399', 18 negative: '#f87171', 19 neutral: '#94a3b8', 20 }; 21 22 async function sendReply({ conversationId, message, channel }) { 23 const res = await fetch(`/api/v1/conversations/${conversationId}/reply`, { 24 method: 'POST', 25 headers: { 'Content-Type': 'application/json' }, 26 body: JSON.stringify({ message, channel }), 27 }); 28 if (!res.ok) throw new Error(await res.text()); 29 return res.json(); 30 } 31 32 function ThreadView({ thread, onClose }) { 33 const qc = useQueryClient(); 34 const [draft, setDraft] = useState(''); 35 const [sending, setSending] = useState(false); 36 const [err, setErr] = useState(null); 37 38 const handleSend = async () => { 39 if (!draft.trim()) return; 40 setSending(true); 41 setErr(null); 42 try { 43 await sendReply({ 44 conversationId: thread.id, 45 message: draft, 46 channel: thread.contact_method, 47 }); 48 setDraft(''); 49 qc.invalidateQueries({ queryKey: ['conversations'] }); 50 } catch (e) { 51 setErr(e.message); 52 } finally { 53 setSending(false); 54 } 55 }; 56 57 const messages = thread.messages ?? []; 58 59 return ( 60 <div className="fixed inset-0 z-50 flex items-end md:items-center justify-center bg-black/50"> 61 <div className="bg-slate-800 rounded-t-2xl md:rounded-2xl w-full md:max-w-2xl max-h-[85vh] flex flex-col border border-slate-700 shadow-2xl"> 62 {/* Header */} 63 <div className="flex items-center justify-between p-4 border-b border-slate-700"> 64 <div> 65 <p className="font-medium text-slate-200">{thread.domain}</p> 66 <p className="text-xs text-slate-500"> 67 {thread.contact_method} · {thread.contact_uri} 68 {thread.intent && ( 69 <span className={`ml-2 ${INTENT_COLORS[thread.intent] ?? 'text-slate-400'}`}> 70 {thread.intent} 71 </span> 72 )} 73 </p> 74 </div> 75 <button 76 onClick={onClose} 77 className="text-slate-500 hover:text-slate-300 text-xl leading-none" 78 > 79 ✕ 80 </button> 81 </div> 82 83 {/* Messages */} 84 <div className="flex-1 overflow-y-auto p-4 space-y-3"> 85 {messages.map((m, i) => ( 86 <div 87 key={i} 88 className={`flex ${m.direction === 'outbound' ? 'justify-end' : 'justify-start'}`} 89 > 90 <div 91 className={`max-w-sm rounded-2xl px-3 py-2 text-sm ${ 92 m.direction === 'outbound' 93 ? 'bg-sky-600 text-white rounded-br-none' 94 : 'bg-slate-700 text-slate-200 rounded-bl-none' 95 }`} 96 > 97 {m.body} 98 <p className="text-xs opacity-60 mt-1">{m.created_at?.slice(11, 16)}</p> 99 </div> 100 </div> 101 ))} 102 {messages.length === 0 && ( 103 <p className="text-slate-600 text-sm text-center py-8">No messages yet.</p> 104 )} 105 </div> 106 107 {/* Reply box */} 108 <div className="p-4 border-t border-slate-700"> 109 {err && <p className="text-red-400 text-xs mb-2">{err}</p>} 110 <div className="flex gap-2"> 111 <textarea 112 value={draft} 113 onChange={e => setDraft(e.target.value)} 114 onKeyDown={e => { 115 if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) handleSend(); 116 }} 117 rows={2} 118 placeholder="Write a reply… (Ctrl+Enter to send)" 119 className="flex-1 bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 placeholder-slate-600 resize-none focus:outline-none focus:border-sky-500" 120 /> 121 <button 122 onClick={handleSend} 123 disabled={sending || !draft.trim()} 124 className="px-4 py-2 bg-sky-600 hover:bg-sky-500 disabled:bg-slate-700 disabled:text-slate-500 text-white text-sm font-medium rounded-lg transition-colors" 125 > 126 {sending ? '…' : 'Send'} 127 </button> 128 </div> 129 {thread.contact_method === 'sms' && ( 130 <p className="text-xs text-slate-600 mt-1"> 131 SMS: keep under 160 chars. {draft.length}/160 132 </p> 133 )} 134 </div> 135 </div> 136 </div> 137 ); 138 } 139 140 export default function Conversations() { 141 const [activeThread, setActiveThread] = useState(null); 142 const [filter, setFilter] = useState('all'); 143 144 const { data, isLoading, error } = useQuery({ 145 queryKey: ['conversations'], 146 queryFn: fetchConversations, 147 refetchInterval: 60_000, // always live — refetch every minute 148 staleTime: 30_000, 149 }); 150 151 if (isLoading) return <div className="text-slate-400 p-8">Loading…</div>; 152 if (error) return <div className="text-red-400 p-8">Error: {error.message}</div>; 153 154 const d = data ?? {}; 155 const stats = d.conversation_stats ?? {}; 156 const sentiment = d.sentiment_distribution ?? []; 157 const threads = d.threads ?? []; 158 159 const filteredThreads = 160 filter === 'all' ? threads : threads.filter(t => t.intent === filter || t.status === filter); 161 162 const intents = [...new Set(threads.map(t => t.intent).filter(Boolean))]; 163 164 const sentimentData = sentiment.map(s => ({ 165 name: s.sentiment, 166 value: s.count, 167 fill: SENTIMENT_COLORS[s.sentiment] ?? '#94a3b8', 168 })); 169 170 return ( 171 <div className="space-y-6"> 172 {/* Stats */} 173 <div className="grid grid-cols-2 md:grid-cols-4 gap-4"> 174 <MetricCard label="Total Conversations" value={stats.total ?? 0} /> 175 <MetricCard 176 label="Unread" 177 value={stats.unread ?? 0} 178 variant={(stats.unread ?? 0) > 0 ? 'warning' : 'default'} 179 /> 180 <MetricCard label="Interested" value={stats.interested ?? 0} variant="success" /> 181 <MetricCard label="Not Interested" value={stats.not_interested ?? 0} variant="error" /> 182 </div> 183 184 {/* Sentiment + intent breakdown */} 185 {sentimentData.length > 0 && ( 186 <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> 187 <div className="bg-slate-800 rounded-xl p-4 border border-slate-700"> 188 <h3 className="text-sm font-medium text-slate-400 mb-3">Sentiment Distribution</h3> 189 <PieChart data={sentimentData} nameKey="name" valueKey="value" /> 190 </div> 191 <div className="bg-slate-800 rounded-xl p-4 border border-slate-700"> 192 <h3 className="text-sm font-medium text-slate-400 mb-3">Intent Breakdown</h3> 193 <div className="space-y-2"> 194 {(d.intent_breakdown ?? []).map(item => ( 195 <div key={item.intent} className="flex items-center justify-between"> 196 <span className={`text-sm ${INTENT_COLORS[item.intent] ?? 'text-slate-400'}`}> 197 {item.intent} 198 </span> 199 <div className="flex items-center gap-2"> 200 <div className="w-32 bg-slate-700 rounded-full h-1.5"> 201 <div 202 className="h-1.5 rounded-full bg-sky-500" 203 style={{ 204 width: `${Math.min(100, (item.count / Math.max(stats.total ?? 1, 1)) * 100)}%`, 205 }} 206 /> 207 </div> 208 <span className="text-slate-400 text-sm w-8 text-right">{item.count}</span> 209 </div> 210 </div> 211 ))} 212 </div> 213 </div> 214 </div> 215 )} 216 217 {/* Thread list */} 218 <div className="bg-slate-800 rounded-xl border border-slate-700"> 219 {/* Filter bar */} 220 <div className="flex items-center gap-2 p-3 border-b border-slate-700 overflow-x-auto"> 221 {['all', 'unread', ...intents].map(f => ( 222 <button 223 key={f} 224 onClick={() => setFilter(f)} 225 className={`text-xs px-2.5 py-1 rounded-full whitespace-nowrap transition-colors ${ 226 filter === f 227 ? 'bg-sky-600 text-white' 228 : 'bg-slate-700 text-slate-400 hover:text-slate-200' 229 }`} 230 > 231 {f} 232 </button> 233 ))} 234 <span className="ml-auto text-xs text-slate-600 whitespace-nowrap"> 235 {filteredThreads.length} threads 236 </span> 237 </div> 238 239 {/* Threads */} 240 <div className="divide-y divide-slate-700 max-h-[60vh] overflow-y-auto"> 241 {filteredThreads.map(thread => { 242 const lastMsg = (thread.messages ?? []).slice(-1)[0]; 243 const unread = !thread.read_at && thread.last_inbound_at; 244 return ( 245 <button 246 key={thread.id} 247 onClick={() => setActiveThread(thread)} 248 className="w-full text-left px-4 py-3 hover:bg-slate-700/50 transition-colors flex items-start gap-3" 249 > 250 {/* Unread dot */} 251 <div 252 className={`mt-1.5 w-2 h-2 rounded-full flex-shrink-0 ${unread ? 'bg-sky-400' : 'bg-transparent'}`} 253 /> 254 <div className="flex-1 min-w-0"> 255 <div className="flex items-center justify-between"> 256 <p className="text-sm font-medium text-slate-200 truncate">{thread.domain}</p> 257 <p className="text-xs text-slate-600 ml-2 whitespace-nowrap"> 258 {thread.last_inbound_at?.slice(0, 10) ?? thread.created_at?.slice(0, 10)} 259 </p> 260 </div> 261 <div className="flex items-center gap-2"> 262 <span className="text-xs text-slate-500">{thread.contact_method}</span> 263 {thread.intent && ( 264 <span 265 className={`text-xs ${INTENT_COLORS[thread.intent] ?? 'text-slate-400'}`} 266 > 267 {thread.intent} 268 </span> 269 )} 270 </div> 271 {lastMsg && ( 272 <p className="text-xs text-slate-500 truncate mt-0.5">{lastMsg.body}</p> 273 )} 274 </div> 275 </button> 276 ); 277 })} 278 {filteredThreads.length === 0 && ( 279 <div className="px-4 py-12 text-center text-slate-600 text-sm">No conversations.</div> 280 )} 281 </div> 282 </div> 283 284 {/* Thread modal */} 285 {activeThread && <ThreadView thread={activeThread} onClose={() => setActiveThread(null)} />} 286 </div> 287 ); 288 }