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> = { 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 = { 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 = { 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( (patient.preferredLanguage as Language | null | undefined) && SUPPORTED_LANGUAGES.includes(patient.preferredLanguage as Language) ? (patient.preferredLanguage as Language) : "English" ); const messagesEndRef = useRef(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({ 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({ 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 (
{/* Header */}
{/* Patient identity row */}
{onBack && ( )}
{patient.firstName[0]}{patient.lastName[0]}

{patient.firstName} {patient.lastName}

{patient.phone}

{/* Controls row: language + template */}
{/* Language selector */}
{/* Template selector */}
{/* New-patient flow indicator */} {pendingStartFlow === "new_patient" && (
New patient flow
)} {/* Reschedule flow indicator */} {pendingStartFlow === "reschedule" && (
Reschedule flow
)} {/* AI handoff toggle */}
Hand off to AI
{/* Messages */}
{isLoading ? (

Loading messages...

) : communications.length === 0 ? (

No messages yet. Start the conversation!

) : ( <> {groupedMessages.map((group) => (
{getDateDivider(group.messages[0]?.createdAt!)}
{group.messages.map((comm) => (
{comm.direction === "inbound" && (
{patient.firstName[0]}{patient.lastName[0]}
{comm.channel === "voice" && (
Voice call
)}

{comm.body}

{comm.createdAt && formatMessageDate(comm.createdAt)}

)} {comm.direction === "outbound" && (
{comm.channel === "voice" && (
Voice call
)}

{comm.body}

{comm.createdAt && formatMessageDate(comm.createdAt)}

)}
))}
))}
)}
{/* Input Area */}
setMessageText(e.target.value)} onKeyPress={handleKeyPress} placeholder="Type your message..." className="flex-1 rounded-full" disabled={sendMessageMutation.isPending} data-testid="input-message" />
); }