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>
604 lines
28 KiB
TypeScript
Executable File
604 lines
28 KiB
TypeScript
Executable File
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>
|
||
);
|
||
}
|