overhaul prospect workspace and tracking views
This commit is contained in:
@@ -3,6 +3,7 @@ import os
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
import urllib.error
|
||||
import unittest
|
||||
from http.client import HTTPConnection
|
||||
@@ -191,6 +192,13 @@ class BrowserOnlyAppTests(unittest.TestCase):
|
||||
saved = server.db().execute("SELECT ai_probability,ai_recommendation,ai_reasoning FROM prospects WHERE id=1").fetchone()
|
||||
self.assertEqual(tuple(saved), (84.0, "contact", "Active business with a clear website opportunity."))
|
||||
|
||||
def test_prospect_workspace_has_pipeline_and_view_switcher(self):
|
||||
script = Path(__file__).resolve().parents[1].joinpath("web", "app.js").read_text()
|
||||
self.assertIn("prospectBoard", script)
|
||||
self.assertIn("prospectViewMode", script)
|
||||
self.assertIn("data-board-status", script)
|
||||
self.assertIn("Needs review", script)
|
||||
|
||||
def test_firecrawl_search_normalises_public_results(self):
|
||||
response = {"success": True, "data": {"web": [{"title": "Cape Town Plumbing", "url": "https://plumber.example.co.za/", "description": "Local plumbing services"}]}}
|
||||
with patch("api.server.http_json", return_value=response) as request, patch("api.server._safe_url", return_value=True):
|
||||
|
||||
+45
-2
@@ -10,7 +10,7 @@ let prospectScoreBand='all',prospectWebsiteFilter='all',prospectEmailFilter='all
|
||||
const HIGH_POTENTIAL_SCORE=70;
|
||||
async function api(path,opt={}){const headers={'Content-Type':'application/json',...(opt.headers||{})};if(csrfToken&&!headers['X-CSRF-Token'])headers['X-CSRF-Token']=csrfToken;const r=await fetch(path,{...opt,credentials:'same-origin',headers});const b=await r.json().catch(()=>({}));if(r.status===401&&!path.startsWith('/api/auth/')){currentUser=null;csrfToken='';renderAuth({setup_required:false});}if(!r.ok)throw Error(b.error||`Request failed (${r.status})`);return b}
|
||||
let loadWarnings=[];
|
||||
async function refreshWorkerStatus(){const dot=$('sourceDot'),label=$('sourceLabel'),sub=$('sourceSub');if(!dot||!label||!sub)return;try{const health=await api('/api/health');const ready=Boolean(health.configured);dot.classList.toggle('offline',!ready);label.textContent=health.source==='browser'?'Browser discovery':'Google Maps worker';sub.textContent=ready?'Ready':'Not configured';}catch(error){dot.classList.add('offline');label.textContent='Discovery status';sub.textContent='Unavailable';}}
|
||||
async function refreshWorkerStatus(){const dot=$('sourceDot'),label=$('sourceLabel'),sub=$('sourceSub');if(!dot||!label||!sub)return;try{const health=await api('/api/health');const ready=Boolean(health.configured);dot.classList.toggle('offline',!ready);label.textContent=health.source==='browser'?'Browser discovery':health.source==='firecrawl'?'Firecrawl web search':'Google Maps worker';sub.textContent=ready?'Ready':'Not configured';}catch(error){dot.classList.add('offline');label.textContent='Discovery status';sub.textContent='Unavailable';}}
|
||||
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 `<section class="page-head"><div><span class="eyebrow">PROSPECTOS</span><h1>${title}</h1><p>${sub}</p></div><div class="head-actions">${actions}</div></section>${body}` }
|
||||
function empty(text){return `<div class="empty"><span>⌕</span><b>${text}</b></div>`}
|
||||
@@ -22,7 +22,7 @@ function prospectTabs(){const categories=[...new Set(prospects.map(prospectCateg
|
||||
function prospectRows(){const q=($('prospectSearch')?.value||'').toLowerCase();return prospects.filter(p=>{const hay=[p.name,p.domain,p.search_category,p.category,p.address,p.emails].join(' ').toLowerCase();const score=effectiveScore(p);const websiteStatus=String(p.website_status||'unknown').toLowerCase();const tabMatch=activeProspectCategory==='all'?score>=HIGH_POTENTIAL_SCORE:prospectCategory(p)===activeProspectCategory;const scoreMatch=prospectScoreBand==='all'||(prospectScoreBand==='hot'&&score>=85)||(prospectScoreBand==='high'&&score>=70&&score<85)||(prospectScoreBand==='medium'&&score>=45&&score<70)||(prospectScoreBand==='low'&&score<45);const websiteMatch=prospectWebsiteFilter==='all'||(prospectWebsiteFilter==='missing'&&(!p.website||websiteStatus==='no_detected_website'))||(prospectWebsiteFilter==='broken'&&['broken','unreachable','error','timeout'].some(v=>websiteStatus.includes(v)))||(prospectWebsiteFilter==='healthy'&&websiteStatus==='healthy');const emailMatch=prospectEmailFilter==='all'||(prospectEmailFilter==='has_email'&&Boolean(String(p.emails||'').trim()))||(prospectEmailFilter==='no_email'&&!String(p.emails||'').trim());const reviewMatch=prospectReviewFilter==='all'||String(p.review_status||'pending')===prospectReviewFilter;return tabMatch&&scoreMatch&&websiteMatch&&emailMatch&&reviewMatch&&(!q||hay.includes(q))}).sort((a,b)=>effectiveScore(b)-effectiveScore(a)||String(a.name||'').localeCompare(String(b.name||'')))}
|
||||
function prospectTable(){const rows=prospectRows();return `<div class="table-wrap"><table><thead><tr><th>Prospect</th><th>Website</th><th>Email</th><th>Potential</th><th></th></tr></thead><tbody>${rows.map(p=>{const score=effectiveScore(p);return `<tr data-open="${p.id}"><td><b>${esc(p.name)}</b><small>${esc(prospectCategory(p))} · ${esc(p.address||'')}</small></td><td><span class="badge ${p.website_status==='healthy'?'good':'warn'}">${esc((p.website_status||'unknown').replaceAll('_',' '))}</span>${p.website?`<a href="${esc(p.website)}" target="_blank" rel="noopener">Open ↗</a>`:'<small>No detected website</small>'}</td><td>${p.emails?`<span class="email">✉ ${esc(p.emails)}</span>`:'<small>Not found</small>'}</td><td><span class="score ${scoreClass(score)}">${score}</span><small>${score>=85?'Hot':score>=70?'High':score>=45?'Medium':'Low'}${p.ai_checked_at?' · AI':''}</small></td><td><button class="icon-action" data-message="${p.id}" title="Draft AI email">✦</button></td></tr>`}).join('')}</tbody></table></div>${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=>effectiveScore(p)>=75).length,withEmail=prospects.filter(p=>p.emails).length;const strongest=[...prospects].sort((a,b)=>effectiveScore(b)-effectiveScore(a)).slice(0,5);$('view').innerHTML=layout('Dashboard','A clear view of what was found, what needs review, and where the best opportunities are.',`<div class="metric-grid"><div class="metric"><small>CATALOGUE</small><b>${total}</b><span>saved prospects</span></div><div class="metric"><small>NEEDS REVIEW</small><b>${pending}</b><span>awaiting your decision</span></div><div class="metric"><small>HIGH OPPORTUNITY</small><b>${hot}</b><span>score 75 or higher</span></div><div class="metric"><small>PUBLIC EMAILS</small><b>${withEmail}</b><span>evidence-backed contacts</span></div></div><div class="dashboard-grid"><article class="panel"><div class="panel-title"><div><span class="eyebrow">NEXT ACTION</span><h2>Review your strongest leads</h2><p>Inspect the evidence before deciding whether to contact them.</p></div><a class="primary" href="#prospects">Open explorer →</a></div><div class="spotlight">${strongest.map(p=>{const score=effectiveScore(p);return `<button class="spot" data-open="${p.id}"><span class="score ${scoreClass(score)}">${score}</span><span><b>${esc(p.name)}</b><small>${esc(p.website_status?.replaceAll('_',' ')||'unknown')} · ${esc(p.emails||'no public email')}${p.ai_checked_at?' · AI assessed':''}</small></span><span>›</span></button>`}).join('')||empty('Run a discovery to build your catalogue')}</div></article><article class="panel"><div class="panel-title"><div><span class="eyebrow">RECENT ACTIVITY</span><h2>Runs</h2></div><a href="#runs">View all</a></div><div class="activity">${runs.slice(0,6).map(r=>`<div><span class="run-dot ${r.status}"></span><span><b>#${r.id} · ${esc(r.query)}</b><small>${esc(r.status)} · ${r.added||0} added</small></span><time>${date(r.created_at)}</time></div>`).join('')||empty('No runs yet')}</div></article></div>`)}
|
||||
function renderDiscover(){ $('view').innerHTML=layout('Discover','Set the criteria for this run. Source configuration and provider settings live in Settings.',`<div class="two-col"><article class="panel form-panel"><div class="panel-title"><div><span class="eyebrow">NEW RUN</span><h2>Find potential customers</h2><p>Search Google Maps, deduplicate the catalogue, then enrich each result.</p></div><span class="pill">Email collection always on</span></div><form id="discoverForm"><label>Keywords<input id="keywords" required placeholder="plumbing services"></label><label>Category<input id="category" required placeholder="plumbers"></label><label>Location<input id="location" value="South Africa" required></label><label>Maximum results<select id="max-results"><option>10</option><option selected>25</option><option>50</option></select></label><label class="checkline"><input type="checkbox" id="extra-reviews"> Collect extra reviews <small>May increase run time</small></label><div class="form-row"><button class="primary" type="submit" id="discoverBtn">Run discovery →</button><span id="discoverMessage" class="notice"></span></div></form></article><article class="panel"><div class="panel-title"><div><span class="eyebrow">HOW IT WORKS</span><h2>Evidence-first pipeline</h2></div></div><ol class="steps"><li><b>Search</b><span>Maps worker finds public business listings.</span></li><li><b>Enrich</b><span>Website, HTTPS, DNS hints and public email checks run safely.</span></li><li><b>Review</b><span>You decide what is relevant before any contact.</span></li></ol></article></div>`)}
|
||||
function renderDiscover(){ $('view').innerHTML=layout('Discover','Set the criteria for this run. Source configuration and provider settings live in Settings.',`<div class="two-col"><article class="panel form-panel"><div class="panel-title"><div><span class="eyebrow">NEW RUN</span><h2>Find potential customers</h2><p>Search your selected discovery source, verify public websites, and review the results before contact.</p></div><span class="pill">Email collection always on</span></div><form id="discoverForm"><label>Keywords<input id="keywords" required placeholder="plumbing services"></label><label>Category<input id="category" required placeholder="plumbers"></label><label>Location<input id="location" value="South Africa" required></label><label>Maximum results<select id="max-results"><option>10</option><option selected>25</option><option>50</option></select></label><label class="checkline"><input type="checkbox" id="extra-reviews"> Collect extra reviews <small>May increase run time</small></label><div class="form-row"><button class="primary" type="submit" id="discoverBtn">Run discovery →</button><span id="discoverMessage" class="notice"></span></div></form></article><article class="panel"><div class="panel-title"><div><span class="eyebrow">HOW IT WORKS</span><h2>Evidence-first pipeline</h2></div></div><ol class="steps"><li><b>Search</b><span>Maps worker finds public business listings.</span></li><li><b>Enrich</b><span>Website, HTTPS, DNS hints and public email checks run safely.</span></li><li><b>Review</b><span>You decide what is relevant before any contact.</span></li></ol></article></div>`)}
|
||||
function renderProspects(){ $('view').innerHTML=layout('Prospects','Browse the strongest opportunities first, then narrow the catalogue with filters and sort by the latest score.',`<article class="panel"><div class="toolbar prospect-filter-row"><div class="search"><span>⌕</span><input id="prospectSearch" placeholder="Search name, address, domain or email"></div><select id="prospectScoreBand" aria-label="Filter by potential"><option value="all">All potential</option><option value="hot">Hot (85+)</option><option value="high">High (70–84)</option><option value="medium">Medium (45–69)</option><option value="low">Low (0–44)</option></select><select id="prospectWebsiteFilter" aria-label="Filter by website"><option value="all">Any website</option><option value="missing">No website</option><option value="healthy">Healthy website</option><option value="broken">Broken / unreachable</option></select><select id="prospectEmailFilter" aria-label="Filter by email"><option value="all">Any email</option><option value="has_email">Has public email</option><option value="no_email">No public email</option></select><select id="prospectReviewFilter" aria-label="Filter by review status"><option value="all">Any review status</option><option value="pending">In review</option><option value="approved">Accepted</option><option value="rejected">Rejected</option><option value="contacted">Contacted</option></select><button class="secondary" id="exportCsv">Export CSV</button><button class="secondary" id="refreshProspects">Refresh</button></div><div id="prospectTabs" class="prospect-tabs">${prospectTabs()}</div><p class="list-hint">Click any prospect to review evidence, change its status, assess it with AI, or draft a tailored message. Review status is changed inside the prospect drawer.</p><div id="prospectTable">${prospectTable()}</div></article><aside id="detail" class="detail-panel" hidden></aside>`);if($('prospectScoreBand'))$('prospectScoreBand').value=prospectScoreBand;if($('prospectWebsiteFilter'))$('prospectWebsiteFilter').value=prospectWebsiteFilter;if($('prospectEmailFilter'))$('prospectEmailFilter').value=prospectEmailFilter;if($('prospectReviewFilter'))$('prospectReviewFilter').value=prospectReviewFilter}
|
||||
function renderRuns(){ $('view').innerHTML=layout('Runs','Monitor discovery jobs, recover interrupted work, and inspect every state transition.',`<article class="panel"><div class="table-wrap"><table><thead><tr><th>Run</th><th>Status</th><th>Results</th><th>Started</th><th>Source</th><th>Actions</th></tr></thead><tbody>${runs.map(r=>{const active=['queued','running'].includes(r.status);const retry=['failed','cancelled'].includes(r.status)||r.recovery_state==='retryable';return `<tr><td><b>#${r.id}</b><small>${esc(r.query)}</small></td><td><span class="status ${r.status}">${esc(r.status)}</span><small>${esc(r.stage||'')}${r.recovery_state?` · ${esc(r.recovery_state)}`:''}</small></td><td>${r.added||0} added / ${r.found||0} found</td><td>${date(r.created_at)}</td><td>${esc(r.source||'google_maps')}</td><td class="run-actions">${active?`<button class="secondary" data-cancel-run="${r.id}">Cancel</button>`:''}${retry?`<button class="secondary" data-retry-run="${r.id}">Retry</button>`:''}<button class="secondary" data-run-events="${r.id}">Events</button></td></tr>`}).join('')}</tbody></table></div>${runs.length?'':empty('No discovery runs yet')}</article><div id="runEventsPanel"></div>`)}
|
||||
function logTabCount(tab,counts){return tab==='errors'?Number(counts?.errors||0):Number(counts?.[tab]||0)}
|
||||
@@ -338,3 +338,46 @@ document.addEventListener('keydown',e=>{if(e.target.id==='messageRevisionInput'&
|
||||
/* The first call above happens before this enhancement is defined. Run the
|
||||
* non-destructive wrapper once so the panel exists before the first click. */
|
||||
ensureMessageDialog(false);
|
||||
|
||||
/* Prospect workspace v2: pipeline-first tracking with a compact alternative list. */
|
||||
let prospectViewMode=localStorage.getItem('prospect-view-mode')||'board';
|
||||
const PROSPECT_STAGES=[['pending','Needs review','Inspect evidence and decide'],['approved','Ready to contact','Approved for follow-up'],['contacted','Contacted','Already actioned'],['rejected','Not now','Kept out of the active queue']];
|
||||
function prospectDisplayRows(){
|
||||
const q=String($('prospectSearch')?.value||'').trim().toLowerCase();
|
||||
return prospects.filter(p=>{
|
||||
const hay=[p.name,p.domain,p.search_category,p.category,p.address,p.emails,p.description].join(' ').toLowerCase();
|
||||
const score=effectiveScore(p), site=String(p.website_status||'unknown').toLowerCase(), review=String(p.review_status||'pending');
|
||||
const scoreOk=prospectScoreBand==='all'||(prospectScoreBand==='hot'&&score>=85)||(prospectScoreBand==='high'&&score>=70&&score<85)||(prospectScoreBand==='medium'&&score>=45&&score<70)||(prospectScoreBand==='low'&&score<45);
|
||||
const siteOk=prospectWebsiteFilter==='all'||(prospectWebsiteFilter==='missing'&&(!p.website||site==='no_detected_website'))||(prospectWebsiteFilter==='broken'&&['broken','unreachable','error','timeout'].some(v=>site.includes(v)))||(prospectWebsiteFilter==='healthy'&&site==='healthy');
|
||||
const emailOk=prospectEmailFilter==='all'||(prospectEmailFilter==='has_email'&&Boolean(String(p.emails||'').trim()))||(prospectEmailFilter==='no_email'&&!String(p.emails||'').trim());
|
||||
const reviewOk=prospectReviewFilter==='all'||review===prospectReviewFilter;
|
||||
return scoreOk&&siteOk&&emailOk&&reviewOk&&(!q||hay.includes(q));
|
||||
}).sort((a,b)=>effectiveScore(b)-effectiveScore(a)||String(a.name||'').localeCompare(String(b.name||'')));
|
||||
}
|
||||
function prospectCard(p){
|
||||
const score=effectiveScore(p),stage=String(p.review_status||'pending'),site=String(p.website_status||'unknown').replaceAll('_',' ');
|
||||
return `<article class="pipeline-card" data-board-open="${p.id}"><button type="button" class="pipeline-card-open" data-open="${p.id}" aria-label="Open ${esc(p.name)}"><div class="pipeline-card-top"><span class="score ${scoreClass(score)}">${score}</span><span class="pipeline-source">${esc(p.source||'web')}</span></div><h3>${esc(p.name||'Unnamed prospect')}</h3><p class="pipeline-meta">${esc(prospectCategory(p))}${p.address?` · ${esc(p.address)}`:''}</p><div class="pipeline-facts"><span class="${site==='healthy'?'fact-good':''}">${site==='healthy'?'✓':'○'} ${esc(site)}</span><span>${p.emails?'✉ Public email':'✉ No email'}</span></div></button><div class="pipeline-card-actions"><button type="button" class="text-action" data-board-open="${p.id}">Review details</button><select aria-label="Move ${esc(p.name)}" data-board-status="${p.id}">${PROSPECT_STAGES.map(([key,label])=>`<option value="${key}" ${stage===key?'selected':''}>${label}</option>`).join('')}</select></div></article>`;
|
||||
}
|
||||
function prospectBoard(){
|
||||
const rows=prospectDisplayRows();
|
||||
return `<div class="pipeline-board">${PROSPECT_STAGES.map(([key,label,desc])=>{const items=rows.filter(p=>String(p.review_status||'pending')===key);return `<section class="pipeline-column pipeline-${key}"><header><div><h2>${label}</h2><p>${desc}</p></div><span>${items.length}</span></header><div class="pipeline-cards">${items.map(prospectCard).join('')||`<div class="pipeline-empty">No prospects here</div>`}</div></section>`}).join('')}</div>`;
|
||||
}
|
||||
function prospectCompactList(){
|
||||
const rows=prospectDisplayRows();
|
||||
return `<div class="compact-list">${rows.map(p=>{const score=effectiveScore(p),stage=String(p.review_status||'pending');return `<button type="button" class="compact-row" data-open="${p.id}"><span class="score ${scoreClass(score)}">${score}</span><span class="compact-main"><b>${esc(p.name)}</b><small>${esc(prospectCategory(p))}${p.address?` · ${esc(p.address)}`:''}</small></span><span class="compact-site">${esc(String(p.website_status||'unknown').replaceAll('_',' '))}</span><span class="compact-email">${p.emails?'Has email':'No email'}</span><span class="compact-stage">${esc(PROSPECT_STAGES.find(s=>s[0]===stage)?.[1]||'Needs review')}</span><span>›</span></button>`}).join('')||empty('No prospects match these filters')}</div>`;
|
||||
}
|
||||
function prospectWorkspace(){
|
||||
const rows=prospectDisplayRows();
|
||||
const body=prospectViewMode==='list'?prospectCompactList():prospectBoard();
|
||||
const pending=prospects.filter(p=>String(p.review_status||'pending')==='pending').length;
|
||||
const hot=prospects.filter(p=>effectiveScore(p)>=85).length;
|
||||
$('prospectTable').innerHTML=body;
|
||||
const count=$('prospectResultCount');if(count)count.textContent=`Showing ${rows.length} of ${prospects.length}`;
|
||||
const summary=$('prospectSummary');if(summary)summary.innerHTML=`<span><b>${pending}</b> need review</span><span><b>${hot}</b> high potential</span><span><b>${prospects.filter(p=>p.emails).length}</b> with public email</span>`;
|
||||
}
|
||||
prospectTable=function(){return prospectViewMode==='list'?prospectCompactList():prospectBoard()};
|
||||
renderProspects=function(){
|
||||
$('view').innerHTML=layout('Prospects','A calm workspace for deciding what matters next. Move each prospect through the pipeline as you work.',`<div class="prospect-workspace-head"><div class="prospect-summary" id="prospectSummary"></div><div class="prospect-view-toggle" role="group" aria-label="Prospect view"><button type="button" class="${prospectViewMode==='board'?'active':''}" data-prospect-view="board">Pipeline</button><button type="button" class="${prospectViewMode==='list'?'active':''}" data-prospect-view="list">List</button></div></div><article class="panel prospect-workspace-panel"><div class="prospect-toolbar"><div class="prospect-search"><span>⌕</span><input id="prospectSearch" placeholder="Search prospects, locations, websites…"></div><select id="prospectScoreBand" aria-label="Filter by potential"><option value="all">Any potential</option><option value="hot">Hot (85+)</option><option value="high">High (70–84)</option><option value="medium">Medium (45–69)</option><option value="low">Low (0–44)</option></select><select id="prospectWebsiteFilter" aria-label="Filter by website"><option value="all">Any website</option><option value="missing">No website</option><option value="healthy">Healthy</option><option value="broken">Broken / unreachable</option></select><select id="prospectEmailFilter" aria-label="Filter by email"><option value="all">Any email</option><option value="has_email">Has public email</option><option value="no_email">No public email</option></select><select id="prospectReviewFilter" aria-label="Filter by stage"><option value="all">All stages</option><option value="pending">Needs review</option><option value="approved">Ready to contact</option><option value="contacted">Contacted</option><option value="rejected">Not now</option></select><span id="prospectResultCount" class="result-count"></span><button class="secondary" id="exportCsv">Export</button><button class="secondary" id="refreshProspects">Refresh</button></div><p class="workspace-help">Start in <b>Needs review</b>. Open a card for evidence, then move it to the next stage. The list view is best for quick scanning.</p><div id="prospectTable"></div></article>`);if($('prospectScoreBand'))$('prospectScoreBand').value=prospectScoreBand;if($('prospectWebsiteFilter'))$('prospectWebsiteFilter').value=prospectWebsiteFilter;if($('prospectEmailFilter'))$('prospectEmailFilter').value=prospectEmailFilter;if($('prospectReviewFilter'))$('prospectReviewFilter').value=prospectReviewFilter;prospectWorkspace();
|
||||
};
|
||||
document.addEventListener('click',e=>{const toggle=e.target.closest('[data-prospect-view]');if(toggle){e.preventDefault();prospectViewMode=toggle.dataset.prospectView;localStorage.setItem('prospect-view-mode',prospectViewMode);renderProspects();return}const open=e.target.closest('[data-board-open]');if(open&&!e.target.closest('select')){e.preventDefault();e.stopPropagation();openDrawer(open.dataset.boardOpen);}},true);
|
||||
document.addEventListener('change',async e=>{const move=e.target.closest('[data-board-status]');if(!move)return;e.preventDefault();e.stopPropagation();const id=move.dataset.boardStatus,status=move.value;move.disabled=true;try{const updated=await api(`/api/prospects/${id}/review`,{method:'PATCH',body:JSON.stringify({status})});const index=prospects.findIndex(p=>String(p.id)===String(id));if(index>=0)prospects[index]={...prospects[index],...updated};prospectWorkspace();}catch(error){move.disabled=false;alert(`Could not move prospect: ${error.message}`)}},true);
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#0d1726"><title>ProspectOS</title><link rel="stylesheet" href="/style.css?v=workspace-v8"></head>
|
||||
<body><div class="app-shell"><aside class="sidebar"><div class="brand"><span class="brand-mark">P</span><div><b>ProspectOS</b><small>Growth workspace</small></div></div><nav id="nav"><a href="#dashboard" data-route="dashboard"><span>⌂</span>Dashboard</a><a href="#discover" data-route="discover"><span>⌁</span>Discover</a><a href="#prospects" data-route="prospects"><span>▦</span>Prospects</a><a href="#runs" data-route="runs"><span>◷</span>Runs</a><a href="#settings" data-route="settings"><span>⚙</span>Settings</a></nav><div class="sidebar-foot"><span class="status-dot" id="sourceDot"></span><div><b id="sourceLabel">Checking worker</b><small id="sourceSub">Connecting…</small></div></div></aside><main class="main"><header class="topbar"><div><span class="crumb">Workspace</span><span class="slash">/</span><b id="pageName">Dashboard</b></div><div class="top-actions"><span id="updated">—</span><button class="icon-btn" id="refreshAll" title="Refresh">↻</button></div></header><div id="view"></div><footer>ProspectOS · public data only · manual review before contact</footer></main></div><aside id="detailDrawer" class="detail-drawer" aria-hidden="true"><div class="drawer-head"><div><span class="eyebrow">PROSPECT DETAIL</span><h2 id="drawerName">Prospect</h2></div><button class="icon-btn" id="closeDrawer">×</button></div><div id="drawerBody"></div></aside><dialog id="messageDialog"><form method="dialog" id="messageForm"><div class="dialog-head"><div><label class="eyebrow">CLIENT-SPECIFIC DRAFT</label><h2 id="messageTitle">Write a message for this prospect</h2><p>Use verified facts and your offer to create an editable draft.</p></div><button class="icon-btn" value="cancel">×</button></div><label for="offerInput">Your offer or instruction</label><textarea id="offerInput" rows="4" placeholder="Move hosting to Gold Plus for R119/month and rebuild the website free."></textarea><div id="messageStatus" class="notice"></div><button class="primary" id="generateMessage" type="button">Generate client-specific draft</button><label for="subjectOutput">Subject</label><input id="subjectOutput"><label for="bodyOutput">Email draft</label><textarea id="bodyOutput" rows="12"></textarea><div class="dialog-actions"><button class="secondary" id="copyMessage" type="button">Copy draft</button><button class="secondary" value="cancel">Close</button></div></form></dialog><script src="/app.js?v=workspace-v9"></script></body></html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#0d1726"><title>ProspectOS</title><link rel="stylesheet" href="/style.css?v=workspace-v9"></head>
|
||||
<body><div class="app-shell"><aside class="sidebar"><div class="brand"><span class="brand-mark">P</span><div><b>ProspectOS</b><small>Growth workspace</small></div></div><nav id="nav"><a href="#dashboard" data-route="dashboard"><span>⌂</span>Dashboard</a><a href="#discover" data-route="discover"><span>⌁</span>Discover</a><a href="#prospects" data-route="prospects"><span>▦</span>Prospects</a><a href="#runs" data-route="runs"><span>◷</span>Runs</a><a href="#settings" data-route="settings"><span>⚙</span>Settings</a></nav><div class="sidebar-foot"><span class="status-dot" id="sourceDot"></span><div><b id="sourceLabel">Checking worker</b><small id="sourceSub">Connecting…</small></div></div></aside><main class="main"><header class="topbar"><div><span class="crumb">Workspace</span><span class="slash">/</span><b id="pageName">Dashboard</b></div><div class="top-actions"><span id="updated">—</span><button class="icon-btn" id="refreshAll" title="Refresh">↻</button></div></header><div id="view"></div><footer>ProspectOS · public data only · manual review before contact</footer></main></div><aside id="detailDrawer" class="detail-drawer" aria-hidden="true"><div class="drawer-head"><div><span class="eyebrow">PROSPECT DETAIL</span><h2 id="drawerName">Prospect</h2></div><button class="icon-btn" id="closeDrawer">×</button></div><div id="drawerBody"></div></aside><dialog id="messageDialog"><form method="dialog" id="messageForm"><div class="dialog-head"><div><label class="eyebrow">CLIENT-SPECIFIC DRAFT</label><h2 id="messageTitle">Write a message for this prospect</h2><p>Use verified facts and your offer to create an editable draft.</p></div><button class="icon-btn" value="cancel">×</button></div><label for="offerInput">Your offer or instruction</label><textarea id="offerInput" rows="4" placeholder="Move hosting to Gold Plus for R119/month and rebuild the website free."></textarea><div id="messageStatus" class="notice"></div><button class="primary" id="generateMessage" type="button">Generate client-specific draft</button><label for="subjectOutput">Subject</label><input id="subjectOutput"><label for="bodyOutput">Email draft</label><textarea id="bodyOutput" rows="12"></textarea><div class="dialog-actions"><button class="secondary" id="copyMessage" type="button">Copy draft</button><button class="secondary" value="cancel">Close</button></div></form></dialog><script src="/app.js?v=workspace-v10"></script></body></html>
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user