🚀 Auto-deploy: melhoria no snap e medição AR em 25/05/2026 00:34:30

This commit is contained in:
2026-05-25 00:34:30 +00:00
parent 3c903c1ff1
commit 2e7402691a
+240 -86
View File
@@ -12,11 +12,11 @@ import { toast } from 'sonner';
import { import {
Users, MessageSquare, Mic, MicOff, Home, Send, Users, MessageSquare, Mic, MicOff, Home, Send,
ArrowRight, ChevronLeft, ChevronRight, Share2, LogOut, ArrowRight, ChevronLeft, ChevronRight, Share2, LogOut,
RotateCw, Award RotateCw, Award, ShieldAlert
} from 'lucide-react'; } from 'lucide-react';
// Interfaces tipadas para evitar any // Interfaces tipadas para evitar any
interface MiiPreset { interface AvatarPreset {
id: number; id: number;
name: string; name: string;
desc: string; desc: string;
@@ -54,17 +54,17 @@ const CHAIRS = [
{ id: '8', name: 'Assento 8', pos: [-1.2, 0.95, 1.45], lookAt: [0, 0.95, 0] }, { id: '8', name: 'Assento 8', pos: [-1.2, 0.95, 1.45], lookAt: [0, 0.95, 0] },
]; ];
const AVATAR_PRESETS: MiiPreset[] = [ const AVATAR_PRESETS: AvatarPreset[] = [
{ id: 1, name: 'Mii Cyber', desc: 'Cabelo curto preto e óculos vermelhos', color: '#ef4444' }, { id: 1, name: 'Cyber', desc: 'Cabelo curto preto e óculos vermelhos', color: '#ef4444' },
{ id: 2, name: 'Mii Classic', desc: 'Pele média com Cabelo castanho clássico', color: '#b45309' }, { id: 2, name: 'Classic', desc: 'Pele média com Cabelo castanho clássico', color: '#b45309' },
{ id: 3, name: 'Mii Goldie', desc: 'Cabelo loiro longo com olhos azuis expressivos', color: '#eab308' }, { id: 3, name: 'Goldie', desc: 'Cabelo loiro longo com olhos azuis expressivos', color: '#eab308' },
{ id: 4, name: 'Mii Sporty', desc: 'Boné azul virado para trás e sorriso aberto', color: '#3b82f6' }, { id: 4, name: 'Sporty', desc: 'Boné azul virado para trás e sorriso aberto', color: '#3b82f6' },
{ id: 5, name: 'Mii Ginger', desc: 'Cabelo ruivo longo ondulado e expressivo', color: '#f97316' }, { id: 5, name: 'Ginger', desc: 'Cabelo ruivo longo ondulado e expressivo', color: '#f97316' },
{ id: 6, name: 'Mii Sleek', desc: 'Pele parda com cabelo curto liso lateral', color: '#10b981' }, { id: 6, name: 'Sleek', desc: 'Pele parda com cabelo curto liso lateral', color: '#10b981' },
{ id: 7, name: 'Mii Beard', desc: 'Careca estilosa com barba e bigode', color: '#78350f' }, { id: 7, name: 'Beard', desc: 'Careca estilosa com barba e bigode', color: '#78350f' },
{ id: 8, name: 'Mii Sun', desc: 'Cabelo raspado e óculos escuros pretos', color: '#1e293b' }, { id: 8, name: 'Sun', desc: 'Cabelo raspado e óculos escuros pretos', color: '#1e293b' },
{ id: 9, name: 'Mii Synth', desc: 'Cabelo roxo ondulado moderno', color: '#a855f7' }, { id: 9, name: 'Synth', desc: 'Cabelo roxo ondulado moderno', color: '#a855f7' },
{ id: 10, name: 'Mii Straw', desc: 'Chapéu de palha do campo clássico', color: '#ca8a04' }, { id: 10, name: 'Straw', desc: 'Chapéu de palha do campo clássico', color: '#ca8a04' },
]; ];
const SKIN_COLORS = ['#fed7aa', '#fdba74', '#f59e0b', '#d97706', '#9a3412', '#ffedd5']; const SKIN_COLORS = ['#fed7aa', '#fdba74', '#f59e0b', '#d97706', '#9a3412', '#ffedd5'];
@@ -88,7 +88,7 @@ const getAvatarBgClass = (avatarId: number): string => {
// --- Subcomponentes 3D --- // --- Subcomponentes 3D ---
// Avatar estilo Mii (Nintendo Wii) // Avatar estilo Mii (Nintendo Wii) - Versão Melhorada (Mais Feliz e Expressivo)
function MiiAvatar({ avatarId, username, isTalking, ...props }: { avatarId: number; username: string; isTalking?: boolean } & Record<string, unknown>) { function MiiAvatar({ avatarId, username, isTalking, ...props }: { avatarId: number; username: string; isTalking?: boolean } & Record<string, unknown>) {
const groupRef = useRef<THREE.Group>(null); const groupRef = useRef<THREE.Group>(null);
@@ -103,7 +103,7 @@ function MiiAvatar({ avatarId, username, isTalking, ...props }: { avatarId: numb
capColor: '#3b82f6', capColor: '#3b82f6',
hasHat: avatarId === 10, hasHat: avatarId === 10,
hatColor: '#b45309', hatColor: '#b45309',
hairStyle: avatarId === 7 ? 'bald' : (avatarId % 3 === 0 ? 'long' : 'short'), hairStyle: avatarId === 7 ? 'bald' : (avatarId === 9 ? 'modern' : (avatarId % 3 === 0 ? 'long' : 'short')),
beard: avatarId === 7, beard: avatarId === 7,
}; };
}, [avatarId]); }, [avatarId]);
@@ -149,18 +149,30 @@ function MiiAvatar({ avatarId, username, isTalking, ...props }: { avatarId: numb
</mesh> </mesh>
)} )}
{/* CABEÇA MII (Esfera ovalada de pele) */} {/* CABEÇA AVATAR (Esfera ovalada de pele) */}
<mesh position={[0, 0.1, 0]} scale={[1, 1.15, 1]}> <mesh position={[0, 0.1, 0]} scale={[1, 1.15, 1]}>
<sphereGeometry args={[0.08, 32, 32]} /> <sphereGeometry args={[0.08, 32, 32]} />
<meshStandardMaterial color={mii.skin} roughness={0.6} /> <meshStandardMaterial color={mii.skin} roughness={0.6} />
</mesh> </mesh>
{/* CABELO */} {/* CABELO MELHORADO */}
{mii.hairStyle === 'short' && ( {mii.hairStyle === 'short' && (
<mesh position={[0, 0.15, -0.015]} scale={[1.05, 0.8, 1.05]}> <group>
<sphereGeometry args={[0.08, 16, 16]} /> {/* Topo do cabelo */}
<meshStandardMaterial color={mii.hairColor} roughness={0.8} /> <mesh position={[0, 0.15, -0.015]} scale={[1.05, 0.8, 1.05]}>
</mesh> <sphereGeometry args={[0.08, 16, 16]} />
<meshStandardMaterial color={mii.hairColor} roughness={0.8} />
</mesh>
{/* Costeletas laterais */}
<mesh position={[-0.078, 0.11, 0.02]} scale={[0.2, 0.5, 0.4]}>
<sphereGeometry args={[0.03, 8, 8]} />
<meshStandardMaterial color={mii.hairColor} roughness={0.8} />
</mesh>
<mesh position={[0.078, 0.11, 0.02]} scale={[0.2, 0.5, 0.4]}>
<sphereGeometry args={[0.03, 8, 8]} />
<meshStandardMaterial color={mii.hairColor} roughness={0.8} />
</mesh>
</group>
)} )}
{mii.hairStyle === 'long' && ( {mii.hairStyle === 'long' && (
@@ -170,13 +182,42 @@ function MiiAvatar({ avatarId, username, isTalking, ...props }: { avatarId: numb
<sphereGeometry args={[0.08, 16, 16]} /> <sphereGeometry args={[0.08, 16, 16]} />
<meshStandardMaterial color={mii.hairColor} roughness={0.8} /> <meshStandardMaterial color={mii.hairColor} roughness={0.8} />
</mesh> </mesh>
{/* Cabelo lateral caindo */} {/* Franja frontal */}
<mesh position={[-0.075, 0.08, -0.02]} scale={[0.4, 1.2, 0.6]}> <mesh position={[0, 0.16, 0.045]} scale={[1.3, 0.3, 0.4]} rotation={[0.1, 0, 0]}>
<sphereGeometry args={[0.04, 16, 16]} /> <sphereGeometry args={[0.04, 16, 16]} />
<meshStandardMaterial color={mii.hairColor} roughness={0.8} /> <meshStandardMaterial color={mii.hairColor} roughness={0.8} />
</mesh> </mesh>
<mesh position={[0.075, 0.08, -0.02]} scale={[0.4, 1.2, 0.6]}> {/* Costeletas longas */}
<sphereGeometry args={[0.04, 16, 16]} /> <mesh position={[-0.076, 0.05, 0.01]} scale={[0.3, 1.6, 0.6]}>
<sphereGeometry args={[0.038, 16, 16]} />
<meshStandardMaterial color={mii.hairColor} roughness={0.8} />
</mesh>
<mesh position={[0.076, 0.05, 0.01]} scale={[0.3, 1.6, 0.6]}>
<sphereGeometry args={[0.038, 16, 16]} />
<meshStandardMaterial color={mii.hairColor} roughness={0.8} />
</mesh>
</group>
)}
{mii.hairStyle === 'modern' && (
<group>
{/* Cabelo Superior */}
<mesh position={[0, 0.16, -0.01]} scale={[1.05, 0.7, 1.05]}>
<sphereGeometry args={[0.08, 16, 16]} />
<meshStandardMaterial color={mii.hairColor} roughness={0.8} />
</mesh>
{/* Topete Moderno */}
<mesh position={[0, 0.19, 0.02]} rotation={[-0.3, 0, 0]} scale={[0.8, 1.2, 0.8]}>
<coneGeometry args={[0.04, 0.08, 4]} />
<meshStandardMaterial color={mii.hairColor} roughness={0.8} />
</mesh>
{/* Costeletas */}
<mesh position={[-0.076, 0.09, 0.01]} scale={[0.3, 1.0, 0.5]}>
<sphereGeometry args={[0.03, 8, 8]} />
<meshStandardMaterial color={mii.hairColor} roughness={0.8} />
</mesh>
<mesh position={[0.076, 0.09, 0.01]} scale={[0.3, 1.0, 0.5]}>
<sphereGeometry args={[0.03, 8, 8]} />
<meshStandardMaterial color={mii.hairColor} roughness={0.8} /> <meshStandardMaterial color={mii.hairColor} roughness={0.8} />
</mesh> </mesh>
</group> </group>
@@ -214,22 +255,35 @@ function MiiAvatar({ avatarId, username, isTalking, ...props }: { avatarId: numb
</group> </group>
)} )}
{/* OLHOS */} {/* OLHOS MELHORADOS (Mais expressivos e com brilho) */}
<mesh position={[-0.028, 0.11, 0.07]} rotation={[0.08, -0.15, 0]}> <group position={[-0.028, 0.11, 0.07]} rotation={[0.08, -0.15, 0]}>
<sphereGeometry args={[0.01, 8, 8]} /> <mesh>
<meshBasicMaterial color="#18181b" /> <sphereGeometry args={[0.011, 16, 16]} />
</mesh> <meshBasicMaterial color="#111827" />
<mesh position={[0.028, 0.11, 0.07]} rotation={[0.08, 0.15, 0]}> </mesh>
<sphereGeometry args={[0.01, 8, 8]} /> <mesh position={[0.004, 0.004, 0.008]}>
<meshBasicMaterial color="#18181b" /> <sphereGeometry args={[0.004, 8, 8]} />
</mesh> <meshBasicMaterial color="#ffffff" />
</mesh>
</group>
<group position={[0.028, 0.11, 0.07]} rotation={[0.08, 0.15, 0]}>
<mesh>
<sphereGeometry args={[0.011, 16, 16]} />
<meshBasicMaterial color="#111827" />
</mesh>
<mesh position={[0.004, 0.004, 0.008]}>
<sphereGeometry args={[0.004, 8, 8]} />
<meshBasicMaterial color="#ffffff" />
</mesh>
</group>
{/* SOBRANCELHAS */} {/* SOBRANCELHAS */}
<mesh position={[-0.028, 0.125, 0.072]} rotation={[0.05, -0.15, 0.08]}> <mesh position={[-0.028, 0.128, 0.072]} rotation={[0.05, -0.15, 0.08]}>
<boxGeometry args={[0.028, 0.005, 0.005]} /> <boxGeometry args={[0.028, 0.005, 0.005]} />
<meshBasicMaterial color="#18181b" /> <meshBasicMaterial color="#18181b" />
</mesh> </mesh>
<mesh position={[0.028, 0.125, 0.072]} rotation={[0.05, 0.15, -0.08]}> <mesh position={[0.028, 0.128, 0.072]} rotation={[0.05, 0.15, -0.08]}>
<boxGeometry args={[0.028, 0.005, 0.005]} /> <boxGeometry args={[0.028, 0.005, 0.005]} />
<meshBasicMaterial color="#18181b" /> <meshBasicMaterial color="#18181b" />
</mesh> </mesh>
@@ -240,9 +294,19 @@ function MiiAvatar({ avatarId, username, isTalking, ...props }: { avatarId: numb
<meshStandardMaterial color={mii.skin} roughness={0.6} /> <meshStandardMaterial color={mii.skin} roughness={0.6} />
</mesh> </mesh>
{/* BOCA */} {/* BOCHECHAS ROSADAS (Deixa o avatar super amigável e feliz) */}
<mesh position={[0, 0.065, 0.073]} rotation={[0.1, 0, 0]} scale={[1, 0.75, 1]}> <mesh position={[-0.042, 0.08, 0.071]} scale={[1, 0.6, 0.2]}>
<torusGeometry args={[0.015, 0.004, 4, 16, Math.PI]} /> <sphereGeometry args={[0.01, 16, 16]} />
<meshBasicMaterial color="#f43f5e" transparent opacity={0.5} />
</mesh>
<mesh position={[0.042, 0.08, 0.071]} scale={[1, 0.6, 0.2]}>
<sphereGeometry args={[0.01, 16, 16]} />
<meshBasicMaterial color="#f43f5e" transparent opacity={0.5} />
</mesh>
{/* BOCA FELIZ E SORRIDENTE (Sem aparência de triste) */}
<mesh position={[0, 0.062, 0.073]} rotation={[0, 0, Math.PI]}>
<torusGeometry args={[0.014, 0.003, 8, 24, Math.PI]} />
<meshBasicMaterial color="#b91c1c" /> <meshBasicMaterial color="#b91c1c" />
</mesh> </mesh>
@@ -539,7 +603,7 @@ export default function MeetingRoom() {
const [selectedChair, setSelectedChair] = useState('1'); const [selectedChair, setSelectedChair] = useState('1');
const [inputRoomId, setInputRoomId] = useState(roomId || ''); const [inputRoomId, setInputRoomId] = useState(roomId || '');
// Estados da Sala // Estados da Sala (Conexão Supabase)
const [peers, setPeers] = useState<Record<string, PeerData>>({}); const [peers, setPeers] = useState<Record<string, PeerData>>({});
const [chatMessages, setChatMessages] = useState<{ id: string; sender: string; text: string; time: string }[]>([]); const [chatMessages, setChatMessages] = useState<{ id: string; sender: string; text: string; time: string }[]>([]);
const [newMessage, setNewMessage] = useState(''); const [newMessage, setNewMessage] = useState('');
@@ -562,13 +626,13 @@ export default function MeetingRoom() {
const channelRef = useRef<ReturnType<typeof supabase.channel> | null>(null); const channelRef = useRef<ReturnType<typeof supabase.channel> | null>(null);
const chatScrollRef = useRef<HTMLDivElement>(null); const chatScrollRef = useRef<HTMLDivElement>(null);
// Se o roomId mudar na url, atualiza o checkbox isPresenter // Se o roomId mudar na url, atualiza o checkbox isPresenter e o inputRoomId
useEffect(() => { useEffect(() => {
setIsPresenter(!roomId); setIsPresenter(!roomId);
setInputRoomId(roomId || ''); setInputRoomId(roomId || '');
}, [roomId]); }, [roomId]);
// Simulação simples de detecção de áudio // Simulação de detecção de áudio
useEffect(() => { useEffect(() => {
if (!inRoom || isMuted) { if (!inRoom || isMuted) {
setIsTalking(false); setIsTalking(false);
@@ -586,6 +650,47 @@ export default function MeetingRoom() {
} }
}, [chatMessages]); }, [chatMessages]);
// --- ESCUTA DO LOBBY EM TEMPO REAL ---
// Conecta ao canal antes do lobby finalizar para ler assentos e avatares ocupados na sala digitada
useEffect(() => {
if (!inputRoomId || inputRoomId.trim().length < 3) {
setPeers({});
return;
}
const roomIdUpper = inputRoomId.trim().toUpperCase();
console.log(`🔌 [Lobby Watcher] Escutando sala: ${roomIdUpper}`);
const tempChannel = supabase.channel(`meeting_room_${roomIdUpper}`, {
config: {
presence: { key: 'lobby_watcher_' + Math.random().toString(36).substring(3, 8) }
}
});
tempChannel
.on('presence', { event: 'sync' }, () => {
const state = tempChannel.presenceState();
const formattedPeers: Record<string, PeerData> = {};
Object.keys(state).forEach((chairKey) => {
// Ignora outros watchers do lobby
if (chairKey.startsWith('lobby_watcher_')) return;
const presences = state[chairKey] as unknown as PeerData[];
if (presences && presences.length > 0) {
formattedPeers[chairKey] = presences[0];
}
});
setPeers(formattedPeers);
})
.subscribe();
return () => {
tempChannel.unsubscribe();
};
}, [inputRoomId, inRoom]);
// Envia as atualizações de apresentação via Broadcast para a sala inteira // Envia as atualizações de apresentação via Broadcast para a sala inteira
const handlePresentationChange = (updatedFields: Partial<PresentationState>) => { const handlePresentationChange = (updatedFields: Partial<PresentationState>) => {
if (!isPresenter || !channelRef.current) return; if (!isPresenter || !channelRef.current) return;
@@ -600,12 +705,26 @@ export default function MeetingRoom() {
}); });
}; };
// Inicializa o Supabase Presence & Broadcast para conexões // Inicializa o Supabase Presence & Broadcast para conexões oficiais
const connectToRoom = (targetRoomId: string) => { const connectToRoom = (targetRoomId: string) => {
if (!name.trim()) { if (!name.trim()) {
toast.error('Digite seu nome primeiro'); toast.error('Digite seu nome primeiro');
return; return;
} }
// Validar se o avatar já está em uso por outra pessoa
const avatarInUse = Object.values(peers).find((p) => p.avatarId === selectedAvatar);
if (avatarInUse) {
toast.error(`O avatar "${AVATAR_PRESETS[selectedAvatar - 1].name}" já está em uso por ${avatarInUse.username}. Escolha outro!`);
return;
}
// Validar se a cadeira está ocupada
if (Object.keys(peers).includes(selectedChair)) {
toast.error('O assento selecionado já está ocupado por outro participante. Por favor, selecione outro assento.');
return;
}
const finalRoomId = targetRoomId.trim().toUpperCase() || Math.random().toString(36).substring(3, 8).toUpperCase(); const finalRoomId = targetRoomId.trim().toUpperCase() || Math.random().toString(36).substring(3, 8).toUpperCase();
setRoomCode(finalRoomId); setRoomCode(finalRoomId);
@@ -628,6 +747,7 @@ export default function MeetingRoom() {
let count = 0; let count = 0;
Object.keys(state).forEach((chairKey) => { Object.keys(state).forEach((chairKey) => {
if (chairKey.startsWith('lobby_watcher_')) return;
const presences = state[chairKey] as unknown as PeerData[]; const presences = state[chairKey] as unknown as PeerData[];
if (presences && presences.length > 0) { if (presences && presences.length > 0) {
formattedPeers[chairKey] = presences[0]; formattedPeers[chairKey] = presences[0];
@@ -638,9 +758,11 @@ export default function MeetingRoom() {
setActiveUsersCount(count); setActiveUsersCount(count);
}) })
.on('presence', { event: 'join' }, ({ key, newPresences }) => { .on('presence', { event: 'join' }, ({ key, newPresences }) => {
if (key.startsWith('lobby_watcher_')) return;
toast.info(`"${(newPresences[0] as unknown as PeerData)?.username}" sentou-se no assento ${key}`); toast.info(`"${(newPresences[0] as unknown as PeerData)?.username}" sentou-se no assento ${key}`);
}) })
.on('presence', { event: 'leave' }, ({ key, leftPresences }) => { .on('presence', { event: 'leave' }, ({ key, leftPresences }) => {
if (key.startsWith('lobby_watcher_')) return;
toast.info(`"${(leftPresences[0] as unknown as PeerData)?.username}" liberou o assento ${key}`); toast.info(`"${(leftPresences[0] as unknown as PeerData)?.username}" liberou o assento ${key}`);
}); });
@@ -712,9 +834,14 @@ export default function MeetingRoom() {
}; };
const occupiedChairs = useMemo(() => { const occupiedChairs = useMemo(() => {
return Object.keys(peers); return Object.keys(peers).filter(k => !k.startsWith('lobby_watcher_'));
}, [peers]); }, [peers]);
// Checar se o avatar selecionado no lobby está em uso
const isAvatarInUse = useMemo(() => {
return Object.values(peers).find((p) => p.avatarId === selectedAvatar);
}, [peers, selectedAvatar]);
const nextAvatar = () => { const nextAvatar = () => {
setSelectedAvatar((prev) => (prev === 10 ? 1 : prev + 1)); setSelectedAvatar((prev) => (prev === 10 ? 1 : prev + 1));
}; };
@@ -726,6 +853,8 @@ export default function MeetingRoom() {
if (!inRoom) { if (!inRoom) {
const currentAvatar = AVATAR_PRESETS[selectedAvatar - 1]; const currentAvatar = AVATAR_PRESETS[selectedAvatar - 1];
const canJoin = name.trim().length > 0 && !isAvatarInUse && !occupiedChairs.includes(selectedChair);
return ( return (
<div className="flex min-h-screen flex-col items-center justify-center bg-background grid-industrial p-6"> <div className="flex min-h-screen flex-col items-center justify-center bg-background grid-industrial p-6">
<div className="absolute top-6 left-6 flex items-center gap-2"> <div className="absolute top-6 left-6 flex items-center gap-2">
@@ -741,98 +870,113 @@ export default function MeetingRoom() {
Lobby de <span className="text-primary">Reunião Virtual</span> Lobby de <span className="text-primary">Reunião Virtual</span>
</h2> </h2>
<p className="mt-1 text-xs font-mono uppercase tracking-wider text-muted-foreground"> <p className="mt-1 text-xs font-mono uppercase tracking-wider text-muted-foreground">
Configure suas credenciais e escolha seu visual Mii Configure suas credenciais e escolha seu avatar
</p> </p>
</div> </div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-12"> <div className="grid grid-cols-1 gap-6 md:grid-cols-12">
{/* Avatar Mii Selector (Lado Esquerdo - 5 colunas) */} {/* Seletor de Avatar (Lado Esquerdo - 5 colunas) */}
<div className="flex flex-col items-center justify-center rounded-xl border bg-muted/30 p-5 md:col-span-5 relative"> <div className="flex flex-col items-center justify-center rounded-xl border bg-muted/30 p-5 md:col-span-5 relative">
<span className="absolute top-3 left-4 font-mono text-[9px] uppercase tracking-widest text-muted-foreground">Visual Mii</span> <span className="absolute top-3 left-4 font-mono text-[9px] uppercase tracking-widest text-muted-foreground">Visual Avatar</span>
{/* Enquadramento centralizado perfeito (Camera apontando e fov adequado) */}
<div className="h-40 w-full flex items-center justify-center relative bg-slate-900/60 rounded-lg overflow-hidden border border-border"> <div className="h-40 w-full flex items-center justify-center relative bg-slate-900/60 rounded-lg overflow-hidden border border-border">
<Canvas camera={{ position: [0, 0.16, 0.28], fov: 40 }}> <Canvas camera={{ position: [0, 0.11, 0.28], fov: 42 }}>
<ambientLight intensity={0.8} /> <ambientLight intensity={0.8} />
<directionalLight position={[2, 3, 2]} intensity={1.5} /> <directionalLight position={[2, 3, 2]} intensity={1.5} />
<pointLight position={[0, 0.15, 0.2]} intensity={1} color={currentAvatar.color} /> <pointLight position={[0, 0.15, 0.2]} intensity={1} color={currentAvatar.color} />
<MiiAvatar avatarId={selectedAvatar} username={name || 'Mii'} position={[0, 0, 0]} isTalking={true} /> <MiiAvatar avatarId={selectedAvatar} username={name || 'Você'} position={[0, 0, 0]} isTalking={true} />
</Canvas> </Canvas>
<Button variant="ghost" size="icon" className="absolute left-2 h-8 w-8 rounded-full border bg-background/50 hover:bg-background" onClick={prevAvatar}> {/* Aviso se o avatar estiver em uso na sala */}
{isAvatarInUse && (
<div className="absolute inset-0 bg-black/75 backdrop-blur-sm flex flex-col items-center justify-center text-center p-3 animate-in fade-in duration-200">
<ShieldAlert className="h-7 w-7 text-destructive animate-bounce mb-1" />
<span className="font-mono text-[10px] font-bold text-destructive uppercase tracking-wider">Avatar em Uso</span>
<span className="text-[9px] text-slate-300 mt-1 max-w-xs px-2 leading-snug">
Este visual está ocupado por <strong className="text-primary uppercase block truncate">{isAvatarInUse.username}</strong>
</span>
</div>
)}
<Button variant="ghost" size="icon" className="absolute left-2 h-8 w-8 rounded-full border bg-background/50 hover:bg-background z-10" onClick={prevAvatar}>
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
</Button> </Button>
<Button variant="ghost" size="icon" className="absolute right-2 h-8 w-8 rounded-full border bg-background/50 hover:bg-background" onClick={nextAvatar}> <Button variant="ghost" size="icon" className="absolute right-2 h-8 w-8 rounded-full border bg-background/50 hover:bg-background z-10" onClick={nextAvatar}>
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
</Button> </Button>
</div> </div>
<div className="mt-3 text-center"> <div className="mt-3 text-center">
<span className="font-mono text-xs font-bold uppercase tracking-wider text-foreground"> <span className="font-mono text-xs font-bold uppercase tracking-wider text-primary">
{currentAvatar.name} {currentAvatar.name}
</span> </span>
<p className="text-[9px] text-muted-foreground mt-0.5">{currentAvatar.desc}</p> <p className="text-[9px] text-muted-foreground mt-0.5">{currentAvatar.desc}</p>
</div> </div>
{/* Nome */} {/* Formulário de Identificação: Nome, Cargo e Empresa agrupados sequencialmente na esquerda */}
<div className="mt-4 w-full space-y-2"> <div className="mt-4 w-full space-y-3">
<label className="font-mono text-[9px] uppercase tracking-wider text-muted-foreground">Seu Nome (Máx 10 letras)</label> <div className="space-y-1">
<Input <label className="font-mono text-[9px] uppercase tracking-wider text-muted-foreground block">Seu Nome (Máx 10 letras)</label>
type="text" <Input
maxLength={10} type="text"
placeholder="Nome do usuário" maxLength={10}
value={name} placeholder="Nome do usuário"
onChange={(e) => setName(e.target.value)} value={name}
className="font-semibold text-center uppercase font-mono tracking-wider text-xs h-9" onChange={(e) => setName(e.target.value)}
/> className="font-semibold text-center uppercase font-mono tracking-wider text-xs h-9"
</div> />
</div> </div>
{/* Credenciais, Assento e Conexão (Lado Direito - 7 colunas) */} <div className="space-y-1">
<div className="flex flex-col justify-between md:col-span-7 space-y-5"> <label className="font-mono text-[9px] uppercase tracking-wider text-muted-foreground block">Cargo / Função</label>
{/* Form de Cargo e Empresa */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="font-mono text-[9px] uppercase tracking-wider text-muted-foreground">Cargo / Função</label>
<Input <Input
type="text" type="text"
placeholder="Ex: Eng. Civil" placeholder="Ex: Eng. Civil"
value={cargo} value={cargo}
onChange={(e) => setCargo(e.target.value)} onChange={(e) => setCargo(e.target.value)}
className="text-xs h-9" className="text-xs h-9 text-center"
/> />
</div> </div>
<div className="space-y-1.5">
<label className="font-mono text-[9px] uppercase tracking-wider text-muted-foreground">Empresa</label> <div className="space-y-1">
<label className="font-mono text-[9px] uppercase tracking-wider text-muted-foreground block">Empresa</label>
<Input <Input
type="text" type="text"
placeholder="Ex: Steel Co." placeholder="Ex: Steel Co."
value={empresa} value={empresa}
onChange={(e) => setEmpresa(e.target.value)} onChange={(e) => setEmpresa(e.target.value)}
className="text-xs h-9" className="text-xs h-9 text-center"
/> />
</div> </div>
</div> </div>
</div>
{/* Seleção do Assento */} {/* Seleção do Assento, Host e Conexão (Lado Direito - 7 colunas) */}
<div className="flex flex-col justify-between md:col-span-7 space-y-5">
{/* Seleção do Assento (Exibe quem está ocupando em tempo real) */}
<div className="space-y-2"> <div className="space-y-2">
<span className="font-mono text-[9px] uppercase tracking-wider text-muted-foreground">Selecione seu Assento na Mesa</span> <span className="font-mono text-[9px] uppercase tracking-wider text-muted-foreground">Selecione seu Assento na Mesa</span>
<div className="grid grid-cols-4 gap-2"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{CHAIRS.map((chair) => { {CHAIRS.map((chair) => {
const isOccupied = occupiedChairs.includes(chair.id); const occupier = Object.values(peers).find((p) => p.chairId === chair.id);
const isOccupied = !!occupier;
const isSelected = selectedChair === chair.id; const isSelected = selectedChair === chair.id;
return ( return (
<Button <Button
key={chair.id} key={chair.id}
variant={isSelected ? 'default' : 'outline'} variant={isSelected ? 'default' : 'outline'}
className={`h-11 flex-col p-1 gap-0.5 text-[9px] font-mono ${ className={`h-12 flex-col p-1 gap-0.5 text-[9px] font-mono ${
isOccupied ? 'border-destructive/30 text-destructive bg-destructive/5 cursor-not-allowed hover:bg-destructive/5' : '' isOccupied ? 'border-destructive/40 text-destructive bg-destructive/10 cursor-not-allowed hover:bg-destructive/10' : ''
}`} }`}
disabled={isOccupied} disabled={isOccupied}
onClick={() => setSelectedChair(chair.id)} onClick={() => setSelectedChair(chair.id)}
> >
<span className="font-semibold">{chair.name}</span> <span className="font-semibold">{chair.name}</span>
<span className="text-[8px] opacity-75"> <span className={`text-[8px] truncate max-w-full font-bold uppercase ${
{isOccupied ? 'Ocupado' : isSelected ? 'Escolhido' : 'Livre'} isOccupied ? 'text-destructive' : isSelected ? 'text-primary-foreground' : 'text-muted-foreground'
}`}>
{isOccupied ? occupier.username : isSelected ? 'Escolhido' : 'Livre'}
</span> </span>
</Button> </Button>
); );
@@ -840,7 +984,7 @@ export default function MeetingRoom() {
</div> </div>
</div> </div>
{/* Papel e Conexão */} {/* Papel e Código de Conexão */}
<div className="rounded-xl border bg-muted/20 p-4 space-y-4"> <div className="rounded-xl border bg-muted/20 p-4 space-y-4">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Checkbox <Checkbox
@@ -871,10 +1015,20 @@ export default function MeetingRoom() {
<Button variant="outline" className="flex-1 font-mono text-xs h-10" onClick={() => navigate('/')}> <Button variant="outline" className="flex-1 font-mono text-xs h-10" onClick={() => navigate('/')}>
Cancelar Cancelar
</Button> </Button>
<Button className="flex-1 glow-primary font-mono text-xs h-10 gap-2" onClick={() => connectToRoom(inputRoomId)}> <Button
className="flex-1 glow-primary font-mono text-xs h-10 gap-2"
disabled={!canJoin}
onClick={() => connectToRoom(inputRoomId)}
>
Acessar Reunião <ArrowRight className="h-4 w-4" /> Acessar Reunião <ArrowRight className="h-4 w-4" />
</Button> </Button>
</div> </div>
{isAvatarInUse && (
<p className="text-[10px] text-destructive font-mono text-center">
* Escolha outro avatar para liberar o acesso.
</p>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -892,7 +1046,7 @@ export default function MeetingRoom() {
<Canvas camera={{ position: [0, 2.5, 2.8], fov: 45 }} shadows gl={{ localClippingEnabled: true }}> <Canvas camera={{ position: [0, 2.5, 2.8], fov: 45 }} shadows gl={{ localClippingEnabled: true }}>
<ThreeMeetingRoomScene currentActiveModel={activeModel} presentationState={presentationState} /> <ThreeMeetingRoomScene currentActiveModel={activeModel} presentationState={presentationState} />
{/* Renderizar avatares de TODOS na sala (incluindo o próprio usuário para ele se ver sentado na cadeira) */} {/* Renderizar avatares de TODOS na sala */}
{Object.keys(peers).map((chairKey) => { {Object.keys(peers).map((chairKey) => {
const peer = peers[chairKey]; const peer = peers[chairKey];
const chairConf = CHAIRS.find((c) => c.id === peer.chairId); const chairConf = CHAIRS.find((c) => c.id === peer.chairId);