const $ = (id) => document.getElementById(id); let prospects = []; let runs = []; let activePoll = null; let selectedProspect = null; async function api(path, options = {}) { const response = await fetch(path, { ...options, headers: { "Content-Type": "application/json", ...(options.headers || {}) }, }); const body = await response.json().catch(() => ({})); if (!response.ok) throw new Error(body.error || body.message || `Request failed (${response.status})`); return body; } function esc(value) { return String(value ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[char])); } function formatDate(value) { if (!value) return "—"; const date = new Date(value); if (Number.isNaN(date.getTime())) return String(value).replace("T", " ").replace("+00:00", ""); return new Intl.DateTimeFormat(undefined, { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" }).format(date); } function plural(count, word) { return `${count} ${word}${count === 1 ? "" : "s"}`; } function renderStats(stats = {}) { $("total").textContent = stats.total ?? prospects.length; $("pending").textContent = stats.pending ?? prospects.filter((item) => item.review_status === "pending").length; $("approved").textContent = stats.approved ?? prospects.filter((item) => item.review_status === "approved").length; $("running").textContent = stats.running ?? runs.filter((item) => ["queued", "running"].includes(item.status)).length; const latest = runs[0]; $("lastRun").textContent = latest ? `last #${latest.id} · ${formatDate(latest.created_at)}` : "no recent run"; } function scoreClass(score) { if (score >= 75) return "high"; if (score >= 55) return "medium"; return "low"; } function renderExplorer() { const query = $("search").value.trim().toLowerCase(); const status = $("status").value; const rows = prospects.filter((item) => { const haystack = [item.name, item.domain, item.category, item.address, item.phone, item.emails].join(" ").toLowerCase(); return (!query || haystack.includes(query)) && (!status || item.review_status === status); }); $("resultCount").textContent = `${plural(rows.length, "place")} ${query || status ? "matching" : "in catalogue"}`; $("explorerList").innerHTML = rows.length ? rows.map((item) => { const score = Number(item.opportunity_score || 0); const website = item.website ? `${esc(item.domain || item.website)} ↗` : `No detected website`; const maps = item.maps_url ? `Open Maps ↗` : ""; const contact = [item.phone && `☎ ${esc(item.phone)}`, item.emails && `✉ ${esc(item.emails)}`].filter(Boolean).join(""); return `
⌖

${esc(item.name)}

${esc(item.category || "Business")} ${item.address ? ` · ${esc(item.address)}` : ""}
${score}/100 opportunity${esc((item.website_status || "unknown").replaceAll("_", " "))}${item.rating != null ? `★ ${esc(Number(item.rating).toFixed(1))} (${esc(item.review_count || 0)})` : ""}
${website}${contact ? `${contact}` : ""}
Found ${esc(formatDate(item.discovered_at))}·${esc(item.source || "google_maps")}${maps ? `${maps}` : ""}
`; }).join("") : `
⌕
${query || status ? "Nothing matches these filters" : "Your catalogue is empty"}

${query || status ? "Try a different search or clear the status filter." : "Run your first discovery above to bring places into the explorer."}

`; } function runStatusLabel(run) { if (run.status === "succeeded") return "Complete"; if (run.status === "failed") return "Failed"; if (run.status === "queued") return "Queued"; return run.stage === "scraping" ? "Searching Maps" : "Preparing"; } function renderActiveRun(run) { const target = $("activeRun"); if (!run || !["queued", "running"].includes(run.status)) { target.className = "active-run empty-run"; target.innerHTML = `
◷
${run && run.status === "failed" ? "Last run failed" : "No active run"}

${run && run.error ? esc(run.error) : "Start a search to see progress here."}

`; return; } const progress = Math.max(5, Math.min(95, Number(run.progress || 5))); target.className = "active-run running-run"; target.innerHTML = `
◌
#${run.id} · ${esc(run.query)}${progress}%

${esc(runStatusLabel(run))} · ${esc(run.found || 0)} places seen

`; } function renderRuns() { const current = runs.find((run) => ["queued", "running"].includes(run.status)); renderActiveRun(current || runs[0]); const list = runs.slice(0, 5); $("runsList").innerHTML = list.length ? list.map((run) => `
#${run.id}${esc(run.query)}
${run.status === "succeeded" ? `+${run.added}` : run.status === "failed" ? "!" : "…"}${esc(runStatusLabel(run))}
`).join("") : '

No runs yet.

'; renderStats(); } async function loadProspects() { prospects = (await api("/api/prospects")).items || []; renderExplorer(); } async function loadRuns() { runs = (await api("/api/runs")).items || []; renderRuns(); } async function loadStats() { renderStats(await api("/api/stats")); } async function loadSchedules() { const data=await api("/api/schedules"); $("scheduleList").innerHTML=(data.items||[]).map(s=>`
${esc(s.keywords)} · ${esc(s.category)} · ${esc(s.location)}every ${s.interval_hours}h · next ${esc(formatDate(s.next_run_at))}
`).join("") || "No schedules configured."; } async function loadConfig() { const config = await api("/api/config"); $("backendLabel").textContent = config.backend === "gmaps" ? "Maps worker" : "Browser fallback"; $("sourceLabel").textContent = config.ready ? "Maps worker ready" : "Setup required"; $("sourceSub").textContent = config.ready ? "Google Maps scraper connected" : (config.gmaps_auth_required ? "SaaS API key required" : "Start the bundled Maps worker"); $("sourceDot").classList.toggle("offline", !config.ready); $("setupNotice").hidden = Boolean(config.ready); if (!config.gmaps_url) $("setupText").textContent = "Start the bundled Maps worker with Docker, then reload this page."; else if (config.gmaps_auth_required && !config.gmaps_api_key) $("setupText").textContent = "SaaS mode is selected but no API key is configured. Use standard mode or add GMAPS_API_KEY."; else $("setupText").textContent = "The standard worker needs no API key. If a run fails, check the gmaps container logs."; } async function refreshAll() { $("updated").textContent = "Refreshing…"; try { await Promise.all([loadConfig(), loadProspects(), loadRuns(), loadStats(), loadSchedules()]); $("updated").textContent = `Updated ${new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`; } catch (error) { $("updated").textContent = "Refresh failed"; $("discoverMessage").textContent = error.message; $("discoverMessage").className = "form-message error"; } } async function pollRun(runId) { if (activePoll) clearTimeout(activePoll); try { const run = await api(`/api/runs/${runId}`); const index = runs.findIndex((item) => item.id === run.id); if (index >= 0) runs[index] = run; else runs.unshift(run); renderRuns(); if (["queued", "running"].includes(run.status)) activePoll = setTimeout(() => pollRun(runId), 2000); else { await Promise.all([loadProspects(), loadStats()]); $("discoverBtn").disabled = false; $("discoverMessage").textContent = run.status === "succeeded" ? `Complete · ${run.added} new ${run.added === 1 ? "place" : "places"} added.` : `Run failed · ${run.error || "Unknown error"}`; $("discoverMessage").className = `form-message ${run.status === "succeeded" ? "success" : "error"}`; } } catch (error) { $("discoverMessage").textContent = error.message; $("discoverMessage").className = "form-message error"; $("discoverBtn").disabled = false; } } $("discoverForm").addEventListener("submit", async (event) => { event.preventDefault(); const button = $("discoverBtn"); const message = $("discoverMessage"); button.disabled = true; message.className = "form-message"; message.textContent = "Queueing Maps search…"; const payload = { category: $("category").value.trim(), keywords: $("keywords").value.trim(), location: $("location").value.trim(), max_results: Number($("max-results").value), include_email: true, extra_reviews: $("extra-reviews").checked }; try { const run = await api("/api/discover", { method: "POST", body: JSON.stringify(payload) }); runs.unshift(run); renderRuns(); message.textContent = "Search queued · this can take a few minutes."; await pollRun(run.id); } catch (error) { button.disabled = false; message.textContent = `Could not start run · ${error.message}`; message.className = "form-message error"; } }); $("explorerList").addEventListener("change", async (event) => { const id = event.target.dataset.review; if (!id) return; event.target.disabled = true; try { const updated = await api(`/api/prospects/${id}/review`, { method: "PATCH", body: JSON.stringify({ status: event.target.value }) }); const index = prospects.findIndex((item) => item.id === updated.id); if (index >= 0) prospects[index] = updated; renderExplorer(); await loadStats(); } catch (error) { event.target.disabled = false; alert(error.message); } }); $("explorerList").addEventListener("click", (event) => { const id = event.target.dataset.message; if (!id) return; selectedProspect = prospects.find((item) => String(item.id) === String(id)); $("messageTitle").textContent = `Draft for ${selectedProspect?.name || "prospect"}`; $("offerInput").value = ""; $("subjectOutput").value = selectedProspect?.ai_subject || ""; $("bodyOutput").value = selectedProspect?.ai_message || ""; $("messageStatus").textContent = ""; $("messageDialog").showModal(); }); $("explorerList").addEventListener("click", async (event) => { const d=event.target.dataset.domains, r=event.target.dataset.rescan; if (d) { try { const x=await api(`/api/prospects/${d}/domains`); alert((x.items||[]).map(i=>`${i.domain} · ${i.status}`).join("\n") || "No domain ideas found."); } catch(e){ alert(e.message); } } if (r) { event.target.disabled=true; try { const updated=await api(`/api/prospects/${r}/scan`,{method:"POST",body:"{}"}); const i=prospects.findIndex(p=>p.id===updated.id); if(i>=0) prospects[i]=updated; renderExplorer(); } catch(e){ alert(e.message); } } }); $("generateMessage").addEventListener("click", async () => { if (!selectedProspect) return; const status = $("messageStatus"); status.textContent = "Generating evidence-grounded draft…"; try { const draft = await api(`/api/prospects/${selectedProspect.id}/message`, {method:"POST", body:JSON.stringify({offer:$("offerInput").value})}); $("subjectOutput").value=draft.subject; $("bodyOutput").value=draft.message; status.textContent="Draft generated. Edit it before sending."; status.className="form-message success"; selectedProspect.ai_subject=draft.subject; selectedProspect.ai_message=draft.message; } catch (error) { status.textContent = error.message; status.className="form-message error"; } }); $("copyMessage").addEventListener("click", async () => { const text = `Subject: ${$("subjectOutput").value}\n\n${$("bodyOutput").value}`; await navigator.clipboard.writeText(text); $("messageStatus").textContent="Copied to clipboard."; }); $("search").addEventListener("input", renderExplorer); $("status").addEventListener("change", renderExplorer); $("refresh").addEventListener("click", loadProspects); document.getElementById("exportCsv")?.addEventListener("click", () => { window.location.href = "/api/export.csv"; }); $("runsRefresh").addEventListener("click", loadRuns); $("refreshAll").addEventListener("click", refreshAll); $("showRuns").addEventListener("click", (event) => { event.preventDefault(); $("runs").scrollIntoView({ behavior: "smooth", block: "center" }); }); $("scheduleForm").addEventListener("submit", async (event) => { event.preventDefault(); const m=$("scheduleMessage"); try { await api("/api/schedules", {method:"POST",body:JSON.stringify({category:$("schedule-category").value,keywords:$("schedule-keywords").value,location:$("schedule-location").value,interval_hours:Number($("schedule-interval").value)})}); m.textContent="Schedule saved."; m.className="form-message success"; await loadSchedules(); } catch(e){m.textContent=e.message;m.className="form-message error";} }); refreshAll().catch((error) => { $("explorerList").innerHTML = `
Unable to load workspace

${esc(error.message)}

`; });