diff --git a/README.md b/README.md index 4c3df47..2812270 100644 --- a/README.md +++ b/README.md @@ -126,4 +126,4 @@ Protegidas (requieren autenticación): ```bash git add . git commit -m "mensaje" # Validación automática -``` \ No newline at end of file +``` diff --git a/app/app.css b/app/app.css index a4834ab..4d65a8e 100644 --- a/app/app.css +++ b/app/app.css @@ -39,3 +39,13 @@ body { .scrollbar-hide::-webkit-scrollbar { display: none; /* Chrome, Safari and Opera */ } + +/* Animación para mensajes */ +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} diff --git a/app/components/DayAvailabilityEditor.tsx b/app/components/DayAvailabilityEditor.tsx new file mode 100644 index 0000000..377a100 --- /dev/null +++ b/app/components/DayAvailabilityEditor.tsx @@ -0,0 +1,136 @@ +import { + Button, + Card, + CardBody, + CardHeader, + Input, + Select, + SelectItem, +} from '@nextui-org/react'; +import { Plus, Trash2 } from 'lucide-react'; +import type { DisponibilidadSlot } from '~/lib/types/tutoria.types'; + +interface DayAvailabilityEditorProps { + dayLabel: string; + dayKey: string; + slots: DisponibilidadSlot[]; + onAddSlot: () => void; + onRemoveSlot: (index: number) => void; + onUpdateSlot: ( + index: number, + field: keyof DisponibilidadSlot, + value: string, + ) => void; +} + +export function DayAvailabilityEditor({ + dayLabel, + slots, + onAddSlot, + onRemoveSlot, + onUpdateSlot, +}: DayAvailabilityEditorProps) { + const validateTimeSlot = (slot: DisponibilidadSlot) => { + if (!slot.start || !slot.end) return null; + if (slot.start >= slot.end) { + return 'La hora de fin debe ser mayor que la hora de inicio'; + } + return null; + }; + + return ( + + +
+

{dayLabel}

+ +
+
+ + {slots.length === 0 ? ( +

+ No hay bloques configurados +

+ ) : ( + slots.map((slot, index) => { + const error = validateTimeSlot(slot); + return ( +
+ onUpdateSlot(index, 'start', e.target.value)} + size="sm" + isInvalid={!!error} + classNames={{ base: 'flex-1' }} + /> + onUpdateSlot(index, 'end', e.target.value)} + size="sm" + isInvalid={!!error} + errorMessage={error} + classNames={{ base: 'flex-1' }} + /> + + onUpdateSlot(index, 'lugar', e.target.value)} + size="sm" + isInvalid={!slot.lugar.trim()} + errorMessage={!slot.lugar.trim() ? 'Campo obligatorio' : ''} + classNames={{ base: 'flex-[2]' }} + /> + +
+ ); + }) + )} +
+
+ ); +} diff --git a/app/components/admin/material-card.tsx b/app/components/admin/material-card.tsx new file mode 100644 index 0000000..c832b7b --- /dev/null +++ b/app/components/admin/material-card.tsx @@ -0,0 +1,239 @@ +/** + * Material Card Component - Optimized + * Card optimizada para mostrar materiales con React.memo + */ + +import { Button, Card, CardBody, Chip } from '@heroui/react'; +import { Download, Edit2, Eye, FileText, Star, Trash2 } from 'lucide-react'; +import { memo } from 'react'; +import type { Material } from '~/lib/types/api.types'; + +interface MaterialCardProps { + material: Material; + viewMode: 'list' | 'grid'; + onOpenDetail: (materialId: string) => void; + onOpenStats: (material: Material) => void; + onOpenEdit: (material: Material) => void; + onOpenDelete: (material: Material) => void; +} + +const MaterialCard = memo( + ({ + material, + viewMode, + onOpenDetail, + onOpenStats, + onOpenEdit, + onOpenDelete, + }: MaterialCardProps) => { + const handleCardClick = () => { + onOpenDetail(material.id); + }; + + const handleStatsClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onOpenStats(material); + }; + + const handleEditClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onOpenEdit(material); + }; + + const handleDeleteClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onOpenDelete(material); + }; + + if (viewMode === 'list') { + return ( + + +
+
+
+
+

+ {material.nombre} +

+

+ Por: {material.tutor} +

+
+
+ + + {material.calificacion + ? material.calificacion.toFixed(1) + : '0.0'} + +
+
+
+ {material.tags && material.tags.length > 0 ? ( + material.tags.map((tag) => ( + + {tag} + + )) + ) : ( + + {material.materia} + + )} +
+
+
+ + {material.vistas} vistas +
+
+ + {material.descargas} descargas +
+
+
+
+ + + +
+
+
+
+ ); + } + + // Grid View + return ( + + +
+ +
+

+ {material.nombre} +

+

{material.tutor}

+
+
+ {material.tags && material.tags.length > 0 ? ( + material.tags.slice(0, 2).map((tag) => ( + + {tag} + + )) + ) : ( + + {material.materia} + + )} +
+
+
+ + {material.vistas} +
+
+ + {material.descargas} +
+
+ + + {material.calificacion + ? material.calificacion.toFixed(1) + : '0.0'} + +
+
+
+ + + +
+
+
+
+ ); + }, + // Comparación personalizada para evitar re-renders innecesarios + (prevProps, nextProps) => { + return ( + prevProps.material.id === nextProps.material.id && + prevProps.material.nombre === nextProps.material.nombre && + prevProps.material.vistas === nextProps.material.vistas && + prevProps.material.descargas === nextProps.material.descargas && + prevProps.material.calificacion === nextProps.material.calificacion && + prevProps.viewMode === nextProps.viewMode + ); + }, +); + +MaterialCard.displayName = 'MaterialCard'; + +export { MaterialCard }; diff --git a/app/components/admin/materias/index.ts b/app/components/admin/materias/index.ts new file mode 100644 index 0000000..66e1273 --- /dev/null +++ b/app/components/admin/materias/index.ts @@ -0,0 +1,2 @@ +export { MateriaForm } from './materia-form'; +export { MateriasManagement } from './materias-management'; diff --git a/app/components/admin/materias/materia-form.tsx b/app/components/admin/materias/materia-form.tsx new file mode 100644 index 0000000..04d8d27 --- /dev/null +++ b/app/components/admin/materias/materia-form.tsx @@ -0,0 +1,130 @@ +import { + Button, + Input, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, +} from '@heroui/react'; +import { useEffect, useState } from 'react'; +import type { CreateSubjectDto, Subject } from '~/lib/types/materias.types'; + +interface MateriaFormProps { + isOpen: boolean; + onClose: () => void; + onSubmit: (data: CreateSubjectDto) => void; + editingMateria?: Subject | null; +} + +export function MateriaForm({ + isOpen, + onClose, + onSubmit, + editingMateria, +}: MateriaFormProps) { + const [formData, setFormData] = useState({ + codigo: editingMateria?.codigo || '', + nombre: editingMateria?.nombre || '', + }); + + const [errors, setErrors] = useState<{ codigo?: string; nombre?: string }>( + {}, + ); + + // Actualizar formulario cuando cambia editingMateria + useEffect(() => { + if (editingMateria) { + setFormData({ + codigo: editingMateria.codigo, + nombre: editingMateria.nombre, + }); + } else { + setFormData({ codigo: '', nombre: '' }); + } + setErrors({}); + }, [editingMateria]); + + const validateForm = () => { + const newErrors: { codigo?: string; nombre?: string } = {}; + + if (!formData.codigo.trim()) { + newErrors.codigo = 'El código es requerido'; + } else if (formData.codigo.length < 4) { + newErrors.codigo = 'El código debe tener mínimo 4 caracteres'; + } else if (!/^[A-Z0-9]+$/.test(formData.codigo)) { + newErrors.codigo = + 'El código solo puede contener letras MAYÚSCULAS y números sin espacios'; + } + + if (!formData.nombre.trim()) { + newErrors.nombre = 'El nombre es requerido'; + } else if (formData.nombre.length < 3) { + newErrors.nombre = 'El nombre debe tener al menos 3 caracteres'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = () => { + if (validateForm()) { + onSubmit(formData); + handleClose(); + } + }; + + const handleClose = () => { + setFormData({ codigo: '', nombre: '' }); + setErrors({}); + onClose(); + }; + + return ( + + + + {editingMateria ? 'Editar Materia' : 'Nueva Materia'} + + +
+ { + const upperValue = e.target.value.toUpperCase(); + setFormData({ ...formData, codigo: upperValue }); + setErrors({ ...errors, codigo: undefined }); + }} + isInvalid={!!errors.codigo} + errorMessage={errors.codigo} + description="Solo letras MAYÚSCULAS y números, mínimo 4 caracteres" + isDisabled={!!editingMateria} + /> + { + setFormData({ ...formData, nombre: e.target.value }); + setErrors({ ...errors, nombre: undefined }); + }} + isInvalid={!!errors.nombre} + errorMessage={errors.nombre} + description="Mínimo 3 caracteres" + /> +
+
+ + + + +
+
+ ); +} diff --git a/app/components/admin/materias/materias-management.tsx b/app/components/admin/materias/materias-management.tsx new file mode 100644 index 0000000..bb48537 --- /dev/null +++ b/app/components/admin/materias/materias-management.tsx @@ -0,0 +1,247 @@ +import { + MagnifyingGlassIcon, + PlusIcon, + TrashIcon, +} from '@heroicons/react/24/outline'; +import { + AcademicCapIcon, + BeakerIcon, + BookOpenIcon, + CalculatorIcon, + ChartBarIcon, + CodeBracketIcon, + CpuChipIcon, + CubeIcon, + LanguageIcon, + LightBulbIcon, + PaintBrushIcon, + RocketLaunchIcon, +} from '@heroicons/react/24/solid'; +import { + Button, + Card, + CardBody, + CardFooter, + CardHeader, + Input, +} from '@heroui/react'; +import { useState } from 'react'; +import type { Subject } from '~/lib/types/materias.types'; + +interface MateriasManagementProps { + materias: Subject[]; + onDelete: (codigo: string) => void; + onEdit: (materia: Subject) => void; + onOpenForm: () => void; +} + +// Función para obtener el icono basado en el código o nombre de la materia +const getMateriaIcon = (codigo: string, nombre: string) => { + const codigoLower = codigo.toLowerCase(); + const nombreLower = nombre.toLowerCase(); + + // Matemáticas y Cálculo + if ( + codigoLower.includes('calc') || + codigoLower.includes('mat') || + nombreLower.includes('cálculo') || + nombreLower.includes('matemática') + ) { + return ; + } + + // Programación y Desarrollo + if ( + codigoLower.includes('prog') || + codigoLower.includes('dosw') || + codigoLower.includes('dev') || + nombreLower.includes('programación') || + nombreLower.includes('desarrollo') + ) { + return ; + } + + // Química y Ciencias Naturales + if ( + codigoLower.includes('quim') || + codigoLower.includes('fis') || + nombreLower.includes('química') || + nombreLower.includes('física') + ) { + return ; + } + + // Computación y Sistemas + if ( + codigoLower.includes('comp') || + codigoLower.includes('sist') || + codigoLower.includes('info') || + nombreLower.includes('computación') || + nombreLower.includes('sistemas') + ) { + return ; + } + + // Estadística y Análisis de Datos + if ( + codigoLower.includes('est') || + codigoLower.includes('data') || + nombreLower.includes('estadística') || + nombreLower.includes('datos') + ) { + return ; + } + + // Idiomas + if ( + codigoLower.includes('ing') || + codigoLower.includes('esp') || + codigoLower.includes('fra') || + nombreLower.includes('inglés') || + nombreLower.includes('idioma') + ) { + return ; + } + + // Arte y Diseño + if ( + codigoLower.includes('art') || + codigoLower.includes('dis') || + nombreLower.includes('arte') || + nombreLower.includes('diseño') + ) { + return ; + } + + // Innovación y Emprendimiento + if ( + codigoLower.includes('inno') || + codigoLower.includes('empr') || + nombreLower.includes('innovación') || + nombreLower.includes('emprendimiento') + ) { + return ; + } + + // Ingeniería + if (codigoLower.includes('ing') || nombreLower.includes('ingeniería')) { + return ; + } + + // Investigación + if (codigoLower.includes('inv') || nombreLower.includes('investigación')) { + return ; + } + + // Por defecto: ícono académico general + return ; +}; + +// Función para obtener el color +const getMateriaColor = () => { + return 'bg-primary'; +}; + +export function MateriasManagement({ + materias, + onDelete, + onEdit, + onOpenForm, +}: MateriasManagementProps) { + const [searchTerm, setSearchTerm] = useState(''); + + const filteredMaterias = materias.filter( + (materia) => + materia.codigo.toLowerCase().includes(searchTerm.toLowerCase()) || + materia.nombre.toLowerCase().includes(searchTerm.toLowerCase()), + ); + + return ( +
+ {/* Barra de búsqueda y botón */} + + +
+ setSearchTerm(e.target.value)} + startContent={ + + } + className="w-full max-w-lg" + /> + +
+
+
+ + {/* Grid de materias */} + {filteredMaterias.length === 0 ? ( + + +
+ +

No hay materias registradas

+
+
+
+ ) : ( + <> +
+ {filteredMaterias.map((materia) => ( + onEdit(materia)} + > + + {getMateriaIcon(materia.codigo, materia.nombre)} +

{materia.codigo}

+
+ +

+ {materia.nombre} +

+
+ + + +
+ ))} +
+ +
+ Mostrando {filteredMaterias.length} de {materias.length} materias +
+ + )} +
+ ); +} diff --git a/app/components/chat-dropdown.tsx b/app/components/chat-dropdown.tsx index e324a71..fb9a79f 100644 --- a/app/components/chat-dropdown.tsx +++ b/app/components/chat-dropdown.tsx @@ -5,11 +5,12 @@ import { DropdownItem, DropdownMenu, DropdownTrigger, - Input, + Spinner, Tooltip, } from '@heroui/react'; -import { MessageSquare, Plus, Search } from 'lucide-react'; -import { useState } from 'react'; +import { MessageSquare, Plus } from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { useChats, useCreateChat } from '~/lib/hooks/useChats'; import { CreateGroupModal } from './create-group-modal'; interface Chat { @@ -21,55 +22,49 @@ interface Chat { unread: boolean; } -const mockChats: Chat[] = [ - { - id: '1', - name: 'Dr. María García', - avatar: 'MG', - lastMessage: - '¡Perfecto! Nos vemos mañana a las 15:00 para la tutoría de Cálculo', - timestamp: new Date(Date.now() - 5 * 60 * 1000), - unread: true, - }, - { - id: '2', - name: 'Ing. Carlos Rodríguez', - avatar: 'CR', - lastMessage: 'Te envié el material de React que me pediste', - timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000), - unread: false, - }, - { - id: '3', - name: 'Grupo Cálculo 2024-1', - avatar: 'GC', - lastMessage: 'Ana: ¿Alguien tiene las notas de la clase de hoy?', - timestamp: new Date(Date.now() - 4 * 60 * 60 * 1000), - unread: true, - }, -]; - interface ChatDropdownProps { - onOpenChat?: (tutor: { + onOpenChat?: (data: { id: number; name: string; title: string; avatarInitials: string; + groupId?: string; }) => void; } export function ChatDropdown({ onOpenChat }: Readonly) { - const [chats, setChats] = useState(mockChats); - const [searchValue, setSearchValue] = useState(''); const [isOpen, setIsOpen] = useState(false); const [isCreateGroupOpen, setIsCreateGroupOpen] = useState(false); + + const { data: chatsData, isLoading, error } = useChats(); + const createChatMutation = useCreateChat(); + + const chats = useMemo(() => { + if (!chatsData) { + console.log('ChatDropdown: No chatsData available'); + return []; + } + console.log('ChatDropdown: Chats loaded:', chatsData); + return chatsData.map((group) => ({ + id: group.id, + name: group.nombre, + avatar: group.nombre + .split(' ') + .map((word) => word[0]) + .join('') + .substring(0, 2) + .toUpperCase(), + lastMessage: 'Grupo creado', + timestamp: new Date(group.fechaCreacion), + unread: false, + })); + }, [chatsData]); + const unreadCount = chats.filter((c) => c.unread).length; - const filteredChats = chats.filter( - (chat) => - chat.name.toLowerCase().includes(searchValue.toLowerCase()) || - chat.lastMessage.toLowerCase().includes(searchValue.toLowerCase()), - ); + if (error) { + console.error('ChatDropdown: Error loading chats:', error); + } const formatTime = (date: Date) => { const now = new Date(); @@ -85,45 +80,31 @@ export function ChatDropdown({ onOpenChat }: Readonly) { const handleChatClick = (chat: Chat) => { if (onOpenChat) { const id = Number.parseInt(chat.id, 10); - let title = 'Grupo'; - if (chat.name.includes('Dr.')) title = 'Profesor'; - else if (chat.name.includes('Ing.')) title = 'Tutor'; - onOpenChat({ id, name: chat.name, - title, + title: 'Grupo', avatarInitials: chat.avatar, + groupId: chat.id, }); setIsOpen(false); } }; - const handleCreateGroup = () => { - setIsCreateGroupOpen(true); - setIsOpen(false); - }; - - const handleGroupCreated = (groupData: { + const handleGroupCreated = async (groupData: { name: string; description: string; - members: any[]; + emails: string[]; }) => { - const newGroup: Chat = { - id: (chats.length + 1).toString(), - name: groupData.name, - avatar: groupData.name - .split(' ') - .map((word) => word[0]) - .join('') - .substring(0, 2) - .toUpperCase(), - lastMessage: 'Grupo creado', - timestamp: new Date(), - unread: false, - }; - setChats((prev) => [newGroup, ...prev]); - console.log('Grupo creado:', groupData); + try { + await createChatMutation.mutateAsync({ + nombre: groupData.name, + emails: groupData.emails, + }); + setIsCreateGroupOpen(false); + } catch (error) { + console.error('Error al crear grupo:', error); + } }; return ( @@ -140,81 +121,72 @@ export function ChatDropdown({ onOpenChat }: Readonly) { -
-
-

Chats

- - - -
- } - size="sm" - variant="bordered" - isClearable - onClear={() => setSearchValue('')} - /> +
+

Chats

+ + +
- - {filteredChats.length === 0 ? ( - + {isLoading ? ( + +
+ +
+
+ ) : chats.length === 0 ? ( +

- {searchValue ? 'No se encontraron chats' : 'No hay chats'} + {error ? 'Error al cargar chats' : 'No hay chats'}

- ) : null} - {filteredChats.map((chat) => ( - handleChatClick(chat)} - > -
- -
-
+ ) : ( + chats.map((chat) => ( + handleChatClick(chat)} + > +
+ +
+
+

+ {chat.name} +

+

+ {formatTime(chat.timestamp)} +

+

- {chat.name} -

-

- {formatTime(chat.timestamp)} + {chat.lastMessage}

+ {chat.unread && ( +
+ +
+ )}
-

- {chat.lastMessage} -

- {chat.unread && ( -
- -
- )}
-
- - ))} + + )) + )} diff --git a/app/components/chat/chatOverlay.tsx b/app/components/chat/chatOverlay.tsx index b58e63f..e98b1d3 100644 --- a/app/components/chat/chatOverlay.tsx +++ b/app/components/chat/chatOverlay.tsx @@ -8,10 +8,18 @@ import { Modal, ModalBody, ModalContent, + ModalHeader, } from '@heroui/react'; -import { Flag, MoreVertical, X } from 'lucide-react'; -import { useEffect, useState } from 'react'; -import { type ChatMessage, chatsService } from '~/lib/services/chats.service'; +import { Flag, MoreVertical, Trash2, Users, X } from 'lucide-react'; +import { useCallback, useEffect, useState } from 'react'; +import { useAuth } from '~/contexts/auth-context'; +import { useDeleteChat } from '~/lib/hooks/useChats'; +import { useWebSocket } from '~/lib/hooks/useWebSocket'; +import { + type ChatMember, + type ChatMessage, + chatsService, +} from '~/lib/services/chats.service'; import MessageInput from './messageInput'; import MessageList from './messageList'; import ReportChatModal from './reportContent/reportChatModal'; @@ -23,6 +31,9 @@ interface Message { name?: string; sender: 'student' | 'tutor'; timestamp: Date; + userAvatar?: string; + userName?: string; + userId?: string; } interface ChatOverlayProps { @@ -41,15 +52,83 @@ export default function ChatOverlay({ tutor, onClose, }: ChatOverlayProps) { + const { user } = useAuth(); const [messages, setMessages] = useState([]); const [isReportModalOpen, setIsReportModalOpen] = useState(false); - const [_isLoading, setIsLoading] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isMembersModalOpen, setIsMembersModalOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [typingUsers, setTypingUsers] = useState([]); + const [members, setMembers] = useState([]); + + const deleteChatMutation = useDeleteChat(); + + const { + isConnected, + onNewMessage, + onUserTyping, + onUserStoppedTyping, + onUserJoined, + onUserLeft, + sendMessage: sendWebSocketMessage, + sendTyping, + sendStopTyping, + } = useWebSocket(groupId); + + const loadMessages = useCallback(async () => { + if (!groupId) return; + setIsLoading(true); + try { + const data = await chatsService.getGroupMessages(groupId); + const convertedMessages = data.map((msg: ChatMessage) => { + const isCurrentUser = msg.usuarioId === user?.id; + const sender: 'student' | 'tutor' = isCurrentUser ? 'student' : 'tutor'; + return { + id: msg.id, + type: 'text' as const, + content: msg.contenido, + sender, + timestamp: new Date(msg.fechaCreacion), + userAvatar: + msg.usuario?.avatar_url || + (isCurrentUser ? user?.avatarUrl : undefined), + userName: msg.usuario + ? `${msg.usuario.nombre} ${msg.usuario.apellido}` + : isCurrentUser + ? user?.name + : 'Usuario', + userId: msg.usuarioId, + }; + }); + setMessages(convertedMessages); + } catch (error) { + console.error('Error loading messages:', error); + } finally { + setIsLoading(false); + } + }, [groupId, user]); + + const loadMembers = useCallback(async () => { + if (!groupId) return; + try { + const groupData = await chatsService.getGroupById(groupId); + if (groupData.miembros) { + setMembers(groupData.miembros); + } + } catch (error) { + console.error('Error loading members:', error); + } + }, [groupId]); useEffect(() => { if (groupId) { + // Marcar como loading y cargar mensajes + setIsLoading(true); loadMessages(); + loadMembers(); } else if (tutor) { // Mensaje de bienvenida automático del tutor si no hay groupId + setIsLoading(false); setMessages([ { id: 'welcome', @@ -60,27 +139,104 @@ export default function ChatOverlay({ }, ]); } - }, [tutor, groupId]); + }, [tutor, groupId, loadMessages, loadMembers]); - const loadMessages = async () => { - if (!groupId) return; - setIsLoading(true); - try { - const data = await chatsService.getGroupMessages(groupId); - const convertedMessages = data.map((msg: ChatMessage) => ({ - id: msg.id, - type: 'text' as const, - content: msg.contenido, - sender: 'student' as const, - timestamp: new Date(msg.fechaCreacion), - })); - setMessages(convertedMessages); - } catch (error) { - console.error('Error loading messages:', error); - } finally { - setIsLoading(false); - } - }; + useEffect(() => { + if (!groupId || !isConnected) return; + + const unsubscribeNewMessage = onNewMessage((message) => { + console.log('[ChatOverlay] New message received via WebSocket:', message); + const isCurrentUser = message.usuario.id === user?.id; + const sender: 'student' | 'tutor' = isCurrentUser ? 'student' : 'tutor'; + + const newMessage: Message = { + id: message.id, + type: 'text', + content: message.contenido, + sender, + timestamp: new Date(message.fechaCreacion), + userAvatar: message.usuario.avatar_url, + userName: `${message.usuario.nombre} ${message.usuario.apellido}`, + userId: message.usuario.id, + }; + + setMessages((prev) => { + // Evitar duplicados: buscar si ya existe el mensaje o un temporal con el mismo contenido + const existingIndex = prev.findIndex( + (m) => + m.id === message.id || + (m.id.startsWith('temp-') && + m.content === message.contenido && + m.userId === message.usuario.id), + ); + + if (existingIndex !== -1) { + // Si es nuestro mensaje temporal, solo actualizar el ID pero mantener los mismos datos + // para evitar re-render del avatar + if (isCurrentUser) { + const updated = [...prev]; + updated[existingIndex] = { + ...prev[existingIndex], + id: message.id, + timestamp: new Date(message.fechaCreacion), + }; + return updated; + } + // Si es mensaje de otro usuario, reemplazarlo completamente + const updated = [...prev]; + updated[existingIndex] = newMessage; + return updated; + } + + // Si no existe y no es nuestro mensaje (ya lo agregamos optimistamente), agregarlo + if (!isCurrentUser) { + return [...prev, newMessage]; + } + + // Si es nuestro mensaje pero no encontramos el temporal, agregarlo de todas formas + return [...prev, newMessage]; + }); + }); + + const unsubscribeUserTyping = onUserTyping((data) => { + console.log('[ChatOverlay] User typing:', data.email); + if (data.userId !== user?.id) { + setTypingUsers((prev) => + prev.includes(data.userId) ? prev : [...prev, data.userId], + ); + } + }); + + const unsubscribeUserStoppedTyping = onUserStoppedTyping((data) => { + console.log('[ChatOverlay] User stopped typing:', data.userId); + setTypingUsers((prev) => prev.filter((id) => id !== data.userId)); + }); + + const unsubscribeUserJoined = onUserJoined((data) => { + console.log('[ChatOverlay] User joined:', data.email); + }); + + const unsubscribeUserLeft = onUserLeft((data) => { + console.log('[ChatOverlay] User left:', data.email); + }); + + return () => { + unsubscribeNewMessage(); + unsubscribeUserTyping(); + unsubscribeUserStoppedTyping(); + unsubscribeUserJoined(); + unsubscribeUserLeft(); + }; + }, [ + groupId, + isConnected, + onNewMessage, + onUserTyping, + onUserStoppedTyping, + onUserJoined, + onUserLeft, + user?.id, + ]); async function sendText(text: string) { if (!groupId) { @@ -107,19 +263,57 @@ export default function ChatOverlay({ return; } - // Enviar al backend + // Crear mensaje temporal para mostrar inmediatamente (optimistic update) + const tempId = `temp-${Date.now()}`; + const optimisticMessage: Message = { + id: tempId, + type: 'text', + content: text, + sender: 'student', + timestamp: new Date(), + userAvatar: user?.avatarUrl, + userName: user?.name || 'Tú', + userId: user?.id, + }; + + // Agregar mensaje inmediatamente a la UI + setMessages((prev) => [...prev, optimisticMessage]); + try { - const newMsg = await chatsService.sendMessage(groupId, text); - const message: Message = { - id: newMsg.id, - type: 'text', - content: newMsg.contenido, - sender: 'student', - timestamp: new Date(newMsg.fechaCreacion), - }; - setMessages((prev) => [...prev, message]); + if (isConnected) { + console.log('[ChatOverlay] Sending message via WebSocket'); + await sendWebSocketMessage(text); + // El evento 'newMessage' del WebSocket actualizará el mensaje automáticamente + } else { + console.log( + '[ChatOverlay] Sending message via HTTP (WebSocket not connected)', + ); + const newMsg = await chatsService.sendMessage(groupId, text); + + // Reemplazar mensaje temporal con el del servidor + setMessages((prev) => + prev.map((msg) => + msg.id === tempId + ? { + id: newMsg.id, + type: 'text', + content: newMsg.contenido, + sender: 'student', + timestamp: new Date(newMsg.fechaCreacion), + userAvatar: newMsg.usuario?.avatar_url || user?.avatarUrl, + userName: newMsg.usuario + ? `${newMsg.usuario.nombre} ${newMsg.usuario.apellido}` + : user?.name || 'Usuario', + userId: newMsg.usuarioId, + } + : msg, + ), + ); + } } catch (error) { - console.error('Error sending message:', error); + console.error('[ChatOverlay] Error sending message:', error); + // Remover mensaje temporal si hay error + setMessages((prev) => prev.filter((msg) => msg.id !== tempId)); } } @@ -152,6 +346,29 @@ export default function ChatOverlay({ alert('Reporte enviado exitosamente. Nuestro equipo lo revisará pronto.'); } + function handleDeleteChat() { + setIsDeleteModalOpen(true); + } + + async function confirmDeleteChat() { + if (!groupId) { + console.warn('No hay groupId para eliminar'); + setIsDeleteModalOpen(false); + onClose(); + return; + } + + try { + await deleteChatMutation.mutateAsync(groupId); + console.log('Chat eliminado exitosamente:', groupId); + setIsDeleteModalOpen(false); + onClose(); + } catch (error) { + console.error('Error al eliminar el chat:', error); + alert('Error al eliminar el chat. Por favor, intenta de nuevo.'); + } + } + if (!tutor) return null; return ( @@ -161,18 +378,18 @@ export default function ChatOverlay({ onClose={onClose} size="lg" scrollBehavior="inside" - isDismissable={false} hideCloseButton={true} classNames={{ - backdrop: 'bg-transparent', - wrapper: '!justify-end !items-stretch', - base: 'rounded-3xl', - body: 'rounded-3xl', + backdrop: 'bg-transparent pointer-events-none', + wrapper: + '!justify-end !items-end !p-0 !m-0 pointer-events-none md:!pr-0 md:!pb-0', + base: 'rounded-t-3xl md:rounded-t-3xl !mb-0 !m-0 max-h-[100vh] md:max-h-[90vh] w-full md:w-[450px] lg:w-[480px] shadow-2xl pointer-events-auto fixed bottom-0 right-0 md:right-20', + body: 'rounded-t-3xl', }} motionProps={{ variants: { enter: { - x: 0, + y: 0, opacity: 1, transition: { duration: 0.3, @@ -180,10 +397,10 @@ export default function ChatOverlay({ }, }, exit: { - x: 50, + y: 100, opacity: 0, transition: { - duration: 0.2, + duration: 0.25, ease: 'easeIn', }, }, @@ -191,19 +408,36 @@ export default function ChatOverlay({ }} > - + {/* Header del chat */} -
-
- -
-

{tutor.name}

-

{tutor.title}

+
+
+
+ + {isConnected && ( + + )} +
+
+

+ {tutor.name} +

+

+ {isConnected ? 'En línea' : tutor.title} +

{/* Botones alineados */} -
+
+ } + onPress={() => setIsMembersModalOpen(true)} + > + Ver miembros + + } + onPress={handleDeleteChat} + > + Eliminar chat + - +
{/* Área de mensajes */} -
- +
+
{/* Input de mensajes */} - + @@ -257,6 +518,121 @@ export default function ChatOverlay({ tutorName={tutor.name} onSubmitReport={handleReport} /> + + {/* Modal de confirmación de eliminación */} + setIsDeleteModalOpen(false)} + size="sm" + > + + +
+
+ +
+
+

+ ¿Eliminar chat? +

+

+ ¿Estás seguro de que quieres eliminar este chat con{' '} + {tutor?.name}? Esta + acción no se puede deshacer. +

+
+
+ + +
+
+
+
+
+ + {/* Modal de miembros */} + setIsMembersModalOpen(false)} + size="md" + scrollBehavior="inside" + placement="top-center" + classNames={{ + backdrop: 'bg-transparent pointer-events-none', + wrapper: + 'pointer-events-none !items-start !justify-end md:pr-24 pr-0 pt-20', + base: 'pointer-events-auto max-h-[80vh] w-full md:w-[380px]', + }} + > + + +
+ +

+ Miembros del grupo +

+
+
+ +
+ {members.length === 0 ? ( +

+ No hay miembros para mostrar +

+ ) : ( + members.map((member) => ( +
+ +
+

+ {member.usuario + ? `${member.usuario.nombre} ${member.usuario.apellido}` + : 'Usuario'} +

+

+ {member.usuario?.email || 'Sin email'} +

+
+
+ )) + )} +
+
+
+
); } diff --git a/app/components/chat/messageInput.tsx b/app/components/chat/messageInput.tsx index 1e6bb85..b9ee6b2 100644 --- a/app/components/chat/messageInput.tsx +++ b/app/components/chat/messageInput.tsx @@ -1,22 +1,55 @@ import { Button } from '@heroui/react'; import { Paperclip, Send } from 'lucide-react'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; interface MessageInputProps { onSendText: (text: string) => void; onSendFile: (file: File) => void; + onTyping?: () => void; + onStopTyping?: () => void; } export default function MessageInput({ onSendText, onSendFile, + onTyping, + onStopTyping, }: MessageInputProps) { const [text, setText] = useState(''); + const typingTimeoutRef = useRef(null); function handleSend() { if (!text.trim()) return; onSendText(text); setText(''); + if (onStopTyping) { + onStopTyping(); + } + if (typingTimeoutRef.current) { + clearTimeout(typingTimeoutRef.current); + typingTimeoutRef.current = null; + } + } + + function handleTextChange(e: React.ChangeEvent) { + const newText = e.target.value; + setText(newText); + + if (newText.length > 0 && onTyping) { + onTyping(); + + if (typingTimeoutRef.current) { + clearTimeout(typingTimeoutRef.current); + } + + typingTimeoutRef.current = setTimeout(() => { + if (onStopTyping) { + onStopTyping(); + } + }, 1000); + } else if (newText.length === 0 && onStopTyping) { + onStopTyping(); + } } function handleKey(e: React.KeyboardEvent) { @@ -36,8 +69,8 @@ export default function MessageInput({ } return ( -
-
+
+
document.getElementById('file-input')?.click()} + className="min-w-8 w-8 h-8 md:min-w-10 md:w-10 md:h-10 flex-shrink-0" + size="sm" > - + setText(e.target.value)} + onChange={handleTextChange} onKeyDown={handleKey} - className="flex-1 border border-gray-300 rounded-full px-4 py-2 focus:outline-none focus:ring-2 focus:ring-red-500" + className="flex-1 min-w-0 border-2 border-primary/20 rounded-full px-3 md:px-4 py-1.5 md:py-2 text-sm md:text-base focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" placeholder="Escribe un mensaje…" />
diff --git a/app/components/chat/messageItem.tsx b/app/components/chat/messageItem.tsx index 6154e24..bd906de 100644 --- a/app/components/chat/messageItem.tsx +++ b/app/components/chat/messageItem.tsx @@ -1,6 +1,15 @@ import { Avatar } from '@heroui/react'; import { Paperclip } from 'lucide-react'; +function fixGoogleAvatarUrl(url: string | undefined): string | undefined { + if (!url) return undefined; + if (url.includes('googleusercontent.com')) { + const baseUrl = url.split('=')[0]; + return `${baseUrl}=s96-c`; + } + return url; +} + interface MessageItemProps { message: { id: string; @@ -9,66 +18,106 @@ interface MessageItemProps { name?: string; sender: 'student' | 'tutor'; timestamp: Date; + userAvatar?: string; + userName?: string; + userId?: string; }; tutorName: string; } export default function MessageItem({ message, tutorName }: MessageItemProps) { const isStudent = message.sender === 'student'; + // Solo animar mensajes de otros usuarios (tutor), no animar mensajes propios ni sus reemplazos + const shouldAnimate = !isStudent; + + const getAvatarProps = () => { + if (message.userAvatar) { + const fixedUrl = fixGoogleAvatarUrl(message.userAvatar); + return { + src: fixedUrl, + name: message.userName || tutorName, + showFallback: true, + imgProps: { + referrerPolicy: 'no-referrer' as const, + }, + }; + } + return { + name: (message.userName || tutorName) + .split(' ') + .map((n) => n[0]) + .join(''), + showFallback: true, + }; + }; return (
{!isStudent && ( n[0]) - .join('')} + {...getAvatarProps()} color="danger" - size="sm" - className="flex-shrink-0" + size="md" + className="flex-shrink-0 w-8 h-8 md:w-10 md:h-10" /> )}
- {message.type === 'text' && ( -

{message.content}

- )} - - {message.type === 'file' && ( - - - {message.name} - - )} - - - {message.timestamp.toLocaleTimeString('es-ES', { - hour: '2-digit', - minute: '2-digit', - })} - + {/* Header con nombre del usuario */} +
+ + {message.userName || tutorName} + +
+ + {/* Contenido del mensaje */} +
+ {message.type === 'text' && ( +

+ {message.content} +

+ )} + + {message.type === 'file' && ( + + + {message.name} + + )} + + + {message.timestamp.toLocaleTimeString('es-ES', { + hour: '2-digit', + minute: '2-digit', + })} + +
+
{isStudent && ( - + )}
); diff --git a/app/components/chat/messageList.tsx b/app/components/chat/messageList.tsx index 1a7422a..fed6eba 100644 --- a/app/components/chat/messageList.tsx +++ b/app/components/chat/messageList.tsx @@ -8,34 +8,100 @@ interface Message { name?: string; sender: 'student' | 'tutor'; timestamp: Date; + userAvatar?: string; + userName?: string; + userId?: string; } interface MessageListProps { messages: Message[]; tutorName: string; + typingUsers?: string[]; + isLoading?: boolean; } -export default function MessageList({ messages, tutorName }: MessageListProps) { +export default function MessageList({ + messages, + tutorName, + typingUsers = [], + isLoading = false, +}: MessageListProps) { const bottomRef = useRef(null); + // biome-ignore lint/correctness/useExhaustiveDependencies: Need to scroll when messages change useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, []); + }, [messages]); return ( -
- {messages.length === 0 ? ( -
-

No hay mensajes aún

+
+ {isLoading ? ( + // Skeleton loader mientras carga + [1, 2, 3].map((i) => ( +
+ {i % 2 === 0 && ( +
+ )} +
+ {i % 2 !== 0 && ( +
+ )} +
+ )) + ) : messages.length === 0 ? ( +
+

No hay mensajes aún

) : ( - messages.map((message) => ( - - )) + <> + {messages.map((message) => ( + + ))} + {typingUsers.length > 0 && ( +
+
+ + ● + + + ● + + + ● + +
+ + {typingUsers.length === 1 + ? 'Alguien está escribiendo...' + : `${typingUsers.length} personas están escribiendo...`} + +
+ )} + )}
diff --git a/app/components/chat/reportContent/reportChatModal.tsx b/app/components/chat/reportContent/reportChatModal.tsx index 4fce092..c2430a9 100644 --- a/app/components/chat/reportContent/reportChatModal.tsx +++ b/app/components/chat/reportContent/reportChatModal.tsx @@ -9,13 +9,19 @@ import { RadioGroup, Textarea, } from '@heroui/react'; -import { AlertTriangle } from 'lucide-react'; +import { AlertTriangle, CheckCircle } from 'lucide-react'; import { useState } from 'react'; +import { + mapFrontendReasonToBackend, + reportesService, + TipoContenido, +} from '~/lib/services/reportes.services'; interface ReportChatModalProps { isOpen: boolean; onClose: () => void; tutorName: string; + messageId?: string; onSubmitReport: (reason: string, details: string) => void; isMessageReport?: boolean; } @@ -24,12 +30,15 @@ export default function ReportChatModal({ isOpen, onClose, tutorName, + messageId, onSubmitReport, isMessageReport = false, }: Readonly) { const [selectedReason, setSelectedReason] = useState(''); const [details, setDetails] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); + const [successMessage, setSuccessMessage] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); const reportReasons = [ { value: 'harassment', label: 'Acoso o intimidación' }, @@ -45,22 +54,48 @@ export default function ReportChatModal({ if (!selectedReason) return; setIsSubmitting(true); + setErrorMessage(''); - // Simular llamada API - await new Promise((resolve) => setTimeout(resolve, 1000)); + try { + const motivo = mapFrontendReasonToBackend(selectedReason); - onSubmitReport(selectedReason, details); + if (messageId) { + await reportesService.createReport({ + contenido_id: messageId, + tipo_contenido: TipoContenido.MENSAJE_CHAT, + motivo, + descripcion: details || undefined, + }); + } else { + // Fallback: si no hay messageId, solo ejecuta el callback + console.warn('No messageId provided, skipping API call'); + } - // Resetear formulario - setSelectedReason(''); - setDetails(''); - setIsSubmitting(false); - onClose(); + onSubmitReport(selectedReason, details); + + setSuccessMessage( + 'Reporte enviado exitosamente. Será revisado por nuestro equipo.', + ); + + setTimeout(() => { + setSelectedReason(''); + setDetails(''); + setSuccessMessage(''); + onClose(); + }, 2000); + } catch (error) { + console.error('Error al crear reporte:', error); + alert('Error al enviar el reporte. Por favor, intenta nuevamente.'); + } finally { + setIsSubmitting(false); + } }; const handleClose = () => { setSelectedReason(''); setDetails(''); + setSuccessMessage(''); + setErrorMessage(''); onClose(); }; @@ -80,50 +115,75 @@ export default function ReportChatModal({ {isMessageReport ? 'Reportar mensaje' : 'Reportar conversación'} -

- {isMessageReport ? ( - <> - Estás reportando un mensaje de {tutorName}. - Esta acción será revisada por nuestro equipo de moderación. - - ) : ( - <> - Estás reportando tu conversación completa con{' '} - {tutorName}. Esta acción será revisada por - nuestro equipo de moderación. - - )} -

- - - {reportReasons.map((reason) => ( - - {reason.label} - - ))} - - -