deploy ProspectOS workspace v7 final

This commit is contained in:
Marco0300
2026-09-08 09:55:58 +02:00
parent 181b83fc5e
commit b9ef1e4dc7
6 changed files with 644 additions and 13 deletions
+87 -3
View File
@@ -71,11 +71,11 @@ async function load(){
}
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 suggestedOffer(p){if(p.ai_offer)return p.ai_offer;const status=String(p.website_status||'').toLowerCase();if(['broken','unreachable','error','timeout'].includes(status))return'First check whether the current site can be recovered; if a rebuild is useful, move hosting to our Gold Plus package at R119/month and include the rebuild at no extra design charge.';if(status==='slow')return'Offer a quick performance review and, if useful, move hosting to our Gold Plus package at R119/month.';if(!p.website)return'If useful, suggest a .co.za domain, Gold Plus hosting at R119/month, professional email, and a simple first website.';if(!p.website_has_viewport)return'Offer a short mobile-friendly website review and only suggest changes that the evidence supports.';return'Offer a friendly website and hosting review; only suggest a change that is supported by the evidence.'}
function jsonArray(value){try{const parsed=JSON.parse(value||'[]');return Array.isArray(parsed)?parsed:[]}catch{return[]}}
function showDraftNotes(d){let notes=$('draftNotes');if(!notes){notes=document.createElement('div');notes.id='draftNotes';notes.className='draft-notes';const status=$('messageStatus');if(status)status.parentNode.insertBefore(notes,status)}if(!notes)return;const unknowns=Array.isArray(d?.unknowns)?d.unknowns:[];const used=Array.isArray(d?.evidence_used)?d.evidence_used:[];notes.innerHTML=`<b>AI review notes</b><p>${esc(d?.rationale||'The draft uses the prospect evidence and your instructions.')}</p>${unknowns.length?`<p><b>Confirm before sending:</b> ${esc(unknowns.join(' · '))}</p>`:''}${used.length?`<p class="muted">Evidence used: ${esc(used.join(', '))}</p>`:''}${d?.follow_up?`<p class="muted">${esc(d.follow_up)}</p>`:''}`}
function promotionOptions(){return (Array.isArray(settings.promotions)?settings.promotions:[]).filter(item=>item.active).map(item=>`<option value="${esc(item.id)}">${esc(item.name)}</option>`).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=`<div class="dialog-head"><div><label class="eyebrow">AI ASSISTED DRAFT</label><h2 id="messageTitle">Tailor a message</h2><p>Start from verified prospect evidence, your company knowledge, and any selected promotion.</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 tailored 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>`}
function ensureMessageDialog(force=false){const dialog=$('messageDialog');const form=$('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>`}
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=`<div class="message-context-title"><span class="score ${scoreClass(score)}">${score}</span><div><b>${esc(selected.name)}</b><small>${esc(prospectCategory(selected))} · ${esc(selected.address||'Location not listed')}</small></div></div><div class="message-context-facts"><span>${selected.website?`Website: ${esc((selected.website_status||'detected').replaceAll('_',' '))}`:'No website detected'}</span><span>${selected.emails?`Public email: ${esc(selected.emails)}`:'No public email found'}</span></div>`;$('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 `<div class="drawer-section review-section"><div class="section-heading"><h3>Review status</h3><span class="muted">Saved to this prospect</span></div><div class="drawer-review-tabs">${options.map(([value,label])=>`<button type="button" class="drawer-review-tab ${p.review_status===value?'active':''}" data-drawer-review="${p.id}" data-status="${value}">${label}</button>`).join('')}</div></div>`}
@@ -105,7 +105,7 @@ document.addEventListener('submit',async e=>{
if(e.target.id==='teachAiForm'){e.preventDefault();const m=$('teachAiMessage');m.textContent='Saving…';m.className='notice';try{const result=await api('/api/settings',{method:'POST',body:JSON.stringify({company_profile:{website_url:$('companyWebsiteUrl').value,name:$('companyName').value,description:$('companyDescription').value,services:$('companyServices').value,packages:$('companyPackages').value,tone:$('companyTone').value,notes:$('companyNotes').value}})});settings.company_profile=result.company_profile||settings.company_profile;m.textContent='Company knowledge saved.';m.className='notice ok'}catch(x){m.textContent=x.message;m.className='notice error'}}
if(e.target.id==='promotionForm'){e.preventDefault();const m=$('promotionMessage');m.textContent='Saving…';m.className='notice';try{await api('/api/promotions',{method:'POST',body:JSON.stringify({name:$('promotionName').value,description:$('promotionDescription').value,terms:$('promotionTerms').value,starts_at:$('promotionStarts').value,ends_at:$('promotionEnds').value,active:$('promotionActive').checked})});route()}catch(x){m.textContent=x.message;m.className='notice error'}}
});
document.addEventListener('click',async e=>{if(e.target.id==='generateMessage'){if(!selected)return;const s=$('messageStatus');const button=e.target;button.disabled=true;button.textContent='Writing…';s.textContent='The AI is comparing the evidence with your offer…';try{const d=await api(`/api/prospects/${selected.id}/message`,{method:'POST',body:JSON.stringify({offer:$('offerInput').value,tone:$('messageTone')?.value||'warm',goal:$('messageGoal')?.value||'conversation',promotion_id:$('messagePromotion')?.value||null,include_mockup:$('includeMockup')?.checked!==false,mockup_id:$('includeMockup')?.dataset.mockupId||null})});$('subjectOutput').value=d.subject;$('bodyOutput').value=d.message;selected={...selected,ai_subject:d.subject,ai_message:d.message,ai_offer:$('offerInput').value,ai_promotion_id:$('messagePromotion')?.value?Number($('messagePromotion').value):null,ai_mockup_id:d.mockup_url?(Number($('includeMockup')?.dataset.mockupId||selected.ai_mockup_id||0)||null):null,ai_reasoning:d.rationale||'',ai_unknowns:JSON.stringify(d.unknowns||[]),ai_evidence_ids:JSON.stringify(d.evidence_used||[]),ai_follow_up:d.follow_up||''};const i=prospects.findIndex(p=>String(p.id)===String(selected.id));if(i>=0)prospects[i]=selected;showDraftNotes(d);s.textContent='Draft ready. Edit it, verify the facts, 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='Regenerate draft'}}if(e.target.id==='copyMessage'){await navigator.clipboard.writeText(`Subject: ${$('subjectOutput').value}\n\n${$('bodyOutput').value}`);$('messageStatus').textContent='Copied to clipboard.'}if(e.target.id==='testAi'){const s=$('settingsMessage');s.textContent='Testing…';s.className='notice';try{const d=await api('/api/ai/test',{method:'POST',body:'{}'});s.textContent=`Connection works · ${d.provider} · ${d.model}`;s.className='notice ok'}catch(x){s.textContent=x.message;s.className='notice error'}}});
document.addEventListener('click',async e=>{if(e.target.id==='generateMessage'){if(!selected)return;const s=$('messageStatus');const button=e.target;button.disabled=true;button.textContent='Writing…';s.textContent='Comparing the prospect facts with your offer…';try{const d=await api(`/api/prospects/${selected.id}/message`,{method:'POST',body:JSON.stringify({offer:$('offerInput').value,tone:$('messageTone')?.value||'warm',goal:$('messageGoal')?.value||'conversation',promotion_id:$('messagePromotion')?.value||null,include_mockup:$('includeMockup')?.checked!==false,mockup_id:$('includeMockup')?.dataset.mockupId||null})});$('subjectOutput').value=d.subject;$('bodyOutput').value=d.message;selected={...selected,ai_subject:d.subject,ai_message:d.message,ai_offer:$('offerInput').value,ai_promotion_id:$('messagePromotion')?.value?Number($('messagePromotion').value):null,ai_mockup_id:d.mockup_url?(Number($('includeMockup')?.dataset.mockupId||selected.ai_mockup_id||0)||null):null,ai_reasoning:d.rationale||'',ai_unknowns:JSON.stringify(d.unknowns||[]),ai_evidence_ids:JSON.stringify(d.evidence_used||[]),ai_follow_up:d.follow_up||''};const i=prospects.findIndex(p=>String(p.id)===String(selected.id));if(i>=0)prospects[i]=selected;showDraftNotes(d);s.textContent='Draft ready. Read it aloud, edit anything you want, then copy it to your mail client.';s.className='notice ok'}catch(x){s.textContent=x.message;s.className='notice error'}finally{button.disabled=false;button.textContent='Generate client-specific draft'}}if(e.target.id==='copyMessage'){await navigator.clipboard.writeText(`Subject: ${$('subjectOutput').value}\n\n${$('bodyOutput').value}`);$('messageStatus').textContent='Copied to clipboard.'}if(e.target.id==='testAi'){const s=$('settingsMessage');s.textContent='Testing…';s.className='notice';try{const d=await api('/api/ai/test',{method:'POST',body:'{}'});s.textContent=`Connection works · ${d.provider} · ${d.model}`;s.className='notice ok'}catch(x){s.textContent=x.message;s.className='notice error'}}});
document.addEventListener('click',async e=>{if(e.target.id==='learnCompanyWebsite'){const button=e.target,m=$('teachAiLearnMessage');const url=$('companyWebsiteUrl')?.value||'';if(!url){m.textContent='Enter a website URL first.';m.className='notice error';return}button.disabled=true;button.textContent='Reading…';m.textContent='Fetching the public page…';m.className='notice';try{const result=await api('/api/company-profile/learn',{method:'POST',body:JSON.stringify({url})});settings.company_profile=result.company_profile||settings.company_profile;const p=settings.company_profile||{};$('companyWebsiteUrl').value=p.website_url||url;if($('companyName')&&!$('companyName').value&&p.website_title)$('companyName').value=p.website_title;m.textContent=`Website snapshot saved (${(p.website_content||'').length.toLocaleString()} characters). Review it, then save your company knowledge.`;m.className='notice ok'}catch(x){m.textContent=x.message;m.className='notice error'}finally{button.disabled=false;button.textContent='Read website now'}}const toggle=e.target.closest('[data-promotion-active]');if(toggle){try{await api(`/api/promotions/${toggle.dataset.promotionActive}`,{method:'PATCH',body:JSON.stringify({active:toggle.checked})});const item=(settings.promotions||[]).find(p=>String(p.id)===String(toggle.dataset.promotionActive));if(item)item.active=toggle.checked?1:0;renderPromotions()}catch(x){toggle.checked=!toggle.checked;alert(x.message)}}const remove=e.target.closest('[data-delete-promotion]');if(remove){if(!confirm('Delete this promotion?'))return;remove.disabled=true;try{await api(`/api/promotions/${remove.dataset.deletePromotion}`,{method:'DELETE'});settings.promotions=(settings.promotions||[]).filter(p=>String(p.id)!==String(remove.dataset.deletePromotion));renderPromotions()}catch(x){remove.disabled=false;alert(x.message)}}});
function aiRecommendationLabel(value){return value==='contact'?'Contact recommended':value==='review'?'Review first':'Do not contact yet'}
function addAssessmentPanel(id){const p=prospects.find(x=>String(x.id)===String(id));const body=$('drawerBody');if(!p||!body)return;const has=Number.isFinite(Number(p.ai_probability))&&p.ai_checked_at;const section=document.createElement('section');section.className='drawer-section';section.id='aiAssessmentPanel';section.innerHTML=has?`<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>`;const actions=body.querySelector('.drawer-actions');if(actions)body.insertBefore(section,actions);else body.append(section)}
@@ -247,3 +247,87 @@ document.addEventListener('click',e=>{
if(!edit)return;
e.preventDefault();e.stopPropagation();openMockupEditor(edit);
},true);
/* AI email draft editor -------------------------------------------------- */
/*
* The draft dialog deliberately stays a method="dialog" form. The chat is
* therefore a button-driven composer rather than a nested form (nested forms
* make the browser close the dialog instead of applying the requested edit).
* Each request sends the live subject/body fields, so manual edits and prior
* chat revisions are both preserved as the next working draft.
*/
const _ensureMessageDialogWithRevisionChat=ensureMessageDialog;
ensureMessageDialog=function(force=false){
_ensureMessageDialogWithRevisionChat(force);
const output=document.querySelector('#messageForm .draft-output');
if(!output||$('messageRevisionPanel'))return;
output.insertAdjacentHTML('afterend',`<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>`);
};
function messageChatAdd(kind,text){
const messages=$('messageChatMessages');
if(!messages)return;
const bubble=document.createElement('div');
bubble.className=`message-chat-message ${kind||'assistant'}`;
bubble.textContent=String(text||'');
messages.append(bubble);
messages.scrollTop=messages.scrollHeight;
}
function rememberMessageDraft(d){
if(!selected||!d)return;
const promotionValue=$('messagePromotion')?.value||'';
const mockupCheckbox=$('includeMockup');
const mockupValue=d.mockup_id??mockupCheckbox?.dataset?.mockupId??selected.ai_mockup_id??null;
selected={...selected,
ai_subject:d.subject||'',ai_message:d.message||'',ai_offer:$('offerInput')?.value||selected.ai_offer||'',
ai_promotion_id:d.promotion_id??(promotionValue?Number(promotionValue):null),
ai_mockup_id:d.mockup_url?Number(mockupValue||0)||null:null,
ai_reasoning:d.rationale||'',ai_unknowns:JSON.stringify(d.unknowns||[]),
ai_evidence_ids:JSON.stringify(d.evidence_used||[]),ai_follow_up:d.follow_up||'',ai_checked_at:d.ai_checked_at||selected.ai_checked_at||''
};
const index=prospects.findIndex(p=>String(p.id)===String(selected.id));
if(index>=0)prospects[index]=selected;
}
const _openMessageWithRevisionChat=openMessage;
openMessage=async function(id){
await _openMessageWithRevisionChat(id);
const messages=$('messageChatMessages');
if(messages){messages.innerHTML='';messageChatAdd('assistant','I can revise the subject and email while keeping the public evidence, exact offer, promotion terms, and selected preview link intact. What would you like changed?');}
const input=$('messageRevisionInput');if(input)input.value='';
const status=$('messageRevisionStatus');if(status){status.textContent='The AI can change wording, tone, order, or emphasis. It cannot add unsupported facts.';status.className='notice';}
};
async function reviseMessageDraft(){
if(!selected)return;
const input=$('messageRevisionInput'),button=$('messageRevisionApply'),status=$('messageRevisionStatus');
const instruction=String(input?.value||'').trim();
const subject=String($('subjectOutput')?.value||'').trim();
const body=String($('bodyOutput')?.value||'').trim();
if(instruction.length<3){if(status){status.textContent='Tell the AI what you would like changed.';status.className='notice error'}input?.focus();return;}
if(!subject||!body){if(status){status.textContent='Generate a draft first, then ask the AI to revise it.';status.className='notice error'}return;}
if(button){button.disabled=true;button.textContent='Applying…';}
if(status){status.textContent='Applying your request to the current draft…';status.className='notice';}
messageChatAdd('user',instruction);
try{
const checkbox=$('includeMockup');
const d=await api(`/api/prospects/${selected.id}/message/edit`,{method:'POST',body:JSON.stringify({
instruction,current_subject:subject,current_message:body,offer:$('offerInput')?.value||'',
tone:$('messageTone')?.value||'warm',goal:$('messageGoal')?.value||'conversation',
promotion_id:$('messagePromotion')?.value||null,include_mockup:checkbox?.checked!==false,mockup_id:checkbox?.dataset?.mockupId||null
})});
$('subjectOutput').value=d.subject||'';$('bodyOutput').value=d.message||'';rememberMessageDraft(d);showDraftNotes(d);
messageChatAdd('assistant',d.rationale?`Updated the draft: ${d.rationale}`:'Updated the draft. Review the new wording and ask for another change if needed.');
if(status){status.textContent='Draft revised. Verify the facts, then copy it to your mail client.';status.className='notice ok';}
if(input)input.value='';
}catch(error){messageChatAdd('system',`I could not revise the draft: ${error.message}`);if(status){status.textContent=error.message;status.className='notice error';}}
finally{if(button){button.disabled=false;button.innerHTML='Apply change <span>→</span>';}input?.focus();}
}
document.addEventListener('click',e=>{if(e.target.id==='messageRevisionApply')reviseMessageDraft()});
document.addEventListener('keydown',e=>{if(e.target.id==='messageRevisionInput'&&e.key==='Enter'&&(e.ctrlKey||e.metaKey)){e.preventDefault();$('messageRevisionApply')?.click()}});
/* The first call above happens before this enhancement is defined. Run the
* non-destructive wrapper once so the panel exists before the first click. */
ensureMessageDialog(false);
+2 -2
View File
@@ -1,3 +1,3 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#0d1726"><title>ProspectOS</title><link rel="stylesheet" href="/style.css?v=workspace-v5"></head>
<body><div class="app-shell"><aside class="sidebar"><div class="brand"><span class="brand-mark">P</span><div><b>ProspectOS</b><small>Growth workspace</small></div></div><nav id="nav"><a href="#dashboard" data-route="dashboard"><span>⌂</span>Dashboard</a><a href="#discover" data-route="discover"><span>⌁</span>Discover</a><a href="#prospects" data-route="prospects"><span>▦</span>Prospects</a><a href="#runs" data-route="runs"><span>◷</span>Runs</a><a href="#settings" data-route="settings"><span>⚙</span>Settings</a></nav><div class="sidebar-foot"><span class="status-dot" id="sourceDot"></span><div><b id="sourceLabel">Checking worker</b><small id="sourceSub">Connecting…</small></div></div></aside><main class="main"><header class="topbar"><div><span class="crumb">Workspace</span><span class="slash">/</span><b id="pageName">Dashboard</b></div><div class="top-actions"><span id="updated">—</span><button class="icon-btn" id="refreshAll" title="Refresh">↻</button></div></header><div id="view"></div><footer>ProspectOS · public data only · manual review before contact</footer></main></div><aside id="detailDrawer" class="detail-drawer" aria-hidden="true"><div class="drawer-head"><div><span class="eyebrow">PROSPECT DETAIL</span><h2 id="drawerName">Prospect</h2></div><button class="icon-btn" id="closeDrawer">×</button></div><div id="drawerBody"></div></aside><dialog id="messageDialog"><form method="dialog" id="messageForm"><div class="dialog-head"><div><label class="eyebrow">AI ASSISTED DRAFT</label><h2 id="messageTitle">Tailor a message</h2><p>Use the evidence and your offer to create an editable draft.</p></div><button class="icon-btn" value="cancel">×</button></div><label for="offerInput">Your offer or instruction</label><textarea id="offerInput" rows="4" placeholder="Move hosting to Gold Plus for R119/month and rebuild the website free."></textarea><div id="messageStatus" class="notice"></div><button class="primary" id="generateMessage" type="button">Generate subject and email</button><label for="subjectOutput">Subject</label><input id="subjectOutput"><label for="bodyOutput">Email draft</label><textarea id="bodyOutput" rows="12"></textarea><div class="dialog-actions"><button class="secondary" id="copyMessage" type="button">Copy draft</button><button class="secondary" value="cancel">Close</button></div></form></dialog><script src="/app.js?v=workspace-v5"></script></body></html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#0d1726"><title>ProspectOS</title><link rel="stylesheet" href="/style.css?v=workspace-v7"></head>
<body><div class="app-shell"><aside class="sidebar"><div class="brand"><span class="brand-mark">P</span><div><b>ProspectOS</b><small>Growth workspace</small></div></div><nav id="nav"><a href="#dashboard" data-route="dashboard"><span>⌂</span>Dashboard</a><a href="#discover" data-route="discover"><span>⌁</span>Discover</a><a href="#prospects" data-route="prospects"><span>▦</span>Prospects</a><a href="#runs" data-route="runs"><span>◷</span>Runs</a><a href="#settings" data-route="settings"><span>⚙</span>Settings</a></nav><div class="sidebar-foot"><span class="status-dot" id="sourceDot"></span><div><b id="sourceLabel">Checking worker</b><small id="sourceSub">Connecting…</small></div></div></aside><main class="main"><header class="topbar"><div><span class="crumb">Workspace</span><span class="slash">/</span><b id="pageName">Dashboard</b></div><div class="top-actions"><span id="updated">—</span><button class="icon-btn" id="refreshAll" title="Refresh">↻</button></div></header><div id="view"></div><footer>ProspectOS · public data only · manual review before contact</footer></main></div><aside id="detailDrawer" class="detail-drawer" aria-hidden="true"><div class="drawer-head"><div><span class="eyebrow">PROSPECT DETAIL</span><h2 id="drawerName">Prospect</h2></div><button class="icon-btn" id="closeDrawer">×</button></div><div id="drawerBody"></div></aside><dialog id="messageDialog"><form method="dialog" id="messageForm"><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 and your offer to create an editable draft.</p></div><button class="icon-btn" value="cancel">×</button></div><label for="offerInput">Your offer or instruction</label><textarea id="offerInput" rows="4" placeholder="Move hosting to Gold Plus for R119/month and rebuild the website free."></textarea><div id="messageStatus" class="notice"></div><button class="primary" id="generateMessage" type="button">Generate client-specific draft</button><label for="subjectOutput">Subject</label><input id="subjectOutput"><label for="bodyOutput">Email draft</label><textarea id="bodyOutput" rows="12"></textarea><div class="dialog-actions"><button class="secondary" id="copyMessage" type="button">Copy draft</button><button class="secondary" value="cancel">Close</button></div></form></dialog><script src="/app.js?v=workspace-v7"></script></body></html>
+11
View File
@@ -172,3 +172,14 @@ input::placeholder { color: #aeb6c5; } input:focus, select:focus { border-color:
.mockup-chat-form{display:grid;gap:8px;padding:14px 16px 12px;border-top:1px solid #edf0f5}.mockup-chat-form label{display:grid;gap:5px;color:#627087;font-size:10px;font-weight:850}.mockup-chat-form select{margin:0;height:34px;font-size:11px}.mockup-chat-form textarea{margin:0;min-height:106px;resize:vertical;font-size:12px;line-height:1.5}.mockup-chat-form .notice{margin:0;font-size:10px;line-height:1.4}.mockup-chat-form button{width:100%;min-height:42px}.mockup-editor-note{display:grid;gap:4px;padding:12px 16px 16px;background:#fafbff;color:#8b96a8;font-size:10px;line-height:1.45}.mockup-editor-note b{color:#5c6a80;font-size:10px}.mockup-editor-note span{display:block}
@media(max-width:900px){.mockup-editor-dialog{width:min(760px,calc(100vw - 20px));height:min(900px,calc(100vh - 20px))}.mockup-editor-layout{grid-template-columns:1fr;grid-template-rows:minmax(310px,1fr) auto}.mockup-editor-preview iframe{min-height:300px}.mockup-editor-chat{grid-template-rows:auto minmax(95px,1fr) auto auto;border-left:0;border-top:1px solid #dfe4ed;max-height:430px}.mockup-chat-messages{min-height:95px;max-height:160px}}
@media(max-width:560px){.mockup-editor-dialog{width:100vw;height:100vh;border-radius:0}.mockup-editor-head{padding:15px 16px}.mockup-editor-head h2{font-size:17px}.mockup-editor-head p{max-width:230px}.mockup-editor-head-actions .pill{display:none}.mockup-editor-layout{grid-template-rows:minmax(260px,1fr) auto}.mockup-editor-preview-bar{padding:9px 12px}.mockup-editor-chat{max-height:430px}.mockup-chat-form{padding:11px 12px}.mockup-editor-note{padding:10px 12px 13px}}
/* AI email draft revision chat */
.message-revision-panel{display:grid;gap:0;margin-top:4px;border:1px solid #e1e5ef;border-radius:13px;overflow:hidden;background:#fff}
.message-revision-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;padding:15px 16px 12px;border-bottom:1px solid #edf0f5;background:linear-gradient(135deg,#fbfaff,#fff)}
.message-revision-head h3{margin:4px 0 3px;color:#2e3b53;font-size:15px;letter-spacing:-.02em}.message-revision-head p{margin:0;color:#8a96a8;font-size:10px;line-height:1.45}
.message-chat-badge{padding:5px 8px;border-radius:999px;background:#eef8f5;color:#17876b;font-size:9px;font-weight:900;white-space:nowrap}
.message-chat-messages{display:grid;align-content:start;gap:8px;max-height:154px;overflow:auto;padding:12px 14px;background:linear-gradient(180deg,#fbfcff,#fff)}
.message-chat-message{max-width:92%;padding:9px 10px;border-radius:10px;font-size:11px;line-height:1.45;white-space:pre-wrap}.message-chat-message.assistant{justify-self:start;border:1px solid #e1e5ef;background:#f5f6fb;color:#59677e}.message-chat-message.user{justify-self:end;background:#635bff;color:#fff;box-shadow:0 6px 14px #635bff2b}.message-chat-message.system{justify-self:start;border:1px solid #f1c7cd;background:#fff5f6;color:#a33e4d}
.message-revision-composer{display:grid;gap:7px;padding:12px 14px 14px;border-top:1px solid #edf0f5;background:#fff}.message-revision-composer label{color:#627087;font-size:10px;font-weight:850}.message-revision-composer textarea{min-height:74px;margin:0;resize:vertical;font-size:12px;line-height:1.5}.message-revision-composer .notice{margin:0;font-size:10px;line-height:1.4}.message-revision-composer button{width:100%;min-height:40px}
.message-dialog{max-height:calc(100vh - 24px);overflow:auto}.message-dialog form{min-width:0}.message-dialog .draft-output{min-width:0}
@media(max-width:560px){.message-revision-head{display:block}.message-chat-badge{display:inline-block;margin-top:9px}.message-chat-messages{max-height:130px}}