/* ProspectOS frontend MVP. Configure before loading with window.API_BASE = 'http://127.0.0.1:8000'; */ (() => { 'use strict'; const API_BASE = (window.API_BASE || localStorage.getItem('prospect_api_base') || '').replace(/\/$/, ''); const endpoint = (path) => `${API_BASE}${path}`; let prospects = []; let selectedId = null; let currentUser = null; const $ = (id) => document.getElementById(id); const esc = (value) => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const scoreClass = (score) => score >= 80 ? 'high' : score >= 60 ? 'medium' : 'low'; const statusOf = (p) => p.suppressed || p.status === 'suppressed' ? 'suppressed' : (p.reviewed || p.status === 'reviewed' || p.reviewed_at ? 'reviewed' : 'review'); const freshness = (p) => { const raw = p.updated_at || p.last_checked_at || p.created_at; if (!raw) return {label:'Unknown', cls:'stale'}; const days = Math.max(0, Math.floor((Date.now() - new Date(raw).getTime()) / 86400000)); return {label: days === 0 ? 'Today' : `${days}d ago`, cls: days <= 7 ? 'good' : 'stale'}; }; const scoreFor = (p) => Number.isFinite(Number(p.score)) ? Number(p.score) : (p.score_factors || p.factors || []).reduce((n, f) => n + ({named_business:20,business_site:30,email:25,phone:15,description:10}[f] || 0), 0); const labelFactor = (f) => ({named_business:'Named business',business_site:'Business site',email:'Email found',phone:'Phone found',description:'Description'}[f] || String(f).replaceAll('_',' ')); async function request(path, options = {}) { const response = await fetch(endpoint(path), {...options, credentials:'include'}); if (response.status === 401) { showLogin('Your session has expired. Please sign in again.'); throw new Error('unauthorized'); } return response; } function showLogin(message = '') { currentUser = null; $('dashboardShell').hidden = true; $('loginScreen').hidden = false; $('loginMessage').textContent = message; $('loginMessage').className = `form-message${message ? ' error' : ''}`; } function showDashboard(user) { currentUser = user || {}; const name = currentUser.name || currentUser.full_name || currentUser.email || 'Workspace member'; const role = currentUser.role || currentUser.roles?.[0] || 'Member'; $('userIdentity').textContent = `${name} · ${role}`; $('userAvatar').textContent = name.split(/\s+/).map(x => x[0]).join('').slice(0,2).toUpperCase(); $('loginScreen').hidden = true; $('dashboardShell').hidden = false; } function renderMetrics(summary) { const total = Number(summary?.businesses ?? summary?.total ?? prospects.length); const high = prospects.filter(p => scoreFor(p) >= 80).length; const review = prospects.filter(p => statusOf(p) === 'review').length; const fresh = prospects.length ? Math.round(prospects.filter(p => freshness(p).cls === 'good').length / prospects.length * 100) : 0; $('metricTotal').textContent = total; $('heroCount').textContent = `${total} prospects`; $('metricReview').textContent = summary?.needs_review ?? review; $('metricHigh').textContent = summary?.high_fit ?? high; $('metricFresh').textContent = `${summary?.freshness_under_7d ?? fresh}%`; } function filtered() { const q = $('searchInput').value.trim().toLowerCase(), sf = $('scoreFilter').value, st = $('statusFilter').value; return prospects.filter(p => { const s=scoreFor(p), text=`${p.name} ${p.website_domain||p.website||''} ${p.location||''}`.toLowerCase(); return (!q || text.includes(q)) && (sf==='all' || (sf==='high'&&s>=80) || (sf==='medium'&&s>=60&&s<80) || (sf==='low'&&s<60)) && (st==='all' || statusOf(p)===st); }); } function renderRows() { const rows = filtered(); $('resultCount').textContent = `Showing ${rows.length} prospect${rows.length===1?'':'s'}`; $('prospectRows').innerHTML = rows.length ? rows.map(p => { const s=scoreFor(p), f=freshness(p), st=statusOf(p), factors=p.score_factors||p.factors||[]; return `${esc(p.name)}${esc(p.website_domain || 'no detected website')}${s} / 100${factors.length} signal${factors.length===1?'':'s'}${esc(factors.slice(0,2).map(labelFactor).join(' · ') || 'Limited evidence')}${f.label}${st==='review'?'Needs review':st[0].toUpperCase()+st.slice(1)}›`; }).join('') : 'No prospects match these filters.'; document.querySelectorAll('#prospectRows tr[data-id]').forEach(row => row.addEventListener('click', () => { selectedId = Number(row.dataset.id); renderRows(); renderDetail(); })); } function renderDetail() { const p = prospects.find(x => Number(x.id) === Number(selectedId)); if (!p) return; const s=scoreFor(p), st=statusOf(p), f=freshness(p), factors=p.score_factors||p.factors||[], blocked=st==='review' || st==='suppressed'; $('detailPanel').innerHTML = `

PROSPECT DETAIL

${esc(p.name)}

${esc(p.website_domain || 'no detected website')}

${st==='review'?'Needs review':st[0].toUpperCase()+st.slice(1)}
Fit score${s}/ 100
${esc(p.confidence || (s>=80?'High':s>=60?'Medium':'Low'))} confidence

Evidence & signals

${factors.length ? factors.map(x=>`

✓ ${esc(labelFactor(x))}${esc(p.confidence || 'Medium')}

`).join('') : '

Limited evidence available for this record.

'}

Data quality

Last checked${f.label}

Website${p.website_domain?'Detected':'no detected website'}

${blocked?`

${st==='suppressed'?'Suppressed records cannot be contacted.':'Review this prospect before outreach is available.'}

`:''}`; } async function loadData() { $('apiStatus').textContent='● Connecting…'; $('apiStatus').classList.remove('live'); try { const [listRes, summaryRes] = await Promise.all([request('/api/v1/businesses'), request('/api/v1/dashboard/summary')]); if (!listRes.ok || !summaryRes.ok) throw new Error('API request failed'); const list=await listRes.json(), summary=await summaryRes.json(); prospects=Array.isArray(list)?list:(list.businesses||list.items||[]); renderMetrics(summary); $('apiStatus').textContent='● API connected'; $('apiStatus').classList.add('live'); } catch (error) { if (error.message === 'unauthorized') { return; } prospects=[]; renderMetrics(null); $('apiStatus').textContent='● API unavailable'; } renderRows(); } async function addProspect(event) { event.preventDefault(); const data=Object.fromEntries(new FormData(event.currentTarget).entries()); const msg=$('formMessage'); try { const res=await request('/api/v1/businesses',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)}); const body=await res.json(); if(!res.ok) throw new Error(body.error||'Could not add prospect'); prospects.unshift(body); msg.textContent='Added to review queue.'; event.currentTarget.reset(); renderMetrics(null); renderRows(); } catch(e) { if(e.message !== 'unauthorized') { msg.textContent=e.message; msg.className='form-message error'; } } } function parseCsv(text) { const lines=text.trim().split(/\r?\n/).filter(Boolean), cells=line=>line.match(/("[^"]*(?:""[^"]*)*"|[^,]+)(?=,|$)/g)?.map(x=>x.replace(/^"|"$/g,'').replaceAll('""','"'))||[]; if(!lines.length)return []; const headers=cells(lines[0]); return lines.slice(1,11).map(l=>Object.fromEntries(cells(l).map((v,i)=>[headers[i]||`column_${i+1}`,v]))); } function renderCsv(rows) { if(!rows.length){$('csvPreview').innerHTML='

No data rows found

';return;} const h=Object.keys(rows[0]); $('csvPreview').className='csv-table'; $('csvPreview').innerHTML=`${h.map(x=>``).join('')}${rows.map(r=>`${h.map(x=>``).join('')}`).join('')}
${esc(x)}
${esc(r[x])}
Showing up to 10 rows · Preview only; nothing added yet.`; } async function login(event) { event.preventDefault(); const form=event.currentTarget, message=$('loginMessage'); const data=Object.fromEntries(new FormData(form).entries()); message.textContent='Signing in…'; message.className='form-message'; try { const res=await fetch(endpoint('/api/v1/auth/login'),{method:'POST',headers:{'Content-Type':'application/json'},credentials:'include',body:JSON.stringify(data)}); const body=await res.json().catch(()=>({})); if(!res.ok) throw new Error(body.error || 'Invalid email or password.'); await bootstrap(); } catch(e) { if(e.message !== 'unauthorized') { message.textContent=e.message; message.className='form-message error'; } } } async function logout() { try { await fetch(endpoint('/api/v1/auth/logout'),{method:'POST',credentials:'include'}); } finally { showLogin('You have been signed out.'); $('loginForm').reset(); } } async function bootstrap() { try { const res=await fetch(endpoint('/api/v1/auth/me'),{credentials:'include'}); if(res.status===401) { showLogin(); return; } if(!res.ok) throw new Error('Could not verify session.'); const user=await res.json(); showDashboard(user.user || user); await loadData(); } catch(e) { if(e.message !== 'unauthorized') showLogin('Unable to connect to the workspace. Try again.'); } } $('loginForm').addEventListener('submit',login); $('logoutBtn').addEventListener('click',logout); $('searchInput').addEventListener('input',renderRows); $('scoreFilter').addEventListener('change',renderRows); $('statusFilter').addEventListener('change',renderRows); $('refreshBtn').addEventListener('click',loadData); $('addForm').addEventListener('submit',addProspect); $('csvInput').addEventListener('change',e=>{const file=e.target.files[0]; if(file){const reader=new FileReader();reader.onload=()=>renderCsv(parseCsv(reader.result));reader.readAsText(file);}}); $('menuBtn').addEventListener('click',()=>document.querySelector('.sidebar').classList.toggle('open')); document.querySelectorAll('[data-scroll]').forEach(b=>b.addEventListener('click',()=>document.querySelector(b.dataset.scroll)?.scrollIntoView())); bootstrap(); })();