deploy Maps v6 update

This commit is contained in:
Marco0300
2026-09-05 17:06:17 +02:00
parent 3d2b87b2df
commit 5e14ddf92c
10 changed files with 673 additions and 41 deletions
+26 -5
View File
@@ -2,6 +2,7 @@ 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, {
@@ -51,14 +52,14 @@ function renderExplorer() {
$("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 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 detected website</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="chips"><span class="score-chip ${scoreClass(score)}"><i></i>${score}/100 opportunity</span><span class="chip ${item.website_status === "healthy" ? "" : "warning"}">${esc((item.website_status || "unknown").replaceAll("_", " "))}</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 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}` : ""}<button class="action-link message-action" data-message="${item.id}" type="button">Draft AI email</button><button class="action-link" data-domains="${item.id}" type="button">Domain ideas</button><button class="action-link" data-rescan="${item.id}" type="button">Rescan</button></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>`;
@@ -95,6 +96,7 @@ function renderRuns() {
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=>`<div class="history-row"><span>${esc(s.keywords)} · ${esc(s.category)} · ${esc(s.location)}</span><small>every ${s.interval_hours}h · next ${esc(formatDate(s.next_run_at))}</small></div>`).join("") || "No schedules configured."; }
async function loadConfig() {
const config = await api("/api/config");
@@ -110,7 +112,7 @@ async function loadConfig() {
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" })}`; }
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"; }
}
@@ -131,7 +133,7 @@ $("discoverForm").addEventListener("submit", async (event) => {
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 };
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"; }
});
@@ -142,12 +144,31 @@ $("explorerList").addEventListener("change", async (event) => {
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 = `<div class="empty-state"><strong>Unable to load workspace</strong><p>${esc(error.message)}</p></div>`; });
+4 -2
View File
@@ -52,7 +52,7 @@
<div class="field"><label for="category">Business category</label><input id="category" name="category" placeholder="e.g. plumbers" required></div>
<div class="field"><label for="location">Location</label><input id="location" name="location" value="South Africa" required></div>
<div class="field"><label for="max-results">Places to import</label><select id="max-results" name="max_results"><option value="10">10 places</option><option value="20">20 places</option><option value="25" selected>25 places</option><option value="50">50 places</option></select></div>
<div class="options wide"><label class="check"><input type="checkbox" id="include-email" name="include_email"><span class="check-box"></span><span><strong>Look for public website emails</strong><small>Slower; only follows the business website found by Maps.</small></span></label><label class="check"><input type="checkbox" id="extra-reviews" name="extra_reviews"><span class="check-box"></span><span><strong>Collect extra reviews</strong><small>Useful for research, but increases run time.</small></span></label></div>
<div class="options wide"><label class="check locked"><input type="checkbox" id="include-email" name="include_email" checked disabled><span class="check-box"></span><span><strong>Collect public website emails</strong><small>Always on. The worker follows each public business website when available.</small></span></label><label class="check"><input type="checkbox" id="extra-reviews" name="extra_reviews"><span class="check-box"></span><span><strong>Collect extra reviews</strong><small>Useful for research, but increases run time.</small></span></label></div>
<div class="form-footer wide"><button class="primary-btn" id="discoverBtn" type="submit"><span>Run discovery</span><span class="btn-arrow">→</span></button><span id="discoverMessage" class="form-message" role="status">Results are deduplicated before saving.</span></div>
</form>
</article>
@@ -65,7 +65,7 @@
</section>
<section class="panel explorer-panel" id="explorer">
<div class="panel-heading explorer-heading"><div><span class="eyebrow">03 · CATALOGUE</span><h2>Prospect explorer</h2><p>Sort by opportunity score, inspect the evidence, and mark your next action.</p></div><div class="explorer-actions"><div class="search-box"><span>⌕</span><input id="search" placeholder="Search catalogue" aria-label="Search catalogue"></div><select id="status" aria-label="Filter by review status"><option value="">All statuses</option><option value="pending">Needs review</option><option value="approved">Approved</option><option value="rejected">Rejected</option><option value="contacted">Contacted</option></select><button class="quiet-btn" id="refresh" type="button">Refresh</button></div></div>
<div class="panel-heading explorer-heading"><div><span class="eyebrow">03 · CATALOGUE</span><h2>Prospect explorer</h2><p>Sort by opportunity score, inspect the evidence, and mark your next action.</p></div><div class="explorer-actions"><div class="search-box"><span>⌕</span><input id="search" placeholder="Search catalogue" aria-label="Search catalogue"></div><select id="status" aria-label="Filter by review status"><option value="">All statuses</option><option value="pending">Needs review</option><option value="approved">Approved</option><option value="rejected">Rejected</option><option value="contacted">Contacted</option></select><button class="quiet-btn" id="exportCsv" type="button">Export CSV</button><button class="quiet-btn" id="refresh" type="button">Refresh</button></div></div>
<div class="explorer-toolbar"><span id="resultCount">0 places</span><span class="legend"><i class="score-high"></i> Higher opportunity first</span></div>
<div id="explorerList" class="prospect-list"><div class="loading-state"><span class="spinner"></span>Loading catalogue…</div></div>
</section>
@@ -73,10 +73,12 @@
<section class="setup-section" id="setup">
<div><span class="eyebrow">SETUP</span><h2>One-time worker connection</h2><p>The bundled standard Maps worker runs locally and does not need a Google or scraper API key.</p></div><ol><li>Run <code>docker compose up -d --build</code>.</li><li>Keep <code>GMAPS_API_MODE=standard</code> in <code>.env</code>.</li><li>Start a discovery run. Open port <code>8081</code> only if you want to inspect the worker's native UI.</li></ol>
</section>
<section class="panel schedule-panel" id="schedules"><div class="panel-heading"><div><span class="eyebrow">AUTOMATION</span><h2>Scheduled discovery</h2><p>Repeat a search on a bounded interval and review the new results.</p></div></div><form id="scheduleForm" class="discovery-form"><div class="field"><label for="schedule-category">Category</label><input id="schedule-category" placeholder="e.g. plumbers"></div><div class="field"><label for="schedule-keywords">Keywords</label><input id="schedule-keywords" placeholder="e.g. plumbing services"></div><div class="field"><label for="schedule-location">Location</label><input id="schedule-location" value="South Africa"></div><div class="field"><label for="schedule-interval">Repeat every</label><select id="schedule-interval"><option value="24">24 hours</option><option value="48">48 hours</option><option value="168">7 days</option></select></div><div class="form-footer wide"><button class="quiet-btn" type="submit">Save schedule</button><span id="scheduleMessage" class="form-message"></span></div></form><div id="scheduleList" class="muted"></div></section>
<footer class="footer"><span>ProspectOS · personal research workspace</span><span>Public data only · no automated outreach</span></footer>
</main>
</div>
<dialog id="messageDialog" class="message-dialog"><form method="dialog" id="messageForm"><div class="dialog-head"><div><span class="eyebrow">AI ASSISTED DRAFT</span><h2 id="messageTitle">Tailor a message</h2><p>Review the evidence, add your offer, then edit before sending.</p></div><button class="icon-btn" value="cancel" aria-label="Close">×</button></div><label for="offerInput">What should we offer?</label><textarea id="offerInput" rows="4" placeholder="e.g. Move their hosting to Gold Plus for R119/month and rebuild the website free."></textarea><div id="messageStatus" class="form-message"></div><button class="primary-btn" id="generateMessage" type="button">Generate subject and email →</button><label for="subjectOutput">Subject</label><input id="subjectOutput" readonly><label for="bodyOutput">Email draft</label><textarea id="bodyOutput" rows="12"></textarea><div class="dialog-actions"><button class="quiet-btn" id="copyMessage" type="button">Copy draft</button><button class="quiet-btn" value="cancel">Close</button></div></form></dialog>
<script src="/app.js"></script>
</body>
</html>
+1
View File
@@ -14,6 +14,7 @@
background: var(--soft);
line-height: 1.45;
}
.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}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }