Files
DomGod/app/static/index.html
Malin 8f387cada2 feat: bulk validator tab + status/niche/type browse filters
- New app/validator.py: background HTTP checker for entire dataset
  - 50 concurrent checks, skips already-validated domains
  - Extracts prescreen_status, server, IP, load_time_ms
  - start/stop/status API at /api/validator/start|stop|status

- New dedicated "Validator 🔬" tab with stats grid, TLD filter,
  Start/Stop controls, live progress indicator

- Browse tab: "Live" column replaced with "Status" dot (color-coded
  ● from prescreen_status, falls back to is_live)
- Browse tab: new Status / Niche / Type filter dropdowns

- db.py: added ip TEXT + load_time_ms INTEGER columns + migrations;
  get_enriched() supports prescreen_status/niche/site_type filters

- main.py: /api/enriched extended with prescreen_status/niche/site_type

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 08:27:24 +02:00

1084 lines
60 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DomGod — Domain Intelligence</title>
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
:root{
--bg:#0f1117;--surface:#1a1d27;--surface2:#222638;--border:#2e3250;
--accent:#6c63ff;--accent2:#00d4aa;--danger:#ff4f6d;--warn:#ffb347;
--text:#e8eaf0;--muted:#8891b0;--r:8px;
--kd:#f59e0b;
}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,sans-serif;font-size:14px}
a{color:var(--accent2);text-decoration:none}
header{background:var(--surface);border-bottom:1px solid var(--border);padding:11px 20px;display:flex;align-items:center;gap:8px;position:sticky;top:0;z-index:200}
header h1{font-size:20px;font-weight:900;letter-spacing:-1px}
header h1 span{color:var(--accent)}
.hbadge{background:var(--surface2);border:1px solid var(--border);color:var(--muted);font-size:11px;padding:2px 8px;border-radius:99px}
.ipill{font-size:11px;padding:3px 9px;border-radius:99px;font-weight:700}
.ip-ok{background:#00d4aa18;color:var(--accent2);border:1px solid #00d4aa33}
.ip-bld{background:#ffb34718;color:var(--warn);border:1px solid #ffb34733}
main{padding:14px 20px;display:flex;flex-direction:column;gap:14px;max-width:1500px;margin:0 auto;width:100%}
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:14px}
.ct{font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.6px;margin-bottom:10px}
/* Stats */
.sg{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:10px}
.sb{background:var(--surface2);border:1px solid var(--border);border-radius:var(--r);padding:11px 13px}
.sb .l{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.3px}
.sb .v{font-size:22px;font-weight:800;margin-top:2px}
.sb .s{font-size:11px;color:var(--muted);margin-top:1px}
.c1{color:var(--accent2)} .c2{color:var(--danger)} .c3{color:var(--warn)} .c4{color:var(--muted)}
.ckd{color:var(--kd)}
/* Tabs */
.tabs{display:flex;gap:2px;border-bottom:1px solid var(--border)}
.tab{padding:8px 15px;cursor:pointer;font-size:13px;font-weight:500;color:var(--muted);border-radius:6px 6px 0 0;border:1px solid transparent;border-bottom:none;user-select:none}
.tab.active{background:var(--surface);color:var(--text);border-color:var(--border)}
.tab:hover:not(.active){color:var(--text)}
/* Filters */
.frow{display:flex;flex-wrap:wrap;gap:8px;align-items:flex-end;margin-bottom:10px}
.field{display:flex;flex-direction:column;gap:3px}
.field label{font-size:11px;color:var(--muted);text-transform:uppercase;font-weight:600}
input[type=text],input[type=number],select{
background:var(--surface2);border:1px solid var(--border);color:var(--text);
padding:5px 9px;border-radius:6px;font-size:13px;outline:none
}
input[type=text]:focus,select:focus{border-color:var(--accent)}
input[type=range]{accent-color:var(--accent);width:100px;cursor:pointer}
.tog{display:flex;align-items:center;gap:5px;cursor:pointer;padding:5px 0;font-size:12px;color:var(--muted)}
.tog input{accent-color:var(--accent);width:14px;height:14px;cursor:pointer}
.tog strong{color:var(--text)}
/* Buttons */
.btn{padding:6px 13px;border-radius:6px;font-size:12px;font-weight:700;cursor:pointer;border:none;transition:opacity .15s;white-space:nowrap}
.btn:hover:not(:disabled){opacity:.82} .btn:disabled{opacity:.35;cursor:not-allowed}
.bp{background:var(--accent);color:#fff}
.bs{background:var(--accent2);color:#111}
.bd{background:var(--danger);color:#fff}
.bw{background:var(--warn);color:#111}
.bg{background:var(--surface2);color:var(--text);border:1px solid var(--border)}
.bkd{background:#f59e0b22;color:var(--kd);border:1px solid #f59e0b44}
.bai{background:#a855f722;color:#c084fc;border:1px solid #a855f744}
.bps{background:#0f9d5822;color:#34d399;border:1px solid #0f9d5844}
.sm{padding:4px 9px;font-size:11px}
/* Niche / type pills */
.pni{background:#0ea5e918;color:#38bdf8;border:1px solid #0ea5e933}
.pty{background:#8b5cf618;color:#a78bfa;border:1px solid #8b5cf633}
/* Prescreen status dot */
.ps-live{color:#34d399} .ps-dead{color:#f87171} .ps-parked{color:#fbbf24} .ps-redirect{color:#94a3b8}
/* Table */
.tw{overflow-x:auto;border-radius:var(--r);border:1px solid var(--border)}
table{width:100%;border-collapse:collapse;font-size:12px}
th{text-align:left;padding:7px 9px;font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.4px;background:var(--surface2);border-bottom:1px solid var(--border);white-space:nowrap}
td{padding:6px 9px;border-bottom:1px solid var(--border);vertical-align:middle}
tr:last-child td{border-bottom:none}
tr:hover td{background:rgba(255,255,255,.025)}
.pill{display:inline-block;padding:1px 6px;border-radius:99px;font-size:10px;font-weight:700}
.pg{background:#00d4aa18;color:var(--accent2)} .pr{background:#ff4f6d18;color:var(--danger)}
.pp{background:#ffffff11;color:var(--muted)} .pc{background:#6c63ff18;color:var(--accent)}
.pkd{background:#f59e0b18;color:var(--kd);border:1px solid #f59e0b33}
/* AI quality pills */
.ai-hot{background:#ff4f6d18;color:var(--danger);border:1px solid #ff4f6d33}
.ai-warm{background:#ffb34718;color:var(--warn);border:1px solid #ffb34733}
.ai-cold{background:#6c7aff18;color:#6c7aff;border:1px solid #6c7aff33}
.ai-none{background:#ffffff08;color:var(--muted)}
.score{display:inline-block;padding:1px 6px;border-radius:5px;font-weight:800;font-size:11px;min-width:28px;text-align:center}
/* Contact chips */
.contact-chips{display:flex;flex-wrap:wrap;gap:3px;align-items:center}
.chip{display:inline-flex;align-items:center;gap:3px;padding:1px 6px;border-radius:4px;font-size:10px;background:var(--surface2);border:1px solid var(--border);color:var(--muted);max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.chip.email{border-color:#00d4aa33;color:var(--accent2)}
.chip.phone{border-color:#6c63ff33;color:var(--accent)}
.chip.wa{border-color:#22c55e33;color:#4ade80}
/* Social platform icon badges */
.sicon{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:4px;font-size:9px;font-weight:900;text-decoration:none;flex-shrink:0;line-height:1}
.sicon.fb{background:#1877f2;color:#fff}
.sicon.ig{background:linear-gradient(135deg,#f09433 0%,#e6683c 25%,#dc2743 50%,#cc2366 75%,#bc1888 100%);color:#fff}
.sicon.li{background:#0a66c2;color:#fff}
.sicon.tw{background:#000;color:#fff}
.sicon.tt{background:#010101;color:#fff}
.sicon.yt{background:#ff0000;color:#fff}
.sicon.gmb{background:#4285f4;color:#fff}
.sicon.other{background:var(--surface2);border:1px solid var(--border);color:var(--muted)}
/* Tooltip */
[title]{cursor:help}
.pitch-cell{max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px;color:var(--muted);font-style:italic}
/* Pagination */
.pager{display:flex;align-items:center;gap:8px;margin-top:10px;flex-wrap:wrap}
.pager .inf{font-size:11px;color:var(--muted)}
/* Progress */
.pw{background:var(--surface2);border-radius:99px;height:8px;overflow:hidden;margin:6px 0}
.pb{height:100%;background:linear-gradient(90deg,var(--accent),var(--accent2));border-radius:99px;transition:width .5s}
/* Pipeline */
.pipeline{display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px}
.pcol{background:var(--surface2);border-radius:var(--r);border:1px solid var(--border);padding:12px;display:flex;flex-direction:column;gap:7px}
.pcol h3{font-size:14px;font-weight:800}
.pcol .cnt{font-size:28px;font-weight:900;line-height:1}
.samples{display:flex;flex-direction:column;gap:3px}
.sample{font-size:11px;padding:4px 7px;background:var(--surface);border-radius:5px;display:flex;justify-content:space-between;align-items:center;gap:6px}
/* Enrich stats */
.esg{display:grid;grid-template-columns:repeat(auto-fit,minmax(100px,1fr));gap:8px;margin-bottom:12px}
.esb{background:var(--surface2);border-radius:var(--r);padding:9px;text-align:center}
.esb .ev{font-size:20px;font-weight:800}
.esb .el{font-size:10px;color:var(--muted);margin-top:1px}
/* Toast */
.toast{position:fixed;bottom:20px;right:20px;background:var(--surface2);border:1px solid var(--border);border-radius:var(--r);padding:10px 16px;font-size:13px;font-weight:600;z-index:9999;transition:opacity .3s;box-shadow:0 4px 20px #0009}
.toast.hidden{opacity:0;pointer-events:none}
.toast.success{border-color:#00d4aa55;color:var(--accent2)}
.toast.error{border-color:#ff4f6d55;color:var(--danger)}
.toast.info{border-color:#6c63ff55;color:var(--accent)}
/* Chart */
.cw{height:260px}
/* AI detail modal */
.modal-bg{position:fixed;inset:0;background:#000a;z-index:300;display:flex;align-items:center;justify-content:center}
.modal{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:18px;max-width:560px;width:95%;max-height:88vh;overflow-y:auto}
.modal h2{font-size:15px;font-weight:800}
.mrow{display:flex;gap:8px;margin-bottom:6px;font-size:12px;line-height:1.4}
.mlabel{color:var(--muted);min-width:90px;font-size:11px;padding-top:1px;flex-shrink:0}
@media(max-width:700px){.pipeline{grid-template-columns:1fr}.sg{grid-template-columns:1fr 1fr}}
</style>
</head>
<body x-data="app()" x-init="init()">
<!-- Toast -->
<div class="toast" :class="[toast.type, {hidden:!toast.show}]" x-text="toast.msg"></div>
<!-- AI Detail Modal -->
<div class="modal-bg" x-show="modal.open" @click.self="modal.open=false" x-cloak>
<div class="modal" @click.stop>
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:12px">
<h2>AI Report — <span style="color:var(--accent2)" x-text="modal.domain"></span></h2>
<button class="btn bg sm" @click="modal.open=false"></button>
</div>
<!-- Summary banner -->
<div x-show="modal.ai.summary" style="background:var(--surface2);border-radius:6px;padding:10px 12px;margin-bottom:12px;font-size:12px;line-height:1.5;color:var(--text)" x-text="modal.ai.summary"></div>
<!-- Lead + quality -->
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-bottom:12px">
<div style="background:var(--surface2);border-radius:6px;padding:8px;text-align:center">
<div style="font-size:10px;color:var(--muted);margin-bottom:3px">LEAD</div>
<span class="pill" :class="aiPillClass(modal.ai.lead_quality)" x-text="modal.ai.lead_quality||'—'"></span>
</div>
<div style="background:var(--surface2);border-radius:6px;padding:8px;text-align:center">
<div style="font-size:10px;color:var(--muted);margin-bottom:3px">SITE QUALITY</div>
<span class="score" :style="qualityBg(modal.ai.site_quality_score)" x-text="(modal.ai.site_quality_score??'—')+'/10'"></span>
</div>
<div style="background:var(--surface2);border-radius:6px;padding:8px;text-align:center">
<div style="font-size:10px;color:var(--muted);margin-bottom:3px">KIT DIGITAL</div>
<span x-text="modal.ai.kit_digital_confirmed ? '✅ Yes' : '❌ No'" style="font-size:13px;font-weight:700"></span>
</div>
</div>
<div class="mrow"><span class="mlabel">Reasoning</span><span x-text="modal.ai.lead_reasoning||'—'"></span></div>
<div class="mrow"><span class="mlabel">KD notes</span><span x-text="modal.ai.kit_digital_reasoning||'—'"></span></div>
<div class="mrow"><span class="mlabel">CMS</span><span x-text="modal.ai.cms_detected || modal.sa?.cms || '—'"></span></div>
<div class="mrow"><span class="mlabel">Last updated</span><span :style="(modal.ai.site_last_updated&&parseInt(modal.ai.site_last_updated)<2021)?'color:var(--danger)':''" x-text="modal.ai.site_last_updated || (modal.sa?.copyright_year ? 'Copyright '+modal.sa.copyright_year : '—')"></span></div>
<div class="mrow"><span class="mlabel">Performance</span><span x-text="modal.ai.performance_notes||'—'"></span></div>
<div class="mrow"><span class="mlabel">SEO</span><span x-text="modal.ai.seo_status||'—'"></span></div>
<div class="mrow"><span class="mlabel">Hosting</span><span x-text="(modal.sa?.org||'?') + ' / ' + (modal.sa?.ip_country||'?') + (modal.sa?.eu_hosted===false?' ❌ Non-EU':modal.sa?.eu_hosted?' ✅ EU':'')"></span></div>
<div class="mrow"><span class="mlabel">GDPR</span><span :style="(!modal.sa?.has_cookie_notice)?'color:var(--danger)':''" x-text="modal.ai.gdpr_compliance||'—'"></span></div>
<div class="mrow"><span class="mlabel">GMB</span><span :style="!modal.ai.has_gmb?'color:var(--warn)':'color:var(--accent2)'" x-text="modal.ai.has_gmb ? '✅ Found' : '❌ Not detected — opportunity'"></span></div>
<div class="mrow"><span class="mlabel">Social</span><span :style="!modal.ai.has_social_media?'color:var(--warn)':''" x-text="modal.ai.has_social_media ? '✅ Present' : '❌ No social media found — opportunity'"></span></div>
<!-- Content issues -->
<div x-show="(modal.ai.content_issues||[]).length>0" style="margin:8px 0">
<div style="font-size:10px;color:var(--muted);text-transform:uppercase;margin-bottom:4px">Content Issues</div>
<template x-for="issue in (modal.ai.content_issues||[])">
<div style="font-size:12px;color:var(--danger);padding:2px 0"><span x-text="issue"></span></div>
</template>
</div>
<!-- Urgency signals -->
<div x-show="(modal.ai.urgency_signals||[]).length>0" style="margin:8px 0">
<div style="font-size:10px;color:var(--muted);text-transform:uppercase;margin-bottom:4px">Urgency Signals</div>
<template x-for="sig in (modal.ai.urgency_signals||[])">
<div style="font-size:12px;color:var(--warn);padding:2px 0">🔴 <span x-text="sig"></span></div>
</template>
</div>
<!-- Contact -->
<div style="background:var(--surface2);border-radius:6px;padding:10px;margin:8px 0">
<div style="font-size:10px;color:var(--muted);text-transform:uppercase;margin-bottom:6px">Best Contact</div>
<div style="font-size:13px;font-weight:700;color:var(--accent2)" x-text="(modal.ai.best_contact_channel||'unknown').toUpperCase()"></div>
<div style="font-size:12px;color:var(--text);margin-top:2px;word-break:break-all" x-text="modal.ai.best_contact_value||'—'"></div>
<!-- All contacts from site_analysis -->
<div x-show="modal.sa" style="margin-top:8px;display:flex;flex-wrap:wrap;gap:4px">
<template x-for="em in (modal.sa?.emails||[])">
<a :href="'mailto:'+em" class="chip email" x-text="em"></a>
</template>
<template x-for="ph in (modal.sa?.phones||[])">
<a :href="'tel:'+ph" class="chip phone" x-text="ph"></a>
</template>
<template x-for="wa in (modal.sa?.whatsapp||[])">
<a :href="wa" target="_blank" class="chip wa">💬 WhatsApp</a>
</template>
<template x-for="s in (modal.sa?.social_links||[])">
<a :href="s" target="_blank" :class="'sicon '+socialIconClass(s)" :title="s" x-text="socialIconLabel(s)"></a>
</template>
<template x-if="modal.ai?.has_gmb">
<a :href="modal.sa?.gmb_url||'#'" target="_blank" class="sicon gmb" title="Google My Business">G</a>
</template>
</div>
</div>
<!-- Outreach email -->
<div style="background:#6c63ff15;border:1px solid #6c63ff33;border-radius:6px;padding:10px;margin:8px 0">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
<div style="font-size:10px;color:var(--muted);text-transform:uppercase">Outreach Email (ES)</div>
<button class="btn bg sm" @click="copyEmail()" style="font-size:10px;padding:2px 8px">📋 Copy</button>
</div>
<div x-show="modal.ai.email_subject" style="font-size:11px;color:var(--muted);margin-bottom:4px">
<b>Subject:</b> <span x-text="modal.ai.email_subject"></span>
</div>
<div style="font-size:12px;font-style:italic;color:var(--accent2);line-height:1.5;white-space:pre-wrap" x-text="modal.ai.outreach_email || modal.ai.pitch_angle || '—'"></div>
</div>
<!-- Accessibility issues -->
<div x-show="(modal.ai.accessibility_issues||[]).length>0" style="margin:8px 0">
<div style="font-size:10px;color:var(--muted);text-transform:uppercase;margin-bottom:4px">Accessibility Issues</div>
<template x-for="issue in (modal.ai.accessibility_issues||[])">
<div style="font-size:12px;color:var(--warn);padding:2px 0"><span x-text="issue"></span></div>
</template>
</div>
<div class="mrow"><span class="mlabel">Services</span><span x-text="(modal.ai.services_needed||[]).join(', ')||'—'"></span></div>
<div class="mrow"><span class="mlabel">Notes</span><span x-text="modal.ai.outreach_notes||'—'"></span></div>
<!-- Site analysis tech snapshot -->
<div x-show="modal.sa" style="margin-top:10px;padding-top:10px;border-top:1px solid var(--border)">
<div style="font-size:10px;color:var(--muted);text-transform:uppercase;margin-bottom:6px">Technical Snapshot</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:4px;font-size:11px">
<div>Load time: <b x-text="(modal.sa?.load_time_ms||'—')+'ms'"></b></div>
<div>Page size: <b x-text="(modal.sa?.page_size_kb||'—')+'KB'"></b></div>
<div>CMS: <b x-text="modal.sa?.cms||'unknown'"></b></div>
<div>Server: <b x-text="modal.sa?.server||'—'"></b></div>
<div>Sitemap: <b x-text="modal.sa?.has_sitemap?'✅':'❌'"></b></div>
<div>Robots: <b x-text="modal.sa?.has_robots?'✅':'❌'"></b></div>
<div>Analytics: <b x-text="(modal.sa?.analytics_present||[]).join(', ')||'none'"></b></div>
<div>Mobile: <b x-text="modal.sa?.has_mobile_viewport?'✅':'❌'"></b></div>
<div>Lorem ipsum: <b :style="modal.sa?.has_lorem_ipsum?'color:var(--danger)':''" x-text="modal.sa?.has_lorem_ipsum?'⚠ YES':'No'"></b></div>
<div>Words: <b x-text="modal.sa?.word_count||'—'"></b></div>
</div>
</div>
<button class="btn bg" style="margin-top:14px;width:100%" @click="modal.open=false">Close</button>
</div>
</div>
<header>
<h1>Dom<span>God</span></h1>
<span class="hbadge" x-text="stats.total_domains ? stats.total_domains.toLocaleString()+' domains' : 'Loading…'"></span>
<span class="ipill" :class="indexSt.ready ? 'ip-ok' : 'ip-bld'" x-text="indexSt.ready ? '⚡ Index ready' : '⏳ Building index…'"></span>
<span style="flex:1"></span>
<span style="font-size:11px;color:var(--muted)" x-text="aiSt.total ? aiSt.total + ' in AI queue' : ''"></span>
</header>
<main>
<!-- Stats bar -->
<div class="card">
<div class="ct">Overview</div>
<div class="sg">
<div class="sb"><div class="l">Total Domains</div><div class="v c1" x-text="stats.total_domains?.toLocaleString()??'—'"></div></div>
<div class="sb"><div class="l">Enriched</div><div class="v c1" x-text="stats.enriched?.toLocaleString()??'0'"></div><div class="s" x-text="stats.total_domains?((stats.enriched/stats.total_domains*100).toFixed(3)+'%'):''"></div></div>
<div class="sb"><div class="l">Hot Leads</div><div class="v c2" x-text="stats.hot_leads?.toLocaleString()??'0'"></div><div class="s">score ≥ 60</div></div>
<div class="sb"><div class="l">Kit Digital</div><div class="v ckd" x-text="stats.kit_digital_count?.toLocaleString()??'0'"></div><div class="s">detected</div></div>
<div class="sb"><div class="l">Queue</div><div class="v c3" x-text="stats.queue?.pending?.toLocaleString()??'0'"></div><div class="s" x-text="(stats.queue?.running??0)+' running'"></div></div>
<div class="sb"><div class="l">AI Queue</div><div class="v" style="color:#c084fc" x-text="aiSt.pending??'0'"></div><div class="s" x-text="(aiSt.done??0)+' assessed'"></div></div>
</div>
</div>
<!-- Tabs -->
<div class="tabs">
<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==='validator'}" @click="tab='validator';loadValStatus()">Validator 🔬</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>
<!-- ② Browse & Filter -->
<div class="card" x-show="tab==='browse'">
<div class="frow">
<div class="field"><label>TLD</label><input type="text" x-model="f.tld" placeholder="es, com…" style="width:80px" @keydown.enter="search()"></div>
<div class="field"><label>Keyword</label><input type="text" x-model="f.keyword" placeholder="hotel, dental…" style="width:130px" @keydown.enter="search()"></div>
<div class="field"><label>Min Score: <b x-text="f.min_score"></b></label><input type="range" x-model="f.min_score" min="0" max="100" step="5"></div>
<div class="field"><label>CMS</label>
<select x-model="f.cms" style="width:120px">
<option value="">Any CMS</option>
<option>wordpress</option><option>joomla</option><option>drupal</option>
<option>wix</option><option>squarespace</option><option>shopify</option>
<option>prestashop</option><option>magento</option><option>typo3</option><option>opencart</option>
</select>
</div>
<div class="field"><label>Per page</label>
<select x-model="f.limit" style="width:75px">
<option value="50">50</option><option value="100">100</option><option value="250">250</option>
</select>
</div>
<label class="tog"><input type="checkbox" x-model="f.live_only"><strong>Live only</strong></label>
<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 class="field"><label>Status</label>
<select x-model="f.prescreen_status" style="width:105px">
<option value="">Any</option>
<option value="live">● Live</option>
<option value="dead">● Dead</option>
<option value="parked">● Parked</option>
<option value="redirect">↗ Redirect</option>
<option value="none">Not checked</option>
</select>
</div>
<div class="field"><label>Niche</label>
<select x-model="f.niche" style="width:130px">
<option value="">Any</option>
<option>automotive</option><option>beauty_cosmetics</option>
<option>travel_tourism</option><option>hospitality</option>
<option>restaurant_food</option><option>legal</option>
<option>medical_health</option><option>real_estate</option>
<option>technology</option><option>fashion_retail</option>
<option>finance</option><option>education</option>
<option>construction</option><option>sports</option>
<option>entertainment</option><option>agriculture</option>
<option>industrial</option><option>consulting</option><option>other</option>
</select>
</div>
<div class="field"><label>Type</label>
<select x-model="f.site_type" style="width:120px">
<option value="">Any</option>
<option>corporate</option><option>ecommerce</option>
<option>blog</option><option>newspaper</option>
<option>landing_page</option><option>portfolio</option>
<option>directory</option><option>forum</option>
<option>informational</option><option>other</option>
</select>
</div>
</div>
<div style="display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;align-items:center">
<button class="btn bp" @click="search()">Search</button>
<button class="btn bg" @click="resetFilters()">Reset</button>
<button class="btn bs" @click="enqueueSelected()" :disabled="selected.length===0">
+ Enrich (<span x-text="selected.length"></span>)
</button>
<button class="btn bps" @click="prescreenSelected()" :disabled="selected.length===0||prescreening">
<span x-show="!prescreening">🔍 Pre-screen (<span x-text="selected.length"></span>)</span>
<span x-show="prescreening">⏳ Screening…</span>
</button>
<select x-model="aiLang" style="padding:4px 8px;border-radius:6px;border:1px solid var(--border);background:var(--card);color:var(--text);font-size:13px;cursor:pointer" title="Pitch language">
<option value="ES">🇪🇸 ES</option>
<option value="EN">🇬🇧 EN</option>
<option value="RO">🇷🇴 RO</option>
</select>
<button class="btn bai" @click="aiAssessSelected()" :disabled="selected.length===0">
🤖 AI Assess (<span x-text="selected.length"></span>)
</button>
<button class="btn bg sm" @click="selectAll()" x-show="domains.length>0">Select page</button>
<button class="btn bg sm" @click="selected=[]" x-show="selected.length>0">Clear</button>
<span class="inf" x-show="searchTotal>0" x-text="searchTotal.toLocaleString()+' matches'"></span>
</div>
<div class="tw">
<table>
<thead>
<tr>
<th></th><th>Domain</th><th>Score</th><th>KD</th><th>AI</th>
<th>Niche</th><th>Type</th>
<th>Contact</th><th>CMS</th><th>SSL days</th>
<th>Country</th><th>Status</th>
</tr>
</thead>
<tbody>
<template x-if="loading">
<tr><td colspan="10" style="text-align:center;padding:24px;color:var(--muted)">Searching… (first query may take 30-60s before index is ready)</td></tr>
</template>
<template x-if="!loading && domains.length===0">
<tr><td colspan="10" style="text-align:center;padding:24px;color:var(--muted)">No results — enter a TLD or keyword and click Search</td></tr>
</template>
<template x-for="row in domains" :key="row.domain">
<tr>
<td><input type="checkbox" :value="row.domain" x-model="selected"></td>
<td><a :href="'http://'+row.domain" target="_blank" rel="noopener" x-text="row.domain"></a></td>
<td>
<span x-show="row.score!=null" class="score" :style="scoreBg(row.score)" x-text="row.score"></span>
<span x-show="row.score==null" class="pill pp"></span>
</td>
<!-- Kit Digital badge -->
<td>
<span x-show="row.kit_digital" class="pill pkd" :title="parseSignals(row.kit_digital_signals)">🏅 KD</span>
<span x-show="!row.kit_digital" style="color:var(--border)"></span>
</td>
<!-- AI quality -->
<td>
<span x-show="row.ai_lead_quality"
class="pill" :class="aiPillClass(row.ai_lead_quality)"
style="cursor:pointer"
@click="openModal(row)"
x-text="row.ai_lead_quality"></span>
<span x-show="!row.ai_lead_quality" class="pill ai-none" style="cursor:pointer" @click="openModal(row)" title="Click to assess"></span>
</td>
<!-- Niche -->
<td>
<span x-show="row.niche" class="pill pni" x-text="row.niche"></span>
<span x-show="!row.niche" style="color:var(--border)"></span>
</td>
<!-- Type -->
<td>
<span x-show="row.site_type" class="pill pty" x-text="row.site_type"></span>
<span x-show="!row.site_type" style="color:var(--border)"></span>
</td>
<!-- Contact info -->
<td>
<div class="contact-chips" x-data="{c: parseContacts(row.contact_info)}">
<template x-for="em in (c.emails||[]).slice(0,1)" :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,4)" :key="url">
<a :href="url" target="_blank" :class="'sicon '+socialIconClass(url)" :title="url" x-text="socialIconLabel(url)"></a>
</template>
</div>
</td>
<td>
<span x-show="row.cms" class="pill pc" x-text="row.cms"></span>
<span x-show="!row.cms" style="color:var(--border)"></span>
</td>
<td x-text="row.ssl_expiry_days??'—'"></td>
<td x-text="row.ip_country??'—'"></td>
<td style="text-align:center">
<span x-show="row.prescreen_status" :class="prescreenStatusIcon(row.prescreen_status)" :title="row.prescreen_status"></span>
<span x-show="!row.prescreen_status && row.is_live" class="ps-live" title="live (from enricher)"></span>
<span x-show="!row.prescreen_status && !row.is_live" style="color:var(--border)"></span>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="pager">
<button class="btn bg sm" @click="goPage(page-1)" :disabled="page<=1||loading">← Prev</button>
<span class="inf">Page <b x-text="page"></b>
<span x-show="searchTotal>0" x-text="' of '+Math.ceil(searchTotal/Number(f.limit))"></span>
</span>
<button class="btn bg sm" @click="goPage(page+1)" :disabled="loading||domains.length<Number(f.limit)">Next →</button>
<span class="inf" x-show="searchTotal>0" x-text="searchTotal.toLocaleString()+' total'"></span>
</div>
</div>
<!-- ③ Enrichment Queue -->
<div class="card" x-show="tab==='enrich'">
<div class="ct">Enrichment</div>
<div class="esg">
<div class="esb"><div class="ev c3" x-text="qst.pending??'—'"></div><div class="el">Pending</div></div>
<div class="esb"><div class="ev c1" x-text="qst.running??'—'"></div><div class="el">Running</div></div>
<div class="esb"><div class="ev c1" x-text="qst.done??'—'"></div><div class="el">Done</div></div>
<div class="esb"><div class="ev c2" x-text="qst.failed??'—'"></div><div class="el">Failed</div></div>
<div class="esb"><div class="ev c4" x-text="qst.eta_seconds?Math.ceil(qst.eta_seconds/60)+'m':'—'"></div><div class="el">ETA</div></div>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;flex-wrap:wrap;gap:6px">
<span style="font-size:11px;color:var(--muted)" x-text="qLabel()"></span>
<div style="display:flex;gap:6px;flex-wrap:wrap">
<button class="btn bs" x-show="!qst.worker_running" @click="startEnrich()">▶ Start</button>
<button class="btn bw" x-show="qst.worker_running" @click="pauseEnrich()">⏸ Pause</button>
<button class="btn bg" @click="retryFailed()">↺ Retry Failed</button>
<button class="btn bg" @click="runScoring()">★ Re-score All</button>
</div>
</div>
<div class="pw"><div class="pb" :style="'width:'+qPct()+'%'"></div></div>
<div style="font-size:10px;color:var(--muted);margin-top:3px" x-text="qPct().toFixed(1)+'% complete'"></div>
<!-- AI Queue section -->
<div style="margin-top:18px;padding-top:14px;border-top:1px solid var(--border)">
<div class="ct" style="margin-bottom:8px">AI Assessment Queue (Gemini via Replicate)</div>
<div class="esg">
<div class="esb"><div class="ev" style="color:#c084fc" x-text="aiSt.pending??'0'"></div><div class="el">Pending</div></div>
<div class="esb"><div class="ev" style="color:#c084fc" x-text="aiSt.running??'0'"></div><div class="el">Running</div></div>
<div class="esb"><div class="ev c1" x-text="aiSt.done??'0'"></div><div class="el">Done</div></div>
<div class="esb"><div class="ev c2" x-text="aiSt.failed??'0'"></div><div class="el">Failed</div></div>
</div>
<div style="font-size:12px;color:var(--muted);margin-bottom:8px">
Auto-assesses enriched domains via Gemini. Detects Kit Digital confirmation, extracts best contact channel, writes pitch.
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap">
<button class="btn bai" @click="aiAssessAllKD()">🤖 AI Assess all Kit Digital domains</button>
<button class="btn bg sm" @click="restartAiWorker()">↺ Restart AI worker</button>
<button class="btn bw sm" @click="resetAiStuck()">⚡ Reset stuck jobs</button>
<a class="btn bg sm" href="/api/ai/debug" target="_blank" style="text-decoration:none">🔍 Debug AI queue</a>
</div>
</div>
<div style="margin-top:18px;padding-top:14px;border-top:1px solid var(--border)">
<div class="ct" style="margin-bottom:6px">Queue custom domains</div>
<div style="display:flex;gap:8px;align-items:flex-end">
<textarea x-model="customDomains" placeholder="example.com&#10;another.es"
style="flex:1;background:var(--surface2);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:7px;min-height:70px;font-size:12px;resize:vertical"></textarea>
<button class="btn bp" @click="enqueueCustom()">Queue</button>
</div>
</div>
</div>
<!-- ④ Validator -->
<div class="card" x-show="tab==='validator'">
<div class="ct">Bulk Domain Validator</div>
<div style="font-size:12px;color:var(--muted);margin-bottom:14px">
HTTP-checks the entire dataset to determine live/dead/parked/redirect status.
Extracts server type, IP, and load time. Skips already-validated domains.
Results appear as the Status column in Browse &amp; Filter.
</div>
<!-- Stats grid -->
<div class="esg" style="margin-bottom:12px">
<div class="esb"><div class="ev c1" x-text="(valSt.processed??0).toLocaleString()"></div><div class="el">Checked</div></div>
<div class="esb"><div class="ev ps-live" x-text="(valSt.live??0).toLocaleString()"></div><div class="el">Live</div></div>
<div class="esb"><div class="ev ps-dead" x-text="(valSt.dead??0).toLocaleString()"></div><div class="el">Dead</div></div>
<div class="esb"><div class="ev ps-parked" x-text="(valSt.parked??0).toLocaleString()"></div><div class="el">Parked</div></div>
<div class="esb"><div class="ev ps-redirect" x-text="(valSt.redirect??0).toLocaleString()"></div><div class="el">Redirect</div></div>
<div class="esb"><div class="ev c3" x-text="(valSt.rate??0).toFixed(1)"></div><div class="el">dom/sec</div></div>
</div>
<!-- Progress line -->
<div style="font-size:11px;color:var(--muted);margin-bottom:12px"
x-text="valSt.offset ? (valSt.offset??0).toLocaleString()+' rows scanned · '+(valSt.skipped??0).toLocaleString()+' already validated (skipped)' : 'Not started yet'">
</div>
<!-- Controls -->
<div style="display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap">
<div class="field">
<label>TLD filter <span style="font-weight:400;color:var(--muted)">(leave empty for all domains)</span></label>
<input type="text" x-model="valTld" placeholder="es or com or ro" style="width:180px" :disabled="valSt.running">
</div>
<button class="btn bs" :disabled="valSt.running" @click="startValidator()">▶ Start Validator</button>
<button class="btn bd" :disabled="!valSt.running" @click="stopValidator()">⏹ Stop</button>
<span x-show="valSt.running" style="font-size:11px;color:var(--accent2);padding-bottom:6px">⚡ Running…</span>
</div>
<!-- Live rate progress bar (only while running) -->
<div x-show="valSt.running" style="margin-top:14px">
<div class="pw"><div class="pb" style="width:100%;animation:pulse 2s infinite"></div></div>
</div>
</div>
<!-- ⑤ Lead Pipeline -->
<div class="card" x-show="tab==='pipeline'">
<div style="display:flex;justify-content:flex-end;margin-bottom:10px;gap:8px">
<button class="btn bg sm" @click="loadPipeline()">↻ Refresh</button>
<button class="btn bg sm" @click="exportTier('all')">⬇ Export All CSV</button>
</div>
<div class="pipeline">
<div class="pcol" style="border-top:3px solid var(--danger)">
<h3>🔥 Hot</h3><div style="font-size:11px;color:var(--muted)">Score 80100</div>
<div class="cnt c2" x-text="pipeline.hot.count.toLocaleString()"></div>
<div class="samples">
<template x-for="d in pipeline.hot.samples" :key="d.domain">
<div class="sample">
<div style="display:flex;flex-direction:column;gap:2px;min-width:0">
<a :href="'http://'+d.domain" target="_blank" x-text="d.domain" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></a>
<span x-show="d.kit_digital" class="pill pkd" style="font-size:9px;width:fit-content">🏅 Kit Digital</span>
</div>
<span class="score" :style="scoreBg(d.score)" x-text="d.score"></span>
</div>
</template>
</div>
<button class="btn bd sm" style="margin-top:auto" @click="exportTier('hot')">⬇ Export Hot</button>
</div>
<div class="pcol" style="border-top:3px solid var(--warn)">
<h3>♨️ Warm</h3><div style="font-size:11px;color:var(--muted)">Score 5079</div>
<div class="cnt c3" x-text="pipeline.warm.count.toLocaleString()"></div>
<div class="samples">
<template x-for="d in pipeline.warm.samples" :key="d.domain">
<div class="sample">
<div style="display:flex;flex-direction:column;gap:2px;min-width:0">
<a :href="'http://'+d.domain" target="_blank" x-text="d.domain" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></a>
<span x-show="d.kit_digital" class="pill pkd" style="font-size:9px;width:fit-content">🏅 Kit Digital</span>
</div>
<span class="score" :style="scoreBg(d.score)" x-text="d.score"></span>
</div>
</template>
</div>
<button class="btn bw sm" style="margin-top:auto" @click="exportTier('warm')">⬇ Export Warm</button>
</div>
<div class="pcol" style="border-top:3px solid #6c7aff">
<h3>🧊 Cold</h3><div style="font-size:11px;color:var(--muted)">Score &lt; 50</div>
<div class="cnt" style="color:#6c7aff" x-text="pipeline.cold.count.toLocaleString()"></div>
<div class="samples">
<template x-for="d in pipeline.cold.samples" :key="d.domain">
<div class="sample">
<a :href="'http://'+d.domain" target="_blank" x-text="d.domain" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1"></a>
<span class="score" :style="scoreBg(d.score)" x-text="d.score"></span>
</div>
</template>
</div>
<button class="btn bg sm" style="margin-top:auto" @click="exportTier('cold')">⬇ Export Cold</button>
</div>
</div>
</div>
<!-- ⑤ 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>Niche</th><th>Type</th>
<th>Best Contact</th><th>All Contacts</th><th>Pitch</th><th></th>
</tr>
</thead>
<tbody>
<template x-if="leadsLoading">
<tr><td colspan="9" style="text-align:center;padding:24px;color:var(--muted)">Loading…</td></tr>
</template>
<template x-if="!leadsLoading && leadsData.length===0">
<tr><td colspan="9" 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><span x-show="row.niche" class="pill pni" x-text="row.niche"></span><span x-show="!row.niche" style="color:var(--border)"></span></td>
<td><span x-show="row.site_type" class="pill pty" x-text="row.site_type"></span><span x-show="!row.site_type" style="color:var(--border)"></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>
</div>
</main>
<script>
function app() {
return {
tab: 'browse',
stats: {}, indexSt: {ready:false,building:false,total:0},
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,exclude_assessed:false,limit:'100',prescreen_status:'',niche:'',site_type:''},
qst: {}, customDomains: '',
valSt: {running:false,processed:0,live:0,dead:0,parked:0,redirect:0,skipped:0,offset:0,rate:0},
valTld: '',
leadsQ: {quality:'', country:'', limit:'50'},
leadsData: [], leadsTotal: 0, leadsPage: 1, leadsLoading: false,
prescreening: 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},
_chart: null, _poll: null, _toastTimer: null, _lastAiDone: 0,
async init() {
await Promise.all([this.loadStats(), this.pollIndex(), this.loadAiStatus()]);
this._lastAiDone = this.aiSt.done ?? 0;
this._poll = setInterval(async ()=>{
this.loadStats(); this.pollIndex();
await this.loadAiStatus();
// Refresh browse results when new AI assessments complete
if((this.aiSt.done ?? 0) > this._lastAiDone && this.tab==='browse' && this.domains.length>0) {
this._lastAiDone = this.aiSt.done;
await this._fetch();
} else {
this._lastAiDone = this.aiSt.done ?? 0;
}
if(this.tab==='enrich') this.loadQueue();
if(this.tab==='validator') this.loadValStatus();
if(this.tab==='pipeline') this.loadPipeline();
if(this.tab==='leads') this.loadLeads();
}, 3000);
},
async loadStats() {
try {
const s = await fetch('/api/stats').then(r=>r.json());
this.stats = s;
} catch(e){}
},
async pollIndex() {
try { this.indexSt = await fetch('/api/index/status').then(r=>r.json()); } catch(e){}
},
async loadAiStatus() {
try { this.aiSt = await fetch('/api/ai/status').then(r=>r.json()); } catch(e){}
},
async search() { this.page=1; await this._fetch(); },
async goPage(p) { if(p<1) return; this.page=p; await this._fetch(); },
async _fetch() {
this.loading = true;
const p = new URLSearchParams({page:this.page, limit:this.f.limit});
if(this.f.tld) p.set('tld', this.f.tld.trim());
if(this.f.keyword) p.set('keyword', this.f.keyword.trim());
if(this.f.live_only) p.set('live_only','true');
if(this.f.alpha_only) p.set('alpha_only','true');
if(this.f.no_sld) p.set('no_sld','true');
try {
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.exclude_assessed) rows = rows.filter(r=> !r.ai_lead_quality);
if(this.f.prescreen_status==='none') rows = rows.filter(r=> !r.prescreen_status);
else if(this.f.prescreen_status) rows = rows.filter(r=> r.prescreen_status===this.f.prescreen_status);
if(this.f.niche) rows = rows.filter(r=> r.niche===this.f.niche);
if(this.f.site_type) rows = rows.filter(r=> r.site_type===this.f.site_type);
this.domains = rows;
} catch(e) {
this.domains = [];
this.notify('Search failed: '+e.message,'error');
}
this.loading = false;
},
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,exclude_assessed:false,limit:'100',prescreen_status:'',niche:'',site_type:''}; },
async enqueueSelected() {
if(!this.selected.length) return;
try {
const r = await fetch('/api/enrich/batch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({domains:this.selected})});
const d = await r.json();
r.ok ? this.notify(`Queued ${d.queued} for enrichment`,'success') : this.notify('Error: '+(d.error||r.statusText),'error');
this.selected = [];
} catch(e) { this.notify('Request failed: '+e.message,'error'); }
},
async aiAssessSelected() {
if(!this.selected.length) return;
const r = await fetch('/api/ai/assess/batch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({domains:this.selected,language:this.aiLang})});
const d = await r.json();
r.ok ? this.notify(`Queued ${d.queued} for AI assessment [${this.aiLang}]`,'info') : this.notify('Error: '+d.error,'error');
this.selected = [];
},
async aiAssessAllKD() {
// Get all Kit Digital domains and queue them
const r = await fetch('/api/enriched?kit_digital=true&limit=500').then(r=>r.json());
const domains = r.results.map(d=>d.domain);
if(!domains.length) { this.notify('No Kit Digital domains enriched yet','info'); return; }
const r2 = await fetch('/api/ai/assess/batch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({domains,language:this.aiLang})});
const d2 = await r2.json();
this.notify(`Queued ${d2.queued} Kit Digital domains for AI assessment [${this.aiLang}]`,'info');
},
async prescreenSelected() {
if(!this.selected.length || this.prescreening) return;
this.prescreening = true;
const count = this.selected.length;
this.notify(`Pre-screening ${count} domains… (DeepSeek classification may take 1-2 min)`, 'info');
try {
const r = await fetch('/api/prescreen/batch', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({domains: this.selected})
});
const d = await r.json();
if(r.ok) {
this.notify(
`${d.live} live · 🅿 ${d.parked} parked · ↗ ${d.redirect} redirect · ☠ ${d.dead} dead · 🏷 ${d.classified} classified`,
'success'
);
this.selected = [];
// Force full re-fetch of current page to show updated niche/type
this.domains = [];
await this._fetch();
} else {
this.notify('Error: ' + (d.error||'unknown'), 'error');
}
} catch(e) { this.notify('Pre-screen failed: '+e.message, 'error'); }
this.prescreening = false;
this.selected = [];
},
prescreenStatusIcon(status) {
if(status==='live') return 'ps-live';
if(status==='dead') return 'ps-dead';
if(status==='parked') return 'ps-parked';
if(status==='redirect') return 'ps-redirect';
return '';
},
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;
const r = await fetch('/api/enrich/batch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({domains})});
const d = await r.json();
r.ok ? this.notify(`Queued ${d.queued} domains`,'success') : this.notify('Error: '+d.error,'error');
this.customDomains = ''; await this.loadQueue();
},
async loadQueue() {
try { this.qst = await fetch('/api/enrich/status').then(r=>r.json()); } catch(e){}
},
async loadValStatus() {
try { this.valSt = await fetch('/api/validator/status').then(r=>r.json()); } catch(e){}
},
async startValidator() {
const p = new URLSearchParams();
if(this.valTld.trim()) p.set('tld', this.valTld.trim());
await fetch('/api/validator/start'+(p.toString()? '?'+p : ''), {method:'POST'});
this.notify('Validator started', 'success');
await this.loadValStatus();
},
async stopValidator() {
await fetch('/api/validator/stop', {method:'POST'});
this.notify('Validator stopped', 'info');
await this.loadValStatus();
},
async restartAiWorker() { await fetch('/api/ai/worker/restart',{method:'POST'}); this.notify('AI worker restarted','info'); await this.loadAiStatus(); },
copyEmail() {
const subj = this.modal.ai.email_subject ? `Subject: ${this.modal.ai.email_subject}\n\n` : '';
const body = this.modal.ai.outreach_email || this.modal.ai.pitch_angle || '';
navigator.clipboard.writeText(subj + body).then(
() => this.notify('Email copied to clipboard', 'success'),
() => this.notify('Copy failed — select text manually', 'error'),
);
},
async resetAiStuck() { const r=await fetch('/api/ai/reset',{method:'POST'}); const d=await r.json(); this.notify(`Reset ${d.reset} stuck jobs → pending`,'success'); await this.loadAiStatus(); },
async startEnrich() { await fetch('/api/enrich/resume',{method:'POST'}); this.notify('Worker started','success'); await this.loadQueue(); },
async pauseEnrich() { await fetch('/api/enrich/pause',{method:'POST'}); this.notify('Worker paused','success'); await this.loadQueue(); },
async retryFailed() { await fetch('/api/enrich/retry',{method:'POST'}); this.notify('Retrying failed','success'); await this.loadQueue(); },
async runScoring() { const r = await fetch('/api/score/run',{method:'POST'}); const d=await r.json(); this.notify(`Scored ${d.scored} domains`,'success'); },
qPct() { const q=this.qst; return (!q||!q.total)?0:(q.done/q.total*100); },
qLabel() { const q=this.qst; return `${q.done??0} done · ${q.pending??0} pending · ${q.running??0} running · ${q.failed??0} failed`; },
async loadPipeline() {
const [hot,warm,cold] = await Promise.all([
fetch('/api/enriched?min_score=80&limit=5').then(r=>r.json()),
fetch('/api/enriched?min_score=50&limit=5').then(r=>r.json()),
fetch('/api/enriched?min_score=0&limit=5').then(r=>r.json()),
]);
this.pipeline.hot = {count:hot.total??0, samples:hot.results.slice(0,5)};
this.pipeline.warm = {count:Math.max(0,(warm.total??0)-(hot.total??0)), samples:warm.results.filter(d=>d.score<80).slice(0,5)};
this.pipeline.cold = {count:Math.max(0,(cold.total??0)-(warm.total??0)), samples:cold.results.filter(d=>d.score<50).slice(0,5)};
},
exportTier(tier) { window.location = `/api/export?tier=${tier}`; },
openModal(row) {
this.modal.domain = row.domain;
try { this.modal.ai = row.ai_assessment ? JSON.parse(row.ai_assessment) : {}; }
catch(e) { this.modal.ai = {}; }
try { this.modal.sa = row.site_analysis ? JSON.parse(row.site_analysis) : null; }
catch(e) { this.modal.sa = null; }
this.modal.open = true;
},
qualityBg(s) {
if(s==null) return 'background:#333;color:#888';
if(s>=8) return 'background:#00d4aa22;color:var(--accent2)';
if(s>=5) return 'background:#ffb34722;color:var(--warn)';
return 'background:#ff4f6d22;color:var(--danger)';
},
scoreBg(s) {
if(s==null) return 'background:#333;color:#888';
if(s>=80) return 'background:#ff4f6d22;color:#ff4f6d';
if(s>=50) return 'background:#ffb34722;color:#ffb347';
return 'background:#6c7aff22;color:#6c7aff';
},
aiPillClass(q) {
if(!q) return 'ai-none';
if(q==='HOT') return 'ai-hot';
if(q==='WARM') return 'ai-warm';
return 'ai-cold';
},
parseContacts(raw) {
if(!raw) return {};
try { return JSON.parse(raw); } catch(e) { return {}; }
},
socialIconClass(url) {
if(!url) return 'other';
const u = url.toLowerCase();
if(u.includes('facebook.com') || u.includes('fb.com')) return 'fb';
if(u.includes('instagram.com')) return 'ig';
if(u.includes('linkedin.com')) return 'li';
if(u.includes('twitter.com') || u.includes('x.com')) return 'tw';
if(u.includes('tiktok.com')) return 'tt';
if(u.includes('youtube.com') || u.includes('youtu.be')) return 'yt';
return 'other';
},
socialIconLabel(url) {
if(!url) return '?';
const u = url.toLowerCase();
if(u.includes('facebook.com') || u.includes('fb.com')) return 'f';
if(u.includes('instagram.com')) return 'ig';
if(u.includes('linkedin.com')) return 'in';
if(u.includes('twitter.com') || u.includes('x.com')) return '𝕏';
if(u.includes('tiktok.com')) return 'tt';
if(u.includes('youtube.com') || u.includes('youtu.be')) return '▶';
return '↗';
},
parseSignals(raw) {
if(!raw) return 'No signals';
try { return JSON.parse(raw).join('\n'); } catch(e) { return raw; }
},
notify(msg, type='success') {
clearTimeout(this._toastTimer);
this.toast = {show:true,msg,type};
this._toastTimer = setTimeout(()=>{ this.toast.show=false; }, 3500);
},
async renderChart() {
await this.$nextTick();
const canvas = document.getElementById('tldChart');
if(!canvas) return;
if(this._chart){this._chart.destroy();this._chart=null;}
const tlds = this.stats.tld_breakdown || [];
this._chart = new Chart(canvas,{
type:'bar',
data:{
labels:tlds.map(t=>'.'+t.tld),
datasets:[{label:'Domains',data:tlds.map(t=>t.count),backgroundColor:'rgba(108,99,255,.7)',borderColor:'rgba(108,99,255,1)',borderWidth:1,borderRadius:4}]
},
options:{
responsive:true,maintainAspectRatio:false,
plugins:{legend:{display:false}},
scales:{x:{ticks:{color:'#8891b0'},grid:{color:'#2e3250'}},y:{ticks:{color:'#8891b0'},grid:{color:'#2e3250'}}}
}
});
},
}
}
</script>
</body>
</html>