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.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.
`)}
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
SearchMaps worker finds public business listings.
EnrichWebsite, HTTPS, DNS hints and public email checks run safely.
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.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?`
`: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'}
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
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'}
AI PROVIDER
Draft assistant
Used when you assess a prospect or draft an email.
${ai.configured?'Configured':'Not configured'}
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.
`)
}
async function load(){
loadWarnings=[];
const results=await Promise.allSettled([api('/api/prospects'),api('/api/runs'),api('/api/settings'),api('/api/schedules'),api('/api/suppressions'),api('/api/promotions')]);
const value=(result,label,fallback)=>{if(result.status==='fulfilled')return result.value;loadWarnings.push(`${label}: ${result.reason?.message||'request failed'}`);return fallback};
const prospectsResponse=value(results[0],'Prospects',{}),runsResponse=value(results[1],'Runs',{}),settingsResponse=value(results[2],'Settings',{}),schedulesResponse=value(results[3],'Schedules',{}),suppressionsResponse=value(results[4],'Suppressions',{}),promotionsResponse=value(results[5],'Promotions',{});
prospects=Array.isArray(prospectsResponse.items)?prospectsResponse.items:[];
runs=Array.isArray(runsResponse.items)?runsResponse.items:[];
settings={...settingsResponse,schedules:Array.isArray(schedulesResponse.items)?schedulesResponse.items:[],suppressions:Array.isArray(suppressionsResponse.items)?suppressionsResponse.items:[],promotions:Array.isArray(promotionsResponse.items)?promotionsResponse.items:[]};
return loadWarnings;
}
function renderLoadWarnings(){if(!loadWarnings.length)return;const view=$('view');if(!view)return;const notice=document.createElement('div');notice.className='notice error load-warning';notice.setAttribute('role','status');notice.textContent=`Dashboard loaded with limited data. ${loadWarnings.join(' · ')}`;view.prepend(notice)}
async function route(){const r=location.hash.slice(1)||'dashboard';installExtraNav();document.querySelectorAll('[data-route]').forEach(a=>a.classList.toggle('active',a.dataset.route===r));try{await load();({dashboard:renderDashboard,discover:renderDiscover,prospects:renderProspects,runs:renderRuns,logs:renderLogs,settings:renderSettings,'teach-ai':renderTeachAI,promotions:renderPromotions,users:renderUsers}[r]||renderDashboard)();renderLoadWarnings();$('updated').textContent='Updated '+new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});refreshWorkerStatus();}catch(e){$('view').innerHTML=empty(e.message);refreshWorkerStatus();}}
function suggestedOffer(p){if(p.ai_offer)return p.ai_offer;if(['broken','unreachable','error','slow'].includes(String(p.website_status||'')))return'Move their hosting to our Gold Plus package at R119/month and rebuild the website at no extra design charge.';if(!p.website)return'Offer a professional .co.za domain, reliable hosting from R119/month, professional email, and a free one-page website.';return'Offer a friendly website and hosting review; only suggest a change that is supported by the evidence.'}
function jsonArray(value){try{const parsed=JSON.parse(value||'[]');return Array.isArray(parsed)?parsed:[]}catch{return[]}}
function showDraftNotes(d){let notes=$('draftNotes');if(!notes){notes=document.createElement('div');notes.id='draftNotes';notes.className='draft-notes';const status=$('messageStatus');if(status)status.parentNode.insertBefore(notes,status)}if(!notes)return;const unknowns=Array.isArray(d?.unknowns)?d.unknowns:[];const used=Array.isArray(d?.evidence_used)?d.evidence_used:[];notes.innerHTML=`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=`
Tailor a message
Start from verified prospect evidence, your company knowledge, and any selected promotion.
`}
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'}
${setup?'Create the workspace owner account. Existing catalogue data will be kept in this workspace.':'Sign in to continue to your prospect workspace.'}
`;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.
`:''}`);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=`
`)})}
function addHistoryPanel(id){const body=$('drawerBody');if(!body)return;const section=document.createElement('section');section.className='drawer-section';section.id='historyPanel';section.innerHTML='
`})}
/* 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.
`})}
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)}`:''}`:''}
`;
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);