diff --git a/web/app.js b/web/app.js index 095bc5f..e1ad017 100644 --- a/web/app.js +++ b/web/app.js @@ -8,6 +8,8 @@ installLogsNav(); let activeProspectCategory='all'; const HIGH_POTENTIAL_SCORE=70; async function api(path,opt={}){const r=await fetch(path,{...opt,headers:{'Content-Type':'application/json',...(opt.headers||{})}});const b=await r.json().catch(()=>({}));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}
`} @@ -52,8 +54,18 @@ function renderSettings(){
SAFETY

Suppression centre

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

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

No suppressions configured.

'}
`) } -async function load(){[prospects,runs,settings]=await Promise.all([api('/api/prospects').then(x=>x.items||[]),api('/api/runs').then(x=>x.items||[]),Promise.all([api('/api/settings'),api('/api/schedules'),api('/api/suppressions')]).then(([s,sch,sup])=>({...s,schedules:sch.items||[],suppressions:sup.items||[]}))]);} -async function route(){const r=location.hash.slice(1)||'dashboard';installLogsNav();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}[r]||renderDashboard)();$('updated').textContent='Updated '+new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});}catch(e){$('view').innerHTML=empty(e.message)}} +async function load(){ + loadWarnings=[]; + const results=await Promise.allSettled([api('/api/prospects'),api('/api/runs'),api('/api/settings'),api('/api/schedules'),api('/api/suppressions')]); + 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',{}); + 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:[]}; + 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';installLogsNav();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}[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)}

