250 lines
91 KiB
JavaScript
250 lines
91 KiB
JavaScript
const $=id=>document.getElementById(id); const esc=v=>String(v??"").replace(/[&<>\"']/g,c=>({'&':'&','<':'<','>':'>','\"':'"',"'":'''}[c]));
|
||
let prospects=[],runs=[],settings={},logs=[],selected=null,pollTimer=null,currentUser=null,csrfToken='';
|
||
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{const headers={'Content-Type':'application/json'};if(csrfToken)headers['X-CSRF-Token']=csrfToken;fetch('/api/logs',{method:'POST',credentials:'same-origin',headers,body:JSON.stringify({action,details,level,path:location.hash.slice(1)||'dashboard'})}).catch(()=>{})}catch{}}
|
||
function installExtraNav(){const nav=$('nav');if(!nav)return;[['teach-ai','Teach AI','✦'],['promotions','Promotions','%'],['logs','Logs','≋'],['users','Users','♙']].forEach(([route,label,icon])=>{if(nav.querySelector(`[data-route="${route}"]`))return;const link=document.createElement('a');link.href='#'+route;link.dataset.route=route;link.innerHTML=`<span>${icon}</span>${label}`;nav.append(link)})}
|
||
installExtraNav();
|
||
let activeProspectCategory='all';
|
||
let prospectScoreBand='all',prospectWebsiteFilter='all',prospectEmailFilter='all',prospectReviewFilter='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';}}
|
||
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>`}
|
||
function scoreClass(n){return n>=75?'hot':n>=55?'warm':'cool'}
|
||
function effectiveScore(p){const ai=Number(p?.ai_probability);return p?.ai_checked_at&&Number.isFinite(ai)?Math.round(Math.max(0,Math.min(100,ai))):Number(p?.opportunity_score||0)}
|
||
function scoreLabel(p){return p?.ai_checked_at&&Number.isFinite(Number(p?.ai_probability))?'AI contact probability':'Opportunity score'}
|
||
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=>effectiveScore(p)>=HIGH_POTENTIAL_SCORE).length:prospects.filter(p=>prospectCategory(p)===category).length;return `<div class="prospect-tab-list"><button class="prospect-tab ${activeProspectCategory==='all'?'active':''}" data-category-tab="all">All high potential <span>${count('all')}</span></button>${categories.map(category=>`<button class="prospect-tab ${activeProspectCategory===category?'active':''}" data-category-tab="${esc(category)}">${esc(category)} <span>${count(category)}</span></button>`).join('')}</div><p class="tab-hint">All shows scores ${HIGH_POTENTIAL_SCORE}+; category tabs keep every result grouped by the search category.</p>`}
|
||
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 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)}
|
||
function logTabs(counts={}){return `<div class="logs-tab-list">${LOG_TABS.map(([key,label])=>`<button class="logs-tab ${activeLogTab===key?'active':''}" data-log-tab="${key}">${label}<span>${logTabCount(key,counts)}</span></button>`).join('')}</div>`}
|
||
function renderLogTable(){const rows=Array.isArray(logs)?logs:[];return rows.length?`<div class="table-wrap log-table-wrap"><table><thead><tr><th>Time</th><th>Type</th><th>Event</th><th>Status</th><th>Duration</th><th>Details</th></tr></thead><tbody>${rows.map(row=>{const details=typeof row.details==='string'?row.details:JSON.stringify(row.details||{},null,2);return `<tr><td>${esc(date(row.created_at))}</td><td><span class="log-kind ${esc(row.level||'info')}">${esc(row.kind||'system')}</span></td><td><b>${esc(row.event||'')}</b>${row.path?`<small>${esc(row.method||'')} ${esc(row.path)}</small>`:''}${row.error?`<small class="log-error">${esc(row.error)}</small>`:''}</td><td>${row.status?esc(row.status):'—'}</td><td>${row.duration_ms!=null?esc(row.duration_ms)+' ms':'—'}</td><td><details><summary>View</summary><pre>${esc(details)}</pre></details></td></tr>`}).join('')}</tbody></table></div>`: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.',`<article class="panel logs-panel"><div class="panel-title"><div><span class="eyebrow">OPERATIONS JOURNAL</span><h2>Detailed activity</h2><p>Requests and actions are timestamped. API keys, authorization headers, and passwords are never stored.</p></div><div class="head-actions"><button class="secondary" id="refreshLogs">Refresh</button></div></div><div id="logsTabs" class="logs-tabs">${logTabs()}</div><div id="logsStatus" class="notice">Loading logs…</div><div id="logsTable"></div></article>`);refreshLogs()}
|
||
function renderTeachAI(){const p=settings.company_profile||{};$('view').innerHTML=layout('Teach AI','Give the assistant reliable context about your company, services, packages, and preferred voice. This is used when assessing prospects and writing drafts.',`<div class="knowledge-grid"><article class="panel form-panel"><div class="panel-title"><div><span class="eyebrow">COMPANY KNOWLEDGE</span><h2>Teach the assistant</h2><p>Save the facts you want it to use. It will never invent prices, packages, or promises outside this profile and your draft instruction.</p></div><span class="status ${p.configured?'succeeded':'failed'}">${p.configured?'Profile ready':'Not taught yet'}</span></div><form id="teachAiForm" class="knowledge-form"><label>Website URL <small class="field-help">Optional. Read the public homepage to add a bounded text snapshot.</small><input id="companyWebsiteUrl" type="url" value="${esc(p.website_url||'')}" placeholder="https://yourcompany.co.za"></label><div class="form-row"><button type="button" class="secondary" id="learnCompanyWebsite">Read website now</button><span id="teachAiLearnMessage" class="notice">${p.website_fetched_at?`Last read ${esc(date(p.website_fetched_at))}`:''}</span></div><div class="settings-fields"><label>Company name<input id="companyName" value="${esc(p.name||'')}" placeholder="Your company name"></label><label>Preferred voice<input id="companyTone" value="${esc(p.tone||'')}" placeholder="Friendly, practical, South African English"></label></div><label>About the company<textarea id="companyDescription" rows="4" placeholder="Who you are, who you help, and what makes your service useful.">${esc(p.description||'')}</textarea></label><label>Services you offer<textarea id="companyServices" rows="4" placeholder="Domains, hosting, website design, repairs, email, maintenance…">${esc(p.services||'')}</textarea></label><label>Packages and exact prices<textarea id="companyPackages" rows="5" placeholder="Gold Plus hosting — R119/month; include only prices you are happy to quote.">${esc(p.packages||'')}</textarea></label><label>Notes and rules for the assistant<textarea id="companyNotes" rows="4" placeholder="Areas served, preferred CTA, guarantees to avoid, or facts to confirm before sending.">${esc(p.notes||'')}</textarea></label><div class="form-row"><button class="primary" type="submit">Save company knowledge</button><span id="teachAiMessage" class="notice"></span></div></form></article><article class="panel knowledge-preview"><div class="panel-title"><div><span class="eyebrow">LEARNED SNAPSHOT</span><h2>What the assistant can reference</h2></div></div><div class="knowledge-status"><b>${p.website_title?esc(p.website_title):'No website snapshot yet'}</b><span>${p.website_content?`${p.website_content.length.toLocaleString()} characters captured from the public page.`:'Use Read website now, then review the text before saving.'}</span></div>${p.website_content?`<details><summary>View captured website text</summary><p>${esc(p.website_content)}</p></details>`:'<p class="muted">Manual company details are always available, even without a website.</p>'}</article></div>`)}
|
||
function renderPromotions(){const items=Array.isArray(settings.promotions)?settings.promotions:[];$('view').innerHTML=layout('Promotions','Create offers once, then select an active promotion while drafting a prospect email. Inactive promotions stay in your history but cannot be used by the AI.',`<div class="promotions-grid"><article class="panel form-panel"><div class="panel-title"><div><span class="eyebrow">NEW PROMOTION</span><h2>Set up an offer</h2><p>Keep the exact wording, price, dates, and eligibility here so drafts stay accurate.</p></div></div><form id="promotionForm" class="promotion-form"><label>Promotion name<input id="promotionName" required placeholder="Free website rebuild"></label><label>What is included<textarea id="promotionDescription" rows="5" required placeholder="Free rebuild for businesses that move hosting to Gold Plus."></textarea></label><label>Terms or eligibility<textarea id="promotionTerms" rows="4" placeholder="New hosting customers only; confirm scope before sending."></textarea></label><div class="settings-fields"><label>Starts<input id="promotionStarts" type="date"></label><label>Ends<input id="promotionEnds" type="date"></label></div><label class="checkline"><input id="promotionActive" type="checkbox" checked> Active and available for drafts</label><div class="form-row"><button class="primary" type="submit">Save promotion</button><span id="promotionMessage" class="notice"></span></div></form></article><article class="panel"><div class="panel-title"><div><span class="eyebrow">YOUR OFFERS</span><h2>Saved promotions</h2><p>${items.filter(x=>x.active).length} active · ${items.length} total</p></div></div><div class="promotion-list">${items.map(item=>`<div class="promotion-card ${item.active?'active':'inactive'}"><div><div class="promotion-card-head"><b>${esc(item.name)}</b><label class="switch"><input type="checkbox" data-promotion-active="${item.id}" ${item.active?'checked':''}><span>${item.active?'Active':'Inactive'}</span></label></div><p>${esc(item.description)}</p>${item.terms?`<small>Terms: ${esc(item.terms)}</small>`:''}<small>${item.starts_at||item.ends_at?`Valid ${esc(item.starts_at||'now')} → ${esc(item.ends_at||'ongoing')}`:'No dates set'}</small></div><button class="secondary" data-delete-promotion="${item.id}" type="button">Delete</button></div>`).join('')||'<p class="muted">No promotions yet. Add your first offer on the left.</p>'}</div></article></div>`)}
|
||
function renderSettings(){
|
||
const ai=settings.ai||{};
|
||
const maps=settings.maps||{};
|
||
const browser=settings.browser_use||{};
|
||
const limits=settings.limits||{};
|
||
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=>`<option value="${esc(m.id)}">${esc(m.label||m.id)}${m.free?' · free':''}</option>`).join('')+(selectedModel&&!modelList.some(m=>m.id===selectedModel)?`<option value="${esc(selectedModel)}">${esc(selectedModel)}</option>`:'');
|
||
$('view').innerHTML=layout('Settings','All provider, browser, run, and safety controls live here. Daily discovery only asks for the search criteria.',`
|
||
<div class="settings-grid">
|
||
<article class="panel wide-panel">
|
||
<div class="panel-title"><div><span class="eyebrow">RUNTIME CONTROLS</span><h2>Browser and discovery timing</h2><p>These values are saved in the local database and apply to new work immediately. Increase the Browser Use timeout when website investigations need more time.</p></div><span class="status ${maps.ready?'succeeded':'failed'}">${maps.ready?'Maps ready':'Maps not connected'}</span></div>
|
||
<form id="runtimeSettingsForm" class="runtime-settings-form">
|
||
<div class="setting-group"><h3>Browser Use website search</h3><p class="field-help">Headless, public-only browsing for possible official domains. It never logs in or bypasses challenge pages.</p><label class="checkline"><input type="checkbox" id="browserEnabled" ${browser.enabled?'checked':''}> Enable Browser Use</label><div class="settings-fields"><label>Total timeout (seconds)<input id="browserTimeout" type="number" min="30" max="1800" step="30" value="${Number(browser.timeout_seconds||600)}"><small>30 seconds to 30 minutes. This is the full investigation window.</small></label><label>Maximum agent steps<input id="browserSteps" type="number" min="3" max="50" step="1" value="${Number(browser.max_steps||10)}"><small>More steps can find better matches but use more AI calls.</small></label><label>AI call timeout (seconds)<input id="browserLlmTimeout" type="number" min="10" max="300" step="5" value="${Number(browser.llm_timeout_seconds||90)}"><small>Timeout for one Browser Use model request.</small></label></div></div>
|
||
<div class="setting-group"><h3>Google Maps worker</h3><p class="field-help">The local gosom worker is asynchronous. A run is allowed to finish before results are downloaded.</p><div class="setting-line"><span>Connection</span><b>${esc(maps.worker_url||'Not configured')}</b></div><div class="settings-fields"><label>Worker request timeout (seconds)<input id="mapsTimeout" type="number" min="60" max="7200" step="60" value="${Number(maps.request_timeout_seconds||900)}"><small>Default 900 seconds (15 minutes).</small></label><label>Poll interval (seconds)<input id="mapsPoll" type="number" min="0.5" max="60" step="0.5" value="${Number(maps.poll_seconds||2)}"><small>How often the app checks the worker.</small></label><label>Search depth<input id="mapsDepth" type="number" min="1" max="10" step="1" value="${Number(maps.max_depth||1)}"><small>Higher depth takes longer and returns more listings.</small></label></div></div>
|
||
<div class="setting-group"><h3>Website health and email scan</h3><p class="field-help">Every public website is checked for health signals and public email addresses. Email collection remains always on.</p><label>Website scan timeout (seconds)<input id="websiteTimeout" type="number" min="2" max="60" step="1" value="${Number(limits.website_scan_timeout_seconds||8)}"><small>Per-page network timeout, 2–60 seconds.</small></label></div>
|
||
<div class="form-row"><button class="primary" type="submit">Save runtime settings</button><span id="runtimeSettingsMessage" class="notice"></span></div>
|
||
</form>
|
||
</article>
|
||
<article class="panel"><div class="panel-title"><div><span class="eyebrow">AI PROVIDER</span><h2>Draft assistant</h2><p>Used when you assess a prospect or draft an email.</p></div><span class="status ${ai.configured?'succeeded':'failed'}">${ai.configured?'Configured':'Not configured'}</span></div><form id="settingsForm"><label>Provider<select id="aiProvider"><option value="nous" ${provider==='nous'?'selected':''}>Nous Portal · free models</option><option value="custom" ${provider!=='nous'?'selected':''}>Custom OpenAI-compatible</option></select></label><p class="field-help" id="aiProviderNote">${provider==='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.'}</p><label>Base URL<input id="aiBase" value="${esc(ai.base_url||'')}" ${provider==='nous'?'readonly':''} placeholder="https://provider.example/v1"></label><label>API key<input id="aiKey" type="password" placeholder="Leave blank to keep current key"></label><label>Model<input id="aiModel" list="nousModelList" value="${esc(selectedModel)}" placeholder="model-name"><datalist id="nousModelList">${options}</datalist></label><p class="field-help">Nous model availability can change. Refresh Settings to update the free catalog.</p><div class="form-row"><button class="primary" type="submit">Save AI settings</button><button class="secondary" id="testAi" type="button">Test connection</button><span id="settingsMessage" class="notice"></span></div></form></article>
|
||
<article class="panel wide-panel"><div class="panel-title"><div><span class="eyebrow">SCHEDULES</span><h2>Recurring discovery</h2><p>Run bounded searches automatically; all results still arrive as needs-review.</p></div></div><form id="scheduleForm" class="inline-form"><input id="sCat" placeholder="Category"><input id="sKey" placeholder="Keywords"><input id="sLoc" value="South Africa" placeholder="Location"><select id="sHours"><option value="24">Daily</option><option value="48">Every 2 days</option><option value="168">Weekly</option></select><button class="secondary">Add schedule</button></form><div class="list">${sch.map(s=>`<div><b>${esc(s.keywords)} · ${esc(s.category)}</b><span>${esc(s.location)} · every ${s.interval_hours}h · next ${date(s.next_run_at)}</span></div>`).join('')||'<p class="muted">No schedules configured.</p>'}</div></article>
|
||
<article class="panel wide-panel"><div class="panel-title"><div><span class="eyebrow">SAFETY</span><h2>Suppression centre</h2><p>Suppressed domains and emails are excluded before new prospects are saved.</p></div></div><form id="suppressionForm" class="inline-form"><select id="supKind"><option value="domain">Domain</option><option value="email">Email</option></select><input id="supValue" placeholder="example.co.za or info@example.co.za"><input id="supReason" placeholder="Reason"><button class="secondary">Suppress</button></form><div class="list">${sup.map(s=>`<div><b>${esc(s.value)}</b><span>${esc(s.kind)} · ${esc(s.reason||'manual')} · ${date(s.created_at)}</span></div>`).join('')||'<p class="muted">No suppressions configured.</p>'}</div></article>
|
||
</div>`)
|
||
}
|
||
async function load(){
|
||
loadWarnings=[];
|
||
const results=await Promise.allSettled([api('/api/prospects'),api('/api/runs'),api('/api/settings'),api('/api/schedules'),api('/api/suppressions'),api('/api/promotions')]);
|
||
const value=(result,label,fallback)=>{if(result.status==='fulfilled')return result.value;loadWarnings.push(`${label}: ${result.reason?.message||'request failed'}`);return fallback};
|
||
const prospectsResponse=value(results[0],'Prospects',{}),runsResponse=value(results[1],'Runs',{}),settingsResponse=value(results[2],'Settings',{}),schedulesResponse=value(results[3],'Schedules',{}),suppressionsResponse=value(results[4],'Suppressions',{}),promotionsResponse=value(results[5],'Promotions',{});
|
||
prospects=Array.isArray(prospectsResponse.items)?prospectsResponse.items:[];
|
||
runs=Array.isArray(runsResponse.items)?runsResponse.items:[];
|
||
settings={...settingsResponse,schedules:Array.isArray(schedulesResponse.items)?schedulesResponse.items:[],suppressions:Array.isArray(suppressionsResponse.items)?suppressionsResponse.items:[],promotions:Array.isArray(promotionsResponse.items)?promotionsResponse.items:[]};
|
||
return loadWarnings;
|
||
}
|
||
function renderLoadWarnings(){if(!loadWarnings.length)return;const view=$('view');if(!view)return;const notice=document.createElement('div');notice.className='notice error load-warning';notice.setAttribute('role','status');notice.textContent=`Dashboard loaded with limited data. ${loadWarnings.join(' · ')}`;view.prepend(notice)}
|
||
async function route(){const r=location.hash.slice(1)||'dashboard';installExtraNav();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,'teach-ai':renderTeachAI,promotions:renderPromotions,users:renderUsers}[r]||renderDashboard)();renderLoadWarnings();$('updated').textContent='Updated '+new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});refreshWorkerStatus();}catch(e){$('view').innerHTML=empty(e.message);refreshWorkerStatus();}}
|
||
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=`<b>AI review notes</b><p>${esc(d?.rationale||'The draft uses the prospect evidence and your instructions.')}</p>${unknowns.length?`<p><b>Confirm before sending:</b> ${esc(unknowns.join(' · '))}</p>`:''}${used.length?`<p class="muted">Evidence used: ${esc(used.join(', '))}</p>`:''}${d?.follow_up?`<p class="muted">${esc(d.follow_up)}</p>`:''}`}
|
||
function promotionOptions(){return (Array.isArray(settings.promotions)?settings.promotions:[]).filter(item=>item.active).map(item=>`<option value="${esc(item.id)}">${esc(item.name)}</option>`).join('')}
|
||
function ensureMessageDialog(force=false){const dialog=$('messageDialog');const form=$('messageForm');if(dialog)dialog.classList.add('message-dialog');if(!form||(!force&&$('messageTone')))return;form.innerHTML=`<div class="dialog-head"><div><label class="eyebrow">AI ASSISTED DRAFT</label><h2 id="messageTitle">Tailor a message</h2><p>Start from verified prospect evidence, your company knowledge, and any selected promotion.</p></div><button class="icon-btn" value="cancel" aria-label="Close draft">×</button></div><div id="messageContext" class="message-context"></div><div class="message-controls"><label for="messageTone">Voice<select id="messageTone"><option value="warm">Warm and approachable</option><option value="direct">Direct and concise</option><option value="consultative">Consultative and helpful</option></select></label><label for="messageGoal">Call to action<select id="messageGoal"><option value="conversation">Start a conversation</option><option value="review">Offer a short review</option><option value="quote">Invite a quote request</option></select></label><label for="messagePromotion">Promotion<select id="messagePromotion"><option value="">No promotion</option>${promotionOptions()}</select><small class="field-help">Only active promotions appear here.</small></label></div><label for="offerInput">Your offer or instruction <small class="field-help">Prices and promises are copied only from this field.</small></label><textarea id="offerInput" rows="4" placeholder="Move hosting to our Gold Plus package at R119/month and rebuild the website at no extra design charge."></textarea><div id="messageStatus" class="notice"></div><button class="primary draft-generate" id="generateMessage" type="button">Generate tailored draft</button><div class="draft-output"><label for="subjectOutput">Subject <small>Editable</small></label><input id="subjectOutput" placeholder="A specific, natural subject line"><label for="bodyOutput">Email draft <small>Editable plain text</small></label><textarea id="bodyOutput" rows="12" placeholder="Your generated email will appear here."></textarea></div><div class="dialog-actions"><button class="secondary" id="copyMessage" type="button">Copy subject + email</button><button class="secondary" value="cancel">Close</button></div>`}
|
||
async function openMessage(id){ensureMessageDialog(true);selected=prospects.find(p=>String(p.id)===String(id));if(!selected)return;const score=effectiveScore(selected);$('messageTitle').textContent='Draft for '+selected.name;const context=$('messageContext');if(context)context.innerHTML=`<div class="message-context-title"><span class="score ${scoreClass(score)}">${score}</span><div><b>${esc(selected.name)}</b><small>${esc(prospectCategory(selected))} · ${esc(selected.address||'Location not listed')}</small></div></div><div class="message-context-facts"><span>${selected.website?`Website: ${esc((selected.website_status||'detected').replaceAll('_',' '))}`:'No website detected'}</span><span>${selected.emails?`Public email: ${esc(selected.emails)}`:'No public email found'}</span></div>`;$('offerInput').value=suggestedOffer(selected);if($('messageTone'))$('messageTone').value='warm';if($('messageGoal'))$('messageGoal').value='conversation';if($('messagePromotion'))$('messagePromotion').value=selected.ai_promotion_id&&[...$('messagePromotion').options].some(option=>option.value===String(selected.ai_promotion_id))?String(selected.ai_promotion_id):'';$('subjectOutput').value=selected.ai_subject||'';$('bodyOutput').value=selected.ai_message||'';$('messageStatus').textContent='';$('messageStatus').className='notice';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();loadMessageMockupOption(id)}
|
||
function reviewStatusLabel(status){return status==='approved'?'Accepted':status==='rejected'?'Rejected':status==='contacted'?'Contacted':'In review'}
|
||
function reviewStatusTabs(p){const options=[['pending','In review'],['approved','Accepted'],['rejected','Rejected'],['contacted','Contacted']];return `<div class="drawer-section review-section"><div class="section-heading"><h3>Review status</h3><span class="muted">Saved to this prospect</span></div><div class="drawer-review-tabs">${options.map(([value,label])=>`<button type="button" class="drawer-review-tab ${p.review_status===value?'active':''}" data-drawer-review="${p.id}" data-status="${value}">${label}</button>`).join('')}</div></div>`}
|
||
function openDrawer(id){const p=prospects.find(x=>String(x.id)===String(id));if(!p)return;selected=p;const score=effectiveScore(p);const assessed=Boolean(p.ai_checked_at&&Number.isFinite(Number(p.ai_probability)));$('drawerName').textContent=p.name;let reasons=[];try{reasons=JSON.parse(p.score_reasons||'[]')}catch{};$('drawerBody').innerHTML=`<div class="drawer-score"><span class="score ${scoreClass(score)}">${score}</span><div><b>${scoreLabel(p)}</b><small>${assessed?'Updated from the latest AI assessment':'Explainable triage signal; assess with AI to update this score'}</small></div></div>${reviewStatusTabs(p)}<div class="drawer-section"><h3>Contact</h3><p>${esc(p.emails||'No public email found')}</p><p>${esc(p.phone||'No phone listed')}</p></div><div class="drawer-section"><h3>Website health</h3><div class="detail-grid"><span>Status<b>${esc((p.website_status||'unknown').replaceAll('_',' '))}</b></span><span>HTTPS<b>${p.website_https?'Yes':'No / unknown'}</b></span><span>Latency<b>${p.website_latency_ms?p.website_latency_ms+' ms':'—'}</b></span><span>Checked<b>${date(p.website_checked_at)}</b></span></div></div><div class="drawer-section"><h3>Why this scored</h3><ul>${reasons.map(r=>`<li>${esc(r)}</li>`).join('')||'<li>No score reasons recorded</li>'}</ul></div><div class="drawer-actions"><button class="primary" data-message="${p.id}">Draft AI email</button>${p.website?`<a class="secondary" href="${esc(p.website)}" target="_blank" rel="noopener">Open website ↗</a>`:''}</div>`;$('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('click',async e=>{const button=e.target.closest('[data-drawer-review]');if(!button)return;e.preventDefault();e.stopPropagation();const id=button.dataset.drawerReview,status=button.dataset.status;const panel=button.closest('.review-section');const buttons=panel?panel.querySelectorAll('[data-drawer-review]'):[];buttons.forEach(item=>item.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]=updated;selected=updated;openDrawer(id);if($('prospectTable'))$('prospectTable').innerHTML=prospectTable()}catch(error){buttons.forEach(item=>item.disabled=false);if(panel){const notice=document.createElement('p');notice.className='notice error';notice.textContent=`Could not save status: ${error.message}`;panel.append(notice)}}});
|
||
document.addEventListener('change',async e=>{if(e.target.id==='prospectSearch'||['prospectScoreBand','prospectWebsiteFilter','prospectEmailFilter','prospectReviewFilter'].includes(e.target.id)){if(e.target.id==='prospectScoreBand')prospectScoreBand=e.target.value;if(e.target.id==='prospectWebsiteFilter')prospectWebsiteFilter=e.target.value;if(e.target.id==='prospectEmailFilter')prospectEmailFilter=e.target.value;if(e.target.id==='prospectReviewFilter')prospectReviewFilter=e.target.value;if($('prospectTable'))$('prospectTable').innerHTML=prospectTable();return}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=>`<option value="${esc(m.id)}" label="${esc(m.label||m.id)}"></option>`).join('');if(model&&models.length&&!models.some(m=>m.id===model.value))model.value=models[0].id;}catch(err){}}}});
|
||
document.addEventListener('input',e=>{if(e.target.id==='prospectSearch'&&$('prospectTable'))$('prospectTable').innerHTML=prospectTable()});
|
||
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='AI settings saved.';m.className='notice ok';}catch(x){m.textContent=x.message;m.className='notice error'}
|
||
}
|
||
if(e.target.id==='runtimeSettingsForm'){
|
||
e.preventDefault();const m=$('runtimeSettingsMessage');m.textContent='Saving…';m.className='notice';
|
||
try{
|
||
const result=await api('/api/settings',{method:'POST',body:JSON.stringify({browser_use:{enabled:$('browserEnabled').checked,max_steps:Number($('browserSteps').value),timeout_seconds:Number($('browserTimeout').value),llm_timeout_seconds:Number($('browserLlmTimeout').value)},maps:{request_timeout_seconds:Number($('mapsTimeout').value),poll_seconds:Number($('mapsPoll').value),max_depth:Number($('mapsDepth').value)},limits:{website_scan_timeout_seconds:Number($('websiteTimeout').value)}})});
|
||
settings.browser_use=result.browser_use||settings.browser_use;settings.maps={...settings.maps,...(result.maps||{})};settings.limits={...settings.limits,...(result.limits||{})};m.textContent='Runtime settings saved.';m.className='notice ok';
|
||
}catch(x){m.textContent=x.message;m.className='notice error'}
|
||
}
|
||
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)}}
|
||
if(e.target.id==='teachAiForm'){e.preventDefault();const m=$('teachAiMessage');m.textContent='Saving…';m.className='notice';try{const result=await api('/api/settings',{method:'POST',body:JSON.stringify({company_profile:{website_url:$('companyWebsiteUrl').value,name:$('companyName').value,description:$('companyDescription').value,services:$('companyServices').value,packages:$('companyPackages').value,tone:$('companyTone').value,notes:$('companyNotes').value}})});settings.company_profile=result.company_profile||settings.company_profile;m.textContent='Company knowledge saved.';m.className='notice ok'}catch(x){m.textContent=x.message;m.className='notice error'}}
|
||
if(e.target.id==='promotionForm'){e.preventDefault();const m=$('promotionMessage');m.textContent='Saving…';m.className='notice';try{await api('/api/promotions',{method:'POST',body:JSON.stringify({name:$('promotionName').value,description:$('promotionDescription').value,terms:$('promotionTerms').value,starts_at:$('promotionStarts').value,ends_at:$('promotionEnds').value,active:$('promotionActive').checked})});route()}catch(x){m.textContent=x.message;m.className='notice error'}}
|
||
});
|
||
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='Writing…';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,tone:$('messageTone')?.value||'warm',goal:$('messageGoal')?.value||'conversation',promotion_id:$('messagePromotion')?.value||null,include_mockup:$('includeMockup')?.checked!==false,mockup_id:$('includeMockup')?.dataset.mockupId||null})});$('subjectOutput').value=d.subject;$('bodyOutput').value=d.message;selected={...selected,ai_subject:d.subject,ai_message:d.message,ai_offer:$('offerInput').value,ai_promotion_id:$('messagePromotion')?.value?Number($('messagePromotion').value):null,ai_mockup_id:d.mockup_url?(Number($('includeMockup')?.dataset.mockupId||selected.ai_mockup_id||0)||null):null,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. Edit it, verify the facts, then copy it to your mail client.';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'}}});
|
||
document.addEventListener('click',async e=>{if(e.target.id==='learnCompanyWebsite'){const button=e.target,m=$('teachAiLearnMessage');const url=$('companyWebsiteUrl')?.value||'';if(!url){m.textContent='Enter a website URL first.';m.className='notice error';return}button.disabled=true;button.textContent='Reading…';m.textContent='Fetching the public page…';m.className='notice';try{const result=await api('/api/company-profile/learn',{method:'POST',body:JSON.stringify({url})});settings.company_profile=result.company_profile||settings.company_profile;const p=settings.company_profile||{};$('companyWebsiteUrl').value=p.website_url||url;if($('companyName')&&!$('companyName').value&&p.website_title)$('companyName').value=p.website_title;m.textContent=`Website snapshot saved (${(p.website_content||'').length.toLocaleString()} characters). Review it, then save your company knowledge.`;m.className='notice ok'}catch(x){m.textContent=x.message;m.className='notice error'}finally{button.disabled=false;button.textContent='Read website now'}}const toggle=e.target.closest('[data-promotion-active]');if(toggle){try{await api(`/api/promotions/${toggle.dataset.promotionActive}`,{method:'PATCH',body:JSON.stringify({active:toggle.checked})});const item=(settings.promotions||[]).find(p=>String(p.id)===String(toggle.dataset.promotionActive));if(item)item.active=toggle.checked?1:0;renderPromotions()}catch(x){toggle.checked=!toggle.checked;alert(x.message)}}const remove=e.target.closest('[data-delete-promotion]');if(remove){if(!confirm('Delete this promotion?'))return;remove.disabled=true;try{await api(`/api/promotions/${remove.dataset.deletePromotion}`,{method:'DELETE'});settings.promotions=(settings.promotions||[]).filter(p=>String(p.id)!==String(remove.dataset.deletePromotion));renderPromotions()}catch(x){remove.disabled=false;alert(x.message)}}});
|
||
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?`<h3>AI assessment</h3><div class="detail-grid"><span>Contact probability<b>${Math.round(Number(p.ai_probability))}%</b></span><span>Recommendation<b>${esc(aiRecommendationLabel(p.ai_recommendation))}</b></span></div><p>${esc(p.ai_reasoning||'No rationale returned.')}</p><p class="muted">Assessed ${date(p.ai_checked_at)}</p><button class="secondary" data-assess="${p.id}">Reassess with AI</button>`:`<h3>AI assessment</h3><p>Use the collected evidence to estimate whether this company is worth contacting.</p><button class="secondary" data-assess="${p.id}">Assess with AI</button>`;const actions=body.querySelector('.drawer-actions');if(actions)body.insertBefore(section,actions);else body.append(section)}
|
||
function candidateEvidence(candidate){try{const value=JSON.parse(candidate.evidence||'[]');return Array.isArray(value)?value.filter(Boolean).map(String):[]}catch{return[]}}
|
||
function renderWebsiteCandidates(items,id){const section=$('websiteCandidatesPanel');if(!section)return;const rows=Array.isArray(items)?items:[];section.innerHTML=`<h3>Possible official websites</h3><p class="muted">Review the evidence before accepting a domain. Accepting runs the normal website and public-email scan.</p><button class="secondary" data-find-websites="${id}">Search again</button>${rows.length?`<div class="candidate-list">${rows.map(c=>{const evidence=candidateEvidence(c);const status=c.status||'candidate';return `<div class="candidate-card"><div><a href="${esc(c.url)}" target="_blank" rel="noopener">${esc(c.domain)} ↗</a><span class="badge ${status==='accepted'?'good':status==='rejected'?'warn':'info'}">${esc(status)}</span></div><p>Confidence <b>${Number(c.confidence||0)}%</b></p>${evidence.length?`<ul>${evidence.map(item=>`<li>${esc(item)}</li>`).join('')}</ul>`:'<p class="muted">No evidence returned.</p>'}${status==='candidate'?`<div class="candidate-actions"><button class="primary" data-accept-website="${c.id}" data-prospect-id="${id}">Accept domain</button><button class="secondary" data-reject-website="${c.id}" data-prospect-id="${id}">Reject</button></div>`:''}</div>`}).join('')}</div>`:'<p class="muted">No plausible official website candidates found yet.</p>'}`}
|
||
function addWebsitePanel(id){const body=$('drawerBody');const p=prospects.find(x=>String(x.id)===String(id));if(!p||!body)return;const section=document.createElement('section');section.className='drawer-section';section.id='websiteCandidatesPanel';section.innerHTML=`<h3>Find a possible website</h3><p>Searches a few permitted public pages for a likely official domain when Maps did not list one. It never logs in or bypasses access challenges.</p><button class="secondary" data-find-websites="${p.id}">Find possible websites</button><p class="muted">Requires a tested AI provider in Settings.</p>`;const actions=body.querySelector('.drawer-actions');if(actions)body.insertBefore(section,actions);else body.append(section);api(`/api/prospects/${p.id}/website-candidates`).then(d=>{if((d.items||[]).length)renderWebsiteCandidates(d.items,p.id)}).catch(()=>{})}
|
||
const _openDrawerWithAssessment=openDrawer;openDrawer=function(id){_openDrawerWithAssessment(id);addAssessmentPanel(id);addWebsitePanel(id);addWorkflowPanel(id);addHistoryPanel(id);addMockupPanel(id)}
|
||
async function assessProspect(id,button){if(button){button.disabled=true;button.textContent='Assessing…'}try{const d=await api(`/api/prospects/${id}/assess`,{method:'POST',body:'{}'});const index=prospects.findIndex(p=>String(p.id)===String(id));if(index>=0)prospects[index]={...prospects[index],...d};selected=prospects[index]||selected;openDrawer(id);if($('prospectTabs'))$('prospectTabs').innerHTML=prospectTabs();if($('prospectTable'))$('prospectTable').innerHTML=prospectTable()}catch(error){const panel=$('aiAssessmentPanel');if(panel){const notice=document.createElement('p');notice.className='notice error';notice.textContent=error.message;panel.append(notice)}if(button){button.disabled=false;button.textContent='Assess with AI'}}}
|
||
document.addEventListener('click',async e=>{const button=e.target.closest('[data-assess]');if(button){e.preventDefault();e.stopPropagation();await assessProspect(button.dataset.assess,button)}});
|
||
async function findWebsiteCandidates(id,button){if(button){button.disabled=true;button.textContent='Searching…'}const section=$('websiteCandidatesPanel');try{const d=await api(`/api/prospects/${id}/website-candidates`,{method:'POST',body:'{}'});renderWebsiteCandidates(d.items||[],id)}catch(error){if(section){const notice=document.createElement('p');notice.className='notice error';notice.textContent=error.message;section.append(notice)}}finally{if(button){button.disabled=false;button.textContent='Search again'}}}
|
||
document.addEventListener('click',async e=>{const find=e.target.closest('[data-find-websites]');if(find){e.preventDefault();e.stopPropagation();await findWebsiteCandidates(find.dataset.findWebsites,find);return}const accept=e.target.closest('[data-accept-website]');if(accept){e.preventDefault();e.stopPropagation();accept.disabled=true;accept.textContent='Checking…';try{const d=await api(`/api/prospects/${accept.dataset.prospectId}/website-candidates/${accept.dataset.acceptWebsite}`,{method:'PATCH',body:JSON.stringify({status:'accepted'})});if(d.prospect){const i=prospects.findIndex(p=>String(p.id)===String(d.prospect.id));if(i>=0)prospects[i]={...prospects[i],...d.prospect};selected=d.prospect}openDrawer(accept.dataset.prospectId);if($('prospectTable'))$('prospectTable').innerHTML=prospectTable()}catch(error){accept.disabled=false;accept.textContent='Accept domain';const panel=$('websiteCandidatesPanel');if(panel){const notice=document.createElement('p');notice.className='notice error';notice.textContent=error.message;panel.append(notice)}}return}const reject=e.target.closest('[data-reject-website]');if(reject){e.preventDefault();e.stopPropagation();reject.disabled=true;try{const d=await api(`/api/prospects/${reject.dataset.prospectId}/website-candidates/${reject.dataset.rejectWebsite}`,{method:'PATCH',body:JSON.stringify({status:'rejected'})});const current=await api(`/api/prospects/${reject.dataset.prospectId}/website-candidates`);renderWebsiteCandidates(current.items||[],reject.dataset.prospectId)}catch(error){reject.disabled=false;const panel=$('websiteCandidatesPanel');if(panel){const notice=document.createElement('p');notice.className='notice error';notice.textContent=error.message;panel.append(notice)}}}});
|
||
window.addEventListener('hashchange',route);$('refreshAll').addEventListener('click',route);ensureMessageDialog();setInterval(refreshWorkerStatus,30000);bootstrapAuth();
|
||
document.addEventListener('click',e=>{const tab=e.target.closest('[data-category-tab]');if(!tab)return;e.preventDefault();e.stopPropagation();activeProspectCategory=tab.dataset.categoryTab||'all';if($('prospectTabs'))$('prospectTabs').innerHTML=prospectTabs();if($('prospectTable'))$('prospectTable').innerHTML=prospectTable()});
|
||
document.addEventListener('click',e=>{const tab=e.target.closest('[data-log-tab]');if(tab){e.preventDefault();activeLogTab=tab.dataset.logTab||'all';refreshLogs();return}if(e.target.id==='refreshLogs')refreshLogs()});
|
||
document.addEventListener('click',e=>{const target=e.target.closest('button,a,select,input,textarea,tr,[role="button"]');if(!target)return;const details={tag:target.tagName.toLowerCase(),id:target.id||'',label:target.getAttribute('aria-label')||target.getAttribute('title')||'',route:location.hash.slice(1)||'dashboard'};for(const name of ['open','message','assess','findWebsites','acceptWebsite','rejectWebsite','categoryTab','logTab','review'])if(target.dataset[name])details[name]=target.dataset[name];logAction('ui_click',details)},true);
|
||
document.addEventListener('change',e=>{const target=e.target;if(!target||!target.id&&!target.dataset.review)return;logAction('ui_change',{tag:target.tagName.toLowerCase(),id:target.id||'',review_id:target.dataset.review||'',route:location.hash.slice(1)||'dashboard'})},true);
|
||
document.addEventListener('submit',e=>{if(e.target?.id)logAction('ui_submit',{form:e.target.id,route:location.hash.slice(1)||'dashboard'})},true);
|
||
window.addEventListener('error',e=>logAction('client_error',{message:String(e.message||'Unknown browser error').slice(0,500),source:String(e.filename||'').slice(0,500),line:e.lineno||0},'error'));
|
||
window.addEventListener('unhandledrejection',e=>logAction('client_unhandled_rejection',{message:String(e.reason?.message||e.reason||'Unhandled promise rejection').slice(0,500)},'error'));
|
||
|
||
/* Workspace security and workflow UI */
|
||
function authShell(title,setup){const root=$('authRoot')||document.body.appendChild(Object.assign(document.createElement('div'),{id:'authRoot'}));root.innerHTML=`<div class="auth-card"><div class="auth-brand"><span class="brand-mark">P</span><div><b>ProspectOS</b><small>Private growth workspace</small></div></div><span class="eyebrow">${setup?'FIRST-TIME SETUP':'SECURE SIGN IN'}</span><h1>${title}</h1><p>${setup?'Create the workspace owner account. Existing catalogue data will be kept in this workspace.':'Sign in to continue to your prospect workspace.'}</p><form id="${setup?'authSetupForm':'authLoginForm'}"><label>Email<input id="authEmail" type="email" autocomplete="email" required placeholder="you@example.com"></label>${setup?`<label>Your name<input id="authName" required placeholder="Workspace owner"></label><label>Workspace name<input id="authWorkspace" required value="ProspectOS workspace"></label>`:''}<label>Password<input id="authPassword" type="password" autocomplete="${setup?'new-password':'current-password'}" required minlength="8" placeholder="At least 8 characters"></label><button class="primary" type="submit">${setup?'Create workspace':'Sign in'} <span>→</span></button><div id="authMessage" class="notice"></div></form></div>`;root.hidden=false;document.querySelector('.app-shell')?.setAttribute('aria-hidden','true');document.querySelector('.app-shell')?.classList.add('auth-hidden');$('detailDrawer')?.classList.remove('open')}
|
||
function renderAuth(status={}){authShell(status.setup_required?'Create your workspace':'Welcome back',Boolean(status.setup_required));const nav=$('nav');if(nav)nav.setAttribute('aria-hidden','true')}
|
||
function setAppAuthenticated(value){const root=$('authRoot');if(root)root.hidden=value;document.querySelector('.app-shell')?.classList.toggle('auth-hidden',!value);document.querySelector('.app-shell')?.removeAttribute('aria-hidden');const nav=$('nav');if(nav)nav.setAttribute('aria-hidden',value?'false':'true');const actions=document.querySelector('.top-actions');if(actions&&value&&!$('logoutButton')){const button=document.createElement('button');button.id='logoutButton';button.className='secondary';button.textContent='Sign out';actions.prepend(button)}if(actions&&!value)$('logoutButton')?.remove();const who=$('updated');if(who&¤tUser)who.textContent=`${currentUser.name||currentUser.email} · ${currentUser.role}`}
|
||
async function bootstrapAuth(){try{const status=await fetch('/api/auth/status',{credentials:'same-origin'}).then(r=>r.json());if(status.authenticated){const me=await fetch('/api/auth/me',{credentials:'same-origin'}).then(r=>r.json());currentUser=me.user||status.user;csrfToken=me.csrf_token||'';setAppAuthenticated(true);route();return}setAppAuthenticated(false);renderAuth(status)}catch(error){setAppAuthenticated(false);renderAuth({setup_required:false})}}
|
||
async function submitAuth(form){const setup=form.id==='authSetupForm';const message=$('authMessage');const button=form.querySelector('button');button.disabled=true;message.textContent='Working…';try{const payload={email:$('authEmail').value,password:$('authPassword').value};if(setup){payload.name=$('authName').value;payload.workspace_name=$('authWorkspace').value}const response=await fetch(setup?'/api/auth/setup':'/api/auth/login',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});const body=await response.json().catch(()=>({}));if(!response.ok)throw Error(body.error||`Request failed (${response.status})`);currentUser=body.user;csrfToken=body.csrf_token||'';setAppAuthenticated(true);if(!location.hash)location.hash='#dashboard';route()}catch(error){message.textContent=error.message;message.className='notice error';button.disabled=false}}
|
||
async function loadUsers(){const data=await api('/api/users');return data.items||[]}
|
||
function renderUsers(){const canManage=ROLE_ORDER_UI(currentUser?.role)>='admin';$('view').innerHTML=layout('Users & workspace','Control who can access this workspace, what they can do, and who owns follow-up work.',`<div class="settings-grid user-grid"><article class="panel"><div class="panel-title"><div><span class="eyebrow">WORKSPACE</span><h2>Ownership</h2><p>All prospects, runs, notes, and audit records are isolated to this workspace.</p></div></div><div id="workspaceInfo" class="workspace-info">Loading workspace…</div>${canManage?`<form id="workspaceForm" class="inline-form"><input id="workspaceName" placeholder="Workspace name"><button class="secondary">Save name</button></form>`:''}</article><article class="panel"><div class="panel-title"><div><span class="eyebrow">ACCESS</span><h2>Invite a teammate</h2><p>Roles limit destructive and operational actions.</p></div></div>${canManage?`<form id="userForm" class="user-form"><label>Name<input id="newUserName" required placeholder="Team member"></label><label>Email<input id="newUserEmail" type="email" required placeholder="name@example.com"></label><label>Role<select id="newUserRole"><option value="reviewer">Reviewer · review and notes</option><option value="researcher">Researcher · runs and assignments</option><option value="admin">Admin · workspace controls</option></select></label><label>Temporary password<input id="newUserPassword" type="password" required minlength="8" placeholder="At least 8 characters"></label><button class="primary">Create user</button><span id="userMessage" class="notice"></span></form>`:'<p class="muted">Only admins and owners can manage users.</p>'}</article></div><article class="panel"><div class="panel-title"><div><span class="eyebrow">MEMBERS</span><h2>Workspace users</h2></div><button class="secondary" id="refreshUsers">Refresh</button></div><div id="usersList" class="user-list">Loading users…</div></article>${canManage?`<article class="panel"><div class="panel-title"><div><span class="eyebrow">BACKUPS</span><h2>Verified database backups</h2><p>Backups are created online, integrity-checked, and retained according to the server setting.</p></div><div><button class="secondary" id="createBackup">Create backup</button></div></div><div id="backupsList" class="backup-list">Loading backups…</div></article><article class="panel"><div class="panel-title"><div><span class="eyebrow">AUDIT</span><h2>Recent control events</h2><p>Sign-ins, permission changes, assignments, reviews, retries, and backup actions.</p></div><a href="#logs">Open logs →</a></div><div id="auditList" class="audit-list">Loading audit…</div></article>`:''}`);refreshUsersPage()}
|
||
function ROLE_ORDER_UI(role){return {viewer:10,reviewer:20,researcher:30,admin:40,owner:50}[role]||0}
|
||
async function refreshUsersPage(){try{const [workspace,users]=await Promise.all([api('/api/workspace'),loadUsers()]);const info=$('workspaceInfo');if(info)info.innerHTML=`<div class="workspace-stat"><b>${esc(workspace.workspace?.name||'Workspace')}</b><span>${workspace.counts?.prospects||0} prospects · ${workspace.counts?.users||0} users</span></div>`;if($('workspaceName'))$('workspaceName').value=workspace.workspace?.name||'';const list=$('usersList');if(list)list.innerHTML=users.map(u=>`<div class="user-row"><div><b>${esc(u.name||u.email)}</b><small>${esc(u.email)} · joined ${date(u.created_at)}${u.last_login_at?` · last sign-in ${date(u.last_login_at)}`:''}</small></div><div class="user-controls"><span class="pill">${esc(u.role)}</span>${ROLE_ORDER_UI(currentUser?.role)>=40&&u.role!=='owner'?`<select data-user-role="${u.id}"><option value="reviewer" ${u.role==='reviewer'?'selected':''}>Reviewer</option><option value="researcher" ${u.role==='researcher'?'selected':''}>Researcher</option><option value="admin" ${u.role==='admin'?'selected':''}>Admin</option></select><label class="switch"><input type="checkbox" data-user-active="${u.id}" ${u.active?'checked':''}> active</label>`:''}</div></div>`).join('')||empty('No users yet');if($('backupsList')){const backups=await api('/api/backups');$('backupsList').innerHTML=backups.items?.map(b=>`<div class="backup-row"><b>${esc(b.name)}</b><span>${Math.round((b.size_bytes||0)/1024)} KB · ${date(b.created_at)}</span></div>`).join('')||'<p class="muted">No backups created yet.</p>'}if($('auditList')){const audit=await api('/api/audit?limit=30');$('auditList').innerHTML=audit.items?.map(a=>`<div class="audit-row"><b>${esc(a.action)}</b><span>${esc(a.user_name||a.user_email||'system')} · ${esc(a.entity_type||'')} ${a.entity_id||''} · ${date(a.created_at)}</span></div>`).join('')||'<p class="muted">No audit events yet.</p>'}}catch(error){const list=$('usersList');if(list)list.innerHTML=`<p class="notice error">${esc(error.message)}</p>`}}
|
||
function addWorkflowPanel(id){const body=$('drawerBody');const p=prospects.find(x=>String(x.id)===String(id));if(!p||!body)return;const section=document.createElement('section');section.className='drawer-section workflow-panel';section.id='workflowPanel';section.innerHTML=`<div class="section-heading"><h3>Ownership & follow-up</h3><span class="muted">Notes and tasks stay with the prospect</span></div><label class="drawer-field">Assigned to<select id="assignmentSelect"><option value="">Unassigned</option></select></label><div id="notesList" class="workflow-list">Loading notes…</div><form id="noteForm" class="drawer-inline-form"><textarea id="noteBody" rows="2" placeholder="Add a review note…" required></textarea><button class="secondary">Add note</button></form><div id="tasksList" class="workflow-list">Loading tasks…</div><form id="taskForm" class="drawer-inline-form"><input id="taskTitle" placeholder="Add a follow-up task…" required><input id="taskDue" type="date"><button class="secondary">Add task</button></form>`;const actions=body.querySelector('.drawer-actions');if(actions)body.insertBefore(section,actions);else body.append(section);Promise.all([loadUsers(),api(`/api/prospects/${p.id}/notes`),api(`/api/prospects/${p.id}/tasks`)]).then(([users,notes,tasks])=>{const select=$('assignmentSelect');if(select){select.innerHTML='<option value="">Unassigned</option>'+users.filter(u=>u.active).map(u=>`<option value="${u.id}" ${String(u.id)===String(p.assigned_user_id)?'selected':''}>${esc(u.name||u.email)}</option>`).join('')}$('notesList').innerHTML=(notes.items||[]).map(n=>`<div class="workflow-item"><b>${esc(n.user_name||n.user_email||'Note')}</b><span>${esc(n.body)}</span><small>${date(n.created_at)}</small></div>`).join('')||'<p class="muted">No notes yet.</p>';$('tasksList').innerHTML=(tasks.items||[]).map(t=>`<div class="workflow-item task-item"><label><input type="checkbox" data-task-status="${t.id}" data-prospect-id="${p.id}" ${t.status==='done'?'checked':''}> <b>${esc(t.title)}</b></label><small>${t.due_at?`Due ${esc(t.due_at)} · `:''}${esc(t.status)}</small></div>`).join('')||'<p class="muted">No follow-up tasks yet.</p>'}).catch(error=>{section.insertAdjacentHTML('beforeend',`<p class="notice error">${esc(error.message)}</p>`)})}
|
||
function addHistoryPanel(id){const body=$('drawerBody');if(!body)return;const section=document.createElement('section');section.className='drawer-section';section.id='historyPanel';section.innerHTML='<h3>Score & AI history</h3><p class="muted">Loading assessment history…</p>';const actions=body.querySelector('.drawer-actions');if(actions)body.insertBefore(section,actions);else body.append(section);api(`/api/prospects/${id}/history`).then(data=>{const scores=data.score_history||[],ais=data.ai_history||[];section.innerHTML=`<h3>Score & AI history</h3>${scores.length?`<div class="history-list">${scores.slice(0,12).map(s=>`<div class="history-item"><b>${esc(s.score_type)} · ${Number(s.score)}%</b><span>${date(s.created_at)}${s.model?` · ${esc(s.model)}`:''}</span></div>`).join('')}</div>`:'<p class="muted">No score history yet.</p>'}${ais.length?`<details class="history-details"><summary>${ais.length} AI assessment${ais.length===1?'':'s'}</summary>${ais.slice(0,8).map(a=>`<div class="history-assessment"><b>${Number(a.probability)}% · ${esc(aiRecommendationLabel(a.recommendation))}</b><small>${date(a.created_at)}${a.model?` · ${esc(a.model)}`:''}</small><p>${esc(a.reasoning||'')}</p></div>`).join('')}</details>`:''}`}).catch(error=>{section.innerHTML=`<h3>Score & AI history</h3><p class="notice error">${esc(error.message)}</p>`})}
|
||
/* Corrected compact user page (keeps admin controls available to owners/admins). */
|
||
function renderUsers(){const canManage=['owner','admin'].includes(currentUser?.role);$('view').innerHTML=layout('Users & workspace','Manage access, ownership, assignments, backups, and audit visibility.',`<div class="settings-grid user-grid"><article class="panel"><div class="panel-title"><div><span class="eyebrow">WORKSPACE</span><h2>Workspace ownership</h2><p>All catalogue records and operational history are isolated here.</p></div></div><div id="workspaceInfo">Loading…</div>${canManage?`<form id="workspaceForm" class="inline-form"><input id="workspaceName" placeholder="Workspace name"><button class="secondary">Save name</button></form>`:''}</article><article class="panel"><div class="panel-title"><div><span class="eyebrow">TEAM ACCESS</span><h2>Add a user</h2><p>Use reviewer for day-to-day review, researcher for runs, and admin for controls.</p></div></div>${canManage?`<form id="userForm" class="user-form"><label>Name<input id="newUserName" required></label><label>Email<input id="newUserEmail" type="email" required></label><label>Role<select id="newUserRole"><option value="reviewer">Reviewer</option><option value="researcher">Researcher</option><option value="admin">Admin</option></select></label><label>Temporary password<input id="newUserPassword" type="password" minlength="8" required></label><button class="primary">Create user</button><span id="userMessage" class="notice"></span></form>`:'<p class="muted">Only owners and admins can manage users.</p>'}</article></div><article class="panel"><div class="panel-title"><div><span class="eyebrow">MEMBERS</span><h2>Workspace users</h2></div><button class="secondary" id="refreshUsers">Refresh</button></div><div id="usersList" class="user-list">Loading…</div></article>${canManage?`<article class="panel"><div class="panel-title"><div><span class="eyebrow">BACKUPS</span><h2>Verified backups</h2><p>Online SQLite backups are integrity-checked before being listed.</p></div><button class="secondary" id="createBackup">Create backup</button></div><div id="backupsList">Loading…</div></article><article class="panel"><div class="panel-title"><div><span class="eyebrow">AUDIT TRAIL</span><h2>Recent control events</h2></div><a href="#logs">Open logs →</a></div><div id="auditList">Loading…</div></article>`:''}`);refreshUsersPage()}
|
||
document.addEventListener('submit',async e=>{if(e.target.id==='authLoginForm'||e.target.id==='authSetupForm'){e.preventDefault();await submitAuth(e.target);return}if(e.target.id==='userForm'){e.preventDefault();const m=$('userMessage');try{await api('/api/users',{method:'POST',body:JSON.stringify({name:$('newUserName').value,email:$('newUserEmail').value,password:$('newUserPassword').value,role:$('newUserRole').value})});m.textContent='User created.';m.className='notice ok';e.target.reset();refreshUsersPage()}catch(error){m.textContent=error.message;m.className='notice error'}}if(e.target.id==='workspaceForm'){e.preventDefault();try{await api('/api/workspace',{method:'POST',body:JSON.stringify({name:$('workspaceName').value})});refreshUsersPage()}catch(error){alert(error.message)}}if(e.target.id==='noteForm'){e.preventDefault();try{await api(`/api/prospects/${selected.id}/notes`,{method:'POST',body:JSON.stringify({body:$('noteBody').value})});openDrawer(selected.id)}catch(error){alert(error.message)}}if(e.target.id==='taskForm'){e.preventDefault();try{await api(`/api/prospects/${selected.id}/tasks`,{method:'POST',body:JSON.stringify({title:$('taskTitle').value,due_at:$('taskDue').value})});openDrawer(selected.id)}catch(error){alert(error.message)}}});
|
||
document.addEventListener('change',async e=>{if(e.target.id==='assignmentSelect'&&selected){try{const updated=await api(`/api/prospects/${selected.id}/assignment`,{method:'POST',body:JSON.stringify({assigned_user_id:e.target.value||null})});selected=updated;const i=prospects.findIndex(p=>String(p.id)===String(updated.id));if(i>=0)prospects[i]=updated}catch(error){alert(error.message)}}if(e.target.dataset.userRole){try{await api(`/api/users/${e.target.dataset.userRole}`,{method:'PATCH',body:JSON.stringify({role:e.target.value})});refreshUsersPage()}catch(error){alert(error.message)}}if(e.target.dataset.userActive){try{await api(`/api/users/${e.target.dataset.userActive}`,{method:'PATCH',body:JSON.stringify({active:e.target.checked})});refreshUsersPage()}catch(error){e.target.checked=!e.target.checked;alert(error.message)}}if(e.target.dataset.taskStatus&&selected){try{await api(`/api/prospects/${selected.id}/tasks/${e.target.dataset.taskStatus}`,{method:'PATCH',body:JSON.stringify({status:e.target.checked?'done':'open'})});openDrawer(selected.id)}catch(error){alert(error.message)}}});
|
||
document.addEventListener('click',async e=>{if(e.target.id==='logoutButton'){try{await fetch('/api/auth/logout',{method:'POST',credentials:'same-origin',headers:{'X-CSRF-Token':csrfToken}})}finally{currentUser=null;csrfToken='';bootstrapAuth()}}if(e.target.id==='refreshUsers')refreshUsersPage();if(e.target.id==='createBackup'){e.target.disabled=true;try{await api('/api/backups',{method:'POST',body:'{}'});refreshUsersPage()}catch(error){alert(error.message)}finally{e.target.disabled=false}}const retry=e.target.closest('[data-retry-run]');if(retry){retry.disabled=true;try{await api(`/api/runs/${retry.dataset.retryRun}/retry`,{method:'POST',body:'{}'});route()}catch(error){alert(error.message)}finally{retry.disabled=false}}const cancel=e.target.closest('[data-cancel-run]');if(cancel){cancel.disabled=true;try{await api(`/api/runs/${cancel.dataset.cancelRun}/cancel`,{method:'POST',body:'{}'});route()}catch(error){alert(error.message)}finally{cancel.disabled=false}}const events=e.target.closest('[data-run-events]');if(events){try{const data=await api(`/api/runs/${events.dataset.runEvents}/events`);const panel=$('runEventsPanel');if(panel)panel.innerHTML=`<article class="panel"><h2>Run #${events.dataset.runEvents} events</h2>${(data.items||[]).map(item=>`<div class="audit-row"><b>${esc(item.stage)} · ${esc(item.status)}</b><span>${esc(item.message||'')} · ${date(item.created_at)}</span></div>`).join('')||'<p class="muted">No events.</p>'}</article>`}catch(error){alert(error.message)}}});
|
||
|
||
/* Website concept previews */
|
||
function renderMockupItems(items=[]){return items.length?`<div class="mockup-list">${items.map(item=>`<div class="mockup-card"><div class="mockup-card-main"><b>${esc(item.title||'Website concept preview')}</b><small>${esc(item.mode==='multipage'?'Multi-page site':'One-page site')} · ${esc(item.generation_source==='ai'?'AI-generated':'Instant fallback')} · ${date(item.created_at)}</small>${item.pages?.length>1?`<span class="muted">${item.pages.length} pages · ${item.pages.map(page=>`<a href="${esc(page.url)}" target="_blank" rel="noopener">${esc(page.slug)}</a>`).join(' · ')}</span>`:''}${item.source_url?`<span class="muted">Based on ${esc(item.source_url)}</span>`:''}</div><div class="mockup-card-actions"><a class="secondary mockup-link" href="${esc(item.share_url)}" target="_blank" rel="noopener">Open preview ↗</a><button class="secondary" data-copy-mockup="${esc(item.share_url)}">Copy link</button></div></div>`).join('')}</div>`:'<p class="muted">No preview yet. Generate one from the public facts already collected for this prospect.</p>'}
|
||
function addMockupPanel(id){const body=$('drawerBody');const p=prospects.find(x=>String(x.id)===String(id));if(!p||!body)return;const section=document.createElement('section');section.className='drawer-section mockup-panel';section.id='mockupsPanel';section.innerHTML=`<div class="section-heading"><div><h3>Website concept preview</h3><span class="muted">A shareable, mobile-friendly redesign concept based on the public evidence.</span></div><span class="pill">Concept only</span></div><p class="muted mockup-safety">Choose the scope first. The preview is static and sanitised: no forms, scripts, tracking, or invented customer claims.</p><div class="mockup-mode-chooser"><button class="primary" data-generate-mockup="${p.id}" data-mockup-mode="onepage"><b>One page</b><small>Focused landing page</small></button><button class="secondary" data-generate-mockup="${p.id}" data-mockup-mode="multipage"><b>Multiple pages</b><small>Home · Services · About · Contact</small></button></div><div id="mockupStatus" class="notice"></div><div id="mockupsList" class="mockups-list"><span class="muted">Loading previews…</span></div>`;const actions=body.querySelector('.drawer-actions');if(actions)body.insertBefore(section,actions);else body.append(section);api(`/api/prospects/${p.id}/mockups`).then(data=>{const list=$('mockupsList');if(list)list.innerHTML=renderMockupItems(data.items||[])}).catch(error=>{const list=$('mockupsList');if(list)list.innerHTML=`<p class="notice error">${esc(error.message)}</p>`})}
|
||
async function generateMockupPreview(id,button,mode='onepage'){const status=$('mockupStatus');const chooser=document.querySelector('.mockup-mode-chooser');if(button)button.disabled=true;if(chooser)chooser.querySelectorAll('button').forEach(item=>item.disabled=true);if(status){status.textContent=`Generating ${mode==='multipage'?'multi-page':'one-page'} preview… The current public website may take a moment to read.`;status.className='notice'}try{const data=await api(`/api/prospects/${id}/mockup`,{method:'POST',body:JSON.stringify({instructions:'',mode})});if(status){status.innerHTML=`Preview ready (${esc(data.mode==='multipage'?'multiple pages':'one page')}). <a href="${esc(data.share_url)}" target="_blank" rel="noopener">Open share link ↗</a>`;status.className='notice ok'}const list=$('mockupsList');if(list){const latest=await api(`/api/prospects/${id}/mockups`);list.innerHTML=renderMockupItems(latest.items||[])}return data}catch(error){if(status){status.textContent=error.message;status.className='notice error'}throw error}finally{if(button)button.disabled=false;if(chooser)chooser.querySelectorAll('button').forEach(item=>item.disabled=false)}}
|
||
async function loadMessageMockupOption(id){const context=$('messageContext');if(!context)return;context.querySelector('.mockup-option')?.remove();try{const data=await api(`/api/prospects/${id}/mockups`);const latest=(data.items||[])[0];if(!latest)return;const option=document.createElement('label');option.className='mockup-option';option.innerHTML=`<input type="checkbox" id="includeMockup" data-mockup-id="${esc(latest.id)}" checked><span><b>Include latest concept preview</b><small>Share the editable preview link once in the email. <a href="${esc(latest.share_url)}" target="_blank" rel="noopener">Open it ↗</a></small></span>`;context.append(option)}catch(error){logAction('mockup_option_load_failed',{prospect_id:id,error:error.message},'warn')}}
|
||
document.addEventListener('click',async e=>{const generate=e.target.closest('[data-generate-mockup]');if(generate){e.preventDefault();try{await generateMockupPreview(generate.dataset.generateMockup,generate,generate.dataset.mockupMode||'onepage')}catch{}return}const copy=e.target.closest('[data-copy-mockup]');if(copy){e.preventDefault();try{await navigator.clipboard.writeText(copy.dataset.copyMockup);copy.textContent='Copied';setTimeout(()=>{copy.textContent='Copy link'},1400)}catch{prompt('Copy preview link',copy.dataset.copyMockup)}}});
|
||
|
||
/* Keep the mockup generation timeout visible with the other runtime controls. */
|
||
const _renderSettingsWithMockupTimeout=renderSettings;
|
||
renderSettings=function(){
|
||
_renderSettingsWithMockupTimeout();
|
||
const websiteTimeout=$('websiteTimeout');
|
||
const group=websiteTimeout?.closest('.setting-group');
|
||
if(!group||$('aiMockupTimeout'))return;
|
||
const label=document.createElement('label');
|
||
label.innerHTML=`AI mockup copy timeout (seconds)<input id="aiMockupTimeout" type="number" min="20" max="300" step="5" value="${Number(settings.limits?.ai_mockup_timeout_seconds||45)}"><small>After this wait the complete premium fallback renders automatically.</small>`;
|
||
group.append(label);
|
||
};
|
||
/* Capture this form before the legacy handler so the new value is saved in the same request. */
|
||
document.addEventListener('submit',async e=>{
|
||
if(e.target?.id!=='runtimeSettingsForm')return;
|
||
e.preventDefault();e.stopImmediatePropagation();
|
||
const message=$('runtimeSettingsMessage');
|
||
if(message){message.textContent='Saving…';message.className='notice'}
|
||
try{
|
||
const result=await api('/api/settings',{method:'POST',body:JSON.stringify({
|
||
browser_use:{enabled:$('browserEnabled').checked,max_steps:Number($('browserSteps').value),timeout_seconds:Number($('browserTimeout').value),llm_timeout_seconds:Number($('browserLlmTimeout').value)},
|
||
maps:{request_timeout_seconds:Number($('mapsTimeout').value),poll_seconds:Number($('mapsPoll').value),max_depth:Number($('mapsDepth').value)},
|
||
limits:{website_scan_timeout_seconds:Number($('websiteTimeout').value),ai_mockup_timeout_seconds:Number($('aiMockupTimeout')?.value||45)}
|
||
})});
|
||
settings.browser_use=result.browser_use||settings.browser_use;settings.maps={...settings.maps,...(result.maps||{})};settings.limits={...settings.limits,...(result.limits||{})};
|
||
if(message){message.textContent='Runtime settings saved.';message.className='notice ok'}
|
||
}catch(error){if(message){message.textContent=error.message;message.className='notice error'}}
|
||
},true);
|
||
|
||
/* AI template editor ----------------------------------------------------- */
|
||
/*
|
||
* Keep the preview in an iframe and the conversation in the parent app.
|
||
* The public preview itself is static and CSP/sandboxed, so an AI request
|
||
* can never inject a chat script, form handler, or tracking pixel into the
|
||
* share link.
|
||
*/
|
||
renderMockupItems=function(items=[]){
|
||
if(!items.length)return '<p class="muted">No preview yet. Generate one from the public facts already collected for this prospect.</p>';
|
||
return `<div class="mockup-list">${items.map(item=>`<div class="mockup-card"><div class="mockup-card-main"><b>${esc(item.title||'Website concept preview')}</b><small>${esc(item.mode==='multipage'?'Multi-page site':'One-page site')} · ${esc(item.generation_source==='ai'?'AI-generated':'Instant fallback')} · version ${Number(item.version||1)} · ${date(item.created_at)}</small>${item.pages?.length>1?`<span class="muted">${item.pages.length} pages · ${item.pages.map(page=>`<a href="${esc(page.url)}" target="_blank" rel="noopener">${esc(page.slug)}</a>`).join(' · ')}</span>`:''}${item.source_url?`<span class="muted">Based on ${esc(item.source_url)}</span>`:''}${Number(item.version||1)>1?`<span class="muted">Edited from an earlier version${item.edit_instructions?`: ${esc(item.edit_instructions)}`:''}</span>`:''}</div><div class="mockup-card-actions"><button type="button" class="primary" data-edit-mockup="${esc(item.id)}" data-edit-prospect="${esc(item.prospect_id)}" data-edit-share="${esc(item.share_url)}" data-edit-mode="${esc(item.mode||'onepage')}" data-edit-version="${esc(item.version||1)}">Edit template</button><a class="secondary mockup-link" href="${esc(item.share_url)}" target="_blank" rel="noopener">Open preview ↗</a><button type="button" class="secondary" data-copy-mockup="${esc(item.share_url)}">Copy link</button></div></div>`).join('')}</div>`;
|
||
};
|
||
|
||
function editorPageOptions(mode){
|
||
return mode==='multipage'?'<option value="all">Entire site</option><option value="home">Home page</option><option value="services">Services page</option><option value="about">About page</option><option value="contact">Contact page</option>':'<option value="all">Entire page</option>';
|
||
}
|
||
function editorPreviewUrl(url,refresh=''){
|
||
const value=String(url||'');if(!value)return '';
|
||
const join=value.includes('?')?'&':'?';return `${value}${join}embed=1${refresh?`&refresh=${encodeURIComponent(refresh)}`:''}`;
|
||
}
|
||
|
||
function editorAddMessage(messages,kind,text){
|
||
if(!messages)return;
|
||
const bubble=document.createElement('div');bubble.className=`mockup-chat-message ${kind||'assistant'}`;bubble.textContent=String(text||'');messages.append(bubble);messages.scrollTop=messages.scrollHeight;
|
||
}
|
||
|
||
async function openMockupEditor(button){
|
||
const id=button?.dataset?.editMockup;
|
||
if(!id)return;
|
||
document.getElementById('mockupEditorDialog')?.remove();
|
||
let state={id:Number(id),prospect_id:Number(button.dataset.editProspect||0),share_url:button.dataset.editShare||'',mode:button.dataset.editMode||'onepage',version:Number(button.dataset.editVersion||1),title:'Website concept preview'};
|
||
const dialog=document.createElement('dialog');dialog.id='mockupEditorDialog';dialog.className='mockup-editor-dialog';
|
||
dialog.innerHTML=`<div class="mockup-editor-shell"><header class="mockup-editor-head"><div><span class="eyebrow">TEMPLATE EDITOR</span><h2>${esc(state.title)}</h2><p>Ask the AI for a focused change, then review the new version before sharing it.</p></div><div class="mockup-editor-head-actions"><span id="mockupEditorVersion" class="pill">Version ${state.version}</span><button type="button" class="icon-btn" data-editor-close aria-label="Close editor">×</button></div></header><div class="mockup-editor-layout"><section class="mockup-editor-preview"><div class="mockup-editor-preview-bar"><b>Live concept preview</b><a id="mockupEditorOpen" href="${esc(state.share_url)}" target="_blank" rel="noopener">Open full preview ↗</a></div><iframe id="mockupEditorFrame" title="Website concept preview" sandbox="allow-same-origin" referrerpolicy="no-referrer" src="${esc(editorPreviewUrl(state.share_url))}"></iframe></section><aside class="mockup-editor-chat"><div class="mockup-chat-head"><div><span class="eyebrow">AI DESIGN CHAT</span><h3>What should change?</h3></div><span class="mockup-chat-badge">Safe renderer</span></div><div id="mockupEditorMessages" class="mockup-chat-messages"></div><form id="mockupEditorForm" class="mockup-chat-form"><label>Apply to<select id="mockupEditorPage">${editorPageOptions(state.mode)}</select></label><textarea id="mockupEditorInput" rows="5" required maxlength="3000" placeholder="e.g. Make the design darker, make Services more prominent, and change the headline to something more confident."></textarea><div id="mockupEditorStatus" class="notice">The AI uses the prospect evidence and your instruction. It will not add scripts, forms, tracking, or unsupported claims.</div><button class="primary" type="submit" id="mockupEditorApply">Apply change <span>→</span></button></form><div class="mockup-editor-note"><b>Version history is preserved</b><span>Every applied change creates a new share link. The previous version remains available in the prospect drawer.</span></div></aside></div></div>`;
|
||
document.body.append(dialog);
|
||
const close=()=>{try{dialog.close()}catch{}dialog.remove()};
|
||
dialog.querySelector('[data-editor-close]')?.addEventListener('click',close);
|
||
dialog.addEventListener('cancel',e=>{e.preventDefault();close()});
|
||
dialog.addEventListener('click',e=>{if(e.target===dialog)close()});
|
||
try{dialog.showModal()}catch{dialog.setAttribute('open','')}
|
||
const messages=$('mockupEditorMessages'),frame=$('mockupEditorFrame'),openLink=$('mockupEditorOpen'),versionLabel=$('mockupEditorVersion'),form=$('mockupEditorForm'),input=$('mockupEditorInput'),status=$('mockupEditorStatus'),apply=$('mockupEditorApply');
|
||
editorAddMessage(messages,'assistant','I can refine the layout, copy, palette, emphasis, and imagery while keeping the concept grounded in the public prospect evidence. What would you like changed?');
|
||
try{
|
||
const remote=await api(`/api/mockups/${state.id}`);state={...state,...remote};
|
||
const editorTitle=dialog.querySelector('.mockup-editor-head h2');if(editorTitle)editorTitle.textContent=state.title||'Website concept preview';
|
||
if(frame&&state.share_url)frame.src=editorPreviewUrl(state.share_url);if(openLink)openLink.href=state.share_url||'#';if(versionLabel)versionLabel.textContent=`Version ${Number(state.version||1)}`;
|
||
}catch(error){editorAddMessage(messages,'system',`Could not load editor metadata: ${error.message}. The preview can still be edited.`)}
|
||
form?.addEventListener('submit',async event=>{
|
||
event.preventDefault();
|
||
const instruction=String(input?.value||'').trim();if(instruction.length<3){if(status){status.textContent='Tell the AI what you would like changed.';status.className='notice error'}return}
|
||
const page=$('mockupEditorPage')?.value||'all';
|
||
if(apply)apply.disabled=true;if(input)input.disabled=true;if(status){status.textContent='Applying your request and rendering a new version…';status.className='notice'}
|
||
editorAddMessage(messages,'user',instruction);
|
||
try{
|
||
const result=await api(`/api/mockups/${state.id}/edit`,{method:'POST',body:JSON.stringify({instruction,page})});
|
||
state={...state,...result};const editorTitle=dialog.querySelector('.mockup-editor-head h2');if(editorTitle)editorTitle.textContent=state.title||'Website concept preview';editorAddMessage(messages,'assistant',`${result.change_summary||'Your change was applied.'} Version ${Number(result.version||1)} is ready to review.`);
|
||
if(frame&&result.share_url)frame.src=editorPreviewUrl(result.share_url,Date.now());if(openLink)openLink.href=result.share_url||'#';if(versionLabel)versionLabel.textContent=`Version ${Number(result.version||1)}`;if(input)input.value='';if(status){status.textContent='New version ready. Review the preview, then ask for another change or copy the share link.';status.className='notice ok'}
|
||
if(state.prospect_id){try{const latest=await api(`/api/prospects/${state.prospect_id}/mockups`);const list=$('mockupsList');if(list)list.innerHTML=renderMockupItems(latest.items||[])}catch{}}
|
||
}catch(error){editorAddMessage(messages,'system',`I could not apply that change: ${error.message}`);if(status){status.textContent=error.message;status.className='notice error'}}finally{if(apply)apply.disabled=false;if(input)input.disabled=false;input?.focus()}
|
||
});
|
||
input?.focus();
|
||
}
|
||
|
||
/* Capture before row-level click handlers so opening the editor does not
|
||
also open/close the prospect drawer underneath it. */
|
||
document.addEventListener('click',e=>{
|
||
const edit=e.target.closest('[data-edit-mockup]');
|
||
if(!edit)return;
|
||
e.preventDefault();e.stopPropagation();openMockupEditor(edit);
|
||
},true);
|