/** * LeadFlow — Frontend Application Logic v4 * Sidebar Navigation, Lead Drawer, Conversion Funnel, Keyboard Shortcuts */ const API_BASE = (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1") ? "http://localhost:7860/api" : "/api"; // ══════════════════════════ State ══════════════════════════ let allLeads = []; let allTemplates = []; let editingNoteLeadId = null; let editingTemplateId = null; let scrapePollingInterval = null; let activeStatusDropdown = null; // Routing / Views let currentView = "table"; // 'table' or 'kanban' // Pagination state let currentPage = 1; let pageSize = 15; let filteredLeads = []; // Bulk selection let selectedLeadIds = new Set(); // Previous stats for trend let prevStats = null; // ══════════════════════════ Init ══════════════════════════ document.addEventListener("DOMContentLoaded", () => { loadStats(); loadLeads(); loadTemplates(); bindEvents(); initSidebar(); initClock(); initCollapsiblePanels(); checkServerConnection(); // Auto-expand scraper by default const scraperPanel = document.getElementById("scraper-panel"); if (scraperPanel) scraperPanel.classList.add("expanded"); }); // ══════════════════════════ API Helpers ══════════════════════════ async function apiFetch(path, options = {}) { try { const res = await fetch(`${API_BASE}${path}`, { headers: { "Content-Type": "application/json", ...options.headers }, ...options, }); if (!res.ok) { const err = await res.json().catch(() => ({ detail: res.statusText })); throw new Error(err.detail || "Request failed"); } if (res.headers.get("content-type")?.includes("text/csv")) { return await res.text(); } return await res.json(); } catch (err) { if (err.message === "Failed to fetch") { showToast("Cannot connect to backend. Is the server running?", "error"); setConnectionStatus(false); } throw err; } } // ══════════════════════════ Connection Status ══════════════════════════ async function checkServerConnection() { try { await apiFetch("/stats"); setConnectionStatus(true); } catch (e) { setConnectionStatus(false); } } function setConnectionStatus(connected) { const el = document.getElementById("connection-status"); if (!el) return; if (connected) { el.classList.remove("disconnected"); el.querySelector(".connection-text").textContent = "Connected"; el.title = "Backend is running"; } else { el.classList.add("disconnected"); el.querySelector(".connection-text").textContent = "Disconnected"; el.title = "Cannot reach backend"; } } // ══════════════════════════ Clock ══════════════════════════ function initClock() { const clockEl = document.getElementById("live-clock"); if (!clockEl) return; function updateClock() { const now = new Date(); clockEl.textContent = now.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: true, }); } updateClock(); setInterval(updateClock, 30000); } // ══════════════════════════ Sidebar ══════════════════════════ function initSidebar() { const toggleBtn = document.getElementById("sidebar-toggle"); const sidebar = document.getElementById("sidebar"); const mobileBtn = document.getElementById("mobile-menu-btn"); if (toggleBtn) { toggleBtn.addEventListener("click", () => { sidebar.classList.toggle("collapsed"); }); } if (mobileBtn) { mobileBtn.addEventListener("click", () => { sidebar.classList.toggle("mobile-open"); toggleMobileOverlay(sidebar.classList.contains("mobile-open")); }); } // Close on outside click (mobile) document.addEventListener("click", (e) => { if ( sidebar.classList.contains("mobile-open") && !sidebar.contains(e.target) && e.target !== mobileBtn && !mobileBtn.contains(e.target) ) { sidebar.classList.remove("mobile-open"); toggleMobileOverlay(false); } }); } function toggleMobileOverlay(show) { let overlay = document.querySelector(".sidebar-mobile-overlay"); if (show) { if (!overlay) { overlay = document.createElement("div"); overlay.className = "sidebar-mobile-overlay"; overlay.addEventListener("click", () => { document.getElementById("sidebar").classList.remove("mobile-open"); toggleMobileOverlay(false); }); document.body.appendChild(overlay); } requestAnimationFrame(() => overlay.classList.add("active")); } else if (overlay) { overlay.classList.remove("active"); setTimeout(() => overlay.remove(), 300); } } function scrollToSection(id) { const el = document.getElementById(id); if (el) { el.scrollIntoView({ behavior: "smooth", block: "start" }); // Update active sidebar link document.querySelectorAll(".sidebar-link").forEach((l) => l.classList.remove("active")); const link = document.querySelector(`.sidebar-link[data-section="${id.replace('-panel', '').replace('-grid', '')}"]`); if (link) link.classList.add("active"); } // Close mobile sidebar const sidebar = document.getElementById("sidebar"); if (sidebar.classList.contains("mobile-open")) { sidebar.classList.remove("mobile-open"); toggleMobileOverlay(false); } } function toggleTemplatesPanel() { const pane = document.getElementById("templates-panel"); pane.classList.toggle("hidden"); if (!pane.classList.contains("hidden")) { pane.scrollIntoView({ behavior: "smooth" }); } // Close mobile sidebar const sidebar = document.getElementById("sidebar"); if (sidebar.classList.contains("mobile-open")) { sidebar.classList.remove("mobile-open"); toggleMobileOverlay(false); } } // ══════════════════════════ Collapsible Panels ══════════════════════════ function initCollapsiblePanels() { // Already handled via onclick on the panel header } function togglePanel(panelId) { const panel = document.getElementById(panelId); if (panel) panel.classList.toggle("expanded"); } // ══════════════════════════ Stats ══════════════════════════ async function loadStats() { try { const stats = await apiFetch("/stats"); setConnectionStatus(true); // Animate numbers animateNumber("stat-total", stats.total || 0); animateNumber("stat-new", stats.new || 0); animateNumber("stat-contacted", stats.contacted || 0); animateNumber("stat-replied", stats.replied || 0); animateNumber("stat-closed", stats.closed || 0); // Update progress bars const total = stats.total || 1; document.getElementById("bar-total").style.width = "100%"; document.getElementById("bar-new").style.width = `${((stats.new || 0) / total) * 100}%`; document.getElementById("bar-contacted").style.width = `${((stats.contacted || 0) / total) * 100}%`; document.getElementById("bar-replied").style.width = `${((stats.replied || 0) / total) * 100}%`; document.getElementById("bar-closed").style.width = `${((stats.closed || 0) / total) * 100}%`; // Trend indicator for total if (prevStats && stats.total > prevStats.total) { const trendEl = document.getElementById("stat-trend-total"); trendEl.textContent = `↑ ${stats.total - prevStats.total}`; trendEl.style.display = "flex"; setTimeout(() => (trendEl.style.display = "none"), 5000); } // Update conversion funnel updateFunnel(stats); prevStats = stats; } catch (e) { console.warn("Failed to load stats:", e); } } function animateNumber(elementId, target) { const el = document.getElementById(elementId); if (!el) return; const current = parseInt(el.textContent) || 0; if (current === target) return; const duration = 600; const steps = 25; const increment = (target - current) / steps; let step = 0; const timer = setInterval(() => { step++; const val = Math.round(current + increment * step); el.textContent = val; if (step >= steps) { el.textContent = target; clearInterval(timer); } }, duration / steps); } // ══════════════════════════ Conversion Funnel ══════════════════════════ function updateFunnel(stats) { const total = stats.total || 1; const values = { new: stats.new || 0, contacted: stats.contacted || 0, replied: stats.replied || 0, closed: stats.closed || 0, }; Object.keys(values).forEach((key) => { const barEl = document.getElementById(`funnel-${key}`); const valEl = document.getElementById(`funnel-${key}-val`); if (barEl) barEl.style.width = `${(values[key] / total) * 100}%`; if (valEl) valEl.textContent = values[key]; }); } // ══════════════════════════ Leads & Views ══════════════════════════ async function loadLeads() { const searchEl = document.getElementById("search-input"); const statusEl = document.getElementById("filter-status"); const search = searchEl ? searchEl.value : ""; const status = statusEl ? statusEl.value : ""; try { const params = new URLSearchParams(); if (search) params.set("search", search); if (status) params.set("status", status); const data = await apiFetch(`/leads?${params}`); allLeads = data.leads || []; filteredLeads = [...allLeads]; const validIds = new Set(allLeads.map((l) => l.id)); for (let id of selectedLeadIds) { if (!validIds.has(id)) selectedLeadIds.delete(id); } // Update lead count badge const badge = document.getElementById("lead-count-badge"); if (badge) badge.textContent = allLeads.length; renderActiveView(); updateBulkBar(); } catch (e) { console.warn("Failed to load leads:", e); } } function renderActiveView() { if (filteredLeads.length === 0) { document.getElementById("table-view").classList.add("hidden"); document.getElementById("kanban-board").classList.add("hidden"); document.getElementById("pagination").classList.add("hidden"); document.getElementById("empty-state").classList.remove("hidden"); return; } document.getElementById("empty-state").classList.add("hidden"); if (currentView === "table") { document.getElementById("table-view").classList.remove("hidden"); document.getElementById("kanban-board").classList.add("hidden"); document.getElementById("pagination").classList.remove("hidden"); renderTable(); } else { document.getElementById("table-view").classList.add("hidden"); document.getElementById("kanban-board").classList.remove("hidden"); document.getElementById("pagination").classList.add("hidden"); renderKanban(); } } function switchView(view) { currentView = view; document.getElementById("btn-view-table").classList.toggle("active", view === "table"); document.getElementById("btn-view-kanban").classList.toggle("active", view === "kanban"); renderActiveView(); } function buildSocialsHtml(lead) { let h = ""; if (lead.email) h += `📧`; if (lead.instagram) h += `📸`; if (lead.linkedin) h += `💼`; if (h) return ``; return ""; } // ══════════════════════════ Table Rendering ══════════════════════════ function renderTable() { const tbody = document.getElementById("leads-tbody"); const totalItems = filteredLeads.length; const totalPages = Math.ceil(totalItems / pageSize); if (currentPage > totalPages) currentPage = totalPages; if (currentPage < 1) currentPage = 1; const startIndex = (currentPage - 1) * pageSize; const endIndex = Math.min(startIndex + pageSize, totalItems); const pageLeads = filteredLeads.slice(startIndex, endIndex); document.getElementById("page-range").textContent = `${startIndex + 1}-${endIndex}`; document.getElementById("page-total").textContent = totalItems; renderPaginationControls(totalPages); const selectAllCheckbox = document.getElementById("select-all"); const allPageIdsSelected = pageLeads.length > 0 && pageLeads.every((l) => selectedLeadIds.has(l.id)); selectAllCheckbox.checked = allPageIdsSelected; const colors = ["#f87171", "#fb923c", "#fbbf24", "#34d399", "#2dd4bf", "#38bdf8", "#818cf8", "#a78bfa", "#f472b6"]; tbody.innerHTML = pageLeads .map((lead) => { const isSelected = selectedLeadIds.has(lead.id); const initial = lead.name.charAt(0).toUpperCase(); const color = colors[lead.name.length % colors.length]; return `
${initial}
${escapeHtml(lead.name)}
${lead.phone ? escapeHtml(lead.phone) : ''} ${lead.address ? escapeHtml(lead.address) : ''} ${lead.website ? `${truncate(lead.website, 22)}` : ''} ${buildSocialsHtml(lead)} ${lead.status} ${lead.notes ? escapeHtml(lead.notes) : '+ Add'} ${lead.follow_up_date || "—"}
${lead.phone ? `` : ``}
`; }) .join(""); } function handleRowClick(event, leadId) { // Don't open drawer if user clicked on an interactive element if (event.target.closest("button, a, input, .checkbox-cell, .status-badge, .actions-cell")) return; openDrawer(leadId); } function renderPaginationControls(totalPages) { const container = document.getElementById("pagination-controls"); let html = ``; for (let i = 1; i <= totalPages; i++) { if (i === 1 || i === totalPages || (i >= currentPage - 1 && i <= currentPage + 1)) { html += ``; } else if (i === currentPage - 2 || i === currentPage + 2) { html += ``; } } html += ` `; container.innerHTML = html; } function goToPage(page) { currentPage = page; renderTable(); } function changePageSize(size) { pageSize = parseInt(size); currentPage = 1; renderTable(); } // ══════════════════════════ Lead Detail Drawer ══════════════════════════ function openDrawer(leadId) { const lead = allLeads.find((l) => l.id === leadId); if (!lead) return; const drawerEl = document.getElementById("lead-drawer"); const overlayEl = document.getElementById("drawer-overlay"); const bodyEl = document.getElementById("drawer-body"); const nameEl = document.getElementById("drawer-lead-name"); nameEl.textContent = lead.name; const colors = ["#f87171", "#fb923c", "#fbbf24", "#34d399", "#2dd4bf", "#38bdf8", "#818cf8", "#a78bfa", "#f472b6"]; const color = colors[lead.name.length % colors.length]; const initial = lead.name.charAt(0).toUpperCase(); bodyEl.innerHTML = `
${initial}
${escapeHtml(lead.name)}
${lead.status}
Contact Information
Phone ${lead.phone ? `${escapeHtml(lead.phone)}` : "—"}
Email ${lead.email ? `${escapeHtml(lead.email)}` : "—"}
Address ${lead.address ? escapeHtml(lead.address) : "—"}
Website ${lead.website ? `${escapeHtml(lead.website)}` : "—"}
${buildSocialsHtml(lead) ? `
Social Profiles
${buildSocialsHtml(lead)}` : ""}
Notes
${lead.notes ? escapeHtml(lead.notes) : 'No notes yet'}
${lead.follow_up_date ? `
📅 Follow-up: ${lead.follow_up_date}
` : ""}
Actions
${lead.phone ? `` : ""}
`; drawerEl.classList.add("open"); overlayEl.classList.add("active"); } function closeDrawer() { document.getElementById("lead-drawer").classList.remove("open"); document.getElementById("drawer-overlay").classList.remove("active"); } // ══════════════════════════ Kanban Rendering & DND ══════════════════════════ function renderKanban() { const columns = { new: [], contacted: [], replied: [], closed: [] }; filteredLeads.forEach((lead) => { if (columns[lead.status]) columns[lead.status].push(lead); }); const colors = ["#f87171", "#fb923c", "#fbbf24", "#34d399", "#2dd4bf", "#38bdf8", "#818cf8", "#a78bfa", "#f472b6"]; Object.keys(columns).forEach((status) => { const colBody = document.getElementById(`kanban-col-${status}`); const colHeaderCnt = document.querySelector(`.kanban-col-header.${status} .col-count`); colHeaderCnt.textContent = columns[status].length; colBody.innerHTML = columns[status] .map((lead) => { const initial = lead.name.charAt(0).toUpperCase(); const color = colors[lead.name.length % colors.length]; return `
${initial}
${escapeHtml(lead.name)}
${lead.phone ? `
📞 ${escapeHtml(lead.phone)}
` : ""} ${lead.notes ? `
📝 ${truncate(escapeHtml(lead.notes), 40)}
` : ""}
${buildSocialsHtml(lead)} ${lead.phone ? `` : ""}
`; }) .join(""); }); attachKanbanDragEvents(); } let draggedCard = null; function attachKanbanDragEvents() { const cards = document.querySelectorAll(".kanban-card"); const columns = document.querySelectorAll(".kanban-column"); cards.forEach((card) => { card.addEventListener("dragstart", (e) => { draggedCard = card; card.classList.add("dragging"); e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", card.dataset.id); setTimeout(() => (card.style.display = "none"), 0); }); card.addEventListener("dragend", () => { card.classList.remove("dragging"); card.style.display = "block"; draggedCard = null; columns.forEach((col) => col.classList.remove("drag-over")); }); }); columns.forEach((col) => { col.addEventListener("dragover", (e) => { e.preventDefault(); col.classList.add("drag-over"); }); col.addEventListener("dragleave", () => { col.classList.remove("drag-over"); }); col.addEventListener("drop", async (e) => { e.preventDefault(); col.classList.remove("drag-over"); if (!draggedCard) return; const targetStatus = col.dataset.status; const leadId = draggedCard.dataset.id; const lead = filteredLeads.find((l) => l.id == leadId); if (lead && lead.status !== targetStatus) { lead.status = targetStatus; renderActiveView(); try { await apiFetch(`/leads/${leadId}`, { method: "PUT", body: JSON.stringify({ status: targetStatus }), }); loadStats(); showToast(`Moved to "${targetStatus}"`, "success"); } catch (e) { showToast("Failed to update status", "error"); loadLeads(); } } }); }); } // ══════════════════════════ Bulk Selection ══════════════════════════ function toggleLeadSelection(id, isSelected) { if (isSelected) selectedLeadIds.add(id); else selectedLeadIds.delete(id); updateBulkBar(); if (currentView === "table") renderTable(); } function toggleSelectAll(event) { const isChecked = event.target.checked; const startIndex = (currentPage - 1) * pageSize; const endIndex = Math.min(startIndex + pageSize, filteredLeads.length); const pageLeads = filteredLeads.slice(startIndex, endIndex); pageLeads.forEach((lead) => { if (isChecked) selectedLeadIds.add(lead.id); else selectedLeadIds.delete(lead.id); }); updateBulkBar(); if (currentView === "table") renderTable(); } function clearSelection() { selectedLeadIds.clear(); updateBulkBar(); if (currentView === "table") renderTable(); } function updateBulkBar() { const bar = document.getElementById("bulk-bar"); const countSpan = document.getElementById("bulk-count-num"); if (selectedLeadIds.size > 0 && currentView === "table") { countSpan.textContent = selectedLeadIds.size; bar.classList.remove("hidden"); } else { bar.classList.add("hidden"); } } async function bulkUpdateStatus(newStatus) { if (selectedLeadIds.size === 0) return; const ids = Array.from(selectedLeadIds); try { await Promise.all(ids.map((id) => apiFetch(`/leads/${id}`, { method: "PUT", body: JSON.stringify({ status: newStatus }) }))); showToast(`Updated ${ids.length} leads to "${newStatus}"`, "success"); clearSelection(); loadLeads(); loadStats(); } catch (e) { showToast("Failed to bulk update", "error"); } } async function bulkDelete() { if (selectedLeadIds.size === 0) return; if (!confirm(`Are you sure you want to delete ${selectedLeadIds.size} leads?`)) return; const ids = Array.from(selectedLeadIds); try { await Promise.all(ids.map((id) => apiFetch(`/leads/${id}`, { method: "DELETE" }))); showToast(`Deleted ${ids.length} leads`, "success"); clearSelection(); loadLeads(); loadStats(); } catch (e) { showToast("Failed to bulk delete", "error"); } } // ══════════════════════════ Status Dropdown ══════════════════════════ function toggleStatusDropdown(event, leadId) { event.stopPropagation(); closeStatusDropdown(); const badge = event.target; const rect = badge.getBoundingClientRect(); const dropdown = document.createElement("div"); dropdown.className = "status-dropdown"; dropdown.style.top = `${rect.bottom + 8}px`; dropdown.style.left = `${rect.left}px`; const statuses = [ { val: "new", label: "✨ New" }, { val: "contacted", label: "📞 Contacted" }, { val: "replied", label: "💬 Replied" }, { val: "closed", label: "🎯 Closed" }, ]; dropdown.innerHTML = statuses .map( (s) => ` ` ) .join(""); document.body.appendChild(dropdown); activeStatusDropdown = dropdown; } function closeStatusDropdown() { if (activeStatusDropdown) { activeStatusDropdown.remove(); activeStatusDropdown = null; } } document.addEventListener("click", closeStatusDropdown); async function updateLeadStatus(leadId, newStatus) { closeStatusDropdown(); try { await apiFetch(`/leads/${leadId}`, { method: "PUT", body: JSON.stringify({ status: newStatus }) }); showToast(`Status updated to "${newStatus}"`, "success"); loadLeads(); loadStats(); } catch (e) { showToast("Failed to update status", "error"); } } async function deleteLead(leadId) { if (!confirm("Are you sure you want to delete this lead?")) return; try { await apiFetch(`/leads/${leadId}`, { method: "DELETE" }); showToast("Lead deleted", "success"); selectedLeadIds.delete(leadId); updateBulkBar(); loadLeads(); loadStats(); } catch (e) { showToast("Failed to delete lead", "error"); } } // ══════════════════════════ Modals (Notes & WhatsApp) ══════════════════════════ function openNotesModal(leadId) { const lead = allLeads.find((l) => l.id === leadId); if (!lead) return; editingNoteLeadId = leadId; document.getElementById("notes-lead-name").textContent = lead.name; document.getElementById("notes-textarea").value = lead.notes || ""; document.getElementById("notes-followup").value = lead.follow_up_date || ""; openModal("modal-notes"); } async function saveNotes() { if (!editingNoteLeadId) return; try { await apiFetch(`/leads/${editingNoteLeadId}`, { method: "PUT", body: JSON.stringify({ notes: document.getElementById("notes-textarea").value, follow_up_date: document.getElementById("notes-followup").value || null, }), }); showToast("Notes saved successfully", "success"); closeModal("modal-notes"); loadLeads(); } catch (e) { showToast("Failed to save notes", "error"); } } function parseTemplateVariables(content, lead) { if (!content) return ""; let result = content; if (lead && lead.name) { result = result.replace(/\{\{\s*name\s*\}\}/gi, lead.name); } return result; } function openWhatsAppModal(leadId) { const lead = allLeads.find((l) => l.id === leadId); if (!lead || !lead.phone) return; document.getElementById("wa-lead-name").textContent = lead.name; document.getElementById("wa-lead-phone").textContent = lead.phone; // Update avatar const avatarEl = document.getElementById("wa-recipient-avatar"); if (avatarEl) avatarEl.textContent = lead.name.charAt(0).toUpperCase(); const select = document.getElementById("wa-template-select"); select.innerHTML = allTemplates.map((t) => ``).join("") || ''; if (allTemplates.length > 0) { document.getElementById("wa-message").value = parseTemplateVariables(allTemplates[0].content, lead); } else { document.getElementById("wa-message").value = ""; } const btn = document.getElementById("btn-send-wa"); btn.dataset.phone = lead.phone; btn.dataset.leadId = lead.id; openModal("modal-whatsapp"); } function sendWhatsApp() { const btn = document.getElementById("btn-send-wa"); let phone = btn.dataset.phone || ""; const leadId = btn.dataset.leadId; const message = document.getElementById("wa-message").value; phone = phone.replace(/[^\d+]/g, ""); if (!phone.startsWith("+") && !phone.startsWith("91")) phone = "91" + phone; window.open(`https://wa.me/${phone}?text=${encodeURIComponent(message)}`, "_blank"); if (leadId) { apiFetch(`/leads/${leadId}`, { method: "PUT", body: JSON.stringify({ status: "contacted" }) }).then(() => { loadLeads(); loadStats(); }); } closeModal("modal-whatsapp"); showToast("WhatsApp opened! Lead marked as contacted.", "success"); } // ══════════════════════════ Scraper ══════════════════════════ async function startScrape(event) { event.preventDefault(); const query = document.getElementById("scrape-query").value.trim(); const limit = parseInt(document.getElementById("scrape-limit").value) || 20; if (!query) { showToast("Please enter a search query", "error"); return; } const payload = { query, limit }; if (document.getElementById("scrape-radius-toggle").checked) { const lat = parseFloat(document.getElementById("scrape-lat").value); const lng = parseFloat(document.getElementById("scrape-lng").value); const zoom = parseInt(document.getElementById("scrape-zoom").value); if (isNaN(lat) || isNaN(lng)) { showToast("Please enter valid Latitude and Longitude", "error"); return; } payload.lat = lat; payload.lng = lng; payload.zoom = zoom || 14; } const btn = document.getElementById("btn-scrape"); btn.disabled = true; btn.innerHTML = '
Scraping...'; const statusDiv = document.getElementById("scrape-status"); statusDiv.classList.remove("hidden", "completed", "failed"); statusDiv.classList.add("running"); document.getElementById("scrape-status-text").textContent = "Starting browser..."; const progressFill = document.getElementById("scrape-progress-fill"); progressFill.style.width = "5%"; try { await apiFetch("/scrape", { method: "POST", body: JSON.stringify(payload) }); startScrapePolling(limit); } catch (e) { resetScrapeUI(e.message || "Failed to start scraping", "failed"); } } function startScrapePolling(limitTarget) { if (scrapePollingInterval) clearInterval(scrapePollingInterval); scrapePollingInterval = setInterval(async () => { try { const status = await apiFetch("/scrape/status"); const statusText = document.getElementById("scrape-status-text"); const progressFill = document.getElementById("scrape-progress-fill"); statusText.textContent = `${status.message} (${status.total_found} found)`; let pct = (status.total_found / limitTarget) * 100; if (pct < 10) pct = 10; if (pct > 95) pct = 95; progressFill.style.width = `${pct}%`; if (status.status === "completed" || status.status === "failed") { clearInterval(scrapePollingInterval); scrapePollingInterval = null; progressFill.style.width = "100%"; resetScrapeUI(status.status === "completed" ? status.message : "Failed: " + status.message, status.status); loadLeads(); loadStats(); } } catch (e) { console.warn("Polling error:", e); } }, 2000); } function resetScrapeUI(message, stateClass) { const statusDiv = document.getElementById("scrape-status"); statusDiv.classList.remove("running"); statusDiv.classList.add(stateClass); document.getElementById("scrape-spinner").classList.add("hidden"); document.getElementById("scrape-status-text").textContent = message; const btn = document.getElementById("btn-scrape"); btn.disabled = false; btn.innerHTML = ` Start Scraping `; if (stateClass === "completed") showToast(message, "success"); else if (stateClass === "failed") showToast(message, "error"); } // ══════════════════════════ Event Bindings ══════════════════════════ function bindEvents() { document.getElementById("scrape-form").addEventListener("submit", startScrape); // Toggle Advanced Location fields document.getElementById("scrape-radius-toggle").addEventListener("change", (e) => { const el = document.getElementById("advanced-location"); if (el) { if (e.target.checked) el.classList.remove("hidden"); else el.classList.add("hidden"); } }); // View Toggles document.getElementById("btn-view-table").addEventListener("click", () => switchView("table")); document.getElementById("btn-view-kanban").addEventListener("click", () => switchView("kanban")); let searchTimeout; document.getElementById("search-input").addEventListener("input", () => { clearTimeout(searchTimeout); searchTimeout = setTimeout(loadLeads, 400); }); document.getElementById("filter-status").addEventListener("change", loadLeads); document.getElementById("select-all").addEventListener("change", toggleSelectAll); document.getElementById("btn-save-notes").addEventListener("click", saveNotes); document.getElementById("btn-send-wa").addEventListener("click", sendWhatsApp); document.getElementById("wa-template-select").addEventListener("change", (e) => { const tmpl = allTemplates.find((t) => t.id === parseInt(e.target.value)); const btn = document.getElementById("btn-send-wa"); const leadId = parseInt(btn.dataset.leadId); const lead = allLeads.find((l) => l.id === leadId); if (tmpl && lead) document.getElementById("wa-message").value = parseTemplateVariables(tmpl.content, lead); }); document.querySelectorAll(".modal-close, [data-modal]").forEach((btn) => { btn.addEventListener("click", (e) => { const modalId = e.currentTarget.dataset.modal; if (modalId) closeModal(modalId); }); }); // Keyboard shortcuts document.addEventListener("keydown", (e) => { // Escape - close everything if (e.key === "Escape") { document.querySelectorAll(".modal-overlay.active").forEach((m) => m.classList.remove("active")); closeStatusDropdown(); closeDrawer(); return; } // Don't trigger shortcuts if typing in an input if (e.target.matches("input, textarea, select")) return; // ? - show keyboard shortcuts if (e.key === "?") { e.preventDefault(); openModal("modal-shortcuts"); return; } // 1 - table view if (e.key === "1") { switchView("table"); return; } // 2 - kanban view if (e.key === "2") { switchView("kanban"); return; } // E - export CSV if (e.key === "e" || e.key === "E") { exportCSV(); return; } // Cmd+K / Ctrl+K - focus search if ((e.metaKey || e.ctrlKey) && e.key === "k") { e.preventDefault(); document.getElementById("search-input").focus(); return; } }); // Template bindings document.getElementById("btn-add-template").addEventListener("click", openNewTemplateModal); document.getElementById("btn-save-template").addEventListener("click", saveTemplate); } // ══════════════════════════ Keyboard Shortcuts Modal ══════════════════════════ function showKeyboardShortcuts() { openModal("modal-shortcuts"); } // ══════════════════════════ Templates ══════════════════════════ async function loadTemplates() { try { const data = await apiFetch("/templates"); allTemplates = data.templates || []; renderTemplates(); } catch (e) {} } function renderTemplates() { const list = document.getElementById("templates-list"); if (allTemplates.length === 0) { list.innerHTML = `

No templates yet

Create your first message template

`; return; } list.innerHTML = allTemplates .map( (t) => `
✉️ ${escapeHtml(t.name)}
${escapeHtml(t.content)}
` ) .join(""); } function openNewTemplateModal() { editingTemplateId = null; document.getElementById("template-modal-title").innerHTML = "✉️ New Template"; document.getElementById("template-name").value = ""; document.getElementById("template-content").value = ""; openModal("modal-template"); } function editTemplate(id) { const tmpl = allTemplates.find((t) => t.id === id); if (!tmpl) return; editingTemplateId = id; document.getElementById("template-name").value = tmpl.name; document.getElementById("template-content").value = tmpl.content; openModal("modal-template"); } async function saveTemplate() { const name = document.getElementById("template-name").value.trim(); const content = document.getElementById("template-content").value.trim(); if (!name || !content) return; try { if (editingTemplateId) await apiFetch(`/templates/${editingTemplateId}`, { method: "PUT", body: JSON.stringify({ name, content }) }); else await apiFetch("/templates", { method: "POST", body: JSON.stringify({ name, content }) }); showToast("Template saved", "success"); closeModal("modal-template"); loadTemplates(); } catch (e) { showToast("Failed to save template", "error"); } } async function deleteTemplate(id) { if (!confirm("Delete this template?")) return; try { await apiFetch(`/templates/${id}`, { method: "DELETE" }); loadTemplates(); } catch (e) {} } async function exportCSV() { try { const csv = await apiFetch("/leads/export/csv"); const a = document.createElement("a"); a.href = URL.createObjectURL(new Blob([csv], { type: "text/csv" })); a.download = `leads_export.csv`; a.click(); showToast("CSV exported successfully", "success"); } catch (e) { showToast("Failed to export CSV", "error"); } } function openModal(id) { document.getElementById(id).classList.add("active"); } function closeModal(id) { document.getElementById(id).classList.remove("active"); } function showToast(message, type = "info") { const container = document.getElementById("toast-container"); const icons = { success: "✅", error: "❌", info: "ℹ️" }; const toast = document.createElement("div"); toast.className = `toast ${type}`; toast.innerHTML = `${icons[type] || icons.info}${escapeHtml(message)}`; container.appendChild(toast); setTimeout(() => { toast.classList.add("removing"); setTimeout(() => toast.remove(), 300); }, 4000); } function escapeHtml(str) { if (!str) return ""; const div = document.createElement("div"); div.textContent = str; return div.innerHTML; } function truncate(str, len) { if (!str) return ""; return str.length > len ? str.substring(0, len) + "…" : str; }