`:''}`} @@ -91,7 +103,7 @@ async function assessProspect(id,button){if(button){button.disabled=true;button. document.addEventListener('click',async e=>{const button=e.target.closest('[data-assess]');if(button){e.preventDefault();e.stopPropagation();await assessProspect(button.dataset.assess,button)}}); async function findWebsiteCandidates(id,button){if(button){button.disabled=true;button.textContent='Searching…'}const section=$('websiteCandidatesPanel');try{const d=await api(`/api/prospects/${id}/website-candidates`,{method:'POST',body:'{}'});renderWebsiteCandidates(d.items||[],id)}catch(error){if(section){const notice=document.createElement('p');notice.className='notice error';notice.textContent=error.message;section.append(notice)}}finally{if(button){button.disabled=false;button.textContent='Search again'}}} document.addEventListener('click',async e=>{const find=e.target.closest('[data-find-websites]');if(find){e.preventDefault();e.stopPropagation();await findWebsiteCandidates(find.dataset.findWebsites,find);return}const accept=e.target.closest('[data-accept-website]');if(accept){e.preventDefault();e.stopPropagation();accept.disabled=true;accept.textContent='Checking…';try{const d=await api(`/api/prospects/${accept.dataset.prospectId}/website-candidates/${accept.dataset.acceptWebsite}`,{method:'PATCH',body:JSON.stringify({status:'accepted'})});if(d.prospect){const i=prospects.findIndex(p=>String(p.id)===String(d.prospect.id));if(i>=0)prospects[i]={...prospects[i],...d.prospect};selected=d.prospect}openDrawer(accept.dataset.prospectId);if($('prospectTable'))$('prospectTable').innerHTML=prospectTable()}catch(error){accept.disabled=false;accept.textContent='Accept domain';const panel=$('websiteCandidatesPanel');if(panel){const notice=document.createElement('p');notice.className='notice error';notice.textContent=error.message;panel.append(notice)}}return}const reject=e.target.closest('[data-reject-website]');if(reject){e.preventDefault();e.stopPropagation();reject.disabled=true;try{const d=await api(`/api/prospects/${reject.dataset.prospectId}/website-candidates/${reject.dataset.rejectWebsite}`,{method:'PATCH',body:JSON.stringify({status:'rejected'})});const current=await api(`/api/prospects/${reject.dataset.prospectId}/website-candidates`);renderWebsiteCandidates(current.items||[],reject.dataset.prospectId)}catch(error){reject.disabled=false;const panel=$('websiteCandidatesPanel');if(panel){const notice=document.createElement('p');notice.className='notice error';notice.textContent=error.message;panel.append(notice)}}}}); -window.addEventListener('hashchange',route);$('refreshAll').addEventListener('click',route);route(); +window.addEventListener('hashchange',route);$('refreshAll').addEventListener('click',route);refreshWorkerStatus();setInterval(refreshWorkerStatus,30000);route(); document.addEventListener('click',e=>{const tab=e.target.closest('[data-category-tab]');if(!tab)return;e.preventDefault();e.stopPropagation();activeProspectCategory=tab.dataset.categoryTab||'all';if($('prospectTabs'))$('prospectTabs').innerHTML=prospectTabs();if($('prospectTable'))$('prospectTable').innerHTML=prospectTable()}); document.addEventListener('click',e=>{const tab=e.target.closest('[data-log-tab]');if(tab){e.preventDefault();activeLogTab=tab.dataset.logTab||'all';refreshLogs();return}if(e.target.id==='refreshLogs')refreshLogs()}); document.addEventListener('click',e=>{const target=e.target.closest('button,a,select,input,textarea,tr,[role="button"]');if(!target)return;const details={tag:target.tagName.toLowerCase(),id:target.id||'',label:target.getAttribute('aria-label')||target.getAttribute('title')||'',route:location.hash.slice(1)||'dashboard'};for(const name of ['open','message','assess','findWebsites','acceptWebsite','rejectWebsite','categoryTab','logTab','review'])if(target.dataset[name])details[name]=target.dataset[name];logAction('ui_click',details)},true); diff --git a/web/index.html b/web/index.html index 5230fa0..35f036e 100644 --- a/web/index.html +++ b/web/index.html @@ -1,3 +1,3 @@ -ProspectOS -
Workspace/Dashboard
—

Tailor a message

Use the evidence and your offer to create an editable draft.

+ProspectOS +
Workspace/Dashboard
—

Tailor a message

Use the evidence and your offer to create an editable draft.

diff --git a/web/style.css b/web/style.css index ff9b15b..c7543de 100644 --- a/web/style.css +++ b/web/style.css @@ -112,3 +112,25 @@ input::placeholder { color: #aeb6c5; } input:focus, select:focus { border-color: @media (max-width: 980px) { .app-shell { grid-template-columns: 72px minmax(0, 1fr); }.sidebar { padding: 22px 10px 16px; }.brand { justify-content: center; padding: 0; }.brand > div, .side-nav a:not(.active) { display: none; }.side-nav { margin-top: 44px; }.side-nav a { justify-content: center; padding: 12px 0; }.side-nav .nav-icon { width: auto; }.source-card { justify-content: center; padding: 11px 0; }.source-card > div, .sidebar-foot p { display: none; }.main-content { padding: 0 25px 34px; }.workspace-grid { grid-template-columns: 1fr; }.run-panel { order: -1; }.run-panel .mini-history { display: none; } } @media (max-width: 680px) { .app-shell { display: block; }.sidebar { position: static; height: auto; flex-direction: row; align-items: center; justify-content: space-between; padding: 13px 16px; }.side-nav { display: flex; gap: 3px; margin: 0; }.side-nav a, .side-nav a:not(.active) { display: flex; padding: 8px 10px; }.sidebar-foot { display: none; }.main-content { padding: 0 14px 30px; }.topbar { height: 53px; }.updated { display: none; }.page-heading { display: block; padding: 27px 0 20px; }.heading-badge { display: inline-grid; margin-top: 17px; }.stats-grid { grid-template-columns: repeat(2, 1fr); gap: 9px; }.stat-card { min-height: 105px; padding: 14px; }.stat-card strong { font-size: 24px; }.discovery-panel, .run-panel, .explorer-panel { padding: 17px; }.discovery-form { grid-template-columns: 1fr; gap: 13px; }.field.wide, .options.wide, .form-footer.wide { grid-column: auto; }.options { display: grid; gap: 12px; }.form-footer { display: block; }.form-message { display: block; margin-top: 10px; }.panel-heading, .explorer-heading { display: block; }.source-tag { display: inline-flex; margin-top: 14px; }.explorer-actions { display: grid; grid-template-columns: 1fr 1fr; margin-top: 15px; }.search-box { width: auto; grid-column: 1 / -1; }.explorer-actions select, .explorer-actions .quiet-btn { width: 100%; }.prospect-card { grid-template-columns: 1fr; gap: 12px; }.review-control { align-items: flex-start; }.review-control select { width: 100%; }.prospect-meta { flex-wrap: wrap; }.meta-spacer { display: none; }.setup-section { display: block; padding: 17px; }.setup-section ol { margin-top: 14px; }.footer { display: block; line-height: 1.8; } } +.panel-title a.primary, +.panel-title a.primary:visited { + display: inline-flex; + align-items: center; + justify-content: center; + white-space: nowrap; + color: #fff; + background: var(--purple); +} +.panel-title a.primary:hover { + color: #fff; + background: var(--purple-dark); +} +.load-warning { + max-width: 1160px; + margin: 18px 0 -8px; + padding: 11px 13px; + border: 1px solid #f0c7cd; + border-radius: 9px; + background: #fff5f6; + color: #a33e4d; +}