functionprospectTable(){constrows=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=>{constscore=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')}`}
functionrenderDashboard(){consttotal=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;conststrongest=[...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=>{constscore=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>`)}
functionrenderDiscover(){$('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>`)}
functionrenderProspects(){$('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}
functionrenderLogTable(){constrows=Array.isArray(logs)?logs:[];returnrows.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=>{constdetails=typeofrow.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')}
asyncfunctionrefreshLogs(){conststatus=$('logsStatus');if(status)status.textContent='Loading logs…';try{constparams=newURLSearchParams({limit:'250'});if(activeLogTab==='errors')params.set('level','error');elseif(activeLogTab!=='all')params.set('kind',activeLogTab);constdata=awaitapi('/api/logs?'+params.toString());logs=data.items||[];consttabs=$('logsTabs');if(tabs)tabs.innerHTML=logTabs(data.counts||{});consttable=$('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')}}
functionrenderLogs(){$('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()}
functionrenderTeachAI(){constp=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>`)}
functionrenderPromotions(){constitems=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>`)}
$('view').innerHTML=layout('Settings','All provider, browser, run, and safety controls live here. Daily discovery only asks for the search criteria.',`
<article class="panel wide-panel"><div class="panel-title"><div><span class="eyebrow">DISCOVERY SOURCE</span><h2>Where to search for prospects</h2><p>Firecrawl searches the public web and can replace Maps for criteria-first discovery. It does not provide Google place IDs, ratings, coordinates, or guaranteed addresses.</p></div><span class="status ${discovery.backend==='firecrawl'&&discovery.firecrawl_configured?'succeeded':'failed'}">${discovery.backend==='firecrawl'&&discovery.firecrawl_configured?'Firecrawl ready':discovery.backend==='firecrawl'?'Firecrawl needs a key':discovery.backend==='browser'?'Browser search':'Maps worker'}</span></div><form id="discoverySettingsForm" class="settings-fields"><label>Discovery backend<select id="discoveryBackend"><option value="gmaps" ${discovery.backend==='gmaps'?'selected':''}>Google Maps structured data</option><option value="firecrawl" ${discovery.backend==='firecrawl'?'selected':''}>Firecrawl web search</option><option value="browser" ${discovery.backend==='browser'?'selected':''}>Browser fallback</option></select></label><label>Firecrawl API key <input id="firecrawlKey" type="password" placeholder="Leave blank to keep the saved key" autocomplete="new-password"><small>Stored server-side and never returned to the browser.</small></label><div class="form-row"><button class="primary" type="submit">Save discovery settings</button><span id="discoverySettingsMessage" class="notice"></span></div></form></article>
<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>
<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>
<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="deepseek" ${provider==='deepseek'?'selected':''}>DeepSeek · API</option><option value="custom" ${provider!=='nous'&&provider!=='deepseek'?'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.':provider==='deepseek'?'DeepSeek uses https://api.deepseek.com/v1 and requires a DeepSeek API key.':'Use any OpenAI-compatible /v1 endpoint.'}</p><label>Base URL<input id="aiBase" value="${esc(ai.base_url||'')}" ${(provider==='nous'||provider==='deepseek')?'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">DeepSeek defaults to deepseek-chat. Nous model availability can change. Refresh Settings to update catalogs.</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>
functionsuggestedOffer(p){if(p.ai_offer)returnp.ai_offer;conststatus=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; offer to prepare a quick example so they can see the direction.';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.'}
functionensureMessageDialog(force=false){constdialog=$('messageDialog');constform=$('messageForm');if(dialog)dialog.classList.add('message-dialog');if(!form||(!force&&$('messageTone')))return;form.innerHTML=`<div class="dialog-head"><div><label class="eyebrow">CLIENT-SPECIFIC DRAFT</label><h2 id="messageTitle">Write a message for this prospect</h2><p>Use verified facts, your company knowledge, and any selected promotion. You can edit every word before copying it.</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 client-specific 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>`}
functionopenDrawer(id){constp=prospects.find(x=>String(x.id)===String(id));if(!p)return;selected=p;constscore=effectiveScore(p);constassessed=Boolean(p.ai_checked_at&&Number.isFinite(Number(p.ai_probability)));$('drawerName').textContent=p.name;letreasons=[];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',asynce=>{constbutton=e.target.closest('[data-drawer-review]');if(!button)return;e.preventDefault();e.stopPropagation();constid=button.dataset.drawerReview,status=button.dataset.status;constpanel=button.closest('.review-section');constbuttons=panel?panel.querySelectorAll('[data-drawer-review]'):[];buttons.forEach(item=>item.disabled=true);try{constupdated=awaitapi(`/api/prospects/${id}/review`,{method:'PATCH',body:JSON.stringify({status})});constindex=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){constnotice=document.createElement('p');notice.className='notice error';notice.textContent=`Could not save status: ${error.message}`;panel.append(notice)}}});
document.addEventListener('change',asynce=>{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'){constprovider=e.target.value;constfixed=provider==='nous'||provider==='deepseek';$('aiBase').readOnly=fixed;if(provider==='nous'){$('aiBase').value='https://inference-api.nousresearch.com/v1';}elseif(provider==='deepseek'){$('aiBase').value='https://api.deepseek.com/v1';if(!$('aiModel').value||$('aiModel').value==='')$('aiModel').value='deepseek-chat';}constnote=$('aiProviderNote');if(note)note.textContent=provider==='nous'?'Nous uses the official inference endpoint. OAuth is supported by Hermes CLI; this standalone app uses a server-side API key.':provider==='deepseek'?'DeepSeek uses the official API endpoint and requires a DeepSeek API key.':'Use any OpenAI-compatible /v1 endpoint.';if(provider==='nous'||provider==='deepseek'){try{constdata=awaitapi('/api/ai/models?provider='+encodeURIComponent(provider));constmodel=$('aiModel');constlist=$('nousModelList');constmodels=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){}}}});
try{constr=awaitapi('/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}
document.addEventListener('click',asynce=>{if(e.target.id==='generateMessage'){if(!selected)return;consts=$('messageStatus');constbutton=e.target;button.disabled=true;button.textContent='Writing…';s.textContent='Comparing the prospect facts with your offer…';try{constd=awaitapi(`/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||''};consti=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'){awaitnavigator.clipboard.writeText(`Subject: ${$('subjectOutput').value}\n\n${$('bodyOutput').value}`);$('messageStatus').textContent='Copied to clipboard.'}if(e.target.id==='testAi'){consts=$('settingsMessage');s.textContent='Testing…';s.className='notice';try{constd=awaitapi('/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',asynce=>{if(e.target.id==='learnCompanyWebsite'){constbutton=e.target,m=$('teachAiLearnMessage');consturl=$('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{constresult=awaitapi('/api/company-profile/learn',{method:'POST',body:JSON.stringify({url})});settings.company_profile=result.company_profile||settings.company_profile;constp=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'}}consttoggle=e.target.closest('[data-promotion-active]');if(toggle){try{awaitapi(`/api/promotions/${toggle.dataset.promotionActive}`,{method:'PATCH',body:JSON.stringify({active:toggle.checked})});constitem=(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)}}constremove=e.target.closest('[data-delete-promotion]');if(remove){if(!confirm('Delete this promotion?'))return;remove.disabled=true;try{awaitapi(`/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)}}});
functionaiRecommendationLabel(value){returnvalue==='contact'?'Contact recommended':value==='review'?'Review first':'Do not contact yet'}
functionaddAssessmentPanel(id){constp=prospects.find(x=>String(x.id)===String(id));constbody=$('drawerBody');if(!p||!body)return;consthas=Number.isFinite(Number(p.ai_probability))&&p.ai_checked_at;constsection=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>`;constactions=body.querySelector('.drawer-actions');if(actions)body.insertBefore(section,actions);elsebody.append(section)}
functionrenderWebsiteCandidates(items,id){constsection=$('websiteCandidatesPanel');if(!section)return;constrows=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=>{constevidence=candidateEvidence(c);conststatus=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>'}`}
functionaddWebsitePanel(id){constbody=$('drawerBody');constp=prospects.find(x=>String(x.id)===String(id));if(!p||!body)return;constsection=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>`;constactions=body.querySelector('.drawer-actions');if(actions)body.insertBefore(section,actions);elsebody.append(section);api(`/api/prospects/${p.id}/website-candidates`).then(d=>{if((d.items||[]).length)renderWebsiteCandidates(d.items,p.id)}).catch(()=>{})}
asyncfunctionassessProspect(id,button){if(button){button.disabled=true;button.textContent='Assessing…'}try{constd=awaitapi(`/api/prospects/${id}/assess`,{method:'POST',body:'{}'});constindex=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){constpanel=$('aiAssessmentPanel');if(panel){constnotice=document.createElement('p');notice.className='notice error';notice.textContent=error.message;panel.append(notice)}if(button){button.disabled=false;button.textContent='Assess with AI'}}}
functionauthShell(title,setup){constroot=$('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')}
functionrenderAuth(status={}){authShell(status.setup_required?'Create your workspace':'Welcome back',Boolean(status.setup_required));constnav=$('nav');if(nav)nav.setAttribute('aria-hidden','true')}
functionrenderMockupItems(items=[]){returnitems.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>'}
functionaddMockupPanel(id){constbody=$('drawerBody');constp=prospects.find(x=>String(x.id)===String(id));if(!p||!body)return;constsection=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>`;constactions=body.querySelector('.drawer-actions');if(actions)body.insertBefore(section,actions);elsebody.append(section);api(`/api/prospects/${p.id}/mockups`).then(data=>{constlist=$('mockupsList');if(list)list.innerHTML=renderMockupItems(data.items||[])}).catch(error=>{constlist=$('mockupsList');if(list)list.innerHTML=`<p class="notice error">${esc(error.message)}</p>`})}
asyncfunctiongenerateMockupPreview(id,button,mode='onepage'){conststatus=$('mockupStatus');constchooser=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{constdata=awaitapi(`/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'}constlist=$('mockupsList');if(list){constlatest=awaitapi(`/api/prospects/${id}/mockups`);list.innerHTML=renderMockupItems(latest.items||[])}returndata}catch(error){if(status){status.textContent=error.message;status.className='notice error'}throwerror}finally{if(button)button.disabled=false;if(chooser)chooser.querySelectorAll('button').forEach(item=>item.disabled=false)}}
asyncfunctionloadMessageMockupOption(id){constcontext=$('messageContext');if(!context)return;context.querySelector('.mockup-option')?.remove();try{constdata=awaitapi(`/api/prospects/${id}/mockups`);constlatest=(data.items||[])[0];if(!latest)return;constoption=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')}}
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>`;
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?');
}catch(error){editorAddMessage(messages,'system',`Could not load editor metadata: ${error.message}. The preview can still be edited.`)}
form?.addEventListener('submit',asyncevent=>{
event.preventDefault();
constinstruction=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}
constpage=$('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'}
state={...state,...result};consteditorTitle=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'}
}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. */
output.insertAdjacentHTML('afterend',`<section id="messageRevisionPanel" class="message-revision-panel"><div class="message-revision-head"><div><span class="eyebrow">DRAFT CHAT</span><h3>Ask the AI to refine it</h3><p>Describe a change and the current subject and email will be revised in place.</p></div><span class="message-chat-badge">Review before sending</span></div><div id="messageChatMessages" class="message-chat-messages" role="log" aria-live="polite"></div><div class="message-revision-composer"><label for="messageRevisionInput">Requested change</label><textarea id="messageRevisionInput" rows="3" maxlength="3000" placeholder="Make it shorter and less salesy, keep the R119/month offer, and use a softer call to action."></textarea><div id="messageRevisionStatus" class="notice">The AI can change wording, tone, order, or emphasis. It cannot add unsupported facts.</div><button type="button" class="primary" id="messageRevisionApply">Apply change <span>→</span></button></div></section>`);
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?');}
conststatus=$('messageRevisionStatus');if(status){status.textContent='The AI can change wording, tone, order, or emphasis. It cannot add unsupported facts.';status.className='notice';}
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;}
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';}}