Antigravity AI
Fix hardcoded localhost URLs to use relative paths for Hugging Face deployment
3f5ff7f | import { fetchRecommendations, fetchChatResponse } from './api.js'; | |
| let mapInstance = null; | |
| // ── Toast notification (replaces alert()) ── | |
| function showToast(msg, type = 'info') { | |
| const t = document.createElement('div'); | |
| t.textContent = msg; | |
| t.style.cssText = ` | |
| position:fixed; bottom:24px; left:50%; transform:translateX(-50%); | |
| background:rgba(10,16,32,0.95); border:1px solid rgba(212,175,55,0.4); | |
| color:#f4f1ea; font-family:'Tajawal',sans-serif; font-size:14px; | |
| padding:12px 24px; border-radius:12px; z-index:9999; | |
| box-shadow:0 8px 24px rgba(0,0,0,0.5); pointer-events:none; | |
| animation:fadeInUp 0.3s ease; | |
| `; | |
| if (type === 'error') t.style.borderColor = 'rgba(255,75,75,0.5)'; | |
| document.body.appendChild(t); | |
| setTimeout(() => t.remove(), 3500); | |
| } | |
| // ── Sanitize text before inserting into DOM ── | |
| function escapeHtml(str) { | |
| return String(str) | |
| .replace(/&/g, '&') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>') | |
| .replace(/"/g, '"') | |
| .replace(/'/g, '''); | |
| } | |
| // ── Append a chat bubble safely ── | |
| function appendMsg(container, role, html) { | |
| const isBot = role === 'bot'; | |
| container.insertAdjacentHTML('beforeend', ` | |
| <div class="chat-msg ${isBot ? 'bot-msg' : 'user-msg'}"> | |
| <div class="avatar-ic"><i class="fa-solid fa-${isBot ? 'robot' : 'user'}"></i></div> | |
| <div class="msg-bubble">${html}</div> | |
| </div>`); | |
| container.scrollTop = container.scrollHeight; | |
| } | |
| document.addEventListener('DOMContentLoaded', () => { | |
| // ========================================== | |
| // 3D Background Initialization | |
| // ========================================== | |
| const init3DBackground = () => { | |
| const canvas = document.getElementById('bg-canvas'); | |
| if (!canvas || typeof THREE === 'undefined') return; | |
| const scene = new THREE.Scene(); | |
| // Fog to blend particles smoothly into the dark background | |
| scene.fog = new THREE.FogExp2(0x05070d, 0.0015); | |
| const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); | |
| camera.position.z = 400; | |
| const renderer = new THREE.WebGLRenderer({ canvas: canvas, alpha: true, antialias: true }); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| renderer.setPixelRatio(window.devicePixelRatio); | |
| // Create Particles for Network | |
| const particleCount = 120; | |
| const geometry = new THREE.BufferGeometry(); | |
| const positions = new Float32Array(particleCount * 3); | |
| const velocities = []; | |
| for(let i=0; i<particleCount; i++) { | |
| positions[i*3] = (Math.random() - 0.5) * 800; | |
| positions[i*3+1] = (Math.random() - 0.5) * 800; | |
| positions[i*3+2] = (Math.random() - 0.5) * 800; | |
| velocities.push({ | |
| x: (Math.random() - 0.5) * 0.5, | |
| y: (Math.random() - 0.5) * 0.5, | |
| z: (Math.random() - 0.5) * 0.5 | |
| }); | |
| } | |
| geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); | |
| const material = new THREE.PointsMaterial({ | |
| color: 0xd4af37, | |
| size: 3, | |
| transparent: true, | |
| opacity: 0.8, | |
| blending: THREE.AdditiveBlending | |
| }); | |
| const particleSystem = new THREE.Points(geometry, material); | |
| scene.add(particleSystem); | |
| // Lines for Network | |
| const lineMaterial = new THREE.LineBasicMaterial({ | |
| color: 0xd4af37, | |
| transparent: true, | |
| opacity: 0.15, | |
| blending: THREE.AdditiveBlending | |
| }); | |
| const lineGeometry = new THREE.BufferGeometry(); | |
| const linePositions = new Float32Array(particleCount * particleCount * 3); | |
| lineGeometry.setAttribute('position', new THREE.BufferAttribute(linePositions, 3)); | |
| const linesMesh = new THREE.LineSegments(lineGeometry, lineMaterial); | |
| scene.add(linesMesh); | |
| // Mouse interaction | |
| let mouseX = 0; | |
| let mouseY = 0; | |
| document.addEventListener('mousemove', (event) => { | |
| mouseX = (event.clientX - window.innerWidth / 2) * 0.1; | |
| mouseY = (event.clientY - window.innerHeight / 2) * 0.1; | |
| }); | |
| // Animation Loop | |
| const animate = () => { | |
| requestAnimationFrame(animate); | |
| // Move particles | |
| const positionsAttr = geometry.attributes.position.array; | |
| let lineIndex = 0; | |
| for(let i=0; i<particleCount; i++) { | |
| positionsAttr[i*3] += velocities[i].x; | |
| positionsAttr[i*3+1] += velocities[i].y; | |
| positionsAttr[i*3+2] += velocities[i].z; | |
| // Bounce off imaginary bounds | |
| if (Math.abs(positionsAttr[i*3]) > 400) velocities[i].x *= -1; | |
| if (Math.abs(positionsAttr[i*3+1]) > 400) velocities[i].y *= -1; | |
| if (Math.abs(positionsAttr[i*3+2]) > 400) velocities[i].z *= -1; | |
| // Draw lines to close neighbors | |
| for(let j=i+1; j<particleCount; j++) { | |
| const dx = positionsAttr[i*3] - positionsAttr[j*3]; | |
| const dy = positionsAttr[i*3+1] - positionsAttr[j*3+1]; | |
| const dz = positionsAttr[i*3+2] - positionsAttr[j*3+2]; | |
| const distSq = dx*dx + dy*dy + dz*dz; | |
| if(distSq < 15000) { | |
| linePositions[lineIndex++] = positionsAttr[i*3]; | |
| linePositions[lineIndex++] = positionsAttr[i*3+1]; | |
| linePositions[lineIndex++] = positionsAttr[i*3+2]; | |
| linePositions[lineIndex++] = positionsAttr[j*3]; | |
| linePositions[lineIndex++] = positionsAttr[j*3+1]; | |
| linePositions[lineIndex++] = positionsAttr[j*3+2]; | |
| } | |
| } | |
| } | |
| geometry.attributes.position.needsUpdate = true; | |
| linesMesh.geometry.setDrawRange(0, lineIndex / 3); | |
| linesMesh.geometry.attributes.position.needsUpdate = true; | |
| // Camera reacts to mouse | |
| camera.position.x += (mouseX - camera.position.x) * 0.05; | |
| camera.position.y += (-mouseY - camera.position.y) * 0.05; | |
| camera.lookAt(scene.position); | |
| renderer.render(scene, camera); | |
| }; | |
| animate(); | |
| // Handle Resize | |
| window.addEventListener('resize', () => { | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| }); | |
| }; | |
| init3DBackground(); | |
| // 1. Intersection Observer for Reveal Animations | |
| const revealObserver = new IntersectionObserver((entries) => { | |
| entries.forEach(entry => { | |
| if (entry.isIntersecting) { | |
| entry.target.classList.add('active'); | |
| } | |
| }); | |
| }, { threshold: 0.1 }); | |
| document.querySelectorAll('.reveal').forEach(el => revealObserver.observe(el)); | |
| // Interactive Heart Buttons Helper | |
| const bindHeartButtons = () => { | |
| document.querySelectorAll('.heart-btn').forEach(btn => { | |
| btn.addEventListener('click', (e) => { | |
| const targetBtn = e.currentTarget; | |
| targetBtn.classList.toggle('active'); | |
| const icon = targetBtn.querySelector('i'); | |
| if (targetBtn.classList.contains('active')) { | |
| icon.classList.remove('fa-regular'); | |
| icon.classList.add('fa-solid'); | |
| } else { | |
| icon.classList.remove('fa-solid'); | |
| icon.classList.add('fa-regular'); | |
| } | |
| }); | |
| }); | |
| }; | |
| // ========================================== | |
| // Hero Search Bar Logic | |
| // ========================================== | |
| const searchBtn = document.querySelector('.search-btn'); | |
| const searchInput = document.getElementById('searchInput'); | |
| const micBtn = document.querySelector('.mic-btn'); | |
| // Chip quick-search | |
| document.querySelectorAll('.chip[data-q]').forEach(chip => { | |
| chip.addEventListener('click', () => executeSearch(chip.dataset.q)); | |
| }); | |
| // Clear chat | |
| const clearChatBtn = document.getElementById('clearChatBtn'); | |
| if (clearChatBtn) { | |
| clearChatBtn.addEventListener('click', () => { | |
| const history = document.getElementById('inline-chat-history'); | |
| if (history) { | |
| history.innerHTML = `<div class="chat-msg bot-msg"><div class="avatar-ic"><i class="fa-solid fa-robot"></i></div><div class="msg-bubble">مرحباً! أنا مستشارك العقاري الذكي. كيف يمكنني مساعدتك اليوم؟</div></div>`; | |
| inlineChatContext = []; | |
| } | |
| }); | |
| } | |
| const executeSearch = async (queryText) => { | |
| if (!queryText) return; | |
| const inlineInput = document.getElementById('inline-chat-input'); | |
| const inlineSendBtn = document.getElementById('inline-send-btn'); | |
| if (inlineInput && inlineSendBtn) { | |
| inlineInput.value = queryText; | |
| inlineSendBtn.click(); | |
| } | |
| // Fix #2: clear the top search bar after dispatching | |
| if (searchInput) searchInput.value = ''; | |
| }; | |
| if (searchBtn && searchInput) { | |
| searchBtn.addEventListener('click', () => { | |
| executeSearch(searchInput.value.trim()); | |
| }); | |
| searchInput.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter') { | |
| executeSearch(searchInput.value.trim()); | |
| } | |
| }); | |
| } | |
| if (micBtn && searchInput && searchBtn) { | |
| micBtn.addEventListener('click', () => { | |
| const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; | |
| if (SpeechRecognition) { | |
| const recognition = new SpeechRecognition(); | |
| recognition.lang = 'ar-EG'; | |
| recognition.onstart = () => { | |
| micBtn.style.color = 'var(--gold-bright)'; | |
| searchInput.placeholder = 'جاري الاستماع...'; | |
| }; | |
| recognition.onresult = (event) => { | |
| const transcript = event.results[0][0].transcript; | |
| searchInput.value = transcript; | |
| executeSearch(transcript); | |
| }; | |
| recognition.onend = () => { | |
| micBtn.style.color = ''; | |
| searchInput.placeholder = 'اسأل عن عقار، منطقة، استثمار...'; | |
| }; | |
| recognition.start(); | |
| } else { | |
| showToast('عذراً، متصفحك لا يدعم البحث الصوتي', 'error'); | |
| } | |
| }); | |
| } | |
| // ========================================== | |
| // Inline Chat Logic | |
| // ========================================== | |
| const inlineChatContainer = document.getElementById('inline-chat-container'); | |
| const inlineInput = document.getElementById('inline-chat-input'); | |
| const inlineSendBtn = document.getElementById('inline-send-btn'); | |
| const inlineChatHistory = document.getElementById('inline-chat-history'); | |
| let inlineChatContext = []; | |
| // Chat mic button | |
| const chatMicBtn = document.getElementById('chatMicBtn'); | |
| if (chatMicBtn && inlineInput) { | |
| chatMicBtn.addEventListener('click', () => { | |
| const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; | |
| if (!SpeechRecognition) { showToast('متصفحك لا يدعم الإدخال الصوتي', 'error'); return; } | |
| const recognition = new SpeechRecognition(); | |
| recognition.lang = 'ar-EG'; | |
| recognition.onstart = () => { chatMicBtn.style.color = 'var(--gold-bright)'; chatMicBtn.querySelector('i').className = 'fa-solid fa-circle-stop'; }; | |
| recognition.onresult = (e) => { inlineInput.value = e.results[0][0].transcript; inlineSendBtn.click(); }; | |
| recognition.onend = () => { chatMicBtn.style.color = ''; chatMicBtn.querySelector('i').className = 'fa-solid fa-microphone'; }; | |
| recognition.start(); | |
| }); | |
| } | |
| if (inlineChatContainer) { | |
| // Send Message | |
| if (inlineSendBtn && inlineInput && inlineChatHistory) { | |
| inlineSendBtn.addEventListener('click', async () => { | |
| const msg = inlineInput.value.trim().slice(0, 1000); // Fix #5: 1000 char limit | |
| if (!msg) return; | |
| // Disable send button while waiting | |
| inlineSendBtn.disabled = true; | |
| inlineSendBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i>'; | |
| appendMsg(inlineChatHistory, 'user', escapeHtml(msg)); | |
| inlineInput.value = ''; | |
| const typingId = 'typing-' + Date.now(); | |
| inlineChatHistory.insertAdjacentHTML('beforeend', ` | |
| <div id="${typingId}" class="chat-msg bot-msg"> | |
| <div class="avatar-ic"><i class="fa-solid fa-robot"></i></div> | |
| <div class="msg-bubble"><div class="typing-indicator"><span></span><span></span><span></span></div></div> | |
| </div>`); | |
| inlineChatHistory.scrollTop = inlineChatHistory.scrollHeight; | |
| try { | |
| const reply = await fetchChatResponse(msg, inlineChatContext); | |
| document.getElementById(typingId)?.remove(); | |
| // Escape reply but allow line breaks | |
| appendMsg(inlineChatHistory, 'bot', escapeHtml(reply).replace(/\n/g, '<br>')); | |
| // Speak the reply aloud | |
| if (window.speechSynthesis) { | |
| const utt = new SpeechSynthesisUtterance(reply); | |
| utt.lang = 'ar-EG'; | |
| window.speechSynthesis.cancel(); | |
| window.speechSynthesis.speak(utt); | |
| } | |
| inlineChatContext.push({ role: 'user', content: msg }); | |
| inlineChatContext.push({ role: 'assistant', content: reply }); | |
| if (inlineChatContext.length > 10) inlineChatContext = inlineChatContext.slice(-10); | |
| } catch (err) { | |
| document.getElementById(typingId)?.remove(); | |
| appendMsg(inlineChatHistory, 'bot', '<span style="color:#ff6b6b">عذراً، حدث خطأ أثناء الاتصال.</span>'); | |
| } finally { | |
| inlineSendBtn.disabled = false; | |
| inlineSendBtn.innerHTML = '<i class="fa-solid fa-paper-plane"></i>'; | |
| } | |
| }); | |
| inlineInput.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter') { | |
| inlineSendBtn.click(); | |
| } | |
| }); | |
| } | |
| } | |
| // ========================================== | |
| // Fix #1: Load real properties into right column | |
| // ========================================== | |
| const loadSuggestedProperties = async () => { | |
| const loading = document.getElementById('props-loading'); | |
| const list = document.getElementById('props-list'); | |
| const empty = document.getElementById('props-empty'); | |
| if (!list) return; | |
| try { | |
| const res = await fetch('/api/properties?limit=3'); | |
| const data = await res.json(); | |
| const props = data.properties || []; | |
| if (loading) loading.style.display = 'none'; | |
| if (props.length === 0) { if (empty) empty.style.display = 'flex'; return; } | |
| list.innerHTML = props.map(p => ` | |
| <div class="prop-card" onclick="window.location.href='explore.html'"> | |
| <div class="prop-img-wrap"> | |
| <img src="${escapeHtml(p.image || '')}" alt="${escapeHtml(p.title || '')}" loading="lazy"> | |
| <span class="prop-badge${p.status === 'للإيجار' ? ' rent' : ''}">${escapeHtml(p.status || 'للبيع')}</span> | |
| </div> | |
| <div class="prop-info"> | |
| <div class="prop-name">${escapeHtml(p.title || '')}</div> | |
| <div class="prop-loc"><i class="fa-solid fa-location-dot"></i> ${escapeHtml(p.location || '')}</div> | |
| <div class="prop-price">${escapeHtml(p.price || '')} <span>${p.status === 'للإيجار' ? 'ج.م / شهر' : 'ج.م'}</span></div> | |
| </div> | |
| </div>`).join(''); | |
| } catch { | |
| if (loading) loading.style.display = 'none'; | |
| if (empty) empty.style.display = 'flex'; | |
| } | |
| }; | |
| loadSuggestedProperties(); | |
| // ========================================== | |
| // Fix #4: Login modal keyboard trap + Escape | |
| // ========================================== | |
| const loginModal = document.getElementById('loginModal'); | |
| const openLoginBtn = document.getElementById('openLoginBtn'); | |
| const closeLoginBtn = document.getElementById('closeLoginBtn'); | |
| const openModal = () => { | |
| if (!loginModal) return; | |
| loginModal.style.display = 'flex'; | |
| const focusable = loginModal.querySelectorAll('button, input, [tabindex]:not([tabindex="-1"])'); | |
| if (focusable[0]) focusable[0].focus(); | |
| }; | |
| const closeModal = () => { if (loginModal) loginModal.style.display = 'none'; }; | |
| if (openLoginBtn) openLoginBtn.addEventListener('click', openModal); | |
| if (closeLoginBtn) closeLoginBtn.addEventListener('click', closeModal); | |
| // Escape key closes modal | |
| document.addEventListener('keydown', (e) => { | |
| if (e.key === 'Escape' && loginModal && loginModal.style.display !== 'none') closeModal(); | |
| }); | |
| // Trap Tab inside modal | |
| if (loginModal) { | |
| loginModal.addEventListener('keydown', (e) => { | |
| if (e.key !== 'Tab') return; | |
| const focusable = [...loginModal.querySelectorAll('button, input, [tabindex]:not([tabindex="-1"])')].filter(el => !el.disabled); | |
| if (!focusable.length) return; | |
| const first = focusable[0], last = focusable[focusable.length - 1]; | |
| if (e.shiftKey ? document.activeElement === first : document.activeElement === last) { | |
| e.preventDefault(); | |
| (e.shiftKey ? last : first).focus(); | |
| } | |
| }); | |
| // Click outside closes modal | |
| loginModal.addEventListener('click', (e) => { if (e.target === loginModal) closeModal(); }); | |
| } | |
| // Modal tab switching | |
| document.querySelectorAll('.modal-tab').forEach(tab => { | |
| tab.addEventListener('click', () => { | |
| document.querySelectorAll('.modal-tab').forEach(t => { t.classList.remove('active'); t.setAttribute('aria-selected','false'); }); | |
| tab.classList.add('active'); tab.setAttribute('aria-selected','true'); | |
| document.getElementById('loginForm').style.display = tab.dataset.tab === 'login' ? 'flex' : 'none'; | |
| document.getElementById('registerForm').style.display = tab.dataset.tab === 'register' ? 'flex' : 'none'; | |
| }); | |
| }); | |
| // ========================================== | |
| // Fix #7: Highlight active feature card | |
| // ========================================== | |
| const currentPage = window.location.pathname.split('/').pop() || 'index.html'; | |
| document.querySelectorAll('.feat-card').forEach(card => { | |
| const href = card.getAttribute('href') || ''; | |
| if (href && currentPage.includes(href.replace('.html',''))) { | |
| card.classList.add('feat-card-active'); | |
| } | |
| }); | |
| const assistantCard = document.getElementById('assistantSidebar'); | |
| if (startChatBtn && assistantCard) { | |
| const defaultView = assistantCard.querySelector('.default-view'); | |
| const chatView = assistantCard.querySelector('.chat-view'); | |
| const chatHistory = assistantCard.querySelector('.chat-history'); | |
| const chatInput = assistantCard.querySelector('.chat-input input'); | |
| const sendMsgBtn = assistantCard.querySelector('.send-msg-btn'); | |
| const closeBtn = assistantCard.querySelector('.close-chat-btn'); | |
| const expandBtn = assistantCard.querySelector('.expand-chat-btn'); | |
| let chatContext = []; | |
| const openChat = () => { | |
| if (assistantCard) { | |
| assistantCard.style.display = 'flex'; | |
| } | |
| if (defaultView && chatView) { | |
| defaultView.style.display = 'none'; | |
| chatView.style.display = 'flex'; | |
| } | |
| }; | |
| const closeChat = () => { | |
| if (defaultView && chatView) { | |
| chatView.style.display = 'none'; | |
| defaultView.style.display = 'block'; | |
| } | |
| if (window.innerWidth <= 1300 && assistantCard) { | |
| assistantCard.style.display = 'none'; | |
| } | |
| }; | |
| startChatBtn.addEventListener('click', () => openChat()); | |
| if (closeBtn) closeBtn.addEventListener('click', closeChat); | |
| if (sendMsgBtn && chatInput && chatHistory) { | |
| sendMsgBtn.addEventListener('click', async () => { | |
| const msg = chatInput.value.trim(); | |
| if(msg) { | |
| // Add user message | |
| chatHistory.innerHTML += `<div style="background:var(--gold); color:black; padding:8px; border-radius:10px; align-self:flex-end; max-width:80%;">${msg}</div>`; | |
| chatInput.value = ''; | |
| chatHistory.scrollTop = chatHistory.scrollHeight; | |
| // Show typing indicator | |
| const typingId = 'typing-' + Date.now(); | |
| chatHistory.innerHTML += `<div id="${typingId}" style="background:rgba(255,255,255,0.05); padding:10px 16px; border-radius:14px; align-self:flex-start; max-width:80%;"><div class="typing-indicator"><span></span><span></span><span></span></div></div>`; | |
| chatHistory.scrollTop = chatHistory.scrollHeight; | |
| try { | |
| // Call API | |
| const reply = await fetchChatResponse(msg, chatContext); | |
| // Remove typing indicator and add response | |
| const typingEl = document.getElementById(typingId); | |
| if (typingEl) typingEl.remove(); | |
| chatHistory.innerHTML += `<div style="background:rgba(255,255,255,0.1); padding:8px; border-radius:10px; align-self:flex-start; max-width:80%;">${reply.replace(/\n/g, '<br>')}</div>`; | |
| chatHistory.scrollTop = chatHistory.scrollHeight; | |
| // Update context history (keep last 5 interactions) | |
| chatContext.push({"role": "user", "content": msg}); | |
| chatContext.push({"role": "assistant", "content": reply}); | |
| if (chatContext.length > 10) chatContext = chatContext.slice(chatContext.length - 10); | |
| } catch(err) { | |
| const typingEl = document.getElementById(typingId); | |
| if (typingEl) typingEl.remove(); | |
| chatHistory.innerHTML += `<div style="background:rgba(255,0,0,0.2); padding:8px; border-radius:10px; align-self:flex-start; max-width:80%;">عذراً، حدث خطأ.</div>`; | |
| } | |
| } | |
| }); | |
| // Allow sending with Enter key | |
| chatInput.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter') { | |
| sendMsgBtn.click(); | |
| } | |
| }); | |
| } | |
| } | |
| }); | |