import { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, useNavigate } from 'react-router-dom';
import { smsCallApi } from '../api/smsCall';
import type { SmsMessage, CallRecord, LeadTouchpoint, LeadMetrics } from '../types';
import { AppLayout } from '../components/layout/AppLayout';
import { PageLoader } from '../components/ui/LoadingSpinner';
import {
MessageSquare,
Phone,
PhoneIncoming,
PhoneOutgoing,
PhoneMissed,
PhoneCall,
Clock,
Send,
ArrowLeft,
AlertOctagon,
CheckCircle,
XCircle,
ChevronDown,
ChevronUp,
Calendar,
AlertTriangle,
Loader2,
Search,
} from 'lucide-react';
/* โโโ Helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
const DISPOSITION_CONFIG: Record<string, { label: string; color: string; icon: React.ReactNode }> = {
connected: { label: 'Connected', color: 'bg-emerald-100 text-emerald-700', icon: <PhoneCall className="h-3 w-3" /> },
voicemail: { label: 'Voicemail', color: 'bg-amber-100 text-amber-700', icon: <PhoneCall className="h-3 w-3" /> },
no_answer: { label: 'No Answer', color: 'bg-red-100 text-red-700', icon: <PhoneMissed className="h-3 w-3" /> },
busy: { label: 'Busy', color: 'bg-orange-100 text-orange-700', icon: <PhoneMissed className="h-3 w-3" /> },
appointment_set: { label: 'Appt Set', color: 'bg-blue-100 text-blue-700', icon: <Calendar className="h-3 w-3" /> },
};
const CHANNEL_ICONS: Record<string, React.ReactNode> = {
sms: <MessageSquare className="h-3.5 w-3.5" />,
call: <Phone className="h-3.5 w-3.5" />,
email: <Send className="h-3.5 w-3.5" />,
web: <Search className="h-3.5 w-3.5" />,
};
const KEYWORD_BADGES: Record<string, { label: string; color: string }> = {
STOP: { label: 'STOP', color: 'bg-red-100 text-red-700' },
YES: { label: 'YES', color: 'bg-emerald-100 text-emerald-700' },
CALL_ME: { label: 'CALL ME', color: 'bg-amber-100 text-amber-700' },
};
function formatDuration(seconds: number | null): string {
if (!seconds) return 'โ';
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
function formatTime(iso: string): string {
const d = new Date(iso);
return d.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
});
}
/* โโโ Tab: Unified Timeline โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
function TimelineView({ leadId }: { leadId: string }) {
const { data, isLoading } = useQuery({
queryKey: ['lead-timeline', leadId],
queryFn: () => smsCallApi.getLeadTimeline(leadId),
});
if (isLoading) return <PageLoader />;
const touchpoints = data?.touchpoints ?? [];
if (touchpoints.length === 0) {
return (
<div className="flex flex-col items-center justify-center rounded-xl border border-surface-200 bg-white py-16">
<Clock className="h-12 w-12 text-surface-300" />
<p className="mt-4 text-sm font-medium text-surface-700">No activity yet</p>
<p className="mt-1 text-xs text-surface-500">Messages, calls, and emails will appear here.</p>
</div>
);
}
// Group by date
const grouped = useMemo(() => {
const map = new Map<string, LeadTouchpoint[]>();
for (const tp of touchpoints) {
const date = new Date(tp.created_at).toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' });
if (!map.has(date)) map.set(date, []);
map.get(date)!.push(tp);
}
return Array.from(map.entries());
}, [touchpoints]);
return (
<div className="space-y-6">
{grouped.map(([date, items]) => (
<div key={date}>
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wider text-surface-400">{date}</h3>
<div className="space-y-3">
{items.map((tp) => {
const channel = tp.touchpoint_type.split('_')[0] as string;
const icon = CHANNEL_ICONS[channel] ?? <Search className="h-3.5 w-3.5" />;
const isOutbound = tp.direction === 'outbound';
const isInbound = tp.direction === 'inbound';
return (
<div
key={tp.id}
className="flex gap-3 rounded-lg border border-surface-200 bg-white p-4"
>
{/* Icon */}
<div className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full ${
isOutbound ? 'bg-blue-100 text-blue-600' :
isInbound ? 'bg-emerald-100 text-emerald-600' :
'bg-surface-100 text-surface-500'
}`}>
{icon}
</div>
{/* Content */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase ${
isOutbound ? 'bg-blue-50 text-blue-600' :
isInbound ? 'bg-emerald-50 text-emerald-600' :
'bg-surface-100 text-surface-500'
}`}>
{tp.direction}
</span>
<span className="text-xs text-surface-400">{formatTime(tp.created_at)}</span>
</div>
{tp.content && (
<p className="mt-1.5 text-sm text-surface-700 line-clamp-2">{tp.content}</p>
)}
<p className="mt-1 text-xs text-surface-400">
{tp.touchpoint_type.replace(/_/g, ' ')}
{tp.reference_id && ` ยท ${tp.reference_id.slice(0, 8)}`}
</p>
</div>
</div>
);
})}
</div>
</div>
))}
</div>
);
}
/* โโโ Tab: SMS Conversation โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
function SmsConversationView({ leadId }: { leadId: string }) {
const [message, setMessage] = useState('');
const [phone, setPhone] = useState('');
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['messages', leadId],
queryFn: () => smsCallApi.listMessages({ lead_id: leadId }),
});
const sendMutation = useMutation({
mutationFn: (payload: { to_phone: string; body: string; lead_id?: string }) =>
smsCallApi.sendSms(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['messages'] });
queryClient.invalidateQueries({ queryKey: ['lead-timeline'] });
setMessage('');
},
});
const messages = data?.messages ?? [];
const handleSend = (e: React.FormEvent) => {
e.preventDefault();
if (!message.trim() || !phone.trim() || sendMutation.isPending) return;
sendMutation.mutate({ to_phone: phone, body: message, lead_id: leadId });
};
if (isLoading) return <PageLoader />;
return (
<div className="flex flex-col gap-4">
{/* Phone input */}
<div className="flex gap-2">
<input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="Phone number (E.164)"
className="flex-1 rounded-lg border border-surface-300 px-3 py-2 text-sm outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-100"
/>
</div>
{/* Messages */}
{messages.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-xl border border-surface-200 bg-white py-16">
<MessageSquare className="h-12 w-12 text-surface-300" />
<p className="mt-4 text-sm font-medium text-surface-700">No messages yet</p>
</div>
) : (
<div className="space-y-3">
{messages.map((msg) => {
const isOutbound = msg.direction === 'outbound';
const badge = msg.keyword_flag && KEYWORD_BADGES[msg.keyword_flag];
return (
<div
key={msg.id}
className={`flex ${isOutbound ? 'justify-end' : 'justify-start'}`}
>
<div className={`max-w-md rounded-lg px-4 py-3 ${
isOutbound
? 'bg-brand-600 text-white'
: 'bg-white border border-surface-200 text-surface-800'
}`}>
{/* Keyword badge */}
{badge && (
<span className={`mb-1 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold ${badge.color}`}>
{badge.label}
</span>
)}
{/* Message body */}
<p className="text-sm">{msg.body}</p>
{/* Meta */}
<div className={`mt-1 flex items-center gap-2 text-[10px] ${
isOutbound ? 'text-brand-200' : 'text-surface-400'
}`}>
<span>{formatTime(msg.created_at)}</span>
<span>ยท</span>
<span className="capitalize">{msg.status}</span>
{msg.keyword_flag && <span>ยท</span>}
</div>
</div>
</div>
);
})}
</div>
)}
{/* Send form */}
<form onSubmit={handleSend} className="flex gap-2">
<input
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Type a message..."
className="flex-1 rounded-lg border border-surface-300 px-3 py-2 text-sm outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-100"
/>
<button
type="submit"
disabled={!message.trim() || !phone.trim() || sendMutation.isPending}
className="inline-flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50 min-h-[44px]"
>
{sendMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Send className="h-4 w-4" />
Send
</>
)}
</button>
</form>
</div>
);
}
/* โโโ Tab: Call Log โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
function CallLogView({ leadId }: { leadId: string }) {
const [notes, setNotes] = useState('');
const [selectedCallId, setSelectedCallId] = useState<string | null>(null);
const [disposition, setDisposition] = useState('');
const [showForm, setShowForm] = useState(false);
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['calls', leadId],
queryFn: () => smsCallApi.listCalls({ lead_id: leadId }),
});
const dispositionMutation = useMutation({
mutationFn: ({ callId, disposition }: { callId: string; disposition: string }) =>
smsCallApi.updateCallDisposition(callId, disposition),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['calls'] });
queryClient.invalidateQueries({ queryKey: ['lead-timeline'] });
setSelectedCallId(null);
setDisposition('');
setNotes('');
setShowForm(false);
},
});
const calls = data?.calls ?? [];
if (isLoading) return <PageLoader />;
if (calls.length === 0) {
return (
<div className="flex flex-col items-center justify-center rounded-xl border border-surface-200 bg-white py-16">
<Phone className="h-12 w-12 text-surface-300" />
<p className="mt-4 text-sm font-medium text-surface-700">No calls logged</p>
<p className="mt-1 text-xs text-surface-500">Outbound calls will appear here.</p>
</div>
);
}
return (
<div className="space-y-4">
{calls.map((call) => {
const config = DISPOSITION_CONFIG[call.disposition] ?? {
label: call.disposition || 'Unknown',
color: 'bg-surface-100 text-surface-500',
icon: <Phone className="h-3 w-3" />,
};
return (
<div
key={call.id}
className="rounded-lg border border-surface-200 bg-white p-4"
>
<div className="flex items-start justify-between">
<div className="flex gap-3">
{/* Icon */}
<div className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full ${
call.disposition === 'connected' ? 'bg-emerald-100 text-emerald-600' :
call.disposition === 'voicemail' ? 'bg-amber-100 text-amber-600' :
'bg-red-100 text-red-600'
}`}>
{call.direction === 'outbound' ? <PhoneOutgoing className="h-4 w-4" /> : <PhoneIncoming className="h-4 w-4" />}
</div>
{/* Info */}
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-surface-900">{call.to_phone}</span>
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold ${config.color}`}>
{config.icon}
{config.label}
</span>
</div>
<div className="mt-1 flex items-center gap-3 text-xs text-surface-400">
<span>{formatTime(call.created_at)}</span>
<span>Duration: {formatDuration(call.duration_seconds)}</span>
<span className="capitalize">{call.direction}</span>
</div>
</div>
</div>
{/* Update disposition button */}
<button
onClick={() => {
setSelectedCallId(call.id);
setDisposition(call.disposition || '');
setShowForm(true);
}}
className="rounded-md p-2 text-surface-400 hover:text-brand-600 hover:bg-brand-50 transition-colors"
title="Update disposition"
>
<PhoneCall className="h-4 w-4" />
</button>
</div>
{/* Inline disposition form */}
{showForm && selectedCallId === call.id && (
<div className="mt-3 flex flex-col gap-2 border-t border-surface-100 pt-3">
<select
value={disposition}
onChange={(e) => setDisposition(e.target.value)}
className="rounded-lg border border-surface-300 px-3 py-2 text-sm outline-none focus:border-brand-500"
>
<option value="">Select disposition...</option>
{Object.entries(DISPOSITION_CONFIG).map(([key, val]) => (
<option key={key} value={key}>{val.label}</option>
))}
</select>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Add notes..."
rows={2}
className="rounded-lg border border-surface-300 px-3 py-2 text-sm outline-none focus:border-brand-500 resize-none"
/>
<div className="flex gap-2">
<button
onClick={() => {
dispositionMutation.mutate({ callId: call.id, disposition });
}}
disabled={dispositionMutation.isPending || !disposition}
className="rounded-lg bg-brand-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
>
{dispositionMutation.isPending ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Save'}
</button>
<button
onClick={() => { setShowForm(false); setDisposition(''); setNotes(''); }}
className="rounded-lg border border-surface-300 px-3 py-1.5 text-sm text-surface-600 hover:bg-surface-50"
>
Cancel
</button>
</div>
</div>
)}
</div>
);
})}
</div>
);
}
/* โโโ Speed-to-Lead Metrics Card โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
function SpeedToLeadMetrics({ leadId }: { leadId: string }) {
const { data, isLoading } = useQuery({
queryKey: ['lead-metrics', leadId],
queryFn: () => smsCallApi.getLeadMetrics(leadId),
});
if (isLoading) return null;
const metrics = data;
if (!metrics) return null;
const formatMinutes = (mins: number | null) => {
if (mins === null) return 'โ';
if (mins < 1) return '< 1 min';
if (mins >= 60) return `${Math.floor(mins / 60)}h ${mins % 60}m`;
return `${mins} min`;
};
const isFast = metrics.time_to_first_contact_minutes !== null && metrics.time_to_first_contact_minutes < 5;
const isSlow = metrics.time_to_first_contact_minutes !== null && metrics.time_to_first_contact_minutes > 30;
return (
<div className={`rounded-lg border p-4 ${
isSlow ? 'border-red-200 bg-red-50' :
isFast ? 'border-emerald-200 bg-emerald-50' :
'border-surface-200 bg-white'
}`}>
<div className="flex items-center gap-2">
<Clock className={`h-4 w-4 ${isSlow ? 'text-red-500' : isFast ? 'text-emerald-500' : 'text-surface-400'}`} />
<span className="text-sm font-medium text-surface-700">Speed to Lead</span>
</div>
<div className="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-4">
<div>
<p className="text-xs text-surface-400">First Contact</p>
<p className="text-sm font-semibold text-surface-700">{formatMinutes(metrics.time_to_first_contact_minutes)}</p>
</div>
<div>
<p className="text-xs text-surface-400">First SMS</p>
<p className="text-sm font-semibold text-surface-700">{formatMinutes(metrics.time_to_first_sms_minutes)}</p>
</div>
<div>
<p className="text-xs text-surface-400">First Call</p>
<p className="text-sm font-semibold text-surface-700">{formatMinutes(metrics.time_to_first_call_minutes)}</p>
</div>
<div>
<p className="text-xs text-surface-400">Contact Type</p>
<p className="text-sm font-semibold text-surface-700 capitalize">{metrics.first_contact_type ?? 'โ'}</p>
</div>
</div>
</div>
);
}
/* โโโ Main Page โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
type Tab = 'timeline' | 'sms' | 'calls';
export function SmsCallPage() {
const { leadId } = useParams<{ leadId: string }>();
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<Tab>('timeline');
const tabs: { key: Tab; label: string; icon: React.ReactNode }[] = [
{ key: 'timeline', label: 'Timeline', icon: <Clock className="h-4 w-4" /> },
{ key: 'sms', label: 'Messages', icon: <MessageSquare className="h-4 w-4" /> },
{ key: 'calls', label: 'Calls', icon: <Phone className="h-4 w-4" /> },
];
if (!leadId) {
return (
<AppLayout title="SMS & Calls">
<div className="flex flex-col items-center justify-center py-16">
<Phone className="h-12 w-12 text-surface-300" />
<p className="mt-4 text-sm font-medium text-surface-700">No lead selected</p>
<button
onClick={() => navigate(-1)}
className="mt-3 text-sm text-brand-600 hover:text-brand-700"
>
โ Go back
</button>
</div>
</AppLayout>
);
}
return (
<AppLayout title="SMS & Calls">
{/* Back button */}
<button
onClick={() => navigate(-1)}
className="mb-4 inline-flex items-center gap-1 text-sm text-surface-500 hover:text-surface-700"
>
<ArrowLeft className="h-4 w-4" />
Back
</button>
{/* Speed to Lead */}
<div className="mb-6">
<SpeedToLeadMetrics leadId={leadId} />
</div>
{/* Tabs */}
<div className="mb-6 flex gap-1 rounded-lg bg-surface-100 p-1">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`flex flex-1 items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors ${
activeTab === tab.key
? 'bg-white text-surface-900 shadow-sm'
: 'text-surface-500 hover:text-surface-700'
}`}
>
{tab.icon}
{tab.label}
</button>
))}
</div>
{/* Tab content */}
{activeTab === 'timeline' && <TimelineView leadId={leadId} />}
{activeTab === 'sms' && <SmsConversationView leadId={leadId} />}
{activeTab === 'calls' && <CallLogView leadId={leadId} />}
</AppLayout>
);
}