Scraping / frontend /app.js
Harshasnade's picture
πŸš€ LeadFlow - Lead Generation & Outreach System with premium UI
48a3682
/**
* 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;
}