deploy Maps-backed ProspectOS workspace

This commit is contained in:
Marco0300
2026-09-05 15:47:02 +02:00
parent 03d545532a
commit 6dd9531a66
8 changed files with 929 additions and 157 deletions
+152 -10
View File
@@ -1,10 +1,152 @@
const $=id=>document.getElementById(id);let prospects=[];
async function api(path,options={}){const r=await fetch(path,{...options,headers:{'Content-Type':'application/json',...(options.headers||{})}});const b=await r.json().catch(()=>({}));if(!r.ok)throw Error(b.error||'Request failed');return b}
function renderStats(){ $('total').textContent=prospects.length; $('pending').textContent=prospects.filter(x=>x.review_status==='pending').length; $('approved').textContent=prospects.filter(x=>x.review_status==='approved').length; }
function renderExplorer(){const q=$('search').value.trim().toLowerCase(),s=$('status').value;const rows=prospects.filter(x=>(!q||(x.name+' '+x.domain).toLowerCase().includes(q))&&(!s||x.review_status===s));$('explorer').innerHTML=rows.length?rows.map(x=>`<article class="prospect"><div><h3>${esc(x.name)}</h3><a href="${esc(x.website)}" target="_blank" rel="noopener">${esc(x.domain)}</a><div class="meta">Found ${esc(x.discovered_at)} · Query: ${esc(x.query)}</div></div><div class="review"><select data-review="${x.id}"><option value="pending" ${x.review_status==='pending'?'selected':''}>Pending</option><option value="approved" ${x.review_status==='approved'?'selected':''}>Approved</option><option value="rejected" ${x.review_status==='rejected'?'selected':''}>Rejected</option><option value="contacted" ${x.review_status==='contacted'?'selected':''}>Contacted</option></select></div></article>`).join(''):'<p class="muted">No prospects match this filter.</p>';renderStats();}
function esc(v){return String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))}
async function loadProspects(){prospects=(await api('/api/prospects')).items||[];renderExplorer()}
async function loadStats(){const s=await api('/api/stats');$('total').textContent=s.total;$('pending').textContent=s.pending;$('approved').textContent=s.approved}
async function loadRuns(){const rows=(await api('/api/runs')).items||[];$('runs').innerHTML=rows.length?rows.map(x=>`<div class="run"><span>#${x.id} · ${esc(x.query)}</span><span class="${x.status==='succeeded'?'ok':'bad'}">${x.status} · ${x.added} added${x.error?' · '+esc(x.error):''}</span></div>`).join(''):'<p class="muted">No discovery runs yet.</p>';if(rows[0])$('lastRun').textContent='#'+rows[0].id}
$('discoverForm').addEventListener('submit',async e=>{e.preventDefault();const btn=$('discoverBtn'),msg=$('discoverMessage');btn.disabled=true;msg.textContent='Opening headless browser…';try{const result=await api('/api/discover',{method:'POST',body:JSON.stringify(Object.fromEntries(new FormData(e.target)))});msg.textContent=`Found ${result.found}; added ${result.added} new prospect${result.added===1?'':'s'}.`;await Promise.all([loadProspects(),loadRuns(),loadStats()])}catch(err){msg.textContent='Discovery failed: '+err.message;msg.className='bad'}finally{btn.disabled=false}});
$('explorer').addEventListener('change',async e=>{const id=e.target.dataset.review;if(!id)return;try{await api('/api/prospects/'+id+'/review',{method:'PATCH',body:JSON.stringify({status:e.target.value})});await Promise.all([loadProspects(),loadStats()])}catch(err){alert(err.message)}});$('search').addEventListener('input',renderExplorer);$('status').addEventListener('change',renderExplorer);$('refresh').addEventListener('click',loadProspects);$('runsRefresh').addEventListener('click',loadRuns);Promise.all([loadProspects(),loadRuns(),loadStats()]).catch(err=>{$('explorer').innerHTML='<p class="bad">Unable to load the local catalogue: '+esc(err.message)+'</p>'});
const $ = (id) => document.getElementById(id);
let prospects = [];
let runs = [];
let activePoll = 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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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 ? `<a class="inline-link" href="${esc(item.website)}" target="_blank" rel="noopener">${esc(item.domain || item.website)} <span>↗</span></a>` : `<span class="missing-site">No website found</span>`;
const maps = item.maps_url ? `<a class="action-link" href="${esc(item.maps_url)}" target="_blank" rel="noopener">Open Maps ↗</a>` : "";
const contact = [item.phone && `<span>☎ ${esc(item.phone)}</span>`, item.emails && `<span>✉ ${esc(item.emails)}</span>`].filter(Boolean).join("");
return `<article class="prospect-card">
<div class="prospect-main"><div class="prospect-title"><span class="place-pin">⌖</span><div><h3>${esc(item.name)}</h3><div class="subline">${esc(item.category || "Business")} ${item.address ? ` · ${esc(item.address)}` : ""}</div></div></div>
<div class="chips"><span class="score-chip ${scoreClass(score)}"><i></i>${score}/100 opportunity</span>${item.website_status === "missing" ? '<span class="chip warning">Website missing</span>' : ""}${item.rating != null ? `<span class="chip">★ ${esc(Number(item.rating).toFixed(1))} <em>(${esc(item.review_count || 0)})</em></span>` : ""}</div>
<div class="prospect-details">${website}${contact ? `<span class="contact-line">${contact}</span>` : ""}</div>
<div class="prospect-meta"><span>Found ${esc(formatDate(item.discovered_at))}</span><span>·</span><span>${esc(item.source || "google_maps")}</span>${maps ? `<span class="meta-spacer"></span>${maps}` : ""}</div>
</div>
<div class="review-control"><label for="review-${item.id}">Review</label><select id="review-${item.id}" data-review="${item.id}"><option value="pending" ${item.review_status === "pending" ? "selected" : ""}>Needs review</option><option value="approved" ${item.review_status === "approved" ? "selected" : ""}>Approved</option><option value="rejected" ${item.review_status === "rejected" ? "selected" : ""}>Rejected</option><option value="contacted" ${item.review_status === "contacted" ? "selected" : ""}>Contacted</option></select></div>
</article>`;
}).join("") : `<div class="empty-state"><div class="empty-icon">⌕</div><strong>${query || status ? "Nothing matches these filters" : "Your catalogue is empty"}</strong><p>${query || status ? "Try a different search or clear the status filter." : "Run your first discovery above to bring places into the explorer."}</p></div>`;
}
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 = `<div class="run-orb">◷</div><div><strong>${run && run.status === "failed" ? "Last run failed" : "No active run"}</strong><p>${run && run.error ? esc(run.error) : "Start a search to see progress here."}</p></div>`;
return;
}
const progress = Math.max(5, Math.min(95, Number(run.progress || 5)));
target.className = "active-run running-run";
target.innerHTML = `<div class="run-orb spinning">◌</div><div class="run-copy"><div class="run-copy-head"><strong>#${run.id} · ${esc(run.query)}</strong><span>${progress}%</span></div><div class="progress-track"><i style="width:${progress}%"></i></div><p>${esc(runStatusLabel(run))} · ${esc(run.found || 0)} places seen</p></div>`;
}
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) => `<div class="history-row"><div><strong>#${run.id}</strong><span>${esc(run.query)}</span></div><div class="history-result ${run.status === "succeeded" ? "success" : run.status === "failed" ? "failure" : "pending"}"><b>${run.status === "succeeded" ? `+${run.added}` : run.status === "failed" ? "!" : "…"}</b><small>${esc(runStatusLabel(run))}</small></div></div>`).join("") : '<p class="muted">No runs yet.</p>';
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 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" : "Add your API key to continue";
$("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_api_key) $("setupText").textContent = "Create an API key in the Maps worker admin screen, add it as GMAPS_API_KEY, then restart the stack.";
}
async function refreshAll() {
$("updated").textContent = "Refreshing…";
try { await Promise.all([loadConfig(), loadProspects(), loadRuns(), loadStats()]); $("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: $("include-email").checked, 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); }
});
$("search").addEventListener("input", renderExplorer);
$("status").addEventListener("change", renderExplorer);
$("refresh").addEventListener("click", loadProspects);
$("runsRefresh").addEventListener("click", loadRuns);
$("refreshAll").addEventListener("click", refreshAll);
$("showRuns").addEventListener("click", (event) => { event.preventDefault(); $("runs").scrollIntoView({ behavior: "smooth", block: "center" }); });
refreshAll().catch((error) => { $("explorerList").innerHTML = `<div class="empty-state"><strong>Unable to load workspace</strong><p>${esc(error.message)}</p></div>`; });