/ dashboard-v2 / frontend / src / pages / Conversations.jsx
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  }