Phase 1 (no AI credits): httpx checks every selected domain concurrently (30 parallel) with real browser UA — detects live/dead/parked/redirect. Parked: keyword scan in body/title + known parking host redirect check. Results saved to DB immediately; dead/parked never reach DeepSeek. Phase 2 (single DeepSeek call): all live-site titles + snippets bundled into ONE Replicate/DeepSeek-R1 request → returns niche + type for every domain in batch (up to 80 per call, parallelised if more). - app/prescreener.py (new): _check_one(), prescreen_domains(), classify_with_deepseek(), parking signal lists, same-domain redirect logic - app/db.py: prescreen_status/niche/site_type/prescreen_at columns + migrations; save_prescreen_results() upsert helper - app/main.py: POST /api/prescreen/batch endpoint - app/static/index.html: - 🔍 Pre-screen button (disabled while running, shows spinner) - Niche + Type columns in Browse and Leads tables (.pni/.pty pills) - Prescreen status colour dot (●) when niche not yet set - prescreening state flag; result toast shows per-status counts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
977 lines
54 KiB
HTML
977 lines
54 KiB
HTML
<!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==='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>
|
||
|
||
<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>Live</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" :class="prescreenStatusIcon(row.prescreen_status)" :title="row.prescreen_status||''" x-text="prescreenStatusIcon(row.prescreen_status)?'●':'—'"></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><span class="pill" :class="row.is_live?'pg':'pp'" x-text="row.is_live?'Yes':'—'"></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 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>
|
||
|
||
<!-- ④ 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 80–100</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 50–79</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 < 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'},
|
||
qst: {}, customDomains: '',
|
||
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==='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);
|
||
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'}; },
|
||
|
||
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;
|
||
this.notify(`Pre-screening ${this.selected.length} domains… (may take ~30s)`, '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'
|
||
);
|
||
await this._fetch(); // refresh to show niche/type columns
|
||
} 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 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>
|