asyncfunctionrequest(path,options={}){constresponse=awaitfetch(endpoint(path),{...options,credentials:'include'});if(response.status===401){showLogin('Your session has expired. Please sign in again.');thrownewError('unauthorized');}if(response.status===403)thrownewError('Tenant/workspace access denied.');returnresponse;}
asyncfunctionloadData(){$('apiStatus').textContent='● Connecting…';$('apiStatus').classList.remove('live');try{const[listRes,summaryRes]=awaitPromise.all([request(`/api/v1/businesses${listQuery()}`),request('/api/v1/dashboard/summary')]);if(!listRes.ok||!summaryRes.ok)thrownewError('API request failed');constlist=awaitlistRes.json(),summary=awaitsummaryRes.json();prospects=Array.isArray(list)?list:(list.businesses||list.items||[]);hasNextPage=Boolean(list.has_next??list.next_page??list.next??list.next_cursor);renderMetrics(summary);$('apiStatus').textContent='● API connected';$('apiStatus').classList.add('live');}catch(error){if(error.message==='unauthorized')return;prospects=[];hasNextPage=false;renderMetrics(null);$('apiStatus').textContent='● API unavailable';}renderRows();if(selectedId)loadDetail(selectedId);}
panel.innerHTML=`<div class="website-scan-heading"><div><p class="eyebrow">WEBSITE ANALYSIS</p><h4>Website scan</h4></div><div class="website-scan-actions"><button class="button ghost compact" id="refreshWebsiteScanBtn" type="button">↻ Refresh</button><button class="button primary compact" id="scanWebsiteBtn" type="button">Scan website</button></div></div><p class="website-scan-safety">Website analysis is authenticated workspace evidence from the HTTP scanner. It does not enable outreach.</p><div id="websiteScanState" aria-live="polite">${websiteScanFor(p)?renderWebsiteScan(websiteScanFor(p)):'<p class="muted">No website scan has been returned. Start a scan to see HTTP and contact signals.</p>'}</div>`;
if(!payload){state.innerHTML='<p class="muted">No extracted contacts returned. Extract from approved public business pages to review contact evidence.</p>';return;}
constcontacts=extractedContactItems(payload);
if(!contacts.length){state.innerHTML='<div class="contact-extraction-empty"><strong>No public contacts found</strong><p class="muted">No contact details were found on the approved public business pages checked.</p></div>';return;}
functionrenderContactExtractionPanel(p){constpanel=$('contactExtractionPanel');if(!panel)return;panel.innerHTML=`<div class="contact-extraction-heading"><div><p class="eyebrow">PUBLIC CONTACT EXTRACTION</p><h4>Extracted contacts <span class="count">${extractedContactItems(p.extracted_contacts||p.contact_extraction).length}</span></h4></div><div class="contact-extraction-actions"><button class="button ghost compact" id="refreshContactExtractionBtn" type="button">↻ Refresh</button><button class="button primary compact" id="extractContactsBtn" type="button">Extract public contacts</button></div></div><p class="contact-extraction-safety">Extraction uses approved public business pages only. It does not probe SMTP, verify mailbox access, or send outreach.</p><div id="contactExtractionState" aria-live="polite">${p.extracted_contacts||p.contact_extraction?renderExtractedContactsMarkup(p.extracted_contacts||p.contact_extraction):'<p class="muted">No public contacts extracted yet. Start an extraction to review evidence.</p>'}</div>`;}
functionrenderExtractedContactsMarkup(payload){constcontacts=extractedContactItems(payload);if(!contacts.length)return'<p class="muted">No public contacts extracted yet. Start an extraction to review evidence.</p>';return'<p class="muted">Previously extracted contacts are available. Refresh to check approved public business pages again.</p>';}
asyncfunctionloadContactExtraction(id,{extract=false}={}){conststate=$('contactExtractionState'),extractButton=$('extractContactsBtn'),refreshButton=$('refreshContactExtractionBtn');if(!state||!id)return;[extractButton,refreshButton].forEach(button=>{if(button)button.disabled=true;});state.innerHTML='<div class="detail-loading" aria-live="polite">Loading public contacts…</div>';try{constscan=selectedDetail?.website_scan||selectedDetail?.websiteScan||{};constpayload={approved_public_pages_only:true,smtp_probing:false,outreach:false,website_scan_id:scan.id,source_url:scan.final_url||scan.input_url,html:scan.html};constresult=awaitjsonRequest(extract?`/api/v1/businesses/${encodeURIComponent(id)}/contacts/extract`:`/api/v1/contact-extractions?business_id=${encodeURIComponent(id)}`,extract?{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}:{});if(selectedDetail&&Number(selectedDetail.id)===Number(id))selectedDetail={...selectedDetail,extracted_contacts:result.contacts||result.extracted_contacts||result.items||result};renderExtractedContacts(result);}catch(error){if(error.message!=='unauthorized')state.innerHTML=`<div class="detail-error" role="alert"><strong>${extract?'Contact extraction failed':'Unable to load extracted contacts'}</strong><p>${esc(error.message)}</p><button class="button ghost compact" id="retryContactExtractionBtn" type="button">Try again</button></div>`;}finally{[extractButton,refreshButton].forEach(button=>{if(button)button.disabled=false;});}}
functionrenderScoreConfig(payload){constpanel=$('scoreRulesPanel');if(!panel)return;construles=scoreItems(payload),version=scoreValue(payload,['version','score_version','scoring_version'],'Unknown'),editable=canEditScores();panel.innerHTML=`<div class="panel-heading"><div><p class="eyebrow">CONFIGURATION</p><h3>Score rules</h3></div><span class="small-label">${editable?'Admin controls available':'Read-only for this role'}</span></div><p class="score-config-note">Rules are workspace configuration. ${editable?'Changes require an authorized admin action.':'Your role can view rules but cannot edit them.'}</p><div class="score-rule-table">${rules.length?rules.map(rule=>`<div class="score-config-row"><strong>${esc(scoreValue(rule,['label','name','rule','key'],'Scoring rule'))}</strong><span>${scoreValue(rule,['enabled','active'],false)?'Enabled':'Disabled'}</span><span>${esc(scoreValue(rule,['points','weight','value'],0))} pts</span><small>v${esc(scoreValue(rule,['version','rule_version'],version))}</small>${editable?`<button class="button ghost compact" type="button" data-score-edit="${esc(rule.id)}" data-score-points="${esc(scoreValue(rule,['points','weight','value'],0))}">Edit points</button>`:''}</div>`).join(''):'<p class="muted">No score rules returned by the workspace.</p>'}</div><p class="small-label">Configuration version: ${esc(version)}</p>`;}
functionrenderScoreDistribution(payload){constpanel=$('scoreDistributionPanel');if(!panel)return;constbuckets=payloadItems(payload,['distribution','buckets','items']);constbandMap=payload?.bands&&typeofpayload.bands==='object'&&!Array.isArray(payload.bands)?payload.bands:{};constsummary=payload?.summary||payload||{};constfallback=[['High priority',summary.high_priority??bandMap.high??0],['Medium priority',bandMap.medium??0],['Low priority',bandMap.low??0],['Ineligible',bandMap.ineligible??0]];constrows=buckets.length?buckets.map(item=>[scoreValue(item,['label','band','name'],'Score band'),scoreValue(item,['count','total','value'],0)]):fallback;consttotal=rows.reduce((n,row)=>n+Number(row[1]||0),0);panel.innerHTML=`<div class="panel-heading"><div><p class="eyebrow">SCORING SUMMARY</p><h2>Score distribution</h2></div><span class="small-label">${total} prospects</span></div><div class="score-distribution-list">${rows.map(([label,count])=>`<div class="distribution-row"><span>${esc(label)}</span><strong>${esc(count)}</strong><span class="distribution-track"><i style="width:${total?Math.min(100,Number(count)/total*100):0}%"></i></span></div>`).join('')}</div><p class="score-safety">Distribution is tenant-scoped and reflects API results only.</p>`;}
asyncfunctionupdateScoreRule(id,currentPoints){if(!canEditScores()||!id)return;constvalue=window.prompt('Points for this rule (0–100):',currentPoints);if(value===null)return;constpoints=Number(value);if(!Number.isInteger(points)||points<0||points>100){window.alert('Enter a whole number from 0 to 100.');return;}try{awaitjsonRequest(`/api/v1/score-rules/${encodeURIComponent(id)}`,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({points})});awaitloadScorePanels();}catch(error){if(error.message!=='unauthorized')window.alert(error.message||'Unable to update score rule.');}}
panel.innerHTML=`<div class="score-panel-heading"><div><p class="eyebrow">SCORING</p><h4>Score breakdown</h4></div><button class="button ghost compact" id="recalculateScoreBtn" type="button">↻ Recalculate</button></div><div class="score-breakdown-summary"><div><small>Total score</small><strong class="score-total ${scoreClass(score)}">${esc(score)}<small>/ 100</small></strong></div><div><small>Priority</small><b>${esc(priority)}</b></div><div><small>Eligibility</small><b>${esc(eligible)}</b></div></div><dl class="score-meta"><div><dt>Score version</dt><dd>${esc(version)}</dd></div><div><dt>Signals</dt><dd>${factors.length}</dd></div></dl><div class="score-rules-explanation"><h5>Rule explanations</h5>${factors.length?factors.map(raw=>{constitem=scoreExplanation(raw);return`<div class="score-rule-row"><span>${esc(scoreValue(item,['label','name','rule','factor'],'Scoring signal'))}</span><strong>${esc(scoreValue(item,['points','value','weight'],0))} pts</strong><small>${esc(scoreValue(item,['explanation','reason','description'],'Observed evidence contributes to this score.'))}</small></div>`}).join(''):'<p class="muted">No scoring signals returned.</p>'}</div><p class="score-safety">Scores are review signals only. They do not override suppression or authorize outreach.</p><p id="scoreMessage" class="form-message" role="status" aria-live="polite"></p>`;
}
asyncfunctionrecalculateScore(){constbutton=$('recalculateScoreBtn'),messageEl=$('scoreMessage');if(!button||!selectedId)return;button.disabled=true;if(messageEl)messageEl.textContent='Recalculation requested…';try{constresult=awaitjsonRequest(`/api/v1/businesses/${encodeURIComponent(selectedId)}/score/recalculate`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({})});if(messageEl)messageEl.textContent=result?.status==='queued'?'Recalculation queued. Refresh when the job completes.':'Score recalculation accepted. Refresh to see the API result.';}catch(error){if(error.message!=='unauthorized'&&messageEl){messageEl.textContent=error.message||'Unable to recalculate score.';messageEl.className='form-message error';}}finally{button.disabled=false;}}
try{constpayload=awaitjsonRequest('/api/v1/merge-history');constitems=payloadItems(payload,['history','merges','items']).filter(item=>Number(item.source_business_id)===Number(id)||Number(item.target_business_id)===Number(id));history.innerHTML=`<h4>Merge history <span class="count">${items.length}</span></h4>${items.length?`<div class="history-list">${items.map(item=>{constmergeId=item.merge_id||item.id,source=item.source_name||item.source_business_name||('Prospect '+(item.source_business_id||'source')),target=item.target_name||item.target_business_name||('Prospect '+(item.target_business_id||'target'));return`<article class="history-row"><div><strong>${esc(source)} → ${esc(target)}</strong><small>${esc(item.created_at||item.merged_at||'Time unavailable')} · ${esc(item.status||'Merged')}</small></div>${item.reversible!==false&&item.reversed_at==null?`<button class="button ghost compact" type="button" data-reverse-merge="${esc(mergeId)}">Reverse merge</button>`:`<span class="small-label">${item.reversed_at?'Reversed':'Not reversible'}</span>`}</article>`;}).join('')}</div>`:'<p class="muted">No merges recorded for this prospect.</p>'}`;}catch(error){if(error.message!=='unauthorized')history.innerHTML=`<div class="dedup-error" role="alert"><h4>Unable to load merge history</h4><p>${esc(error.message)}</p><button class="button ghost compact" id="retryHistoryBtn" type="button">Try again</button></div>`;}
}
functionopenMergeDialog(targetId,targetName){mergeSource={id:selectedId,name:selectedDetail?.name||prospects.find(p=>Number(p.id)===Number(selectedId))?.name||`Prospect ${selectedId}`};mergeTarget={id:targetId,name:targetName};$('mergeDialogCopy').innerHTML=`You are about to merge <strong>${esc(mergeSource.name)}</strong> (source) into <strong>${esc(mergeTarget.name)}</strong> (target). Review both records before confirming.`;$('mergeDialogMessage').textContent='';$('mergeDialogMessage').className='form-message';$('mergeDialog').hidden=false;$('confirmMergeBtn').disabled=false;}
asyncfunctionconfirmMerge(){if(!mergeSource||!mergeTarget||mergeBusy)return;mergeBusy=true;constbutton=$('confirmMergeBtn');button.disabled=true;$('mergeDialogMessage').textContent='Merging records…';$('mergeDialogMessage').className='form-message';try{awaitjsonRequest(`/api/v1/businesses/${encodeURIComponent(mergeSource.id)}/merge`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({target_id:Number(mergeTarget.id),review_required:true})});$('mergeDialogMessage').textContent='Merge completed and recorded in history.';awaitloadData();closeMergeDialog();if(selectedId)awaitloadDetail(selectedId);}catch(error){if(error.message!=='unauthorized'){$('mergeDialogMessage').textContent=error.message||'Unable to merge records.';$('mergeDialogMessage').className='form-message error';button.disabled=false;}}finally{mergeBusy=false;}}
asyncfunctionreverseMerge(id){if(!window.confirm('Reverse this merge? The original records will be restored.'))return;try{awaitjsonRequest(`/api/v1/merge-history/${encodeURIComponent(id)}/reverse`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({review_required:true})});if(selectedId){awaitloadDetail(selectedId);awaitloadData();}}catch(error){consthistory=$('mergeHistory');if(error.message!=='unauthorized'&&history)history.insertAdjacentHTML('afterbegin',`<p class="form-message error" role="alert">${esc(error.message||'Unable to reverse merge.')}</p>`);}}
asyncfunctionsaveContact(form){constdata=Object.fromEntries(newFormData(form).entries());if(!data.email.trim()){message('contactMessage','Email is required.',true);return;}try{awaitjsonRequest(`/api/v1/businesses/${selectedId}/contacts`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});message('contactMessage','Contact added.');awaitloadDetail(selectedId);}catch(e){if(e.message!=='unauthorized')message('contactMessage',e.message,true);}}
asyncfunctionsaveNote(form){constdata=Object.fromEntries(newFormData(form).entries());if(!data.body.trim()){message('noteMessage','Note cannot be empty.',true);return;}try{awaitjsonRequest(`/api/v1/businesses/${selectedId}/notes`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});message('noteMessage','Note added.');awaitloadDetail(selectedId);}catch(e){if(e.message!=='unauthorized')message('noteMessage',e.message,true);}}
asyncfunctionsaveStage(form){conststage=newFormData(form).get('stage');if(!stage){message('pipelineMessage','Choose a pipeline stage.',true);return;}try{awaitjsonRequest(`/api/v1/businesses/${selectedId}/pipeline`,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({stage})});message('pipelineMessage','Pipeline stage updated.');awaitloadDetail(selectedId);awaitloadData();}catch(e){if(e.message!=='unauthorized')message('pipelineMessage',e.message,true);}}
asyncfunctionaddProspect(event){event.preventDefault();constdata=Object.fromEntries(newFormData(event.currentTarget).entries());constmsg=$('formMessage');try{constbody=awaitjsonRequest('/api/v1/businesses',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});prospects.unshift(body);msg.textContent='Added to review queue.';event.currentTarget.reset();renderMetrics(null);renderRows();}catch(e){if(e.message!=='unauthorized'){msg.textContent=e.message;msg.className='form-message error';}}}
asyncfunctionloadJobs({silent=false}={}){if(!silent){jobMessage('Loading jobs…');resetJobCounts();}try{constpayload=awaitjobsRequest('/api/v1/jobs');jobs=Array.isArray(payload)?payload:(payload.jobs||payload.items||[]);renderJobCounts(payload);renderJobsList();$('jobsUpdatedAt').textContent=`Updated ${newDate().toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}`;jobMessage('');constactive=jobs.some(job=>['queued','running'].includes(jobStatus(job)));if(active&&!jobPollTimer)jobPollTimer=setInterval(()=>loadJobs({silent:true}),5000);if(!active&&jobPollTimer){clearInterval(jobPollTimer);jobPollTimer=null;}if(selectedJobId){constselected=jobs.find(job=>String(job.id)===String(selectedJobId));if(selected)awaitloadJobDetail(selectedJobId);}}catch(error){jobs=[];resetJobCounts();renderJobsList();if(error.message!=='unauthorized')jobMessage(error.message||'Unable to load jobs.',true);}}
asyncfunctionstartDemoJob(){if(!canManageJobs()){jobMessage('Your role is not permitted to start jobs.',true);return;}constbutton=$('startDemoJobBtn');button.disabled=true;jobMessage('Starting demo job…');try{constjob=awaitjobsRequest('/api/v1/jobs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({type:'noop',payload:{},idempotency_key:`demo-${Date.now()}-${Math.random().toString(36).slice(2)}`})});awaitloadJobs({silent:true});if(job?.id)awaitloadJobDetail(job.id);jobMessage('Demo job started.');}catch(error){if(error.message!=='unauthorized')jobMessage(error.message||'Unable to start demo job.',true);}finally{button.disabled=!canManageJobs();}}
asyncfunctionjobAction(action){constjob=jobs.find(item=>String(item.id)===String(selectedJobId));if(!job||!canManageJobs())return;constendpointPath=action==='cancel'?`/api/v1/jobs/${encodeURIComponent(job.id)}/cancel`:`/api/v1/jobs/${encodeURIComponent(job.id)}/retry`;constlabel=action==='cancel'?'cancel':'retry';try{awaitjobsRequest(endpointPath,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({})});jobMessage(`Job ${label} requested.`);awaitloadJobs({silent:true});awaitloadJobDetail(job.id);}catch(error){if(error.message!=='unauthorized')jobMessage(error.message||`Unable to ${label} job.`,true);}}
functionrenderSourceRecords(items){constlist=$('sourceRecordsList');if(!items.length){list.innerHTML='<div class="source-empty">No source records returned by the workspace.</div>';return;}list.innerHTML=`<div class="source-record-table"><table><thead><tr><th>Record</th><th>Source</th><th>Status</th><th>Observed</th></tr></thead><tbody>${items.slice(0,25).map(record=>`<tr><td>${esc(record.name||record.title||record.external_id||record.id||'Unnamed record')}</td><td>${esc(record.source_name||record.source||'Unknown source')}</td><td><span class="status">${esc(record.status||'Pending')}</span></td><td>${esc(record.observed_at||record.created_at||'Time unavailable')}</td></tr>`).join('')}</tbody></table></div>`;}
asyncfunctionloadSources(){sourceMessage('Loading sources…');$('sourcesList').innerHTML='<div class="detail-loading" aria-live="polite">Loading source registry…</div>';$('sourceRecordsList').innerHTML='<div class="detail-loading" aria-live="polite">Loading source records…</div>';try{const[sourcePayload,recordPayload]=awaitPromise.all([jsonRequest('/api/v1/sources'),jsonRequest('/api/v1/source-records?page_size=25')]);sources=sourceItems(sourcePayload);renderSources();renderSourceRecords(sourceItems(recordPayload));$('sourcesUpdatedAt').textContent=`Updated ${newDate().toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}`;sourceMessage(sources.some(sourceState)?'':'No live source is enabled.');}catch(error){sources=[];renderSources();renderSourceRecords([]);if(error.message!=='unauthorized')sourceMessage(error.message||'Unable to load sources.',true);}}
asyncfunctionsaveSource(event){event.preventDefault();constform=event.currentTarget,fields=Object.fromEntries(newFormData(form).entries());if(fields.source_type==='csv'&&!fields.csv_content.trim()){message('sourceFormMessage','CSV content is required for a CSV source.',true);return;}constconfig={url:fields.url,terms_url:fields.terms_url,owner:fields.owner,rate_limit:fields.rate_limit};if(fields.source_type==='csv')config.csv=fields.csv_content;elseconfig.rows=[];try{awaitjsonRequest('/api/v1/sources',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:fields.name,kind:fields.source_type,config,enabled:false})});message('sourceFormMessage','Source saved. It remains disabled until explicitly enabled.');form.reset();$('sourceCsvField').hidden=true;awaitloadSources();}catch(error){if(error.message!=='unauthorized')message('sourceFormMessage',error.message||'Unable to save source.',true);}}
asyncfunctionsourceAction(id,action){constsource=sources.find(item=>String(item.id)===String(id));if(!source)return;try{if(action==='test'){awaitjsonRequest(`/api/v1/sources/${encodeURIComponent(id)}/test`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({})});sourceMessage('Source test completed.');}else{constenabled=sourceState(source);awaitjsonRequest(`/api/v1/sources/${encodeURIComponent(id)}`,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled:!enabled})});sourceMessage(`Source ${enabled?'disabled':'enabled'} by the workspace.`);}awaitloadSources();}catch(error){if(error.message!=='unauthorized')sourceMessage(error.message||`Unable to ${action} source.`,true);}}
asyncfunctionrunDiscovery(dryRun){constform=$('discoveryForm'),data=Object.fromEntries(newFormData(form).entries());data.dry_run=Boolean(dryRun);if(!data.source_id||!data.query.trim()){message('discoveryMessage','Select a source and enter a query.',true);return;}constsource=sources.find(item=>String(item.id)===String(data.source_id));if(!dryRun&&!sourceState(source)){message('discoveryMessage','This source is disabled. Enable it only after review.',true);return;}constbutton=dryRun?$('discoveryDryRunBtn'):$('discoveryRunBtn');button.disabled=true;message('discoveryMessage',dryRun?'Validating query…':'Starting discovery…');try{constquery=awaitjsonRequest('/api/v1/discovery-queries',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({source_id:Number(data.source_id),name:data.query.trim().slice(0,80),query:{text:data.query.trim()},dry_run:data.dry_run})});if(!dryRun)awaitjsonRequest(`/api/v1/discovery-queries/${encodeURIComponent(query.id)}/run`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({})});message('discoveryMessage',dryRun?'Dry run completed; no discovery job was started.':'Discovery request accepted. Check Jobs for progress.');}catch(error){if(error.message!=='unauthorized')message('discoveryMessage',error.message||'Discovery request failed.',true);}finally{button.disabled=false;}}
functionrenderCsv(rows){if(!rows.length){$('csvPreview').innerHTML='<span>⊞</span><p>No data rows found</p>';return;}consth=Object.keys(rows[0]);$('csvPreview').className='csv-table';$('csvPreview').innerHTML=`<table><thead><tr>${h.map(x=>`<th>${esc(x)}</th>`).join('')}</tr></thead><tbody>${rows.map(r=>`<tr>${h.map(x=>`<td>${esc(r[x])}</td>`).join('')}</tr>`).join('')}</tbody></table><small class="muted">Showing up to 10 rows · Preview only; nothing added yet.</small>`;}
asyncfunctionlogin(event){event.preventDefault();constform=event.currentTarget,messageEl=$('loginMessage'),data=Object.fromEntries(newFormData(form).entries());messageEl.textContent='Signing in…';messageEl.className='form-message';try{constres=awaitfetch(endpoint('/api/v1/auth/login'),{method:'POST',headers:{'Content-Type':'application/json'},credentials:'include',body:JSON.stringify(data)});constbody=awaitres.json().catch(()=>({}));if(!res.ok)thrownewError(body.error||'Invalid email or password.');awaitbootstrap();}catch(e){if(e.message!=='unauthorized'){messageEl.textContent=e.message;messageEl.className='form-message error';}}}
asyncfunctionlogout(){try{awaitfetch(endpoint('/api/v1/auth/logout'),{method:'POST',credentials:'include'});}finally{showLogin('You have been signed out.');$('loginForm').reset();}}
asyncfunctionbootstrap(){try{constres=awaitfetch(endpoint('/api/v1/auth/me'),{credentials:'include'});if(res.status===401){showLogin();return;}if(!res.ok)thrownewError('Could not verify session.');constuser=awaitres.json();showDashboard(user.user||user);awaitloadData();awaitloadJobs();awaitloadSources();awaitloadScorePanels();}catch(e){if(e.message!=='unauthorized')showLogin('Unable to connect to the workspace. Try again.');}}