const $=id=>document.getElementById(id); const esc=v=>String(v??"").replace(/[&<>\"']/g,c=>({'&':'&','<':'<','>':'>','\"':'"',"'":'''}[c]));
let prospects=[],runs=[],settings={},logs=[],selected=null,pollTimer=null;
let activeLogTab='all';
const LOG_TABS=[['all','All'],['action','Actions'],['api','API calls'],['ai','AI calls'],['search','Searches'],['browser','Browser/tool steps'],['errors','Errors']];
function logAction(action,details={},level='info'){try{fetch('/api/logs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action,details,level,path:location.hash.slice(1)||'dashboard'})}).catch(()=>{})}catch{}}
function installLogsNav(){const nav=$('nav');if(!nav||nav.querySelector('[data-route="logs"]'))return;const link=document.createElement('a');link.href='#logs';link.dataset.route='logs';link.innerHTML='≋Logs';nav.append(link)}
installLogsNav();
let activeProspectCategory='all';
const HIGH_POTENTIAL_SCORE=70;
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 `
PROSPECTOS
${title}
${sub}
${actions}
${body}` }
function empty(text){return `
⌕${text}
`}
function scoreClass(n){return n>=75?'hot':n>=55?'warm':'cool'}
function prospectCategory(p){const raw=String(p.search_category||p.category||'').trim();return raw?raw.replace(/\s+/g,' ').replace(/^./,c=>c.toUpperCase()):'Other'}
function prospectTabs(){const categories=[...new Set(prospects.map(prospectCategory))].sort((a,b)=>a.localeCompare(b));if(activeProspectCategory!=='all'&&!categories.includes(activeProspectCategory))activeProspectCategory='all';const count=category=>category==='all'?prospects.filter(p=>Number(p.opportunity_score||0)>=HIGH_POTENTIAL_SCORE).length:prospects.filter(p=>prospectCategory(p)===category).length;return `
${categories.map(category=>``).join('')}
All shows scores ${HIGH_POTENTIAL_SCORE}+; category tabs keep every result grouped by the search category.
`}
function prospectRows(){const q=($('prospectSearch')?.value||'').toLowerCase();const st=$('prospectStatus')?.value||'';return prospects.filter(p=>{const hay=[p.name,p.domain,p.search_category,p.category,p.address,p.emails].join(' ').toLowerCase();const tabMatch=activeProspectCategory==='all'?Number(p.opportunity_score||0)>=HIGH_POTENTIAL_SCORE:prospectCategory(p)===activeProspectCategory;return tabMatch&&(!q||hay.includes(q))&&(!st||p.review_status===st)}).sort((a,b)=>Number(b.opportunity_score||0)-Number(a.opportunity_score||0)||String(a.name||'').localeCompare(String(b.name||'')))}
function prospectTable(){const rows=prospectRows();return `
${rows.length?'':empty('No prospects match this tab and filter')}`}
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.
`)}
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
SearchMaps worker finds public business listings.
EnrichWebsite, HTTPS, DNS hints and public email checks run safely.
ReviewYou decide what is relevant before any contact.
`)}
function renderProspects(){ $('view').innerHTML=layout('Prospects','Browse the strongest opportunities first, then switch to a search category when you want a focused list.',`
⌕
${prospectTabs()}
${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 logTabCount(tab,counts){return tab==='errors'?Number(counts?.errors||0):Number(counts?.[tab]||0)}
function logTabs(counts={}){return `
${LOG_TABS.map(([key,label])=>``).join('')}
`}
function renderLogTable(){const rows=Array.isArray(logs)?logs:[];return rows.length?`
`:empty('No log entries for this tab')}
async function refreshLogs(){const status=$('logsStatus');if(status)status.textContent='Loading logs…';try{const params=new URLSearchParams({limit:'250'});if(activeLogTab==='errors')params.set('level','error');else if(activeLogTab!=='all')params.set('kind',activeLogTab);const data=await api('/api/logs?'+params.toString());logs=data.items||[];const tabs=$('logsTabs');if(tabs)tabs.innerHTML=logTabs(data.counts||{});const table=$('logsTable');if(table)table.innerHTML=renderLogTable();if(status)status.textContent=`Showing ${logs.length} of ${data.total||logs.length} entries · credentials are redacted`; }catch(error){if(status)status.textContent=error.message;if($('logsTable'))$('logsTable').innerHTML=empty('Logs could not be loaded')}}
function renderLogs(){ $('view').innerHTML=layout('Logs','Inspect user actions, API calls, AI requests, searches, Browser Use steps, and errors in one place.',`
OPERATIONS JOURNAL
Detailed activity
Requests and actions are timestamped. API keys, authorization headers, and passwords are never stored.
${logTabs()}
Loading logs…
`);refreshLogs()}
function renderSettings(){const ai=settings.ai||{};const sch=settings.schedules||[];const sup=settings.suppressions||[];const provider=ai.provider||'custom';const modelList=Array.isArray(ai.models)?ai.models:[];const selectedModel=ai.model||'';const options=modelList.map(m=>``).join('')+(selectedModel&&!modelList.some(m=>m.id===selectedModel)?``:'');$('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.
`)}
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';installLogsNav();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,logs:renderLogs,settings:renderSettings}[r]||renderDashboard)();$('updated').textContent='Updated '+new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});}catch(e){$('view').innerHTML=empty(e.message)}}
function suggestedOffer(p){if(p.ai_offer)return p.ai_offer;if(['broken','unreachable','error','slow'].includes(String(p.website_status||'')))return'Move their hosting to our Gold Plus package at R119/month and rebuild the website at no extra design charge.';if(!p.website)return'Offer a professional .co.za domain, reliable hosting from R119/month, professional email, and a free one-page website.';return'Offer a friendly website and hosting review; only suggest a change that is supported by the evidence.'}
function jsonArray(value){try{const parsed=JSON.parse(value||'[]');return Array.isArray(parsed)?parsed:[]}catch{return[]}}
function showDraftNotes(d){let notes=$('draftNotes');if(!notes){notes=document.createElement('div');notes.id='draftNotes';notes.className='draft-notes';const status=$('messageStatus');if(status)status.parentNode.insertBefore(notes,status)}if(!notes)return;const unknowns=Array.isArray(d?.unknowns)?d.unknowns:[];const used=Array.isArray(d?.evidence_used)?d.evidence_used:[];notes.innerHTML=`AI review notes
${esc(d?.rationale||'The draft uses the prospect evidence and your instructions.')}
${unknowns.length?`
Confirm before sending: ${esc(unknowns.join(' · '))}
`:''}${used.length?`
Evidence used: ${esc(used.join(', '))}
`:''}${d?.follow_up?`
${esc(d.follow_up)}
`:''}`}
async function openMessage(id){selected=prospects.find(p=>String(p.id)===String(id));if(!selected)return;$('messageTitle').textContent='Draft for '+selected.name;$('offerInput').value=suggestedOffer(selected);$('subjectOutput').value=selected.ai_subject||'';$('bodyOutput').value=selected.ai_message||'';$('messageStatus').textContent='';showDraftNotes({rationale:selected.ai_reasoning,unknowns:jsonArray(selected.ai_unknowns),evidence_used:jsonArray(selected.ai_evidence_ids),follow_up:selected.ai_follow_up});$('messageDialog').showModal()}
function openDrawer(id){const p=prospects.find(x=>String(x.id)===String(id));if(!p)return;selected=p;$('drawerName').textContent=p.name;let reasons=[];try{reasons=JSON.parse(p.score_reasons||'[]')}catch{};$('drawerBody').innerHTML=`
${p.opportunity_score||0}
Opportunity scoreExplainable triage signal, not an AI verdict
`;$('detailDrawer').classList.add('open');$('detailDrawer').setAttribute('aria-hidden','false')}
document.addEventListener('click',async e=>{const open=e.target.closest('[data-open]');if(open)openDrawer(open.dataset.open);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();if(e.target.id==='closeDrawer'||!e.target.closest('#detailDrawer')&&$('detailDrawer').classList.contains('open')&&!e.target.closest('[data-open]')){$('detailDrawer').classList.remove('open');$('detailDrawer').setAttribute('aria-hidden','true')}});
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();if(e.target.id==='aiProvider'){const nous=e.target.value==='nous';$('aiBase').readOnly=nous;$('aiBase').value=nous?'https://inference-api.nousresearch.com/v1':$('aiBase').value;const note=$('aiProviderNote');if(note)note.textContent=nous?'Nous uses the official inference endpoint. OAuth is supported by Hermes CLI; this standalone app uses a server-side API key.':'Use any OpenAI-compatible /v1 endpoint.';if(nous){try{const data=await api('/api/ai/models?provider=nous');const model=$('aiModel');const list=$('nousModelList');const models=data.models||[];if(list)list.innerHTML=models.map(m=>``).join('');if(model&&models.length&&!models.some(m=>m.id===model.value))model.value=models[0].id;}catch(err){}}}});
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_PROVIDER:$('aiProvider').value,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');const button=e.target;button.disabled=true;button.textContent='Generating…';s.textContent='The AI is comparing the evidence with your offer…';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;selected={...selected,ai_subject:d.subject,ai_message:d.message,ai_offer:$('offerInput').value,ai_reasoning:d.rationale||'',ai_unknowns:JSON.stringify(d.unknowns||[]),ai_evidence_ids:JSON.stringify(d.evidence_used||[]),ai_follow_up:d.follow_up||''};const i=prospects.findIndex(p=>String(p.id)===String(selected.id));if(i>=0)prospects[i]=selected;showDraftNotes(d);s.textContent='Draft ready. Review, edit, and confirm the facts before sending.';s.className='notice ok'}catch(x){s.textContent=x.message;s.className='notice error'}finally{button.disabled=false;button.textContent='Regenerate draft'}}if(e.target.id==='copyMessage'){await navigator.clipboard.writeText(`Subject: ${$('subjectOutput').value}\n\n${$('bodyOutput').value}`);$('messageStatus').textContent='Copied to clipboard.'}if(e.target.id==='testAi'){const s=$('settingsMessage');s.textContent='Testing…';s.className='notice';try{const d=await api('/api/ai/test',{method:'POST',body:'{}'});s.textContent=`Connection works · ${d.provider} · ${d.model}`;s.className='notice ok'}catch(x){s.textContent=x.message;s.className='notice error'}}});
function aiRecommendationLabel(value){return value==='contact'?'Contact recommended':value==='review'?'Review first':'Do not contact yet'}
function addAssessmentPanel(id){const p=prospects.find(x=>String(x.id)===String(id));const body=$('drawerBody');if(!p||!body)return;const has=Number.isFinite(Number(p.ai_probability))&&p.ai_checked_at;const section=document.createElement('section');section.className='drawer-section';section.id='aiAssessmentPanel';section.innerHTML=has?`