diff --git a/README.md b/README.md
index 18eb815..884ce42 100644
--- a/README.md
+++ b/README.md
@@ -5,9 +5,9 @@ the bundled [gosom/google-maps-scraper](https://github.com/gosom/google-maps-scr
worker, waits for its asynchronous job to finish, filters places already in
the local catalogue, and presents the remaining places for human review.
-The interface includes discovery, run progress, catalogue search, website
-health and email evidence, explainable scoring, domain ideas, suppression,
-CSV export, scheduled runs, rescans, and human review. It never sends outreach
+The interface is split into Dashboard, Discover, Prospects, Runs, and Settings
+views. It includes website health and email evidence, explainable scoring,
+domain ideas, suppression, CSV export, scheduled runs, rescans, and human review. It never sends outreach
automatically. Optional AI can analyse a prospect and draft a subject/email
from supplied evidence and your offer; you edit and approve the draft yourself.
diff --git a/api/server.py b/api/server.py
index d357b99..7a3ea09 100644
--- a/api/server.py
+++ b/api/server.py
@@ -16,7 +16,6 @@ import os
import re
import sqlite3
import threading
-from threading import Thread
import time
import urllib.error
import urllib.request
@@ -149,6 +148,9 @@ def db() -> sqlite3.Connection:
interval_hours INTEGER NOT NULL DEFAULT 24, enabled INTEGER NOT NULL DEFAULT 1,
next_run_at TEXT NOT NULL, last_run_id INTEGER, created_at TEXT NOT NULL
)""")
+ conn.execute("""CREATE TABLE IF NOT EXISTS settings (
+ key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL
+ )""")
# Migrate databases created by v1-v5 without destroying the user's
# catalogue. SQLite permits adding nullable/defaulted columns in place.
@@ -891,7 +893,15 @@ def scheduler_loop() -> None:
def ai_generate_message(prospect: dict[str, Any], offer: str) -> dict[str, Any]:
"""Generate an evidence-grounded draft through an OpenAI-compatible endpoint."""
- if not AI_BASE_URL or not AI_MODEL:
+ runtime = {}
+ try:
+ c = db(); runtime = {str(r["key"]): str(r["value"]) for r in c.execute("SELECT key,value FROM settings")}; c.close()
+ except Exception:
+ pass
+ ai_base_url = runtime.get("AI_BASE_URL", AI_BASE_URL).strip().rstrip("/")
+ ai_api_key = runtime.get("AI_API_KEY", AI_API_KEY).strip()
+ ai_model = runtime.get("AI_MODEL", AI_MODEL).strip()
+ if not ai_base_url or not ai_model:
raise RuntimeError("ai_not_configured_set_AI_BASE_URL_and_AI_MODEL")
evidence = {
"business": prospect.get("name"), "category": prospect.get("category"),
@@ -906,8 +916,8 @@ def ai_generate_message(prospect: dict[str, Any], offer: str) -> dict[str, Any]:
"Do not mention scraping or hidden analysis. Return strict JSON with keys subject, message, rationale. "
"The message must be plain text, personalised, transparent, and include a polite opt-out line.")
prompt = json.dumps({"evidence": evidence, "user_offer_or_instruction": offer[:2000]}, ensure_ascii=False)
- headers = {"Authorization": "Bearer " + AI_API_KEY} if AI_API_KEY else {}
- result = http_json("POST", AI_BASE_URL + "/chat/completions", {"model": AI_MODEL, "temperature": 0.4, "response_format": {"type": "json_object"}, "messages": [{"role": "system", "content": system}, {"role": "user", "content": prompt}]}, headers=headers, timeout=60)
+ headers = {"Authorization": "Bearer " + ai_api_key} if ai_api_key else {}
+ result = http_json("POST", ai_base_url + "/chat/completions", {"model": ai_model, "temperature": 0.4, "response_format": {"type": "json_object"}, "messages": [{"role": "system", "content": system}, {"role": "user", "content": prompt}]}, headers=headers, timeout=60)
content = (((result.get("choices") or [{}])[0].get("message") or {}).get("content") or "") if isinstance(result, dict) else ""
try:
parsed = json.loads(content)
@@ -952,6 +962,7 @@ class Handler(BaseHTTPRequestHandler):
if parsed.path == "/api/config":
auth_required = GMAPS_API_MODE == "saas"
ready = backend_mode() == "browser" or (bool(GMAPS_URL) and (not auth_required or bool(GMAPS_API_KEY)))
+ saved = {str(r["key"]): str(r["value"]) for r in conn.execute("SELECT key,value FROM settings")}
return self.send_json(200, {
"backend": backend_mode(),
"gmaps_url": bool(GMAPS_URL),
@@ -961,8 +972,11 @@ class Handler(BaseHTTPRequestHandler):
"ready": ready,
"email_extraction_default": True,
"email_extraction_always": True,
- "ai_configured": bool(AI_BASE_URL and AI_MODEL),
+ "ai_configured": bool(saved.get("AI_BASE_URL", AI_BASE_URL) and saved.get("AI_MODEL", AI_MODEL)),
})
+ if parsed.path == "/api/settings":
+ saved = {str(r["key"]): str(r["value"]) for r in conn.execute("SELECT key,value FROM settings")}
+ return self.send_json(200, {"maps": {"backend": backend_mode(), "worker_url": GMAPS_URL, "ready": backend_mode() == "browser" or bool(GMAPS_URL)}, "ai": {"base_url": saved.get("AI_BASE_URL", AI_BASE_URL), "model": saved.get("AI_MODEL", AI_MODEL), "configured": bool(saved.get("AI_BASE_URL", AI_BASE_URL) and saved.get("AI_MODEL", AI_MODEL))}, "email_collection": {"always_on": True, "public_only": True}, "limits": {"max_results": MAX_RESULTS, "website_scan_timeout_seconds": 8}})
if parsed.path == "/api/prospects":
params = parse_qs(parsed.query)
q = (params.get("q", [""])[0]).strip().lower()
@@ -1041,6 +1055,18 @@ class Handler(BaseHTTPRequestHandler):
self.wfile.write(content)
def do_POST(self) -> None:
+ if self.path == "/api/settings":
+ try:
+ payload = self.read_json(); ai = payload.get("ai") if isinstance(payload.get("ai"), dict) else {}
+ conn = db()
+ for key in ("AI_BASE_URL", "AI_API_KEY", "AI_MODEL"):
+ if key in ai:
+ value = str(ai.get(key) or "").strip()[:500]
+ conn.execute("INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at", (key, value, now()))
+ conn.commit(); saved={str(r["key"]):str(r["value"]) for r in conn.execute("SELECT key,value FROM settings")}; conn.close()
+ return self.send_json(200, {"saved": True, "ai": {"base_url": saved.get("AI_BASE_URL", AI_BASE_URL), "model": saved.get("AI_MODEL", AI_MODEL), "configured": bool(saved.get("AI_BASE_URL", AI_BASE_URL) and saved.get("AI_MODEL", AI_MODEL))}})
+ except Exception as exc:
+ return self.send_json(400, {"error": str(exc)})
message_match = re.fullmatch(r"/api/prospects/(\d+)/message", urlparse(self.path).path)
if message_match:
conn = None
diff --git a/web/app.js b/web/app.js
index f32d0e8..635538a 100644
--- a/web/app.js
+++ b/web/app.js
@@ -1,174 +1,22 @@
-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}` : ""}Draft AI email Domain ideas Rescan
-
- Review Needs review Approved Rejected Contacted
- `;
- }).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)}
`; });
+const $=id=>document.getElementById(id); const esc=v=>String(v??"").replace(/[&<>\"']/g,c=>({'&':'&','<':'<','>':'>','\"':'"',"'":'''}[c]));
+let prospects=[],runs=[],settings={},selected=null,pollTimer=null;
+async function api(path,opt={}){const r=await fetch(path,{...opt,headers:{'Content-Type':'application/json',...(opt.headers||{})}});const b=await r.json().catch(()=>({}));if(!r.ok)throw Error(b.error||`Request failed (${r.status})`);return b}
+function date(v){if(!v)return'—';const d=new Date(v);return isNaN(d)?String(v):d.toLocaleString(undefined,{day:'numeric',month:'short',hour:'2-digit',minute:'2-digit'})}
+function layout(title,sub,body,actions=''){ $('pageName').textContent=title; return `${body}` }
+function empty(text){return `⌕ ${text}
`}
+function scoreClass(n){return n>=75?'hot':n>=55?'warm':'cool'}
+function prospectRows(){const q=($('prospectSearch')?.value||'').toLowerCase();const st=$('prospectStatus')?.value||'';return prospects.filter(p=>{const hay=[p.name,p.domain,p.category,p.address,p.emails].join(' ').toLowerCase();return(!q||hay.includes(q))&&(!st||p.review_status===st)})}
+function prospectTable(){const rows=prospectRows();return `Prospect Website Email Score Review ${rows.map(p=>`${esc(p.name)} ${esc(p.category||'Business')} · ${esc(p.address||'')} ${esc((p.website_status||'unknown').replaceAll('_',' '))} ${p.website?`Open ↗ `:'No detected website '}${p.emails?`✉ ${esc(p.emails)} `:'Not found '} ${p.opportunity_score||0} Needs review Approved Rejected Contacted ✦ `).join('')}
${rows.length?'':empty('No prospects match these filters')}`}
+function renderDashboard(){const total=prospects.length,pending=prospects.filter(p=>p.review_status==='pending').length,hot=prospects.filter(p=>p.opportunity_score>=75).length,withEmail=prospects.filter(p=>p.emails).length;const latest=runs[0];$('view').innerHTML=layout('Dashboard','A clear view of what was found, what needs review, and where the best opportunities are.',`CATALOGUE ${total} saved prospects
NEEDS REVIEW ${pending} awaiting your decision
HIGH OPPORTUNITY ${hot} score 75 or higher
PUBLIC EMAILS ${withEmail} evidence-backed contacts
NEXT ACTION Review your strongest leads Inspect the evidence before deciding whether to contact them.
Open explorer → ${prospects.slice(0,5).map(p=>`${p.opportunity_score} ${esc(p.name)} ${esc(p.website_status?.replaceAll('_',' ')||'unknown')} · ${esc(p.emails||'no public email')} › `).join('')||empty('Run a discovery to build your catalogue')}
${runs.slice(0,6).map(r=>`
#${r.id} · ${esc(r.query)} ${esc(r.status)} · ${r.added||0} added ${date(r.created_at)}
`).join('')||empty('No runs yet')}
`)}
+function renderDiscover(){ $('view').innerHTML=layout('Discover','Set the criteria for this run. Source configuration and provider settings live in Settings.',`NEW RUN Find potential customers Search Google Maps, deduplicate the catalogue, then enrich each result.
Email collection always on HOW IT WORKS
Evidence-first pipeline Search Maps worker finds public business listings. Enrich Website, HTTPS, DNS hints and public email checks run safely. Review You decide what is relevant before any contact. `)}
+function renderProspects(){ $('view').innerHTML=layout('Prospects','Search, filter and review your catalogue. Click a row to inspect the evidence.',`${prospectTable()}
`)}
+function renderRuns(){ $('view').innerHTML=layout('Runs','Monitor discovery jobs and inspect errors without leaving the run history.',`Run Status Results Started Source ${runs.map(r=>`#${r.id} ${esc(r.query)} ${esc(r.status)} ${esc(r.stage||'')} ${r.added||0} added / ${r.found||0} found ${date(r.created_at)} ${esc(r.source||'google_maps')} `).join('')}
${runs.length?'':empty('No discovery runs yet')} `)}
+function renderSettings(){const ai=settings.ai||{};const sch=settings.schedules||[];const sup=settings.suppressions||[];$('view').innerHTML=layout('Settings','Configure providers, automation and safety controls here. These settings are separate from daily discovery.',`SOURCE Google Maps worker The bundled standard gosom worker runs locally and does not require an API key.
${settings.maps?.ready?'Ready':'Not connected'} Connection ${esc(settings.maps?.worker_url||'Not configured')}
Email extraction Always on · public pages only
Website scan 8 second bounded timeout
AI PROVIDER Draft assistant Used only when you click “Draft AI email”.
SCHEDULES Recurring discovery Run bounded searches automatically; all results still arrive as needs-review.
${sch.map(s=>`
${esc(s.keywords)} · ${esc(s.category)} ${esc(s.location)} · every ${s.interval_hours}h · next ${date(s.next_run_at)}
`).join('')||'
No schedules configured.
'}
SAFETY Suppression centre Suppressed domains and emails are excluded before new prospects are saved.
${sup.map(s=>`
${esc(s.value)} ${esc(s.kind)} · ${esc(s.reason||'manual')} · ${date(s.created_at)}
`).join('')||'
No suppressions configured.
'}
`)}
+async function load(){[prospects,runs,settings]=await Promise.all([api('/api/prospects').then(x=>x.items||[]),api('/api/runs').then(x=>x.items||[]),Promise.all([api('/api/settings'),api('/api/schedules'),api('/api/suppressions')]).then(([s,sch,sup])=>({...s,schedules:sch.items||[],suppressions:sup.items||[]}))]);}
+async function route(){const r=location.hash.slice(1)||'dashboard';document.querySelectorAll('[data-route]').forEach(a=>a.classList.toggle('active',a.dataset.route===r));try{await load();({dashboard:renderDashboard,discover:renderDiscover,prospects:renderProspects,runs:renderRuns,settings:renderSettings}[r]||renderDashboard)();$('updated').textContent='Updated '+new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});}catch(e){$('view').innerHTML=empty(e.message)}}
+async function openMessage(id){selected=prospects.find(p=>String(p.id)===String(id));if(!selected)return;$('messageTitle').textContent='Draft for '+selected.name;$('offerInput').value='';$('subjectOutput').value=selected.ai_subject||'';$('bodyOutput').value=selected.ai_message||'';$('messageStatus').textContent='';$('messageDialog').showModal()}
+document.addEventListener('click',async e=>{const open=e.target.closest('[data-open]');if(open){const p=prospects.find(x=>String(x.id)===open.dataset.open);if(p){selected=p;alert(`${p.name}\n\nScore: ${p.opportunity_score}/100\nWebsite: ${p.website_status}\nReasons: ${JSON.parse(p.score_reasons||'[]').join(', ')||'—'}\n\nEmails: ${p.emails||'None found'}\nEvidence: ${p.email_evidence||'[]'}`)}}const msg=e.target.closest('[data-message]');if(msg)openMessage(msg.dataset.message);if(e.target.id==='exportCsv')location.href='/api/export.csv';if(e.target.id==='refreshProspects')route();});
+document.addEventListener('change',async e=>{if(e.target.dataset.review){try{const p=await api(`/api/prospects/${e.target.dataset.review}/review`,{method:'PATCH',body:JSON.stringify({status:e.target.value})});const i=prospects.findIndex(x=>x.id===p.id);if(i>=0)prospects[i]=p}catch(err){alert(err.message)}}if(e.target.id==='prospectSearch'||e.target.id==='prospectStatus')$('prospectTable').innerHTML=prospectTable()});
+document.addEventListener('submit',async e=>{if(e.target.id==='discoverForm'){e.preventDefault();const m=$('discoverMessage');$('discoverBtn').disabled=true;m.textContent='Queueing…';try{const r=await api('/api/discover',{method:'POST',body:JSON.stringify({keywords:$('keywords').value,category:$('category').value,location:$('location').value,max_results:Number($('max-results').value),extra_reviews:$('extra-reviews').checked,include_email:true})});m.textContent=`Run #${r.id} started. View it in Runs.`;setTimeout(route,500)}catch(x){m.textContent=x.message}finally{$('discoverBtn').disabled=false}}if(e.target.id==='settingsForm'){e.preventDefault();const m=$('settingsMessage');try{const ai={AI_BASE_URL:$('aiBase').value,AI_MODEL:$('aiModel').value};if($('aiKey').value)ai.AI_API_KEY=$('aiKey').value;await api('/api/settings',{method:'POST',body:JSON.stringify({ai})});m.textContent='Settings saved.';m.className='notice ok';}catch(x){m.textContent=x.message}}if(e.target.id==='scheduleForm'){e.preventDefault();try{await api('/api/schedules',{method:'POST',body:JSON.stringify({category:$('sCat').value,keywords:$('sKey').value,location:$('sLoc').value,interval_hours:Number($('sHours').value)})});route()}catch(x){alert(x.message)}}if(e.target.id==='suppressionForm'){e.preventDefault();try{await api('/api/suppressions',{method:'POST',body:JSON.stringify({kind:$('supKind').value,value:$('supValue').value,reason:$('supReason').value})});route()}catch(x){alert(x.message)}}});
+document.addEventListener('click',async e=>{if(e.target.id==='generateMessage'){if(!selected)return;const s=$('messageStatus');s.textContent='Generating…';try{const d=await api(`/api/prospects/${selected.id}/message`,{method:'POST',body:JSON.stringify({offer:$('offerInput').value})});$('subjectOutput').value=d.subject;$('bodyOutput').value=d.message;s.textContent='Draft ready. Review and edit before sending.';s.className='notice ok'}catch(x){s.textContent=x.message;s.className='notice error'}}if(e.target.id==='copyMessage'){await navigator.clipboard.writeText(`Subject: ${$('subjectOutput').value}\n\n${$('bodyOutput').value}`);$('messageStatus').textContent='Copied to clipboard.'}});
+window.addEventListener('hashchange',route);$('refreshAll').addEventListener('click',route);route();
diff --git a/web/index.html b/web/index.html
index 3ec7026..4722146 100644
--- a/web/index.html
+++ b/web/index.html
@@ -1,84 +1,3 @@
-
-
-
-
-
- ProspectOS · Maps Scout
-
-
-
-
-
-
-
-
- Workspace / Maps Scout
- — ↻
-
-
-
- PERSONAL DISCOVERY WORKSPACE Find the businesses worth a closer look. Search Google Maps, remove places already in your catalogue, then review the strongest opportunities in one calm workspace.
- Maps worker private & local
-
-
-
- !
Finish the Maps worker setup Start the bundled worker with Docker, then reload this page. The standard worker needs no API key.
Setup guide ↓
-
-
-
- ◈ Catalogue
0 places saved
- ● Needs review
0 pending decisions
- ✓ Approved
0 ready for your next step
- ◷ Runs active
0 no recent run
-
-
-
-
- 01 · NEW SEARCH Start a discovery run Describe the businesses you want. These criteria apply only to this run.
Google Maps
-
-
-
-
- 02 · ACTIVITY
Latest run Refresh
- ◷
No active run Start a search to see progress here.
-
-
-
-
-
- 03 · CATALOGUE Prospect explorer Sort by opportunity score, inspect the evidence, and mark your next action.
- 0 places Higher opportunity first
-
-
-
-
- SETUP One-time worker connection The bundled standard Maps worker runs locally and does not need a Google or scraper API key.
Run docker compose up -d --build. Keep GMAPS_API_MODE=standard in .env. Start a discovery run. Open port 8081 only if you want to inspect the worker's native UI.
-
- AUTOMATION Scheduled discovery Repeat a search on a bounded interval and review the new results.
-
-
-
-
-
-
-
-
+ProspectOS
+
ProspectOS · public data only · manual review before contact AI ASSISTED DRAFT Tailor a message Use the evidence and your offer to create an editable draft.
× Your offer or instruction
Generate subject and email Subject Email draft Copy draft Close
diff --git a/web/style.css b/web/style.css
index 4b58f11..e0d5592 100644
--- a/web/style.css
+++ b/web/style.css
@@ -16,6 +16,11 @@
}
.message-dialog{width:min(680px,calc(100% - 32px));border:0;border-radius:18px;padding:0;box-shadow:0 24px 80px #11182733}.message-dialog::backdrop{background:#11182766}.message-dialog form{padding:26px;display:grid;gap:10px}.dialog-head{display:flex;justify-content:space-between;gap:16px;margin-bottom:8px}.message-dialog textarea,.message-dialog input{width:100%;box-sizing:border-box;border:1px solid #d7dbea;border-radius:10px;padding:11px;font:inherit}.dialog-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:8px}
+/* v7 workspace redesign */
+*{box-sizing:border-box}body{background:#f4f6fa;color:#182338;font-family:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}.app-shell{display:flex;min-height:100vh}.sidebar{width:238px;background:#0d1726;color:#cbd5e1;display:flex;flex-direction:column;padding:25px 16px;position:fixed;inset:0 auto 0 0}.brand{display:flex;align-items:center;gap:11px;padding:0 10px 32px}.brand-mark{width:34px;height:34px;border-radius:10px;background:#635bff;color:white;display:grid;place-items:center;font-weight:900}.brand b{display:block;color:#fff;font-size:15px}.brand small{color:#8290a6;font-size:11px}.sidebar nav{display:grid;gap:5px}.sidebar nav a{color:#9eabc0;text-decoration:none;border-radius:9px;padding:12px 13px;display:flex;gap:12px;align-items:center;font-size:13px}.sidebar nav a span{width:18px;text-align:center;font-size:16px}.sidebar nav a:hover,.sidebar nav a.active{background:#1b2940;color:#fff}.sidebar nav a.active{box-shadow:inset 3px 0 #8179ff}.sidebar-foot{margin-top:auto;border-top:1px solid #233149;padding:17px 8px;display:flex;gap:9px;align-items:flex-start;font-size:11px}.sidebar-foot b{display:block;color:#dfe7f3;font-size:11px}.sidebar-foot small{color:#8190a7}.status-dot{width:8px;height:8px;border-radius:50%;background:#35c98b;margin-top:4px}.main{margin-left:238px;width:calc(100% - 238px);max-width:1500px;padding:0 42px 35px}.topbar{height:70px;border-bottom:1px solid #e5e9f1;display:flex;align-items:center;justify-content:space-between}.crumb{color:#6d7b90}.slash{padding:0 10px;color:#bac3d0}.top-actions{display:flex;gap:14px;align-items:center;color:#8a96a8;font-size:12px}.icon-btn{border:1px solid #dfe4ed;background:#fff;border-radius:9px;width:34px;height:34px;cursor:pointer;font-size:18px;color:#516079}.page-head{display:flex;justify-content:space-between;gap:24px;padding:38px 0 28px;align-items:end}.eyebrow{font-size:10px;letter-spacing:1.8px;color:#655bff;font-weight:800}.page-head h1{font-size:30px;letter-spacing:-.8px;margin:8px 0}.page-head p{margin:0;color:#708099;max-width:650px}.head-actions{display:flex;gap:8px}.metric-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px}.metric,.panel{background:#fff;border:1px solid #e3e8f0;border-radius:14px;box-shadow:0 3px 12px #1f2a3a08}.metric{padding:20px 21px}.metric small{display:block;color:#7b879c;letter-spacing:1.2px;font-size:10px;font-weight:800}.metric b{display:block;font-size:31px;margin:8px 0 2px;letter-spacing:-1px}.metric span{font-size:12px;color:#8894a6}.dashboard-grid,.two-col{display:grid;grid-template-columns:1.4fr 1fr;gap:18px}.panel{padding:23px}.panel-title{display:flex;justify-content:space-between;gap:15px;align-items:flex-start}.panel-title h2{font-size:18px;margin:6px 0}.panel-title p{color:#8491a5;font-size:12px;margin:0;line-height:1.5}.panel-title a{color:#635bff;text-decoration:none;font-size:12px;font-weight:700}.primary,.secondary{border-radius:9px;padding:10px 15px;border:0;font:inherit;font-size:12px;font-weight:800;cursor:pointer;text-decoration:none}.primary{background:#635bff;color:#fff}.secondary{background:#fff;border:1px solid #d9e0eb;color:#344258}.spotlight{margin-top:18px}.spot{display:flex;width:100%;align-items:center;text-align:left;border:0;border-top:1px solid #edf0f5;background:transparent;padding:13px 2px;gap:12px;cursor:pointer;color:inherit}.spot>span:nth-child(2){flex:1}.spot b,.spot small{display:block}.spot small{color:#8995a8;font-size:11px;margin-top:3px}.spot>span:last-child{color:#a3adbc;font-size:20px}.score{display:inline-grid;place-items:center;width:35px;height:28px;border-radius:8px;font-weight:900;font-size:12px}.score.hot{background:#ffe7e9;color:#d14457}.score.warm{background:#fff2d2;color:#aa7510}.score.cool{background:#e7f0ff;color:#3768ba}.activity{margin-top:12px}.activity>div{display:flex;align-items:center;gap:10px;border-top:1px solid #edf0f5;padding:12px 0}.activity>div>span:nth-child(2){flex:1}.activity b,.activity small{display:block}.activity b{font-size:12px}.activity small,.activity time{font-size:11px;color:#8b96a8}.run-dot{width:8px;height:8px;border-radius:50%;background:#aeb7c5}.run-dot.succeeded{background:#2dbd80}.run-dot.failed{background:#dc5663}.empty{padding:38px;text-align:center;color:#8995a8}.empty span{display:block;font-size:28px;margin-bottom:8px}.form-panel{min-height:430px}.form-panel form{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:25px}.form-panel label,.settings-grid label{display:grid;gap:7px;color:#4d5c73;font-size:12px;font-weight:700}.form-panel label:first-child{grid-column:1/-1}.form-panel input,.form-panel select,.settings-grid input,.inline-form input,.inline-form select,.toolbar input,.toolbar select,.message-dialog input,.message-dialog textarea{border:1px solid #dbe2ed;border-radius:8px;padding:11px;background:#fff;font:inherit;color:#21304a;font-size:13px}.checkline{display:flex!important;grid-column:1/-1;align-items:center;gap:8px}.checkline small{font-weight:400;color:#8995a8}.form-row{grid-column:1/-1;display:flex;align-items:center;gap:12px}.notice{font-size:12px;color:#7c899c}.notice.ok{color:#14845d}.notice.error{color:#c44959}.pill,.badge,.status{font-size:10px;font-weight:800;padding:6px 9px;border-radius:99px;background:#ecebff;color:#5b53d8;white-space:nowrap}.steps{list-style:none;padding:0;margin:20px 0}.steps li{border-top:1px solid #edf0f5;padding:15px 0;display:grid;gap:4px}.steps span{font-size:12px;color:#8793a5}.toolbar{display:flex;gap:10px;margin-bottom:18px}.search{display:flex;align-items:center;border:1px solid #dbe2ed;border-radius:8px;background:#fff;padding:0 10px;flex:1}.search input{border:0;outline:0;width:100%}.toolbar select{width:170px}.table-wrap{overflow:auto}table{width:100%;border-collapse:collapse;font-size:12px}th{text-align:left;color:#7e8a9c;font-size:10px;letter-spacing:.8px;text-transform:uppercase;padding:11px 10px;border-bottom:1px solid #e6ebf2}td{padding:14px 10px;border-bottom:1px solid #edf0f5;vertical-align:middle}td b,td small{display:block}td small{color:#8a96a8;font-size:11px;margin-top:4px}td a{color:#635bff;text-decoration:none;font-size:11px;display:block;margin-top:5px}.badge.good{background:#e2f7ee;color:#19815e}.badge.warn{background:#fff0d3;color:#aa7510}.email{color:#45546d}.table-wrap select{border:1px solid #dbe2ed;background:#fff;border-radius:7px;padding:7px;font-size:11px}.icon-action{border:0;background:#f0efff;color:#635bff;border-radius:7px;padding:8px;cursor:pointer}.settings-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px}.wide-panel{grid-column:1/-1}.setting-line{display:flex;justify-content:space-between;border-top:1px solid #edf0f5;padding:13px 0;margin-top:13px;color:#7d899b;font-size:12px}.setting-line b{color:#334158;text-align:right;max-width:60%;overflow-wrap:anywhere}.good-text{color:#14845d!important}.settings-grid form{display:grid;gap:14px;margin-top:18px}.inline-form{display:flex;gap:8px;margin-top:18px}.inline-form>*{flex:1}.list{margin-top:13px}.list>div{border-top:1px solid #edf0f5;padding:12px 0;display:flex;justify-content:space-between;gap:15px;font-size:12px}.list span{color:#8a96a8}.status.succeeded{background:#e1f6ed;color:#16815b}.status.failed{background:#ffe7e9;color:#bc4052}.status.running{background:#e8efff;color:#3d67af}footer{color:#96a1b2;font-size:11px;padding:28px 0}.message-dialog{width:min(700px,calc(100% - 30px));border:0;border-radius:15px;padding:0;box-shadow:0 30px 100px #121b2d44}.message-dialog form{padding:26px;display:grid;gap:10px}.dialog-head{display:flex;justify-content:space-between}.dialog-head h2{margin:7px 0}.dialog-head p{color:#8995a8;font-size:12px}.dialog-actions{display:flex;justify-content:flex-end;gap:8px}
+@media(max-width:900px){.sidebar{width:70px;padding:18px 10px}.brand div,.sidebar nav a:not(.active)::after,.sidebar-foot div{display:none}.brand{padding:0 8px 25px}.sidebar nav a{justify-content:center;padding:12px}.main{margin-left:70px;width:calc(100% - 70px);padding:0 20px}.metric-grid{grid-template-columns:repeat(2,1fr)}.settings-grid,.dashboard-grid,.two-col{grid-template-columns:1fr}.wide-panel{grid-column:auto}.toolbar{flex-wrap:wrap}.toolbar .search{flex-basis:100%}}
+@media(max-width:560px){.main{padding:0 14px}.page-head{display:block}.page-head h1{font-size:25px}.metric-grid{gap:8px}.metric{padding:14px}.metric b{font-size:25px}.form-panel form{grid-template-columns:1fr}.form-panel label:first-child{grid-column:auto}.form-row{grid-column:auto}.inline-form{display:grid}.topbar{height:58px}.toolbar select,.toolbar button{flex:1}.list>div{display:block}.list span{display:block;margin-top:4px}}
+
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body { margin: 0; background: var(--soft); }