build prospect intelligence platform MVP

This commit is contained in:
Marco0300
2026-09-02 17:38:50 +02:00
commit 44be4efc82
29 changed files with 973 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
FROM python:3.13-alpine
RUN addgroup -S app && adduser -S -G app app
WORKDIR /srv
COPY index.html /srv/index.html
COPY styles.css /srv/styles.css
COPY app.js /srv/app.js
COPY healthz /srv/healthz
RUN chown -R app:app /srv
USER app
EXPOSE 8080
CMD ["python", "-m", "http.server", "8080", "--bind", "0.0.0.0", "--directory", "/srv"]
+32
View File
@@ -0,0 +1,32 @@
# ProspectOS web MVP
Self-contained static frontend for the Prospect Platform API. There is no bundler or runtime dependency: serve this directory with any static HTTP server.
## Configure and run
The API base is configurable before `app.js` runs:
```html
<script>window.API_BASE = 'http://127.0.0.1:8000';</script>
<script src="app.js"></script>
```
If not set, the UI uses `localStorage.prospect_api_base` when present, then renders clearly-labelled demo data. The API contract used by this page is the current MVP contract:
- `GET /api/v1/businesses` (optional filtering is performed client-side)
- `GET /api/v1/dashboard/summary`
- `POST /api/v1/businesses`
The CSV control is intentionally preview-only. The backend's `POST /api/v1/imports/preview` can be wired to a confirmation flow later; this UI does not claim that import rows have been persisted.
## Browser verification
1. Start the API from `apps/api` with `python3 app/main.py`.
2. Serve this directory: `python3 -m http.server 8080 --directory apps/web`.
3. Open `http://127.0.0.1:8080`, with `window.API_BASE` set to `http://127.0.0.1:8000` using a tiny pre-load edit or browser devtools.
4. Confirm the header changes to **API connected**, metrics populate, search and score/status filters update the table, selecting a row opens evidence/confidence/freshness, and adding a prospect POSTs to `/api/v1/businesses`.
5. Confirm rows with `status: review` show **Outreach unavailable — Review this prospect before outreach is available**, and suppressed rows show **Outreach unavailable — Suppressed records cannot be contacted**. There is no outreach/send endpoint or button.
6. Select a CSV and confirm a local, preview-only table appears without a network request.
7. Resize below 700px to verify the collapsible nav, stacked panels, and horizontally scrollable explorer table.
A zero-dependency static smoke page (`smoke-test.html`) checks the key DOM contract in an iframe and reports pass/fail.
+60
View File
@@ -0,0 +1,60 @@
/* 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}`;
const demoProspects = [
{id:1,name:'Northstar Creative',website:'https://northstarcreative.co.za',website_domain:'northstarcreative.co.za',location:'Cape Town, ZA',score:92,score_factors:['named_business','business_site','email','phone'],email:'hello@northstarcreative.co.za',phone:'+27215550101',updated_at:'2026-08-31T09:00:00Z',status:'reviewed',confidence:'High'},
{id:2,name:'Berg & Bloom',website:'https://bergandbloom.co.za',website_domain:'bergandbloom.co.za',location:'Johannesburg, ZA',score:78,score_factors:['named_business','business_site','description'],description:'Independent retail studio',updated_at:'2026-08-29T09:00:00Z',status:'review',confidence:'Medium'},
{id:3,name:'Mosaic Studio',website:'',website_domain:'',location:'Durban, ZA',score:45,score_factors:['named_business'],updated_at:'2026-08-12T09:00:00Z',status:'review',confidence:'Low'},
{id:4,name:'Cedar Works',website:'https://cedarworks.co.za',website_domain:'cedarworks.co.za',location:'Pretoria, ZA',score:83,score_factors:['named_business','business_site','phone'],phone:'+27125550102',updated_at:'2026-08-30T09:00:00Z',status:'reviewed',confidence:'High'},
{id:5,name:'Studio Lumen',website:'https://instagram.com/studiolumen',website_domain:'instagram.com',location:'Gqeberha, ZA',score:55,score_factors:['named_business'],updated_at:'2026-08-20T09:00:00Z',status:'suppressed',suppressed:true,suppression_reason:'Suppressed by domain match',confidence:'Low'},
{id:6,name:'Field Notes Co.',website:'https://fieldnotes.example',website_domain:'fieldnotes.example',location:'Cape Town, ZA',score:67,score_factors:['named_business','business_site'],updated_at:'2026-08-25T09:00:00Z',status:'review',confidence:'Medium'}
];
let prospects = [];
let selectedId = null;
const $ = (id) => document.getElementById(id);
const esc = (value) => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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('_',' '));
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 `<tr data-id="${esc(p.id)}" class="${p.id===selectedId?'selected':''}"><td>${esc(p.name)}<span class="company-sub">${esc(p.website_domain || 'no detected website')}</span></td><td><span class="score ${scoreClass(s)}">${s} <small>/ 100</small></span></td><td class="evidence"><strong>${factors.length} signal${factors.length===1?'':'s'}</strong>${esc(factors.slice(0,2).map(labelFactor).join(' · ') || 'Limited evidence')}</td><td><span class="fresh ${f.cls}">${f.label}</span></td><td><span class="status ${st}">${st==='review'?'Needs review':st[0].toUpperCase()+st.slice(1)}</span></td><td class="row-arrow"></td></tr>`; }).join('') : '<tr><td colspan="6" class="muted">No prospects match these filters.</td></tr>';
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 = `<div class="detail-head"><div><p class="eyebrow">PROSPECT DETAIL</p><h3>${esc(p.name)}</h3><p class="detail-domain">${esc(p.website_domain || 'no detected website')}</p></div><span class="status ${st}">${st==='review'?'Needs review':st[0].toUpperCase()+st.slice(1)}</span></div><div class="detail-score"><div><small>Fit score</small><b>${s}<small>/ 100</small></b></div><span class="score ${scoreClass(s)}">${esc(p.confidence || (s>=80?'High':s>=60?'Medium':'Low'))} confidence</span></div><div class="detail-block"><h4>Evidence & signals</h4>${factors.length ? factors.map(x=>`<p class="evidence-line"><span>✓ ${esc(labelFactor(x))}</span><span class="confidence">${esc(p.confidence || 'Medium')}</span></p>`).join('') : '<p>Limited evidence available for this record.</p>'}</div><div class="detail-block"><h4>Data quality</h4><p class="evidence-line"><span>Last checked</span><span>${f.label}</span></p><p class="evidence-line"><span>Website</span><span>${p.website_domain?'Detected':'no detected website'}</span></p></div>${blocked?`<button class="button disabled-action" disabled aria-disabled="true">Outreach unavailable</button><p class="disabled-reason">${st==='suppressed'?'Suppressed records cannot be contacted.':'Review this prospect before outreach is available.'}</p>`:''}`;
}
async function loadData() {
$('apiStatus').textContent = API_BASE ? '● Connecting…' : '● Demo data';
try { const [listRes, summaryRes] = await Promise.all([fetch(endpoint('/api/v1/businesses')), fetch(endpoint('/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) { prospects=demoProspects; renderMetrics(null); $('apiStatus').textContent=API_BASE?'● API unavailable · demo data':'● Demo data'; }
renderRows();
}
async function addProspect(event) { event.preventDefault(); const data=Object.fromEntries(new FormData(event.currentTarget).entries()); const msg=$('formMessage'); if (!API_BASE) { prospects.unshift({...data,id:`local-${Date.now()}`,score:data.website?50:20,status:'review',confidence:'Low'}); msg.textContent='Added to local preview review queue.'; event.currentTarget.reset(); renderMetrics(null); renderRows(); return; } try { const res=await fetch(endpoint('/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) { 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='<span>⊞</span><p>No data rows found</p>';return;} const h=Object.keys(rows[0]); $('csvPreview').className='csv-table'; $('csvPreview').innerHTML=`<table><thead><tr>${h.map(x=>`<th>${esc(x)}</th>`).join('')}</tr></thead><tbody>${rows.map(r=>`<tr>${h.map(x=>`<td>${esc(r[x])}</td>`).join('')}</tr>`).join('')}</tbody></table><small class="muted">Showing up to 10 rows · Preview only; nothing added yet.</small>`; }
$('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()));
loadData();
})();
+1
View File
@@ -0,0 +1 @@
ok
+47
View File
@@ -0,0 +1,47 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ProspectOS · Pipeline intelligence</title>
<meta name="description" content="Prospect discovery and review dashboard">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="app-shell">
<aside class="sidebar">
<a class="brand" href="#top" aria-label="ProspectOS home"><span class="brand-mark"></span><span>Prospect<span class="brand-light">OS</span></span></a>
<nav aria-label="Primary navigation">
<a class="nav-item active" href="#dashboard"><span></span> Dashboard</a>
<a class="nav-item" href="#explorer"><span></span> Prospect explorer</a>
<a class="nav-item" href="#add"><span></span> Add prospects</a>
</nav>
<div class="sidebar-foot"><div class="live-dot"></div><div><strong>Workspace live</strong><small>Data sync is healthy</small></div></div>
</aside>
<main class="main" id="top">
<header class="topbar"><button class="mobile-menu" id="menuBtn" aria-label="Toggle navigation"></button><div class="crumb">Workspace <span>/</span> Growth pipeline</div><div class="top-actions"><span class="api-status" id="apiStatus">● Demo data</span><button class="icon-button" aria-label="Notifications"></button><div class="avatar">AR</div></div></header>
<div class="content">
<section class="hero" id="dashboard"><div><p class="eyebrow">EVIDENCE-LED PROSPECTING</p><h1>Good morning, Alex <span></span></h1><p class="hero-sub">Your pipeline has <strong id="heroCount">0 prospects</strong> ready for review.</p></div><button class="button primary" data-scroll="#add"> Add prospect</button></section>
<section class="metrics" aria-label="Dashboard metrics">
<article class="metric-card"><div class="metric-icon violet"></div><div><p>Total prospects</p><h2 id="metricTotal">0</h2><span class="trend neutral">● Current workspace</span></div></article>
<article class="metric-card"><div class="metric-icon amber"></div><div><p>Needs review</p><h2 id="metricReview">0</h2><span class="trend neutral">● Human verification</span></div></article>
<article class="metric-card"><div class="metric-icon green"></div><div><p>High-fit prospects</p><h2 id="metricHigh">0</h2><span class="trend neutral">● Score 80+</span></div></article>
<article class="metric-card"><div class="metric-icon blue"></div><div><p>Freshness under 7d</p><h2 id="metricFresh">0%</h2><span class="trend neutral">● Evidence coverage</span></div></article>
</section>
<section class="workspace-grid" id="explorer">
<div class="explorer-panel panel"><div class="panel-heading"><div><p class="eyebrow">PIPELINE</p><h2>Prospect explorer</h2></div><button class="button ghost" id="refreshBtn">↻ Refresh</button></div>
<div class="filters"><label class="search-wrap"><span></span><input id="searchInput" type="search" placeholder="Search companies, domains, locations…" autocomplete="off"></label><select id="scoreFilter" aria-label="Filter by score"><option value="all">All scores</option><option value="high">High fit · 80+</option><option value="medium">Medium · 6079</option><option value="low">Low · under 60</option></select><select id="statusFilter" aria-label="Filter by status"><option value="all">All statuses</option><option value="review">Needs review</option><option value="reviewed">Reviewed</option><option value="suppressed">Suppressed</option></select></div>
<div class="table-meta"><span id="resultCount">Showing 0 prospects</span><span class="legend"><span class="legend-dot high-dot"></span> High fit <span class="legend-dot review-dot"></span> Needs review</span></div>
<div class="table-scroll"><table><thead><tr><th>Company</th><th>Fit score</th><th>Evidence</th><th>Freshness</th><th>Status</th><th></th></tr></thead><tbody id="prospectRows"></tbody></table></div>
</div>
<aside class="detail-panel panel" id="detailPanel"><div class="empty-detail"><span class="empty-icon"></span><h3>Select a prospect</h3><p>Review evidence, confidence, and eligibility before taking action.</p></div></aside>
</section>
<section class="lower-grid" id="add"><article class="panel add-panel"><div class="panel-heading"><div><p class="eyebrow">INTAKE</p><h2>Add a prospect</h2></div><span class="small-label">Manual entry</span></div><form id="addForm"><div class="form-grid"><label>Company name<input required name="name" placeholder="Acme Inc."></label><label>Website <span class="optional">optional</span><input name="website" type="url" placeholder="https://acme.com"></label><label>Location<input name="location" placeholder="Cape Town, ZA"></label><label>Notes <span class="optional">optional</span><input name="description" placeholder="Why this is a fit…"></label></div><div class="form-footer"><p id="formMessage" class="form-message" role="status"></p><button class="button primary" type="submit">Add to review queue</button></div></form></article>
<article class="panel csv-panel"><div class="panel-heading"><div><p class="eyebrow">BULK INTAKE</p><h2>CSV preview</h2></div><label class="button ghost upload-label" for="csvInput">↑ Choose CSV</label><input id="csvInput" type="file" accept=".csv,text/csv" hidden></div><p class="muted">Preview rows before adding them to your review queue.</p><div id="csvPreview" class="csv-empty"><span></span><p>No file selected</p><small>CSV stays in your browser until you confirm.</small></div></article></section>
<footer><span>ProspectOS</span><span>Evidence-led prospecting · <a href="#explorer">Review queue</a></span></footer>
</div>
</main>
</div>
<script src="app.js"></script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
<!doctype html><meta charset="utf-8"><title>ProspectOS smoke test</title><style>body{font:16px system-ui;padding:2rem;background:#f7f8fb;color:#172033}li{margin:.5rem 0}.pass{color:#16845b}.fail{color:#b84d55}</style><h1>ProspectOS static smoke test</h1><p id="summary">Running…</p><ul id="checks"></ul><iframe id="app" src="index.html" hidden></iframe><script>const checks=[['Dashboard metrics',d=>!!d.querySelector('#metricTotal')],['Explorer table',d=>!!d.querySelector('#prospectRows')],['Add prospect form',d=>!!d.querySelector('#addForm')],['CSV preview control',d=>!!d.querySelector('#csvInput')],['No outreach/send controls',d=>!Array.from(d.querySelectorAll('button')).some(x=>/outreach|send/i.test(x.textContent)&&!x.disabled)]];const frame=document.querySelector('#app');frame.onload=()=>setTimeout(()=>{const d=frame.contentDocument;let passed=0;document.querySelector('#checks').innerHTML=checks.map(([name,test])=>{const ok=test(d);if(ok)passed++;return `<li class="${ok?'pass':'fail'}">${ok?'PASS':'FAIL'}${name}</li>`}).join('');document.querySelector('#summary').textContent=`${passed}/${checks.length} checks passed`;},500);</script>
File diff suppressed because one or more lines are too long