Spaces:
Sleeping
Sleeping
| /** | |
| * 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 += `<a href="mailto:${lead.email}" class="social-icon email" title="${escapeHtml(lead.email)}">π§</a>`; | |
| if (lead.instagram) h += `<a href="${lead.instagram}" target="_blank" class="social-icon instagram" title="Instagram">πΈ</a>`; | |
| if (lead.linkedin) h += `<a href="${lead.linkedin}" target="_blank" class="social-icon linkedin" title="LinkedIn">πΌ</a>`; | |
| if (h) return `<div class="social-links">${h}</div>`; | |
| 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 ` | |
| <tr data-id="${lead.id}" class="${isSelected ? "selected" : ""}" onclick="handleRowClick(event, ${lead.id})"> | |
| <td class="checkbox-cell" onclick="event.stopPropagation()"> | |
| <input type="checkbox" class="lead-checkbox" value="${lead.id}" ${isSelected ? "checked" : ""} onchange="toggleLeadSelection(${lead.id}, this.checked)"> | |
| </td> | |
| <td> | |
| <div class="lead-name-cell"> | |
| <div class="lead-avatar" style="background: ${color}">${initial}</div> | |
| <span class="lead-name">${escapeHtml(lead.name)}</span> | |
| </div> | |
| </td> | |
| <td class="lead-phone">${lead.phone ? escapeHtml(lead.phone) : '<span style="color:var(--text-muted)">β</span>'}</td> | |
| <td class="lead-address hide-mobile" title="${escapeHtml(lead.address || "")}">${lead.address ? escapeHtml(lead.address) : '<span style="color:var(--text-muted)">β</span>'}</td> | |
| <td class="lead-website hide-mobile"> | |
| ${lead.website ? `<a href="${escapeHtml(lead.website)}" target="_blank" rel="noopener" onclick="event.stopPropagation()">${truncate(lead.website, 22)}</a>` : '<span style="color:var(--text-muted)">β</span>'} | |
| ${buildSocialsHtml(lead)} | |
| </td> | |
| <td class="relative" onclick="event.stopPropagation()"> | |
| <span class="status-badge ${lead.status}" onclick="toggleStatusDropdown(event, ${lead.id})">${lead.status}</span> | |
| </td> | |
| <td class="lead-notes hide-mobile" onclick="event.stopPropagation(); openNotesModal(${lead.id})" title="${escapeHtml(lead.notes || "Click to add notes")}">${lead.notes ? escapeHtml(lead.notes) : '<span style="color:var(--text-muted)">+ Add</span>'}</td> | |
| <td class="hide-mobile" style="color:var(--text-secondary); font-size: 0.8rem;" onclick="event.stopPropagation()">${lead.follow_up_date || "β"}</td> | |
| <td onclick="event.stopPropagation()"> | |
| <div class="actions-cell"> | |
| ${lead.phone ? `<button class="btn btn-whatsapp btn-sm btn-icon" onclick="openWhatsAppModal(${lead.id})" title="Send WhatsApp">π¬</button>` : `<button class="btn btn-secondary btn-sm btn-icon" disabled style="opacity:0.2">π¬</button>`} | |
| <button class="btn btn-secondary btn-sm btn-icon" onclick="openNotesModal(${lead.id})" title="Edit notes">π</button> | |
| <button class="btn btn-danger btn-sm btn-icon" onclick="deleteLead(${lead.id})" title="Delete lead">ποΈ</button> | |
| </div> | |
| </td> | |
| </tr> | |
| `; | |
| }) | |
| .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 = `<button ${currentPage === 1 ? "disabled" : ""} onclick="goToPage(${currentPage - 1})">β¨</button>`; | |
| for (let i = 1; i <= totalPages; i++) { | |
| if (i === 1 || i === totalPages || (i >= currentPage - 1 && i <= currentPage + 1)) { | |
| html += `<button class="${i === currentPage ? "active" : ""}" onclick="goToPage(${i})">${i}</button>`; | |
| } else if (i === currentPage - 2 || i === currentPage + 2) { | |
| html += `<button disabled style="border:none; background:transparent">...</button>`; | |
| } | |
| } | |
| html += ` | |
| <button ${currentPage === totalPages || totalPages === 0 ? "disabled" : ""} onclick="goToPage(${currentPage + 1})">β©</button> | |
| <select class="page-size-select" style="margin-left: 8px" onchange="changePageSize(this.value)"> | |
| <option value="15" ${pageSize === 15 ? "selected" : ""}>15 / page</option> | |
| <option value="30" ${pageSize === 30 ? "selected" : ""}>30 / page</option> | |
| <option value="50" ${pageSize === 50 ? "selected" : ""}>50 / page</option> | |
| <option value="100" ${pageSize === 100 ? "selected" : ""}>100 / page</option> | |
| </select> | |
| `; | |
| 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 = ` | |
| <div style="display:flex; align-items:center; gap:14px; margin-bottom:8px;"> | |
| <div class="lead-avatar" style="background:${color}; width:48px; height:48px; font-size:1.1rem; border-radius:var(--radius-md);">${initial}</div> | |
| <div> | |
| <div style="font-weight:700; font-size:1.1rem;">${escapeHtml(lead.name)}</div> | |
| <span class="status-badge ${lead.status}" style="margin-top:4px; cursor:default;">${lead.status}</span> | |
| </div> | |
| </div> | |
| <div class="drawer-section-title">Contact Information</div> | |
| <div class="drawer-info-grid"> | |
| <div class="drawer-info-item"> | |
| <span class="drawer-info-label">Phone</span> | |
| <span class="drawer-info-value">${lead.phone ? `<a href="tel:${lead.phone}">${escapeHtml(lead.phone)}</a>` : "β"}</span> | |
| </div> | |
| <div class="drawer-info-item"> | |
| <span class="drawer-info-label">Email</span> | |
| <span class="drawer-info-value">${lead.email ? `<a href="mailto:${lead.email}">${escapeHtml(lead.email)}</a>` : "β"}</span> | |
| </div> | |
| <div class="drawer-info-item" style="grid-column: 1 / -1;"> | |
| <span class="drawer-info-label">Address</span> | |
| <span class="drawer-info-value">${lead.address ? escapeHtml(lead.address) : "β"}</span> | |
| </div> | |
| <div class="drawer-info-item" style="grid-column: 1 / -1;"> | |
| <span class="drawer-info-label">Website</span> | |
| <span class="drawer-info-value">${lead.website ? `<a href="${escapeHtml(lead.website)}" target="_blank" rel="noopener">${escapeHtml(lead.website)}</a>` : "β"}</span> | |
| </div> | |
| </div> | |
| ${buildSocialsHtml(lead) ? `<div class="drawer-section-title" style="margin-top:12px">Social Profiles</div>${buildSocialsHtml(lead)}` : ""} | |
| <div class="drawer-section-title" style="margin-top:12px">Notes</div> | |
| <div style="font-size:0.88rem; color:var(--text-secondary); line-height:1.6; padding:12px 16px; background:var(--bg-glass); border-radius:var(--radius-sm); border:1px solid var(--border-subtle); min-height:60px;"> | |
| ${lead.notes ? escapeHtml(lead.notes) : '<span style="color:var(--text-muted)">No notes yet</span>'} | |
| </div> | |
| ${lead.follow_up_date ? `<div style="font-size:0.82rem; color:var(--text-muted); margin-top:4px;">π Follow-up: <span style="color:var(--text-secondary)">${lead.follow_up_date}</span></div>` : ""} | |
| <div class="drawer-section-title" style="margin-top:12px">Actions</div> | |
| <div class="drawer-actions"> | |
| ${lead.phone ? `<button class="btn btn-whatsapp btn-sm" onclick="closeDrawer(); openWhatsAppModal(${lead.id})">π¬ WhatsApp</button>` : ""} | |
| <button class="btn btn-secondary btn-sm" onclick="closeDrawer(); openNotesModal(${lead.id})">π Edit Notes</button> | |
| <button class="btn btn-danger btn-sm" onclick="closeDrawer(); deleteLead(${lead.id})">ποΈ Delete</button> | |
| </div> | |
| `; | |
| 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 ` | |
| <div class="kanban-card" draggable="true" data-id="${lead.id}" onclick="openDrawer(${lead.id})"> | |
| <div style="display:flex; align-items:center; gap:8px; margin-bottom:6px;"> | |
| <div class="lead-avatar" style="background:${color}; width:26px; height:26px; font-size:0.65rem; border-radius:6px;">${initial}</div> | |
| <div class="kanban-card-title" style="margin:0;">${escapeHtml(lead.name)}</div> | |
| </div> | |
| ${lead.phone ? `<div class="kanban-card-info">π ${escapeHtml(lead.phone)}</div>` : ""} | |
| ${lead.notes ? `<div class="kanban-card-info" style="color:var(--text-muted); font-style:italic;">π ${truncate(escapeHtml(lead.notes), 40)}</div>` : ""} | |
| <div style="display:flex; justify-content:space-between; align-items:center; margin-top:6px;"> | |
| ${buildSocialsHtml(lead)} | |
| ${lead.phone ? `<button class="btn btn-whatsapp btn-sm btn-icon" onclick="event.stopPropagation(); openWhatsAppModal(${lead.id})" style="font-size:0.75rem; padding:4px 6px;">π¬</button>` : ""} | |
| </div> | |
| </div> | |
| `; | |
| }) | |
| .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) => ` | |
| <button onclick="updateLeadStatus(${leadId}, '${s.val}')"> | |
| <span class="status-badge ${s.val}">${s.val}</span> | |
| </button> | |
| ` | |
| ) | |
| .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) => `<option value="${t.id}">${escapeHtml(t.name)}</option>`).join("") || '<option value="">No templates found</option>'; | |
| 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 = '<div class="spinner"></div> 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 = ` | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg> | |
| 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 = `<div class="empty-state" style="padding:30px;"><p>No templates yet</p><p class="hint">Create your first message template</p></div>`; | |
| return; | |
| } | |
| list.innerHTML = allTemplates | |
| .map( | |
| (t) => ` | |
| <div class="template-card" data-id="${t.id}"> | |
| <div class="template-card-header"> | |
| <span class="template-card-name">βοΈ ${escapeHtml(t.name)}</span> | |
| <div class="template-card-actions"> | |
| <button class="btn btn-ghost btn-icon" onclick="editTemplate(${t.id})" title="Edit">βοΈ</button> | |
| <button class="btn btn-ghost btn-icon" style="color:var(--danger)" onclick="deleteTemplate(${t.id})" title="Delete">ποΈ</button> | |
| </div> | |
| </div> | |
| <div class="template-card-content">${escapeHtml(t.content)}</div> | |
| </div> | |
| ` | |
| ) | |
| .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 = `<span class="toast-icon">${icons[type] || icons.info}</span><span class="toast-message">${escapeHtml(message)}</span>`; | |
| 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; | |
| } | |