feat: add Leads tab and Hide Assessed filter in Browse

- db.py: get_enriched() accepts ai_only + lead_quality params
- main.py: /api/enriched exposes ai_only + lead_quality query params;
  new /api/export/leads endpoint produces CSV with contacts + pitch
- index.html:
  - New "Leads 🤖" tab shows all AI-assessed domains with contacts
    (quality/country/limit filters, per-row 📋 copy email, 🔍 modal,
    CSV export, pagination, auto-refreshes every 3s)
  - Browse: "Hide assessed" checkbox filters out already-processed
    domains so you can focus on fresh targets
  - Poll cycle refreshes Leads tab when active

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-14 18:57:15 +02:00
parent 22eae3f9b7
commit 63f961dc80
3 changed files with 188 additions and 9 deletions

View File

@@ -309,6 +309,7 @@ tr:hover td{background:rgba(255,255,255,.025)}
<div class="tab" :class="{active:tab==='browse'}" @click="tab='browse'">Browse & Filter</div>
<div class="tab" :class="{active:tab==='enrich'}" @click="tab='enrich';loadQueue()">Enrichment</div>
<div class="tab" :class="{active:tab==='pipeline'}" @click="tab='pipeline';loadPipeline()">Lead Pipeline</div>
<div class="tab" :class="{active:tab==='leads'}" @click="tab='leads';loadLeads(true)">Leads 🤖</div>
<div class="tab" :class="{active:tab==='chart'}" @click="tab='chart';renderChart()">TLD Chart</div>
</div>
@@ -335,6 +336,7 @@ tr:hover td{background:rgba(255,255,255,.025)}
<label class="tog"><input type="checkbox" x-model="f.alpha_only"><strong>Alpha only</strong> <span>(no hyphens/nums)</span></label>
<label class="tog"><input type="checkbox" x-model="f.no_sld"><strong>No SLD</strong> <span>(skip com.es)</span></label>
<label class="tog"><input type="checkbox" x-model="f.kit_digital_only"><strong style="color:var(--kd)">🏅 Kit Digital only</strong></label>
<label class="tog"><input type="checkbox" x-model="f.exclude_assessed"><strong>Hide assessed</strong></label>
</div>
<div style="display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;align-items:center">
@@ -541,7 +543,93 @@ tr:hover td{background:rgba(255,255,255,.025)}
</div>
</div>
<!--TLD Chart -->
<!--Leads (AI Assessed) -->
<div class="card" x-show="tab==='leads'">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;flex-wrap:wrap;gap:8px">
<div class="ct" style="margin:0">Assessed Leads (<span x-text="leadsTotal.toLocaleString()"></span>)</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center">
<select x-model="leadsQ.quality" @change="loadLeads(true)" style="padding:4px 8px;border-radius:6px;border:1px solid var(--border);background:var(--card);color:var(--text);font-size:13px">
<option value="">All Qualities</option>
<option value="HOT">🔥 HOT</option>
<option value="WARM">♨️ WARM</option>
<option value="COLD">🧊 COLD</option>
</select>
<input type="text" x-model="leadsQ.country" placeholder="Country…" @keydown.enter="loadLeads(true)" style="width:80px;padding:4px 8px;border-radius:6px;border:1px solid var(--border);background:var(--card);color:var(--text);font-size:13px">
<select x-model="leadsQ.limit" @change="loadLeads(true)" style="padding:4px 8px;border-radius:6px;border:1px solid var(--border);background:var(--card);color:var(--text);font-size:13px">
<option value="25">25</option><option value="50">50</option><option value="100">100</option>
</select>
<button class="btn bp sm" @click="loadLeads(true)">↻ Refresh</button>
<a class="btn bg sm" :href="leadsExportUrl()" target="_blank" style="text-decoration:none">⬇ CSV</a>
</div>
</div>
<div class="tw">
<table>
<thead>
<tr>
<th>Quality</th><th>Domain</th><th>Score</th>
<th>Best Contact</th><th>All Contacts</th><th>Pitch</th><th></th>
</tr>
</thead>
<tbody>
<template x-if="leadsLoading">
<tr><td colspan="7" style="text-align:center;padding:24px;color:var(--muted)">Loading…</td></tr>
</template>
<template x-if="!leadsLoading && leadsData.length===0">
<tr><td colspan="7" style="text-align:center;padding:24px;color:var(--muted)">No assessed leads yet — run 🤖 AI Assess on some domains in Browse</td></tr>
</template>
<template x-for="row in leadsData" :key="row.domain">
<tr>
<td>
<span class="pill" :class="aiPillClass(row.ai_lead_quality)"
style="cursor:pointer" @click="openModal(row)"
x-text="row.ai_lead_quality||'—'"></span>
</td>
<td><a :href="'http://'+row.domain" target="_blank" rel="noopener" x-text="row.domain"></a></td>
<td><span class="score" :style="scoreBg(row.score)" x-text="row.score??'—'"></span></td>
<td style="max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px">
<span :title="row.ai_contact_value" x-text="row.ai_contact_value||'—'"></span>
</td>
<td>
<div class="contact-chips" x-data="{c: parseContacts(row.contact_info)}">
<template x-for="em in (c.emails||[]).slice(0,2)" :key="em">
<a :href="'mailto:'+em" class="chip email" :title="em"><span x-text="em"></span></a>
</template>
<template x-for="ph in (c.phones||[]).slice(0,1)" :key="ph">
<a :href="'tel:'+ph" class="chip phone" :title="ph">📞 <span x-text="ph"></span></a>
</template>
<template x-for="wa in (c.whatsapp||[]).slice(0,1)" :key="wa">
<a :href="wa" target="_blank" class="chip wa" title="WhatsApp">💬</a>
</template>
<template x-for="url in (c.social||[]).slice(0,3)" :key="url">
<a :href="url" target="_blank" :class="'sicon '+socialIconClass(url)" :title="url" x-text="socialIconLabel(url)"></a>
</template>
</div>
</td>
<td style="max-width:220px;font-size:11px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
<span :title="row.ai_pitch" x-text="row.ai_pitch||'—'"></span>
</td>
<td style="white-space:nowrap">
<button class="btn bg sm" @click="openModal(row)" title="Full details">🔍</button>
<button class="btn bai sm" @click="copyLeadEmail(row)" title="Copy outreach email">📋</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="pager">
<button class="btn bg sm" @click="if(leadsPage>1){leadsPage--;loadLeads();}" :disabled="leadsPage<=1||leadsLoading">← Prev</button>
<span class="inf">Page <b x-text="leadsPage"></b>
<span x-show="leadsTotal>0" x-text="' of '+Math.ceil(leadsTotal/Number(leadsQ.limit))"></span>
</span>
<button class="btn bg sm" @click="if(leadsData.length>=Number(leadsQ.limit)){leadsPage++;loadLeads();}" :disabled="leadsLoading||leadsData.length<Number(leadsQ.limit)">Next →</button>
<span class="inf" x-show="leadsTotal>0" x-text="leadsTotal.toLocaleString()+' assessed'"></span>
</div>
</div>
<!-- ⑥ TLD Chart -->
<div class="card" x-show="tab==='chart'">
<div class="ct">Top 20 TLDs</div>
<div class="cw"><canvas id="tldChart"></canvas></div>
@@ -557,8 +645,10 @@ function app() {
aiSt: {pending:0,running:0,done:0,failed:0,total:0},
domains: [], selected: [], aiLang: 'ES',
loading: false, page: 1, searchTotal: 0,
f: {tld:'',keyword:'',min_score:0,cms:'',live_only:false,alpha_only:false,no_sld:false,kit_digital_only:false,limit:'100'},
f: {tld:'',keyword:'',min_score:0,cms:'',live_only:false,alpha_only:false,no_sld:false,kit_digital_only:false,exclude_assessed:false,limit:'100'},
qst: {}, customDomains: '',
leadsQ: {quality:'', country:'', limit:'50'},
leadsData: [], leadsTotal: 0, leadsPage: 1, leadsLoading: false,
pipeline: {hot:{count:0,samples:[]},warm:{count:0,samples:[]},cold:{count:0,samples:[]}},
toast: {show:false,msg:'',type:'success'},
modal: {open:false, domain:'', ai:{}, sa:null},
@@ -577,8 +667,9 @@ function app() {
} else {
this._lastAiDone = this.aiSt.done ?? 0;
}
if(this.tab==='enrich') this.loadQueue();
if(this.tab==='enrich') this.loadQueue();
if(this.tab==='pipeline') this.loadPipeline();
if(this.tab==='leads') this.loadLeads();
}, 3000);
},
@@ -612,9 +703,10 @@ function app() {
const data = await fetch('/api/domains?'+p).then(r=>r.json());
this.searchTotal = data.total ?? 0;
let rows = data.results;
if(this.f.min_score>0) rows = rows.filter(r=> r.score==null || r.score>=Number(this.f.min_score));
if(this.f.cms) rows = rows.filter(r=> r.cms===this.f.cms);
if(this.f.kit_digital_only) rows = rows.filter(r=> r.kit_digital);
if(this.f.min_score>0) rows = rows.filter(r=> r.score==null || r.score>=Number(this.f.min_score));
if(this.f.cms) rows = rows.filter(r=> r.cms===this.f.cms);
if(this.f.kit_digital_only) rows = rows.filter(r=> r.kit_digital);
if(this.f.exclude_assessed) rows = rows.filter(r=> !r.ai_lead_quality);
this.domains = rows;
} catch(e) {
this.domains = [];
@@ -624,7 +716,7 @@ function app() {
},
selectAll() { this.selected = this.domains.map(d=>d.domain); },
resetFilters() { this.f={tld:'',keyword:'',min_score:0,cms:'',live_only:false,alpha_only:false,no_sld:false,kit_digital_only:false,limit:'100'}; },
resetFilters() { this.f={tld:'',keyword:'',min_score:0,cms:'',live_only:false,alpha_only:false,no_sld:false,kit_digital_only:false,exclude_assessed:false,limit:'100'}; },
async enqueueSelected() {
if(!this.selected.length) return;
@@ -654,6 +746,38 @@ function app() {
this.notify(`Queued ${d2.queued} Kit Digital domains for AI assessment [${this.aiLang}]`,'info');
},
async loadLeads(reset=false) {
if(reset) this.leadsPage = 1;
this.leadsLoading = true;
const p = new URLSearchParams({page:this.leadsPage, limit:this.leadsQ.limit, min_score:'0', ai_only:'true'});
if(this.leadsQ.quality) p.set('lead_quality', this.leadsQ.quality);
if(this.leadsQ.country) p.set('country', this.leadsQ.country.trim());
try {
const d = await fetch('/api/enriched?'+p).then(r=>r.json());
this.leadsData = d.results;
this.leadsTotal = d.total ?? 0;
} catch(e) { this.notify('Failed to load leads: '+e.message,'error'); }
this.leadsLoading = false;
},
copyLeadEmail(row) {
let ai = {};
try { ai = JSON.parse(row.ai_assessment || '{}'); } catch(e) {}
const subj = ai.email_subject ? `Subject: ${ai.email_subject}\n\n` : '';
const body = ai.outreach_email || ai.pitch_angle || '';
navigator.clipboard.writeText(subj + body).then(
() => this.notify('Email copied to clipboard','success'),
() => this.notify('Copy failed — select text manually','error')
);
},
leadsExportUrl() {
const p = new URLSearchParams();
if(this.leadsQ.quality) p.set('lead_quality', this.leadsQ.quality);
if(this.leadsQ.country) p.set('country', this.leadsQ.country.trim());
return '/api/export/leads' + (p.toString() ? '?'+p : '');
},
async enqueueCustom() {
const domains = this.customDomains.split('\n').map(d=>d.trim()).filter(Boolean);
if(!domains.length) return;