diff --git a/src/App.tsx b/src/App.tsx
index 42fbbb6..0c0ca1b 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -7,6 +7,7 @@ import Index from "./pages/Index";
import Viewer from "./pages/Viewer";
import XRSession from "./pages/XRSession";
import Watch from "./pages/Watch";
+import MeetingRoom from "./pages/MeetingRoom";
import NotFound from "./pages/NotFound";
import "@/lib/remoteLogger";
@@ -23,6 +24,8 @@ const App = () => (
} />
} />
} />
+ } />
+ } />
} />
diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx
index c020dcc..4056262 100644
--- a/src/pages/Index.tsx
+++ b/src/pages/Index.tsx
@@ -1,6 +1,6 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
-import { Upload, Glasses, CheckCircle, XCircle, Loader2, Box, Package } from 'lucide-react';
+import { Upload, Glasses, CheckCircle, XCircle, Loader2, Box, Package, Users } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useModelStore } from '@/stores/useModelStore';
import { toast } from 'sonner';
@@ -304,6 +304,15 @@ const Index = () => {
Visualizar Modelo 3D
+ {/* Enter virtual meeting */}
+
+
{/* XR Status */}
{xrSupported === null ?
diff --git a/src/pages/MeetingRoom.tsx b/src/pages/MeetingRoom.tsx
new file mode 100644
index 0000000..922dded
--- /dev/null
+++ b/src/pages/MeetingRoom.tsx
@@ -0,0 +1,824 @@
+import { useEffect, useRef, useState, useMemo } from 'react';
+import { useParams, useNavigate } from 'react-router-dom';
+import { Canvas, useFrame, useThree } from '@react-three/fiber';
+import { OrbitControls, Text, Line } from '@react-three/drei';
+import * as THREE from 'three';
+import { supabase } from '@/integrations/supabase/client';
+import { useModelStore } from '@/stores/useModelStore';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { toast } from 'sonner';
+import {
+ Users, MessageSquare, Mic, MicOff, Home, Send,
+ ArrowRight, Check, ChevronLeft, ChevronRight, Share2, LogOut
+} from 'lucide-react';
+
+// --- Configurações da Mesa de Reunião ---
+// Coordenadas das 8 cadeiras ao redor da mesa elíptica
+const CHAIRS = [
+ { id: '1', name: 'Assento 1', pos: [-1.2, 0.95, -0.6], lookAt: [0, 0.95, 0] },
+ { id: '2', name: 'Assento 2', pos: [-0.6, 0.95, -0.9], lookAt: [0, 0.95, 0] },
+ { id: '3', name: 'Assento 3', pos: [0.6, 0.95, -0.9], lookAt: [0, 0.95, 0] },
+ { id: '4', name: 'Assento 4', pos: [1.2, 0.95, -0.6], lookAt: [0, 0.95, 0] },
+ { id: '5', name: 'Assento 5', pos: [1.2, 0.95, 0.6], lookAt: [0, 0.95, 0] },
+ { id: '6', name: 'Assento 6', pos: [0.6, 0.95, 0.9], lookAt: [0, 0.95, 0] },
+ { id: '7', name: 'Assento 7', pos: [-0.6, 0.95, 0.9], lookAt: [0, 0.95, 0] },
+ { id: '8', name: 'Assento 8', pos: [-1.2, 0.95, 0.6], lookAt: [0, 0.95, 0] },
+];
+
+// 10 Modelos de Avatar Futurista (representações no Lobby)
+const AVATAR_PRESETS = [
+ { id: 1, name: 'Cyber Helmet', desc: 'Elmo octaédrico com visor ciano', color: '#0ea5e9' },
+ { id: 2, name: 'Gold Mask', desc: 'Máscara poliédrica dourada', color: '#eab308' },
+ { id: 3, name: 'Neon Ring', desc: 'Esfera com órbita luminosa', color: '#ec4899' },
+ { id: 4, name: 'Retro Bot', desc: 'Unidade cúbica com visor esmeralda', color: '#10b981' },
+ { id: 5, name: 'Crystal Prism', desc: 'Estrutura geométrica ametista', color: '#a855f7' },
+ { id: 6, name: 'Vector Visor', desc: 'Busto metálico com LED vermelho', color: '#ef4444' },
+ { id: 7, name: 'Quantum Core', desc: 'Núcleo de energia flutuante', color: '#00f5ff' },
+ { id: 8, name: 'Obsidian Shell', desc: 'Casca geométrica com núcleo laranja', color: '#f97316' },
+ { id: 9, name: 'Helix Guardian', desc: 'Espiral cósmica dupla', color: '#3b82f6' },
+ { id: 10, name: 'Carbon Apex', desc: 'Estrutura furtiva em cinza grafite', color: '#64748b' },
+];
+
+// --- Subcomponentes 3D do Cenário ---
+
+// Avatar Tridimensional Renderizado
+function ThreeAvatar({ avatarId, color, isTalking, username, ...props }: { avatarId: number; color: string; isTalking?: boolean; username: string } & any) {
+ const groupRef = useRef
(null);
+ const colorObj = useMemo(() => new THREE.Color(color), [color]);
+
+ useFrame((state) => {
+ if (groupRef.current) {
+ // Pequena oscilação flutuante sutil
+ groupRef.current.position.y = props.position[1] + Math.sin(state.clock.getElapsedTime() * 2 + avatarId) * 0.015;
+ }
+ });
+
+ return (
+
+ {/* Nome do Participante acima da cabeça */}
+
+ {/* Painel do nome */}
+
+
+
+
+
+ {username}
+
+
+
+ {/* Indicador de Fala (anel pulsante) */}
+ {isTalking && (
+
+
+
+
+ )}
+
+ {/* Renderizadores dos avatares baseados em presets geométricos */}
+ {avatarId === 1 && (
+ // Cyber Helmet
+
+
+
+
+
+
+
+
+ )}
+
+ {avatarId === 2 && (
+ // Gold Mask
+
+
+
+
+
+
+
+
+ )}
+
+ {avatarId === 3 && (
+ // Neon Ring
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {avatarId === 4 && (
+ // Retro Bot
+
+
+
+
+
+ {/* Olhos */}
+
+
+
+
+
+
+
+
+
+ )}
+
+ {avatarId === 5 && (
+ // Crystal Prism
+
+
+
+
+
+
+
+
+ )}
+
+ {avatarId === 6 && (
+ // Vector Visor
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {avatarId === 7 && (
+ // Quantum Core
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {avatarId === 8 && (
+ // Obsidian Shell
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {avatarId === 9 && (
+ // Synth Head
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {avatarId === 10 && (
+ // Cosmic Helix
+
+
+
+
+
+
+ )}
+
+ );
+}
+
+// Mesa, Cadeiras e Detalhes da Sala de Reunião
+function ThreeMeetingRoomScene({ currentActiveModel }: { currentActiveModel: any }) {
+ const tableGeom = useMemo(() => {
+ // Geometria da mesa em U
+ const shape = new THREE.Shape();
+ shape.moveTo(-1.6, -1.1);
+ shape.lineTo(1.6, -1.1);
+ shape.quadraticCurveTo(1.9, -1.1, 1.9, -0.8);
+ shape.lineTo(1.9, 0.8);
+ shape.quadraticCurveTo(1.9, 1.1, 1.6, 1.1);
+ shape.lineTo(-1.6, 1.1);
+ shape.quadraticCurveTo(-1.9, 1.1, -1.9, 0.8);
+ shape.lineTo(-1.9, -0.8);
+ shape.quadraticCurveTo(-1.9, -1.1, -1.6, -1.1);
+
+ // Furo elíptico central para projetar o holograma
+ const hole = new THREE.Path();
+ hole.absellipse(0, 0, 0.9, 0.45, 0, Math.PI * 2, true);
+ shape.holes.push(hole);
+
+ return new THREE.ExtrudeGeometry(shape, { depth: 0.06, bevelEnabled: true, bevelThickness: 0.01, bevelSize: 0.01, bevelSegments: 3 });
+ }, []);
+
+ return (
+
+ {/* Piso Industrial */}
+
+
+
+
+
+ {/* Tapete Central */}
+
+
+
+
+
+ {/* Paredes e Painéis de Fundo */}
+
+
+
+
+ {/* Janelas panorâmicas com vista simulada (gradiente brilhante nas laterais) */}
+
+
+
+
+
+
+
+
+
+ {/* Mesa Elíptica em U */}
+
+
+
+
+
+
+ {/* Pés da mesa (suporte) */}
+ {[-1.3, 1.3].map((x) =>
+ [-0.7, 0.7].map((z) => (
+
+
+
+
+ ))
+ )}
+
+ {/* Projetor de Holograma (Centro da mesa) */}
+
+
+
+
+
+ {/* Placa metálica brilhante */}
+
+
+
+
+ {/* Feixe de Luz Holográfico */}
+
+
+
+
+
+ {/* Modelo 3D da Maquete Holográfica (Representação/Pivot) */}
+
+ {currentActiveModel ? (
+
+
+
+
+ ) : (
+ // Holograma Padrão (Estrutura geométrica representativa de maquete)
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+
+ {/* Renderizar as Cadeiras Físicas da mesa */}
+ {CHAIRS.map((chair) => (
+
+ {/* Base giratória metálica */}
+
+
+
+
+ {/* Pés base */}
+
+
+
+
+ {/* Assento */}
+
+
+
+
+ {/* Encosto */}
+
+
+
+
+
+ ))}
+
+ {/* Iluminação da Sala */}
+
+
+
+
+
+ );
+}
+
+// --- Componente Principal da Tela ---
+
+export default function MeetingRoom() {
+ const { roomId } = useParams<{ roomId?: string }>();
+ const navigate = useNavigate();
+ const activeModel = useModelStore((s) => s.model);
+
+ // Estados do Lobby
+ const [inRoom, setInRoom] = useState(false);
+ const [name, setName] = useState('');
+ const [selectedAvatar, setSelectedAvatar] = useState(1);
+ const [selectedChair, setSelectedChair] = useState('1');
+ const [inputRoomId, setInputRoomId] = useState(roomId || '');
+
+ // Estados da Sala
+ const [peers, setPeers] = useState>({});
+ const [chatMessages, setChatMessages] = useState<{ id: string; sender: string; text: string; time: string }[]>([]);
+ const [newMessage, setNewMessage] = useState('');
+ const [isMuted, setIsMuted] = useState(false);
+ const [isTalking, setIsTalking] = useState(false);
+ const [activeUsersCount, setActiveUsersCount] = useState(1);
+ const [roomCode, setRoomCode] = useState(roomId || '');
+
+ const channelRef = useRef(null);
+ const chatScrollRef = useRef(null);
+
+ // Gerencia o loop de simulação de fala
+ useEffect(() => {
+ if (!inRoom || isMuted) {
+ setIsTalking(false);
+ return;
+ }
+ const interval = setInterval(() => {
+ // Simula detecção de microfone com probabilidade de atividade
+ setIsTalking(Math.random() > 0.7);
+ }, 1500);
+ return () => clearInterval(interval);
+ }, [inRoom, isMuted]);
+
+ // Rola o chat para o fim quando houver mensagens
+ useEffect(() => {
+ if (chatScrollRef.current) {
+ chatScrollRef.current.scrollTop = chatScrollRef.current.scrollHeight;
+ }
+ }, [chatMessages]);
+
+ // Inicializa o Supabase Presence & Broadcast para conexões
+ const connectToRoom = (targetRoomId: string) => {
+ if (!name.trim()) {
+ toast.error('Digite seu nome primeiro');
+ return;
+ }
+ const finalRoomId = targetRoomId.trim().toUpperCase() || Math.random().toString(36).substring(3, 8).toUpperCase();
+ setRoomCode(finalRoomId);
+
+ console.log(`🔌 [MeetingRoom] Conectando à sala: ${finalRoomId}`);
+
+ const channel = supabase.channel(`meeting_room_${finalRoomId}`, {
+ config: {
+ broadcast: { self: true },
+ presence: { key: selectedChair },
+ },
+ });
+
+ channelRef.current = channel;
+
+ // Sincroniza participantes
+ channel
+ .on('presence', { event: 'sync' }, () => {
+ const state = channel.presenceState();
+ const formattedPeers: Record = {};
+ let count = 0;
+
+ Object.keys(state).forEach((chairKey) => {
+ const presences = state[chairKey] as any[];
+ if (presences && presences.length > 0) {
+ formattedPeers[chairKey] = presences[0];
+ count++;
+ }
+ });
+ setPeers(formattedPeers);
+ setActiveUsersCount(count);
+ })
+ .on('presence', { event: 'join' }, ({ key, newPresences }) => {
+ toast.info(`Participante "${newPresences[0]?.username}" entrou no assento ${key}`);
+ })
+ .on('presence', { event: 'leave' }, ({ key, leftPresences }) => {
+ toast.info(`Participante "${leftPresences[0]?.username}" desocupou o assento ${key}`);
+ });
+
+ // Escuta mensagens do Chat via Broadcast
+ channel.on('broadcast', { event: 'chat_msg' }, (payload) => {
+ setChatMessages((prev) => [
+ ...prev,
+ {
+ id: Math.random().toString(),
+ sender: payload.payload.sender,
+ text: payload.payload.text,
+ time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
+ },
+ ]);
+ });
+
+ // Inscrever no WebSocket
+ channel.subscribe(async (status) => {
+ if (status === 'SUBSCRIBED') {
+ // Envia as informações do usuário atual para a presença
+ await channel.track({
+ username: name.substring(0, 10),
+ avatarId: selectedAvatar,
+ chairId: selectedChair,
+ joinedAt: new Date().toISOString(),
+ });
+ setInRoom(true);
+ toast.success(`Entrou na sala de reunião: ${finalRoomId}`);
+ navigate(`/meeting/${finalRoomId}`, { replace: true });
+ } else if (status === 'CHANNEL_ERROR') {
+ toast.error('Erro de conexão ao canal de reuniões.');
+ }
+ });
+ };
+
+ // Enviar Mensagem de Chat
+ const handleSendMessage = () => {
+ if (!newMessage.trim() || !channelRef.current) return;
+ channelRef.current.send({
+ type: 'broadcast',
+ event: 'chat_msg',
+ payload: {
+ sender: name.substring(0, 10),
+ text: newMessage.trim(),
+ },
+ });
+ setNewMessage('');
+ };
+
+ // Sair da Sala de Reunião
+ const handleLeaveRoom = async () => {
+ if (channelRef.current) {
+ console.log('🔌 [MeetingRoom] Saindo da sala...');
+ await supabase.removeChannel(channelRef.current);
+ }
+ setInRoom(false);
+ setPeers({});
+ setChatMessages([]);
+ navigate('/');
+ };
+
+ // Identifica quais cadeiras já estão ocupadas por outros participantes
+ const occupiedChairs = useMemo(() => {
+ return Object.keys(peers);
+ }, [peers]);
+
+ // Escolha do Preset de Avatar no Lobby (Navegação)
+ const nextAvatar = () => {
+ setSelectedAvatar((prev) => (prev === 10 ? 1 : prev + 1));
+ };
+ const prevAvatar = () => {
+ setSelectedAvatar((prev) => (prev === 1 ? 10 : prev - 1));
+ };
+
+ // --- Renderização do Lobby ---
+
+ if (!inRoom) {
+ const currentAvatar = AVATAR_PRESETS[selectedAvatar - 1];
+ return (
+
+
+
+ TrackSteelXR
+
+
+
+
+
+ Lobby de Reunião Virtual
+
+
+ Prepare seu avatar e escolha seu assento
+
+
+
+
+ {/* Seletor de Avatar (Lado Esquerdo - 5 colunas) */}
+
+
Escolha o Avatar
+
+ {/* Modelo Virtual do Avatar no Centro */}
+
+
+
+ {/* Navegadores de carrossel de avatar */}
+
+
+
+
+ {/* Informações do avatar */}
+
+
+ {currentAvatar.name}
+
+
{currentAvatar.desc}
+
+
+ {/* Nome */}
+
+
+ setName(e.target.value)}
+ className="font-semibold text-center uppercase font-mono tracking-wider"
+ />
+
+
+
+ {/* Configurações de Assento e Conexão (Lado Direito - 7 colunas) */}
+
+ {/* Seleção do Assento */}
+
+
Selecione seu Assento na Mesa
+
+ {CHAIRS.map((chair) => {
+ const isOccupied = occupiedChairs.includes(chair.id);
+ const isSelected = selectedChair === chair.id;
+ return (
+
+ );
+ })}
+
+
+
+ {/* ID da Sala / Ação */}
+
+
+
+
+ setInputRoomId(e.target.value.toUpperCase())}
+ className="font-mono tracking-widest font-bold text-center"
+ />
+
+
+
+
+
+
+
+
+
+ {/* Aviso da Peça Ativa */}
+
+
+
+
+ {activeModel ? `Maquete Ativa: ${activeModel.fileName}` : 'Nenhuma maquete ativa. Sala iniciará com maquete padrão.'}
+
+
+ {activeModel && (
+
IFC/GLB
+ )}
+
+
+
+
+
+ );
+ }
+
+ // --- Renderização da Sala de Reunião Ativa ---
+
+ return (
+
+ {/* 3D Canvas - Sala Virtual */}
+
+
+
+ {/* HUD Superior / Detalhes da Conexão */}
+
+
+
+
+ Reunião Conectada
+
+ Cadeiras Ocupadas: {activeUsersCount}/8
+
+
+
+
+
+ {/* Código da sala */}
+
+ CÓDIGO:
+ {roomCode}
+
+
+
+ {/* Sair */}
+
+
+
+
+ {/* HUD Inferior - Controles de Áudio e Instrução */}
+
+
+
+
+ Dica Imersiva
+
+ Clique e arraste com o botão esquerdo do mouse para girar a maquete e a sala
+
+
+
+
+
+ {/* Painel Lateral Direito - Chat e Lista de Usuários (Largura 320px) */}
+
+ {/* Header do Painel */}
+
+
+
+ Chat & Notas
+
+
Realtime
+
+
+ {/* Histórico do Chat */}
+
+ {chatMessages.length === 0 ? (
+
+
+
+ Nenhuma mensagem enviada. Seja o primeiro a digitar no chat da reunião!
+
+
+ ) : (
+ chatMessages.map((msg) => (
+
+
+ {msg.sender}
+ {msg.time}
+
+
+ {msg.text}
+
+
+ ))
+ )}
+
+
+ {/* Input do Chat */}
+
+ setNewMessage(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') handleSendMessage();
+ }}
+ className="flex-1 text-xs border-slate-800 bg-slate-950/65"
+ />
+
+
+
+
+ );
+}