2026-09-05 15:47:02 +02:00
const $ = ( id ) => document . getElementById ( id );
let prospects = [];
let runs = [];
let activePoll = null ;
2026-09-05 17:06:17 +02:00
let selectedProspect = null ;
2026-09-05 15:47:02 +02:00
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 );
2026-09-05 17:06:17 +02:00
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>` ;
2026-09-05 15:47:02 +02:00
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>
2026-09-05 17:06:17 +02:00
<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>
2026-09-05 15:47:02 +02:00
<div class="prospect-details"> ${ website }${ contact ? `<span class="contact-line"> ${ contact } </span>` : "" } </div>
2026-09-05 17:06:17 +02:00
<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>
2026-09-05 15:47:02 +02:00
</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" )); }
2026-09-05 17:06:17 +02:00
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." ; }
2026-09-05 15:47:02 +02:00
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" ;
2026-09-05 16:15:03 +02:00
$ ( "sourceSub" ). textContent = config . ready ? "Google Maps scraper connected" : ( config . gmaps_auth_required ? "SaaS API key required" : "Start the bundled Maps worker" );
2026-09-05 15:47:02 +02:00
$ ( "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." ;
2026-09-05 16:15:03 +02:00
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." ;
2026-09-05 15:47:02 +02:00
}
async function refreshAll () {
$ ( "updated" ). textContent = "Refreshing…" ;
2026-09-05 17:06:17 +02:00
try { await Promise . all ([ loadConfig (), loadProspects (), loadRuns (), loadStats (), loadSchedules ()]); $ ( "updated" ). textContent = `Updated ${ new Date (). toLocaleTimeString ([], { hour : "2-digit" , minute : "2-digit" } )}` ; }
2026-09-05 15:47:02 +02:00
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…" ;
2026-09-05 17:06:17 +02:00
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 };
2026-09-05 15:47:02 +02:00
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 ); }
});
2026-09-05 17:06:17 +02:00
$ ( "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." ; });
2026-09-05 15:47:02 +02:00
$ ( "search" ). addEventListener ( "input" , renderExplorer );
$ ( "status" ). addEventListener ( "change" , renderExplorer );
$ ( "refresh" ). addEventListener ( "click" , loadProspects );
2026-09-05 17:06:17 +02:00
document . getElementById ( "exportCsv" ) ? . addEventListener ( "click" , () => { window . location . href = "/api/export.csv" ; });
2026-09-05 15:47:02 +02:00
$ ( "runsRefresh" ). addEventListener ( "click" , loadRuns );
$ ( "refreshAll" ). addEventListener ( "click" , refreshAll );
$ ( "showRuns" ). addEventListener ( "click" , ( event ) => { event . preventDefault (); $ ( "runs" ). scrollIntoView ({ behavior : "smooth" , block : "center" }); });
2026-09-05 17:06:17 +02:00
$ ( "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" ;} });
2026-09-05 15:47:02 +02:00
refreshAll (). catch (( error ) => { $ ( "explorerList" ). innerHTML = `<div class="empty-state"><strong>Unable to load workspace</strong><p> ${ esc ( error . message ) } </p></div>` ; });