Fernando Pirichowski Aguiar 389a191f20 fix: corrige persistência de avatar, agendamento de consulta e download de PDF
- Avatar do paciente agora persiste após reload (adiciona timestamp para evitar cache)
- Agendamento usa patient_id correto ao invés de user_id
- Botão de download de PDF desbloqueado com logs detalhados
2025-11-15 08:36:41 -03:00

371 lines
12 KiB
TypeScript

import React, { useEffect, useState, useRef } from "react";
import conniImage from "./images/CONNI.png";
/**
* Chatbot.tsx
* React + TypeScript component designed for MediConnect.
* - Floating action button (bottom-right)
* - Modal / popup chat window
* - Sends user messages to a backend endpoint (/api/chat) which proxies to an LLM
* - DOES NOT send/collect any sensitive data (PHI). The frontend strips/flags sensitive fields.
* - Configurable persona: "Assistente Virtual do MediConnect"
*
* Integration notes (short):
* - Backend should be a Supabase Edge Function (or Cloudflare Worker) at /api/chat
* - The Edge Function will contain the OpenAI (or other LLM) key and apply the system prompt.
* - Frontend only uses a short-term session id; it never stores patient-identifying data.
*/
type Message = {
id: string;
role: "user" | "assistant" | "system";
text: string;
time?: string;
};
interface ChatbotProps {
className?: string;
}
const Chatbot: React.FC<ChatbotProps> = ({ className = "" }) => {
const [isOpen, setIsOpen] = useState(false);
const [messages, setMessages] = useState<Message[]>([
{
id: "welcome",
role: "assistant",
text: "Olá! 👋 Sou a Conni, sua Assistente Virtual do MediConnect. Estou aqui para ajudá-lo com dúvidas sobre agendamento de consultas, navegação no sistema, funcionalidades e suporte. Como posso ajudar você hoje?",
time: new Date().toLocaleTimeString("pt-BR", {
hour: "2-digit",
minute: "2-digit",
}),
},
]);
const [inputValue, setInputValue] = useState("");
const [isLoading, setIsLoading] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
/**
* Sanitize user input before sending.
* This is a basic approach. For production, you might do more thorough checks.
*/
function sanitizeUserMessage(text: string): string {
// Remove potential HTML/script tags (very naive approach)
const cleaned = text.replace(/<[^>]*>/g, "");
// Truncate if too long
return cleaned.slice(0, 1000);
}
/**
* Send message to backend /api/chat.
* The backend returns { reply: string } in JSON.
*/
async function callChatApi(userText: string): Promise<string> {
try {
// Get auth token from localStorage
const authData = localStorage.getItem(
"sb-yuanqfswhberkoevtmfr-auth-token"
);
let token = "";
if (authData) {
try {
const parsedAuth = JSON.parse(authData);
token = parsedAuth?.access_token || "";
} catch (e) {
console.warn("Failed to parse auth token:", e);
}
}
console.log("[Chatbot] Sending message to /api/chat");
const response = await fetch("/api/chat", {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token && { Authorization: `Bearer ${token}` }),
},
body: JSON.stringify({
messages: [
{
role: "user",
content: userText,
},
],
token: token,
}),
});
console.log("[Chatbot] Response status:", response.status);
if (!response.ok) {
const errorText = await response.text();
console.error(
"Chat API error:",
response.status,
response.statusText,
errorText
);
return "Desculpe, ocorreu um erro ao processar sua mensagem. Por favor, tente novamente em alguns instantes.";
}
const data = await response.json();
console.log("[Chatbot] Response data:", data);
return data.reply || "Sem resposta do servidor.";
} catch (error) {
console.error("Erro ao chamar a API de chat:", error);
return "Não foi possível conectar ao servidor. Verifique sua conexão e tente novamente.";
}
}
const handleSend = async () => {
if (!inputValue.trim()) return;
const sanitized = sanitizeUserMessage(inputValue);
const userMessage: Message = {
id: Date.now().toString(),
role: "user",
text: sanitized,
time: new Date().toLocaleTimeString("pt-BR", {
hour: "2-digit",
minute: "2-digit",
}),
};
setMessages((prev) => [...prev, userMessage]);
setInputValue("");
setIsLoading(true);
// Call AI backend
const reply = await callChatApi(sanitized);
const assistantMessage: Message = {
id: (Date.now() + 1).toString(),
role: "assistant",
text: reply,
time: new Date().toLocaleTimeString("pt-BR", {
hour: "2-digit",
minute: "2-digit",
}),
};
setMessages((prev) => [...prev, assistantMessage]);
setIsLoading(false);
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const quickReplies = [
"Como agendar uma consulta?",
"Como cancelar um agendamento?",
"Esqueci minha senha",
"Onde vejo minhas consultas?",
];
const handleQuickReply = (text: string) => {
setInputValue(text);
};
return (
<div className={`fixed bottom-6 left-6 z-40 ${className}`}>
{/* Floating Button */}
{!isOpen && (
<button
onClick={() => setIsOpen(true)}
className="bg-blue-600 hover:bg-blue-700 text-white rounded-full p-3 shadow-lg transition-all hover:scale-110 flex items-center gap-2 group"
aria-label="Abrir chat de ajuda"
>
{/* MessageCircle Icon (inline SVG) */}
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-6 h-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
<span className="font-medium hidden sm:inline">
Precisa de ajuda?
</span>
</button>
)}
{/* Chat Window */}
{isOpen && (
<div className="bg-white rounded-lg shadow-2xl w-96 max-w-[calc(100vw-3rem)] max-h-[75vh] flex flex-col">
{/* Header */}
<div className="bg-gradient-to-r from-blue-600 to-blue-700 text-white p-4 rounded-t-lg flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full overflow-hidden bg-white">
<img
src={conniImage}
alt="Conni"
className="w-full h-full object-cover"
/>
</div>
<div>
<h3 className="font-semibold">
Conni - Assistente MediConnect
</h3>
<p className="text-xs text-blue-100">Online AI-Powered</p>
</div>
</div>
<button
onClick={() => setIsOpen(false)}
className="hover:bg-white/20 rounded-full p-1 transition"
aria-label="Fechar chat"
>
{/* X Icon */}
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4 bg-gray-50">
{messages.map((message) => (
<div
key={message.id}
className={`flex ${
message.role === "user" ? "justify-end" : "justify-start"
}`}
>
<div
className={`max-w-[80%] rounded-lg p-3 ${
message.role === "user"
? "bg-blue-600 text-white"
: "bg-white text-gray-800 shadow"
}`}
>
<p className="text-sm whitespace-pre-line">{message.text}</p>
{message.time && (
<p
className={`text-xs mt-1 ${
message.role === "user"
? "text-blue-100"
: "text-gray-400"
}`}
>
{message.time}
</p>
)}
</div>
</div>
))}
{isLoading && (
<div className="flex justify-start">
<div className="bg-white text-gray-800 shadow rounded-lg p-3">
<div className="flex gap-1">
<div
className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
style={{ animationDelay: "0ms" }}
></div>
<div
className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
style={{ animationDelay: "150ms" }}
></div>
<div
className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
style={{ animationDelay: "300ms" }}
></div>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Quick Replies */}
{messages.length <= 1 && (
<div className="px-4 py-2 border-t bg-white">
<p className="text-xs text-gray-500 mb-2">
Perguntas frequentes:
</p>
<div className="flex flex-wrap gap-2">
{quickReplies.map((reply, index) => (
<button
key={index}
onClick={() => handleQuickReply(reply)}
className="text-xs bg-blue-50 hover:bg-blue-100 text-blue-700 px-3 py-1 rounded-full transition"
>
{reply}
</button>
))}
</div>
</div>
)}
{/* Input */}
<div className="p-4 border-t bg-white rounded-b-lg">
<div className="flex gap-2">
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Digite sua mensagem..."
className="flex-1 border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<button
onClick={handleSend}
disabled={!inputValue.trim() || isLoading}
className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed text-white rounded-lg p-2 transition"
aria-label="Enviar mensagem"
>
{/* Send Icon */}
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
/>
</svg>
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default Chatbot;