Files
DentalManagement09/apps/Frontend/src/components/patient-connection/message-thread.tsx
T
GiteadandClaude Sonnet 5 79ac6aaa31 feat: migrate SMS/voice to Telnyx (TeXML), keep browser dial pad on Twilio
Twilio now handles only the in-browser dial pad (Access Token + browser-Device
outbound TwiML); Telnyx takes over SMS, inbound voicemail, the AI voice
conversation loop, and staff-triggered outbound calls via TeXML. Adds
TelnyxSettings model, Communication.provider discriminator, new comms/routes
modules, and a real Telnyx Settings page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 15:39:43 -04:00

604 lines
28 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { Send, ArrowLeft, FileText, Globe, Bot, UserPlus, CalendarX, Phone } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import type { Patient, Communication } from "@repo/db/types";
import { format, isToday, isYesterday, parseISO } from "date-fns";
import { es, pt, zhCN, zhTW, ar, fr } from "date-fns/locale";
import type { Locale } from "date-fns";
// ─── Language config ──────────────────────────────────────────────────────────
export const SUPPORTED_LANGUAGES = [
"English",
"Spanish",
"Portuguese",
"Mandarin",
"Cantonese",
"Arabic",
"Haitian Creole",
] as const;
export type Language = (typeof SUPPORTED_LANGUAGES)[number];
const LOCALES: Partial<Record<Language, Locale>> = {
Spanish: es,
Portuguese: pt,
Mandarin: zhCN,
Cantonese: zhTW,
Arabic: ar,
"Haitian Creole": fr, // French locale — closest approximation for day/month names
};
// Date pattern per language (date-fns format tokens)
const DATE_FMT: Record<Language, string> = {
English: "MMMM do, EEEE",
Spanish: "EEEE, d 'de' MMMM",
Portuguese: "EEEE, d 'de' MMMM",
Mandarin: "M月d日(EEEE)",
Cantonese: "M月d日(EEEE)",
Arabic: "EEEE d MMMM",
"Haitian Creole": "EEEE d MMMM",
};
function formatTime(h: number, m: number, lang: Language): string {
const pad = (n: number) => String(n).padStart(2, "0");
const h12 = h % 12 || 12;
if (lang === "Mandarin" || lang === "Cantonese") {
return `${h >= 12 ? "下午" : "上午"}${h12}:${pad(m)}`;
}
if (lang === "Arabic") {
return `${h12}:${pad(m)} ${h >= 12 ? "مساءً" : "صباحاً"}`;
}
if (lang === "Spanish" || lang === "Portuguese") {
return `${pad(h)}:${pad(m)}`;
}
// English, Haitian Creole
return `${h12}:${pad(m)} ${h >= 12 ? "pm" : "am"}`;
}
function buildApptPhrase(date: string, startTime: string, lang: Language): string {
const [y, mo, d] = date.split("-").map(Number);
const dateObj = new Date(y!, mo! - 1, d!);
const locale = LOCALES[lang];
const datePart = format(dateObj, DATE_FMT[lang], { locale });
const [h, m] = startTime.split(":").map(Number);
const timePart = formatTime(h!, m!, lang);
switch (lang) {
case "Spanish": return `el ${datePart} a las ${timePart}`;
case "Portuguese": return `na ${datePart} às ${timePart}`;
case "Mandarin": return `在${datePart}${timePart}`;
case "Cantonese": return `喺${datePart}${timePart}`;
case "Arabic": return `في ${datePart} الساعة ${timePart}`;
case "Haitian Creole": return `le ${datePart} a ${timePart}`;
default: return `on ${datePart} at ${timePart}`;
}
}
// ─── Office contact ───────────────────────────────────────────────────────────
interface OfficeContact {
officeName?: string | null;
phoneNumber?: string | null;
}
// ─── Template builders ────────────────────────────────────────────────────────
export interface AppointmentInfo {
date: string; // "YYYY-MM-DD"
startTime: string; // "HH:MM"
}
const DEFAULT_OFFICE_NAME: Record<Language, string> = {
English: "the dental office",
Spanish: "la clínica dental",
Portuguese: "o consultório dentário",
Mandarin: "牙科诊所",
Cantonese: "牙科診所",
Arabic: "عيادة الأسنان",
"Haitian Creole": "kabinè dantis la",
};
function callPhrase(phone: string | null | undefined, lang: Language): string {
if (!phone?.trim()) {
return { English: "call us", Spanish: "llámenos", Portuguese: "ligue-nos",
Mandarin: "联系我们", Cantonese: "聯繫我們", Arabic: "اتصل بنا",
"Haitian Creole": "rele nou" }[lang];
}
return {
English: `call us at ${phone}`,
Spanish: `llámenos al ${phone}`,
Portuguese: `ligue-nos pelo ${phone}`,
Mandarin: `致电 ${phone}`,
Cantonese: `致電 ${phone}`,
Arabic: `اتصل بنا على ${phone}`,
"Haitian Creole": `rele nou nan ${phone}`,
}[lang];
}
function buildTemplates(
patient: Patient,
lang: Language,
apptInfo?: AppointmentInfo,
office?: OfficeContact | null,
) {
const n = patient.firstName;
const o = office?.officeName?.trim() || DEFAULT_OFFICE_NAME[lang];
const c = callPhrase(office?.phoneNumber, lang);
const appt = apptInfo ? buildApptPhrase(apptInfo.date, apptInfo.startTime, lang) : "";
const T = {
English: {
label_reminder: "Appointment Reminder",
label_confirmed: "Appointment Confirmed",
label_followup: "Follow-Up",
label_payment: "Payment Reminder",
label_general: "General",
reminder: apptInfo
? `Hi ${n}, this is ${o}. Reminder: You have an appointment scheduled ${appt}. Please confirm through text messages or ${c} if you need to reschedule.`
: `Hi ${n}, this is ${o}. Reminder: You have an upcoming appointment. Please confirm through text messages or ${c} if you need to reschedule.`,
confirmed: `Hi ${n}, this is ${o}. Your appointment has been confirmed. We look forward to seeing you! If you have any questions, please ${c}.`,
followup: `Hi ${n}, this is ${o}. Thank you for visiting us. How are you feeling after your treatment? Please let us know if you have any concerns.`,
payment: `Hi ${n}, this is ${o}. This is a friendly reminder about your outstanding balance. Please ${c} to discuss payment options.`,
general: `Hi ${n}, this is ${o}. `,
},
Spanish: {
label_reminder: "Recordatorio de cita",
label_confirmed: "Cita confirmada",
label_followup: "Seguimiento",
label_payment: "Recordatorio de pago",
label_general: "General",
reminder: apptInfo
? `Hola ${n}, le habla ${o}. Recordatorio: Tiene una cita programada ${appt}. Por favor confirme por mensaje de texto o ${c} si necesita reprogramar.`
: `Hola ${n}, le habla ${o}. Recordatorio: Tiene una próxima cita. Por favor confirme por mensaje de texto o ${c} si necesita reprogramar.`,
confirmed: `Hola ${n}, le habla ${o}. Su cita ha sido confirmada. ¡Esperamos verle pronto! Si tiene alguna pregunta, por favor ${c}.`,
followup: `Hola ${n}, le habla ${o}. Gracias por visitarnos. ¿Cómo se siente después de su tratamiento? Por favor, háganos saber si tiene alguna inquietud.`,
payment: `Hola ${n}, le habla ${o}. Este es un recordatorio amable sobre su saldo pendiente. Por favor ${c} para hablar sobre las opciones de pago.`,
general: `Hola ${n}, le habla ${o}. `,
},
Portuguese: {
label_reminder: "Lembrete de consulta",
label_confirmed: "Consulta confirmada",
label_followup: "Acompanhamento",
label_payment: "Lembrete de pagamento",
label_general: "Geral",
reminder: apptInfo
? `Olá ${n}, aqui é ${o}. Lembrete: Você tem uma consulta agendada ${appt}. Por favor confirme por mensagem de texto ou ${c} se precisar reagendar.`
: `Olá ${n}, aqui é ${o}. Lembrete: Você tem uma consulta próxima. Por favor confirme por mensagem de texto ou ${c} se precisar reagendar.`,
confirmed: `Olá ${n}, aqui é ${o}. Sua consulta foi confirmada. Aguardamos sua visita! Se tiver alguma dúvida, por favor ${c}.`,
followup: `Olá ${n}, aqui é ${o}. Obrigado pela sua visita. Como está se sentindo após o tratamento? Por favor, nos informe se tiver alguma preocupação.`,
payment: `Olá ${n}, aqui é ${o}. Este é um lembrete amigável sobre o seu saldo pendente. Por favor ${c} para discutir as opções de pagamento.`,
general: `Olá ${n}, aqui é ${o}. `,
},
Mandarin: {
label_reminder: "预约提醒",
label_confirmed: "预约确认",
label_followup: "回访",
label_payment: "付款提醒",
label_general: "一般消息",
reminder: apptInfo
? `您好 ${n},我们是${o}。提醒:您有一个预约安排${appt}。请通过短信确认,或如需重新安排请${c}。`
: `您好 ${n},我们是${o}。提醒:您有一个即将到来的预约。请通过短信确认,或如需重新安排请${c}。`,
confirmed: `您好 ${n},我们是${o}。您的预约已确认。期待您的光临!如有疑问,请${c}。`,
followup: `您好 ${n},我们是${o}。感谢您的来访。治疗后您感觉怎么样?如有任何疑虑,请告诉我们。`,
payment: `您好 ${n},我们是${o}。这是关于您未付余额的友好提醒。请${c}讨论付款方案。`,
general: `您好 ${n},我们是${o}。`,
},
Cantonese: {
label_reminder: "預約提醒",
label_confirmed: "預約確認",
label_followup: "跟進",
label_payment: "付款提醒",
label_general: "一般消息",
reminder: apptInfo
? `您好 ${n},我哋係${o}。提醒:您有一個預約安排${appt}。請透過短訊確認,或如需重新安排請${c}。`
: `您好 ${n},我哋係${o}。提醒:您有一個即將到嘅預約。請透過短訊確認,或如需重新安排請${c}。`,
confirmed: `您好 ${n},我哋係${o}。您嘅預約已確認。期待見到您!如有疑問,請${c}。`,
followup: `您好 ${n},我哋係${o}。感謝您嘅到訪。治療後您感覺點樣?如有任何疑慮,請告知我們。`,
payment: `您好 ${n},我哋係${o}。呢係關於您未付餘額嘅友好提醒。請${c}討論付款方案。`,
general: `您好 ${n},我哋係${o}。`,
},
Arabic: {
label_reminder: "تذكير بالموعد",
label_confirmed: "تأكيد الموعد",
label_followup: "متابعة",
label_payment: "تذكير بالدفع",
label_general: "رسالة عامة",
reminder: apptInfo
? `مرحباً ${n}، نحن ${o}. تذكير: لديك موعد مجدول ${appt}. يرجى التأكيد عبر الرسائل النصية أو ${c} إذا كنت بحاجة إلى إعادة الجدولة.`
: `مرحباً ${n}، نحن ${o}. تذكير: لديك موعد قادم. يرجى التأكيد عبر الرسائل النصية أو ${c} إذا كنت بحاجة إلى إعادة الجدولة.`,
confirmed: `مرحباً ${n}، نحن ${o}. تم تأكيد موعدك. نتطلع إلى رؤيتك! إذا كان لديك أي أسئلة، يرجى ${c}.`,
followup: `مرحباً ${n}، نحن ${o}. شكراً لزيارتك. كيف تشعر بعد العلاج؟ يرجى إبلاغنا إذا كان لديك أي مخاوف.`,
payment: `مرحباً ${n}، نحن ${o}. هذا تذكير ودي بشأن رصيدك المستحق. يرجى ${c} لمناقشة خيارات الدفع.`,
general: `مرحباً ${n}، نحن ${o}. `,
},
"Haitian Creole": {
label_reminder: "Rapèl randevou",
label_confirmed: "Randevou konfime",
label_followup: "Swivi",
label_payment: "Rapèl peman",
label_general: "Jeneral",
reminder: apptInfo
? `Bonjou ${n}, se ${o} k'ap pale. Rapèl: Ou gen yon randevou pwograme ${appt}. Tanpri konfime pa mesaj tèks oswa ${c} si ou bezwen repwograme.`
: `Bonjou ${n}, se ${o} k'ap pale. Rapèl: Ou gen yon randevou k'ap vini. Tanpri konfime pa mesaj tèks oswa ${c} si ou bezwen repwograme.`,
confirmed: `Bonjou ${n}, se ${o} k'ap pale. Randevou ou a konfime. N'ap tann ou! Si ou gen kesyon, tanpri ${c}.`,
followup: `Bonjou ${n}, se ${o} k'ap pale. Mèsi dèske ou te vizite nou. Kijan ou santi ou apre tretman ou? Tanpri fè nou konnen si ou gen nenpòt enkyetid.`,
payment: `Bonjou ${n}, se ${o} k'ap pale. Sa se yon rapèl amical sou balans ou ki annatant. Tanpri ${c} pou diskite opsyon peman.`,
general: `Bonjou ${n}, se ${o} k'ap pale. `,
},
}[lang];
return [
{ key: "appointment_reminder", label: T.label_reminder, body: T.reminder },
{ key: "appointment_confirmed", label: T.label_confirmed, body: T.confirmed },
{ key: "follow_up", label: T.label_followup, body: T.followup },
{ key: "payment_reminder", label: T.label_payment, body: T.payment },
{ key: "general", label: T.label_general, body: T.general },
];
}
// ─── Component ────────────────────────────────────────────────────────────────
interface MessageThreadProps {
patient: Patient;
onBack?: () => void;
appointmentInfo?: AppointmentInfo;
}
export function MessageThread({ patient, onBack, appointmentInfo }: MessageThreadProps) {
const { toast } = useToast();
const [messageText, setMessageText] = useState("");
const [language, setLanguage] = useState<Language>(
(patient.preferredLanguage as Language | null | undefined) &&
SUPPORTED_LANGUAGES.includes(patient.preferredLanguage as Language)
? (patient.preferredLanguage as Language)
: "English"
);
const messagesEndRef = useRef<HTMLDivElement>(null);
const [handOffToAI, setHandOffToAI] = useState(true);
const [pendingStartFlow, setPendingStartFlow] = useState<"new_patient" | "reschedule" | null>(null);
useQuery<{ enabled: boolean }>({
queryKey: ["/api/telnyx/ai-handoff", patient.id],
queryFn: async () => {
const res = await apiRequest("GET", `/api/telnyx/ai-handoff/${patient.id}`);
return res.json();
},
onSuccess: (data: { enabled: boolean }) => setHandOffToAI(data.enabled),
} as any);
const { data: aiChatTemplates } = useQuery<{ newPatientGreeting: string; rescheduleGreeting: string } | null>({
queryKey: ["/api/ai/chat-templates"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/ai/chat-templates");
if (!res.ok) return null;
return res.json();
},
staleTime: 60_000,
});
const handoffMutation = useMutation({
mutationFn: async (enabled: boolean) =>
apiRequest("PUT", `/api/telnyx/ai-handoff/${patient.id}`, { enabled }),
});
const handleHandoffToggle = (enabled: boolean) => {
setHandOffToAI(enabled);
handoffMutation.mutate(enabled);
};
const { data: officeContact } = useQuery<OfficeContact | null>({
queryKey: ["/api/office-contact"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/office-contact");
if (!res.ok) return null;
return res.json();
},
staleTime: 60_000,
});
const { data: communications = [], isLoading } = useQuery<Communication[]>({
queryKey: ["/api/patients", patient.id, "communications"],
queryFn: async () => {
const res = await apiRequest("GET", `/api/patients/${patient.id}/communications`);
return res.json();
},
refetchInterval: 5000,
});
const sendMessageMutation = useMutation({
mutationFn: async (message: string) =>
apiRequest("POST", "/api/telnyx/send-sms", {
to: patient.phone,
message,
patientId: patient.id,
...(pendingStartFlow ? { startFlow: pendingStartFlow } : {}),
}),
onSuccess: () => {
setMessageText("");
setPendingStartFlow(null);
queryClient.invalidateQueries({ queryKey: ["/api/patients", patient.id, "communications"] });
toast({ title: "Message sent", description: "Your message has been sent successfully." });
},
onError: (error: any) => {
toast({
title: "Failed to send message",
description: error.message || "Unable to send message. Please try again.",
variant: "destructive",
});
},
});
const handleSendMessage = () => {
if (!messageText.trim()) return;
sendMessageMutation.mutate(messageText);
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
};
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [communications]);
const formatMessageDate = (dateValue: string | Date) => {
const date = typeof dateValue === "string" ? parseISO(dateValue) : dateValue;
if (isToday(date)) return format(date, "h:mm a");
if (isYesterday(date)) return `Yesterday ${format(date, "h:mm a")}`;
return format(date, "MMM d, h:mm a");
};
const getDateDivider = (dateValue: string | Date) => {
const d = typeof dateValue === "string" ? parseISO(dateValue) : dateValue;
if (isToday(d)) return "Today";
if (isYesterday(d)) return "Yesterday";
return format(d, "MMMM d, yyyy");
};
const groupedMessages: { date: string; messages: Communication[] }[] = [];
communications.forEach((comm) => {
if (!comm.createdAt) return;
const messageDate = typeof comm.createdAt === "string" ? parseISO(comm.createdAt) : comm.createdAt;
const dateKey = format(messageDate, "yyyy-MM-dd");
const existing = groupedMessages.find((g) => g.date === dateKey);
if (existing) existing.messages.push(comm);
else groupedMessages.push({ date: dateKey, messages: [comm] });
});
const templates = buildTemplates(patient, language, appointmentInfo, officeContact);
return (
<div className="flex flex-col h-full bg-white rounded-lg border">
{/* Header */}
<div className="p-4 border-b bg-gray-50 space-y-2">
{/* Patient identity row */}
<div className="flex items-center gap-3">
{onBack && (
<Button variant="ghost" size="icon" onClick={onBack} data-testid="button-back">
<ArrowLeft className="h-5 w-5" />
</Button>
)}
<div className="h-10 w-10 rounded-full bg-primary text-primary-foreground flex items-center justify-center font-semibold flex-shrink-0">
{patient.firstName[0]}{patient.lastName[0]}
</div>
<div>
<h3 className="font-semibold text-base">
{patient.firstName} {patient.lastName}
</h3>
<p className="text-sm text-muted-foreground">{patient.phone}</p>
</div>
</div>
{/* Controls row: language + template */}
<div className="flex items-center gap-2 pl-1 flex-wrap">
{/* Language selector */}
<div className="flex items-center gap-1.5">
<Globe className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
<Select
value={language}
onValueChange={(v) => setLanguage(v as Language)}
>
<SelectTrigger className="h-7 text-xs w-[130px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SUPPORTED_LANGUAGES.map((l) => (
<SelectItem key={l} value={l} className="text-xs">{l}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Template selector */}
<div className="flex items-center gap-1.5">
<FileText className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
<Select
value=""
onValueChange={(key) => {
if (key === "__new_patient__") {
const greeting = aiChatTemplates?.newPatientGreeting ||
"Hi! My name is Lisa, the dedicated AI assistant at our dental office. I can help you schedule an appointment, check your insurance, and answer general questions 24/7. How can I help you today?";
setMessageText(greeting);
setPendingStartFlow("new_patient");
} else if (key === "__reschedule__") {
const greeting = aiChatTemplates?.rescheduleGreeting ||
"Hi! My name is Lisa, the dedicated AI assistant at our dental office. I can help you find a new appointment time that works for you. Would you like to reschedule your appointment?";
setMessageText(greeting);
setPendingStartFlow("reschedule");
} else {
const tpl = templates.find((t) => t.key === key);
if (tpl) { setMessageText(tpl.body); setPendingStartFlow(null); }
}
}}
>
<SelectTrigger className="h-7 text-xs border-dashed w-[180px]">
<SelectValue placeholder="Use a template…" />
</SelectTrigger>
<SelectContent>
{/* New patient scheduling — uses AI New Patient Greeting */}
<SelectItem value="__new_patient__" className="text-xs font-medium text-primary">
<span className="flex items-center gap-1.5">
<UserPlus className="h-3 w-3" />
Schedule a New Patient
</span>
</SelectItem>
{/* Reschedule patients — uses AI Reschedule Greeting */}
<SelectItem value="__reschedule__" className="text-xs font-medium text-primary">
<span className="flex items-center gap-1.5">
<CalendarX className="h-3 w-3" />
Reschedule Patients
</span>
</SelectItem>
<div className="my-1 border-t" />
{templates.map((t) => (
<SelectItem key={t.key} value={t.key} className="text-xs">
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* New-patient flow indicator */}
{pendingStartFlow === "new_patient" && (
<div className="flex items-center gap-1 text-xs text-primary bg-primary/5 border border-primary/20 rounded px-2 py-0.5">
<UserPlus className="h-3 w-3" />
New patient flow
</div>
)}
{/* Reschedule flow indicator */}
{pendingStartFlow === "reschedule" && (
<div className="flex items-center gap-1 text-xs text-primary bg-primary/5 border border-primary/20 rounded px-2 py-0.5">
<CalendarX className="h-3 w-3" />
Reschedule flow
</div>
)}
{/* AI handoff toggle */}
<div className="flex items-center gap-1.5 ml-auto">
<Bot className={`h-3.5 w-3.5 flex-shrink-0 ${handOffToAI ? "text-primary" : "text-muted-foreground"}`} />
<span className="text-xs text-muted-foreground">Hand off to AI</span>
<Switch
checked={handOffToAI}
onCheckedChange={handleHandoffToggle}
className="scale-75 origin-left"
/>
</div>
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-6 space-y-4" data-testid="messages-container">
{isLoading ? (
<div className="flex items-center justify-center h-full">
<p className="text-muted-foreground">Loading messages...</p>
</div>
) : communications.length === 0 ? (
<div className="flex items-center justify-center h-full">
<p className="text-muted-foreground">No messages yet. Start the conversation!</p>
</div>
) : (
<>
{groupedMessages.map((group) => (
<div key={group.date}>
<div className="flex items-center justify-center my-8">
<div className="px-4 py-1 bg-gray-100 rounded-full text-xs text-muted-foreground">
{getDateDivider(group.messages[0]?.createdAt!)}
</div>
</div>
{group.messages.map((comm) => (
<div
key={comm.id}
className={`flex mb-4 ${comm.direction === "outbound" ? "justify-end" : "justify-start"}`}
data-testid={`message-${comm.id}`}
>
<div className={`max-w-md ${comm.direction === "outbound" ? "ml-auto" : "mr-auto"}`}>
{comm.direction === "inbound" && (
<div className="flex items-start gap-2">
<div className="h-8 w-8 rounded-full bg-gray-300 flex items-center justify-center text-xs font-semibold flex-shrink-0">
{patient.firstName[0]}{patient.lastName[0]}
</div>
<div>
{comm.channel === "voice" && (
<div className="flex items-center gap-1 mb-1 text-[10px] font-medium text-muted-foreground">
<Phone className="h-3 w-3" />
Voice call
</div>
)}
<div className="p-3 rounded-2xl bg-gray-100 text-gray-900 rounded-tl-md">
<p className="text-sm whitespace-pre-wrap break-words">{comm.body}</p>
</div>
<p className="text-xs text-muted-foreground mt-1">
{comm.createdAt && formatMessageDate(comm.createdAt)}
</p>
</div>
</div>
)}
{comm.direction === "outbound" && (
<div>
{comm.channel === "voice" && (
<div className="flex items-center justify-end gap-1 mb-1 text-[10px] font-medium text-muted-foreground">
<Phone className="h-3 w-3" />
Voice call
</div>
)}
<div className="p-3 rounded-2xl bg-primary text-primary-foreground rounded-tr-md">
<p className="text-sm whitespace-pre-wrap break-words">{comm.body}</p>
</div>
<p className="text-xs text-muted-foreground mt-1 text-right">
{comm.createdAt && formatMessageDate(comm.createdAt)}
</p>
</div>
)}
</div>
</div>
))}
</div>
))}
<div ref={messagesEndRef} />
</>
)}
</div>
{/* Input Area */}
<div className="p-4 border-t bg-gray-50">
<div className="flex items-center gap-2">
<Input
value={messageText}
onChange={(e) => setMessageText(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Type your message..."
className="flex-1 rounded-full"
disabled={sendMessageMutation.isPending}
data-testid="input-message"
/>
<Button
onClick={handleSendMessage}
disabled={!messageText.trim() || sendMessageMutation.isPending}
size="icon"
className="rounded-full h-10 w-10"
data-testid="button-send"
>
<Send className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
}