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=`${icon}${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 `
PROSPECTOS

${title}

${sub}

${actions}
${body}` } function empty(text){return `
⌕${text}
`} 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 `
${categories.map(category=>``).join('')}

All shows scores ${HIGH_POTENTIAL_SCORE}+; category tabs keep every result grouped by the search category.

`} function prospectRows(){const q=($('prospectSearch')?.value||'').toLowerCase();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 `
${rows.map(p=>{const score=effectiveScore(p);return ``}).join('')}
ProspectWebsiteEmailPotential
${esc(p.name)}${esc(prospectCategory(p))} · ${esc(p.address||'')}${esc((p.website_status||'unknown').replaceAll('_',' '))}${p.website?`Open ↗`:'No detected website'}${p.emails?``:'Not found'}${score}${score>=85?'Hot':score>=70?'High':score>=45?'Medium':'Low'}${p.ai_checked_at?' · AI':''}
${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.',`
CATALOGUE${total}saved prospects
NEEDS REVIEW${pending}awaiting your decision
HIGH OPPORTUNITY${hot}score 75 or higher
PUBLIC EMAILS${withEmail}evidence-backed contacts
NEXT ACTION

Review your strongest leads

Inspect the evidence before deciding whether to contact them.

Open explorer →
${strongest.map(p=>{const score=effectiveScore(p);return ``}).join('')||empty('Run a discovery to build your catalogue')}
RECENT ACTIVITY

Runs

View all
${runs.slice(0,6).map(r=>`
#${r.id} · ${esc(r.query)}${esc(r.status)} · ${r.added||0} added
`).join('')||empty('No runs yet')}
`)} function renderDiscover(){ $('view').innerHTML=layout('Discover','Set the criteria for this run. Source configuration and provider settings live in Settings.',`
NEW RUN

Find potential customers

Search Google Maps, deduplicate the catalogue, then enrich each result.

Email collection always on
HOW IT WORKS

Evidence-first pipeline

  1. SearchMaps worker finds public business listings.
  2. EnrichWebsite, HTTPS, DNS hints and public email checks run safely.
  3. ReviewYou decide what is relevant before any contact.
`)} function renderProspects(){ $('view').innerHTML=layout('Prospects','Browse the strongest opportunities first, then narrow the catalogue with filters and sort by the latest score.',`
${prospectTabs()}

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.

${prospectTable()}
`);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.',`
${runs.map(r=>{const active=['queued','running'].includes(r.status);const retry=['failed','cancelled'].includes(r.status)||r.recovery_state==='retryable';return ``}).join('')}
RunStatusResultsStartedSourceActions
#${r.id}${esc(r.query)}${esc(r.status)}${esc(r.stage||'')}${r.recovery_state?` · ${esc(r.recovery_state)}`:''}${r.added||0} added / ${r.found||0} found${date(r.created_at)}${esc(r.source||'google_maps')}${active?``:''}${retry?``:''}
${runs.length?'':empty('No discovery runs yet')}
`)} function logTabCount(tab,counts){return tab==='errors'?Number(counts?.errors||0):Number(counts?.[tab]||0)} function logTabs(counts={}){return `
${LOG_TABS.map(([key,label])=>``).join('')}
`} function renderLogTable(){const rows=Array.isArray(logs)?logs:[];return rows.length?`
${rows.map(row=>{const details=typeof row.details==='string'?row.details:JSON.stringify(row.details||{},null,2);return ``}).join('')}
TimeTypeEventStatusDurationDetails
${esc(date(row.created_at))}${esc(row.kind||'system')}${esc(row.event||'')}${row.path?`${esc(row.method||'')} ${esc(row.path)}`:''}${row.error?`${esc(row.error)}`:''}${row.status?esc(row.status):'—'}${row.duration_ms!=null?esc(row.duration_ms)+' ms':'—'}
View
${esc(details)}
`:empty('No log entries for this tab')} async function refreshLogs(){const status=$('logsStatus');if(status)status.textContent='Loading logs…';try{const params=new URLSearchParams({limit:'250'});if(activeLogTab==='errors')params.set('level','error');else if(activeLogTab!=='all')params.set('kind',activeLogTab);const data=await api('/api/logs?'+params.toString());logs=data.items||[];const tabs=$('logsTabs');if(tabs)tabs.innerHTML=logTabs(data.counts||{});const table=$('logsTable');if(table)table.innerHTML=renderLogTable();if(status)status.textContent=`Showing ${logs.length} of ${data.total||logs.length} entries · credentials are redacted`; }catch(error){if(status)status.textContent=error.message;if($('logsTable'))$('logsTable').innerHTML=empty('Logs could not be loaded')}} function renderLogs(){ $('view').innerHTML=layout('Logs','Inspect user actions, API calls, AI requests, searches, Browser Use steps, and errors in one place.',`
OPERATIONS JOURNAL

Detailed activity

Requests and actions are timestamped. API keys, authorization headers, and passwords are never stored.

${logTabs()}
Loading logs…
`);refreshLogs()} function 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.',`
COMPANY KNOWLEDGE

Teach the assistant

Save the facts you want it to use. It will never invent prices, packages, or promises outside this profile and your draft instruction.

${p.configured?'Profile ready':'Not taught yet'}
${p.website_fetched_at?`Last read ${esc(date(p.website_fetched_at))}`:''}
LEARNED SNAPSHOT

What the assistant can reference

${p.website_title?esc(p.website_title):'No website snapshot yet'}${p.website_content?`${p.website_content.length.toLocaleString()} characters captured from the public page.`:'Use Read website now, then review the text before saving.'}
${p.website_content?`
View captured website text

${esc(p.website_content)}

`:'

Manual company details are always available, even without a website.

'}
`)} 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.',`
NEW PROMOTION

Set up an offer

Keep the exact wording, price, dates, and eligibility here so drafts stay accurate.

YOUR OFFERS

Saved promotions

${items.filter(x=>x.active).length} active · ${items.length} total

${items.map(item=>`
${esc(item.name)}

${esc(item.description)}

${item.terms?`Terms: ${esc(item.terms)}`:''}${item.starts_at||item.ends_at?`Valid ${esc(item.starts_at||'now')} → ${esc(item.ends_at||'ongoing')}`:'No dates set'}
`).join('')||'

No promotions yet. Add your first offer on the left.

'}
`)} 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=>``).join('')+(selectedModel&&!modelList.some(m=>m.id===selectedModel)?``:''); $('view').innerHTML=layout('Settings','All provider, browser, run, and safety controls live here. Daily discovery only asks for the search criteria.',`
RUNTIME CONTROLS

Browser and discovery timing

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.

${maps.ready?'Maps ready':'Maps not connected'}

Browser Use website search

Headless, public-only browsing for possible official domains. It never logs in or bypasses challenge pages.

Google Maps worker

The local gosom worker is asynchronous. A run is allowed to finish before results are downloaded.

Connection${esc(maps.worker_url||'Not configured')}

Website health and email scan

Every public website is checked for health signals and public email addresses. Email collection remains always on.

AI PROVIDER

Draft assistant

Used when you assess a prospect or draft an email.

${ai.configured?'Configured':'Not configured'}

${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.'}

Nous model availability can change. Refresh Settings to update the free catalog.

SCHEDULES

Recurring discovery

Run bounded searches automatically; all results still arrive as needs-review.

${sch.map(s=>`
${esc(s.keywords)} · ${esc(s.category)}${esc(s.location)} · every ${s.interval_hours}h · next ${date(s.next_run_at)}
`).join('')||'

No schedules configured.

'}
SAFETY

Suppression centre

Suppressed domains and emails are excluded before new prospects are saved.

${sup.map(s=>`
${esc(s.value)}${esc(s.kind)} · ${esc(s.reason||'manual')} · ${date(s.created_at)}
`).join('')||'

No suppressions configured.

'}
`) } 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;const status=String(p.website_status||'').toLowerCase();if(['broken','unreachable','error','timeout'].includes(status))return'First check whether the current site can be recovered; if a rebuild is useful, move hosting to our Gold Plus package at R119/month and include the rebuild at no extra design charge.';if(status==='slow')return'Offer a quick performance review and, if useful, move hosting to our Gold Plus package at R119/month.';if(!p.website)return'If useful, suggest a .co.za domain, Gold Plus hosting at R119/month, professional email, and a simple first website.';if(!p.website_has_viewport)return'Offer a short mobile-friendly website review and only suggest changes that the evidence supports.';return'Offer a friendly website and hosting review; only suggest a change that is supported by the evidence.'} function jsonArray(value){try{const parsed=JSON.parse(value||'[]');return Array.isArray(parsed)?parsed:[]}catch{return[]}} function showDraftNotes(d){let notes=$('draftNotes');if(!notes){notes=document.createElement('div');notes.id='draftNotes';notes.className='draft-notes';const status=$('messageStatus');if(status)status.parentNode.insertBefore(notes,status)}if(!notes)return;const unknowns=Array.isArray(d?.unknowns)?d.unknowns:[];const used=Array.isArray(d?.evidence_used)?d.evidence_used:[];notes.innerHTML=`AI review notes

${esc(d?.rationale||'The draft uses the prospect evidence and your instructions.')}

${unknowns.length?`

Confirm before sending: ${esc(unknowns.join(' · '))}

`:''}${used.length?`

Evidence used: ${esc(used.join(', '))}

`:''}${d?.follow_up?`

${esc(d.follow_up)}

`:''}`} function promotionOptions(){return (Array.isArray(settings.promotions)?settings.promotions:[]).filter(item=>item.active).map(item=>``).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=`

Write a message for this prospect

Use verified facts, your company knowledge, and any selected promotion. You can edit every word before copying it.

`} 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=`
${score}
${esc(selected.name)}${esc(prospectCategory(selected))} · ${esc(selected.address||'Location not listed')}
${selected.website?`Website: ${esc((selected.website_status||'detected').replaceAll('_',' '))}`:'No website detected'}${selected.emails?`Public email: ${esc(selected.emails)}`:'No public email found'}
`;$('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 `

Review status

Saved to this prospect
${options.map(([value,label])=>``).join('')}
`} 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=`
${score}
${scoreLabel(p)}${assessed?'Updated from the latest AI assessment':'Explainable triage signal; assess with AI to update this score'}
${reviewStatusTabs(p)}

Contact

${esc(p.emails||'No public email found')}

${esc(p.phone||'No phone listed')}

Website health

Status${esc((p.website_status||'unknown').replaceAll('_',' '))}HTTPS${p.website_https?'Yes':'No / unknown'}Latency${p.website_latency_ms?p.website_latency_ms+' ms':'—'}Checked${date(p.website_checked_at)}

Why this scored

${p.website?`Open website ↗`:''}
`;$('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=>``).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='Comparing the prospect facts 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. Read it aloud, edit anything you want, 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='Generate client-specific 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?`

AI assessment

Contact probability${Math.round(Number(p.ai_probability))}%Recommendation${esc(aiRecommendationLabel(p.ai_recommendation))}

${esc(p.ai_reasoning||'No rationale returned.')}

Assessed ${date(p.ai_checked_at)}

`:`

AI assessment

Use the collected evidence to estimate whether this company is worth contacting.

`;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=`

Possible official websites

Review the evidence before accepting a domain. Accepting runs the normal website and public-email scan.

${rows.length?`
${rows.map(c=>{const evidence=candidateEvidence(c);const status=c.status||'candidate';return `
${esc(c.domain)} ↗${esc(status)}

Confidence ${Number(c.confidence||0)}%

${evidence.length?``:'

No evidence returned.

'}${status==='candidate'?`
`:''}
`}).join('')}
`:'

No plausible official website candidates found yet.

'}`} 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=`

Find a possible website

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.

Requires a tested AI provider in Settings.

`;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=`
P
ProspectOSPrivate growth workspace
${setup?'FIRST-TIME SETUP':'SECURE SIGN IN'}

${title}

${setup?'Create the workspace owner account. Existing catalogue data will be kept in this workspace.':'Sign in to continue to your prospect workspace.'}

${setup?``:''}
`;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.',`
WORKSPACE

Ownership

All prospects, runs, notes, and audit records are isolated to this workspace.

Loading workspace…
${canManage?`
`:''}
ACCESS

Invite a teammate

Roles limit destructive and operational actions.

${canManage?`
`:'

Only admins and owners can manage users.

'}
MEMBERS

Workspace users

Loading users…
${canManage?`
BACKUPS

Verified database backups

Backups are created online, integrity-checked, and retained according to the server setting.

Loading backups…
AUDIT

Recent control events

Sign-ins, permission changes, assignments, reviews, retries, and backup actions.

Open logs →
Loading audit…
`:''}`);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=`
${esc(workspace.workspace?.name||'Workspace')}${workspace.counts?.prospects||0} prospects · ${workspace.counts?.users||0} users
`;if($('workspaceName'))$('workspaceName').value=workspace.workspace?.name||'';const list=$('usersList');if(list)list.innerHTML=users.map(u=>`
${esc(u.name||u.email)}${esc(u.email)} · joined ${date(u.created_at)}${u.last_login_at?` · last sign-in ${date(u.last_login_at)}`:''}
${esc(u.role)}${ROLE_ORDER_UI(currentUser?.role)>=40&&u.role!=='owner'?``:''}
`).join('')||empty('No users yet');if($('backupsList')){const backups=await api('/api/backups');$('backupsList').innerHTML=backups.items?.map(b=>`
${esc(b.name)}${Math.round((b.size_bytes||0)/1024)} KB · ${date(b.created_at)}
`).join('')||'

No backups created yet.

'}if($('auditList')){const audit=await api('/api/audit?limit=30');$('auditList').innerHTML=audit.items?.map(a=>`
${esc(a.action)}${esc(a.user_name||a.user_email||'system')} · ${esc(a.entity_type||'')} ${a.entity_id||''} · ${date(a.created_at)}
`).join('')||'

No audit events yet.

'}}catch(error){const list=$('usersList');if(list)list.innerHTML=`

${esc(error.message)}

`}} 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=`

Ownership & follow-up

Notes and tasks stay with the prospect
Loading notes…
Loading tasks…
`;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=''+users.filter(u=>u.active).map(u=>``).join('')}$('notesList').innerHTML=(notes.items||[]).map(n=>`
${esc(n.user_name||n.user_email||'Note')}${esc(n.body)}${date(n.created_at)}
`).join('')||'

No notes yet.

';$('tasksList').innerHTML=(tasks.items||[]).map(t=>`
${t.due_at?`Due ${esc(t.due_at)} · `:''}${esc(t.status)}
`).join('')||'

No follow-up tasks yet.

'}).catch(error=>{section.insertAdjacentHTML('beforeend',`

${esc(error.message)}

`)})} function addHistoryPanel(id){const body=$('drawerBody');if(!body)return;const section=document.createElement('section');section.className='drawer-section';section.id='historyPanel';section.innerHTML='

Score & AI history

Loading assessment history…

';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=`

Score & AI history

${scores.length?`
${scores.slice(0,12).map(s=>`
${esc(s.score_type)} · ${Number(s.score)}%${date(s.created_at)}${s.model?` · ${esc(s.model)}`:''}
`).join('')}
`:'

No score history yet.

'}${ais.length?`
${ais.length} AI assessment${ais.length===1?'':'s'}${ais.slice(0,8).map(a=>`
${Number(a.probability)}% · ${esc(aiRecommendationLabel(a.recommendation))}${date(a.created_at)}${a.model?` · ${esc(a.model)}`:''}

${esc(a.reasoning||'')}

`).join('')}
`:''}`}).catch(error=>{section.innerHTML=`

Score & AI history

${esc(error.message)}

`})} /* 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.',`
WORKSPACE

Workspace ownership

All catalogue records and operational history are isolated here.

Loading…
${canManage?`
`:''}
TEAM ACCESS

Add a user

Use reviewer for day-to-day review, researcher for runs, and admin for controls.

${canManage?`
`:'

Only owners and admins can manage users.

'}
MEMBERS

Workspace users

Loading…
${canManage?`
BACKUPS

Verified backups

Online SQLite backups are integrity-checked before being listed.

Loading…
AUDIT TRAIL

Recent control events

Open logs →
Loading…
`:''}`);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=`

Run #${events.dataset.runEvents} events

${(data.items||[]).map(item=>`
${esc(item.stage)} · ${esc(item.status)}${esc(item.message||'')} · ${date(item.created_at)}
`).join('')||'

No events.

'}
`}catch(error){alert(error.message)}}}); /* Website concept previews */ function renderMockupItems(items=[]){return items.length?`
${items.map(item=>`
${esc(item.title||'Website concept preview')}${esc(item.mode==='multipage'?'Multi-page site':'One-page site')} · ${esc(item.generation_source==='ai'?'AI-generated':'Instant fallback')} · ${date(item.created_at)}${item.pages?.length>1?`${item.pages.length} pages · ${item.pages.map(page=>`${esc(page.slug)}`).join(' · ')}`:''}${item.source_url?`Based on ${esc(item.source_url)}`:''}
`).join('')}
`:'

No preview yet. Generate one from the public facts already collected for this prospect.

'} 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=`

Website concept preview

A shareable, mobile-friendly redesign concept based on the public evidence.
Concept only

Choose the scope first. The preview is static and sanitised: no forms, scripts, tracking, or invented customer claims.

Loading previews…
`;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=`

${esc(error.message)}

`})} 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')}). Open share link ↗`;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=`Include latest concept previewShare the editable preview link once in the email. Open it ↗`;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)After this wait the complete premium fallback renders automatically.`; 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 '

No preview yet. Generate one from the public facts already collected for this prospect.

'; return `
${items.map(item=>`
${esc(item.title||'Website concept preview')}${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)}${item.pages?.length>1?`${item.pages.length} pages · ${item.pages.map(page=>`${esc(page.slug)}`).join(' · ')}`:''}${item.source_url?`Based on ${esc(item.source_url)}`:''}${Number(item.version||1)>1?`Edited from an earlier version${item.edit_instructions?`: ${esc(item.edit_instructions)}`:''}`:''}
Open preview ↗
`).join('')}
`; }; function editorPageOptions(mode){ return mode==='multipage'?'':''; } 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=`
TEMPLATE EDITOR

${esc(state.title)}

Ask the AI for a focused change, then review the new version before sharing it.

Version ${state.version}
Live concept previewOpen full preview ↗
`; 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); /* AI email draft editor -------------------------------------------------- */ /* * The draft dialog deliberately stays a method="dialog" form. The chat is * therefore a button-driven composer rather than a nested form (nested forms * make the browser close the dialog instead of applying the requested edit). * Each request sends the live subject/body fields, so manual edits and prior * chat revisions are both preserved as the next working draft. */ const _ensureMessageDialogWithRevisionChat=ensureMessageDialog; ensureMessageDialog=function(force=false){ _ensureMessageDialogWithRevisionChat(force); const output=document.querySelector('#messageForm .draft-output'); if(!output||$('messageRevisionPanel'))return; output.insertAdjacentHTML('afterend',`
DRAFT CHAT

Ask the AI to refine it

Describe a change and the current subject and email will be revised in place.

Review before sending
The AI can change wording, tone, order, or emphasis. It cannot add unsupported facts.
`); }; function messageChatAdd(kind,text){ const messages=$('messageChatMessages'); if(!messages)return; const bubble=document.createElement('div'); bubble.className=`message-chat-message ${kind||'assistant'}`; bubble.textContent=String(text||''); messages.append(bubble); messages.scrollTop=messages.scrollHeight; } function rememberMessageDraft(d){ if(!selected||!d)return; const promotionValue=$('messagePromotion')?.value||''; const mockupCheckbox=$('includeMockup'); const mockupValue=d.mockup_id??mockupCheckbox?.dataset?.mockupId??selected.ai_mockup_id??null; selected={...selected, ai_subject:d.subject||'',ai_message:d.message||'',ai_offer:$('offerInput')?.value||selected.ai_offer||'', ai_promotion_id:d.promotion_id??(promotionValue?Number(promotionValue):null), ai_mockup_id:d.mockup_url?Number(mockupValue||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||'',ai_checked_at:d.ai_checked_at||selected.ai_checked_at||'' }; const index=prospects.findIndex(p=>String(p.id)===String(selected.id)); if(index>=0)prospects[index]=selected; } const _openMessageWithRevisionChat=openMessage; openMessage=async function(id){ await _openMessageWithRevisionChat(id); const messages=$('messageChatMessages'); if(messages){messages.innerHTML='';messageChatAdd('assistant','I can revise the subject and email while keeping the public evidence, exact offer, promotion terms, and selected preview link intact. What would you like changed?');} const input=$('messageRevisionInput');if(input)input.value=''; const status=$('messageRevisionStatus');if(status){status.textContent='The AI can change wording, tone, order, or emphasis. It cannot add unsupported facts.';status.className='notice';} }; async function reviseMessageDraft(){ if(!selected)return; const input=$('messageRevisionInput'),button=$('messageRevisionApply'),status=$('messageRevisionStatus'); const instruction=String(input?.value||'').trim(); const subject=String($('subjectOutput')?.value||'').trim(); const body=String($('bodyOutput')?.value||'').trim(); if(instruction.length<3){if(status){status.textContent='Tell the AI what you would like changed.';status.className='notice error'}input?.focus();return;} if(!subject||!body){if(status){status.textContent='Generate a draft first, then ask the AI to revise it.';status.className='notice error'}return;} if(button){button.disabled=true;button.textContent='Applying…';} if(status){status.textContent='Applying your request to the current draft…';status.className='notice';} messageChatAdd('user',instruction); try{ const checkbox=$('includeMockup'); const d=await api(`/api/prospects/${selected.id}/message/edit`,{method:'POST',body:JSON.stringify({ instruction,current_subject:subject,current_message:body,offer:$('offerInput')?.value||'', tone:$('messageTone')?.value||'warm',goal:$('messageGoal')?.value||'conversation', promotion_id:$('messagePromotion')?.value||null,include_mockup:checkbox?.checked!==false,mockup_id:checkbox?.dataset?.mockupId||null })}); $('subjectOutput').value=d.subject||'';$('bodyOutput').value=d.message||'';rememberMessageDraft(d);showDraftNotes(d); messageChatAdd('assistant',d.rationale?`Updated the draft: ${d.rationale}`:'Updated the draft. Review the new wording and ask for another change if needed.'); if(status){status.textContent='Draft revised. Verify the facts, then copy it to your mail client.';status.className='notice ok';} if(input)input.value=''; }catch(error){messageChatAdd('system',`I could not revise the draft: ${error.message}`);if(status){status.textContent=error.message;status.className='notice error';}} finally{if(button){button.disabled=false;button.innerHTML='Apply change →';}input?.focus();} } document.addEventListener('click',e=>{if(e.target.id==='messageRevisionApply')reviseMessageDraft()}); document.addEventListener('keydown',e=>{if(e.target.id==='messageRevisionInput'&&e.key==='Enter'&&(e.ctrlKey||e.metaKey)){e.preventDefault();$('messageRevisionApply')?.click()}}); /* The first call above happens before this enhancement is defined. Run the * non-destructive wrapper once so the panel exists before the first click. */ ensureMessageDialog(false);