fix: beauty frontend server-side filtering and bulk actions
- add keyword and tld params to get_enriched() in db.py (LIKE on domain + page_title) - forward keyword/tld through /api/enriched in beauty_main.py - rewrite beauty/index.html loadDomains() to pass all filters server-side via URLSearchParams - track domainsTotal from API response for correct pagination display - add Pre-screen Selected and B2B Assess Selected bulk action buttons - add per-row Screen and Assess buttons - goSearch() resets to page 1 before fetching Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -153,12 +153,15 @@ async def enriched(
|
||||
prescreen_status: str = Query(None),
|
||||
niche: str = Query(None),
|
||||
site_type: str = Query(None),
|
||||
keyword: str = Query(None),
|
||||
tld: str = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
):
|
||||
total, rows = await get_enriched(
|
||||
min_score=min_score, country=country,
|
||||
prescreen_status=prescreen_status, niche=niche, site_type=site_type,
|
||||
keyword=keyword, tld=tld,
|
||||
page=page, limit=limit,
|
||||
)
|
||||
return {"page": page, "limit": limit, "total": total, "results": rows}
|
||||
|
||||
11
app/db.py
11
app/db.py
@@ -334,6 +334,7 @@ async def get_stats():
|
||||
async def get_enriched(min_score=0, cms=None, country=None, kit_digital=None,
|
||||
ai_only=False, lead_quality=None,
|
||||
prescreen_status=None, niche=None, site_type=None,
|
||||
keyword=None, tld=None,
|
||||
page=1, limit=100):
|
||||
offset = (page - 1) * limit
|
||||
conditions = ["score >= ?"]
|
||||
@@ -343,7 +344,7 @@ async def get_enriched(min_score=0, cms=None, country=None, kit_digital=None,
|
||||
params.append(cms)
|
||||
if country:
|
||||
conditions.append("ip_country = ?")
|
||||
params.append(country)
|
||||
params.append(country.upper())
|
||||
if kit_digital is not None:
|
||||
conditions.append("kit_digital = ?")
|
||||
params.append(1 if kit_digital else 0)
|
||||
@@ -363,6 +364,14 @@ async def get_enriched(min_score=0, cms=None, country=None, kit_digital=None,
|
||||
if site_type:
|
||||
conditions.append("site_type = ?")
|
||||
params.append(site_type)
|
||||
if keyword:
|
||||
kw = f"%{keyword.lower()}%"
|
||||
conditions.append("(LOWER(domain) LIKE ? OR LOWER(COALESCE(page_title,'')) LIKE ?)")
|
||||
params.extend([kw, kw])
|
||||
if tld:
|
||||
tld_clean = tld.lower().lstrip(".")
|
||||
conditions.append("LOWER(domain) LIKE ?")
|
||||
params.append(f"%.{tld_clean}")
|
||||
where = "WHERE " + " AND ".join(conditions)
|
||||
async with aiosqlite.connect(SQLITE_PATH, timeout=30) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
|
||||
@@ -6,19 +6,10 @@
|
||||
<title>BeautyLeads — Cosmetics B2B Intelligence</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f0f13;
|
||||
--surface: #18181f;
|
||||
--card: #1e1e28;
|
||||
--border: #2a2a38;
|
||||
--text: #e2e0f0;
|
||||
--muted: #7c7a96;
|
||||
--accent: #e879a0;
|
||||
--accent2: #c026d3;
|
||||
--success: #34d399;
|
||||
--warn: #f97316;
|
||||
--danger: #f43f5e;
|
||||
--info: #818cf8;
|
||||
:root{
|
||||
--bg:#0f0f13;--surface:#18181f;--card:#1e1e28;--border:#2a2a38;
|
||||
--text:#e2e0f0;--muted:#7c7a96;--accent:#e879a0;--accent2:#c026d3;
|
||||
--success:#34d399;--warn:#f97316;--danger:#f43f5e;--info:#818cf8;
|
||||
}
|
||||
*{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:13px;min-height:100vh}
|
||||
@@ -26,43 +17,37 @@ a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
|
||||
input,select,textarea{background:var(--surface);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:5px 8px;font-size:12px;outline:none}
|
||||
input:focus,select:focus,textarea:focus{border-color:var(--accent)}
|
||||
button{cursor:pointer;border:none;border-radius:6px;padding:6px 14px;font-size:12px;font-weight:600;transition:opacity .15s}
|
||||
button:hover{opacity:.85} button:disabled{opacity:.4;cursor:default}
|
||||
button:hover{opacity:.85}button:disabled{opacity:.4;cursor:default}
|
||||
.btn-primary{background:var(--accent);color:#fff}
|
||||
.btn-ok{background:#16a34a;color:#fff}
|
||||
.btn-secondary{background:var(--surface);color:var(--text);border:1px solid var(--border)}
|
||||
.btn-danger{background:var(--danger);color:#fff}
|
||||
.btn-sm{padding:4px 10px;font-size:11px}
|
||||
.card{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:16px}
|
||||
|
||||
/* Header */
|
||||
.header{background:var(--surface);border-bottom:1px solid var(--border);padding:10px 24px;display:flex;align-items:center;gap:16px}
|
||||
.logo{font-size:18px;font-weight:700;color:var(--accent)}.logo span{color:var(--muted);font-weight:400;font-size:13px;margin-left:8px}
|
||||
.logo{font-size:18px;font-weight:700;background:linear-gradient(135deg,var(--accent),var(--accent2));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
||||
.logo-sub{color:var(--muted);font-weight:400;font-size:12px;-webkit-text-fill-color:var(--muted)}
|
||||
.tabs{display:flex;gap:2px;margin-left:auto}
|
||||
.tab-btn{background:none;border:none;color:var(--muted);padding:8px 16px;border-radius:8px;font-size:13px;font-weight:500;cursor:pointer;transition:all .15s}
|
||||
.tab-btn:hover{color:var(--text);background:var(--card)}
|
||||
.tab-btn.active{color:var(--accent);background:var(--card)}
|
||||
|
||||
/* Stats row */
|
||||
.stat-row{display:flex;gap:12px;flex-wrap:wrap;padding:16px 24px}
|
||||
.stat-box{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:12px 18px;min-width:110px;text-align:center}
|
||||
.stat-val{font-size:22px;font-weight:700;line-height:1.1}
|
||||
.stat-lbl{font-size:11px;color:var(--muted);margin-top:2px}
|
||||
.hot-color{color:var(--danger)} .warm-color{color:var(--warn)} .cold-color{color:var(--info)}
|
||||
.live-color{color:var(--success)} .dead-color{color:var(--danger)} .error-color{color:var(--warn)}
|
||||
.stat-row{display:flex;gap:10px;flex-wrap:wrap;padding:14px 24px}
|
||||
.stat-box{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:10px 16px;min-width:100px;text-align:center}
|
||||
.stat-val{font-size:20px;font-weight:700;line-height:1.1}
|
||||
.stat-lbl{font-size:10px;color:var(--muted);margin-top:2px;text-transform:uppercase;letter-spacing:.04em}
|
||||
|
||||
/* Filter bar */
|
||||
.filter-bar{padding:0 24px 12px;display:flex;gap:8px;flex-wrap:wrap;align-items:center}
|
||||
.filter-bar input,.filter-bar select{padding:5px 10px}
|
||||
.filter-bar label{color:var(--muted);font-size:11px}
|
||||
.filter-bar{padding:0 24px 10px;display:flex;gap:7px;flex-wrap:wrap;align-items:center}
|
||||
.filter-label{color:var(--muted);font-size:11px;white-space:nowrap}
|
||||
|
||||
/* Table */
|
||||
.tbl-wrap{padding:0 24px 24px;overflow-x:auto}
|
||||
table{width:100%;border-collapse:collapse}
|
||||
th{background:var(--surface);color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.05em;padding:8px 10px;text-align:left;border-bottom:1px solid var(--border);white-space:nowrap}
|
||||
td{padding:7px 10px;border-bottom:1px solid var(--border);vertical-align:top;max-width:260px}
|
||||
th{background:var(--surface);color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.05em;padding:8px 10px;text-align:left;border-bottom:1px solid var(--border);white-space:nowrap;position:sticky;top:0;z-index:1}
|
||||
td{padding:7px 10px;border-bottom:1px solid var(--border);vertical-align:middle}
|
||||
tr:hover td{background:rgba(232,121,160,.04)}
|
||||
|
||||
/* Badges */
|
||||
.badge{display:inline-block;padding:2px 8px;border-radius:20px;font-size:10px;font-weight:700;letter-spacing:.04em}
|
||||
.badge{display:inline-block;padding:2px 8px;border-radius:20px;font-size:10px;font-weight:700;letter-spacing:.04em;white-space:nowrap}
|
||||
.badge-hot{background:rgba(244,63,94,.18);color:var(--danger)}
|
||||
.badge-warm{background:rgba(249,115,22,.18);color:var(--warn)}
|
||||
.badge-cold{background:rgba(129,140,248,.18);color:var(--info)}
|
||||
@@ -73,133 +58,125 @@ tr:hover td{background:rgba(232,121,160,.04)}
|
||||
.badge-parked{background:rgba(251,191,36,.15);color:#fbbf24}
|
||||
.badge-redirect{background:rgba(148,163,184,.15);color:#94a3b8}
|
||||
|
||||
/* Brand chips */
|
||||
.chip{display:inline-block;background:rgba(232,121,160,.12);color:var(--accent);border:1px solid rgba(232,121,160,.25);border-radius:12px;padding:1px 7px;font-size:10px;margin:1px}
|
||||
.chip-match{background:rgba(52,211,153,.12);color:var(--success);border-color:rgba(52,211,153,.3)}
|
||||
.chip{display:inline-block;background:rgba(232,121,160,.1);color:var(--accent);border:1px solid rgba(232,121,160,.2);border-radius:10px;padding:1px 6px;font-size:10px;margin:1px 1px 1px 0}
|
||||
.chip-match{background:rgba(52,211,153,.1);color:var(--success);border-color:rgba(52,211,153,.25)}
|
||||
|
||||
/* Pipeline detail expand */
|
||||
.detail-row td{background:rgba(30,30,40,.8);padding:12px 16px}
|
||||
.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
||||
.detail-section{background:var(--surface);border-radius:8px;padding:10px 14px}
|
||||
.detail-section h4{color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px}
|
||||
.detail-section p{font-size:12px;line-height:1.5;color:var(--text)}
|
||||
.detail-row>td{background:#16161e;padding:14px 18px;border-bottom:2px solid var(--border)}
|
||||
.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}
|
||||
.detail-box{background:var(--surface);border-radius:8px;padding:10px 14px}
|
||||
.detail-box h4{color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px}
|
||||
.detail-box p{font-size:12px;line-height:1.55}
|
||||
|
||||
/* Toast */
|
||||
.toast-container{position:fixed;bottom:20px;right:20px;z-index:999;display:flex;flex-direction:column;gap:8px}
|
||||
.val-grid{display:grid;grid-template-columns:repeat(6,1fr);gap:10px;margin-bottom:16px}
|
||||
.esb{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:12px;text-align:center}
|
||||
.ev{font-size:20px;font-weight:700}.el{font-size:10px;color:var(--muted);margin-top:2px}
|
||||
|
||||
.bulk-bar{padding:0 24px 8px;display:flex;gap:8px;align-items:center;flex-wrap:wrap;
|
||||
background:rgba(232,121,160,.06);border-top:1px solid rgba(232,121,160,.15);
|
||||
border-bottom:1px solid rgba(232,121,160,.15);margin-bottom:4px;min-height:40px}
|
||||
|
||||
.toast-wrap{position:fixed;bottom:20px;right:20px;z-index:999;display:flex;flex-direction:column;gap:8px}
|
||||
.toast{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:10px 16px;font-size:12px;min-width:240px;max-width:380px;animation:slideIn .2s ease}
|
||||
.toast.success{border-color:rgba(52,211,153,.4);color:var(--success)}
|
||||
.toast.error{border-color:rgba(244,63,94,.4);color:var(--danger)}
|
||||
.toast.info{border-color:rgba(129,140,248,.4);color:var(--info)}
|
||||
@keyframes slideIn{from{transform:translateX(30px);opacity:0}to{transform:translateX(0);opacity:1}}
|
||||
@keyframes slideIn{from{transform:translateX(20px);opacity:0}to{transform:translateX(0);opacity:1}}
|
||||
|
||||
/* Validator (reuse same style) */
|
||||
.val-grid{display:grid;grid-template-columns:repeat(6,1fr);gap:10px;padding:0 24px 16px}
|
||||
.esb{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:12px;text-align:center}
|
||||
.ev{font-size:20px;font-weight:700}
|
||||
.el{font-size:10px;color:var(--muted);margin-top:2px}
|
||||
|
||||
/* Prescreen */
|
||||
.prescreen-wrap{padding:0 24px}
|
||||
textarea{width:100%;font-family:monospace;resize:vertical}
|
||||
|
||||
/* Progress bar */
|
||||
.progress-wrap{padding:0 24px 12px}
|
||||
.progress-bar{background:var(--surface);border-radius:4px;height:6px;overflow:hidden}
|
||||
.progress-fill{background:var(--accent);height:100%;border-radius:4px;transition:width .5s}
|
||||
|
||||
/* Copy btn */
|
||||
.copy-btn{background:var(--surface);border:1px solid var(--border);color:var(--muted);padding:2px 8px;font-size:10px;border-radius:4px;cursor:pointer}
|
||||
.copy-btn:hover{color:var(--text)}
|
||||
|
||||
/* Section header */
|
||||
.section-header{padding:0 24px 12px;display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
||||
.section-header h2{font-size:15px;font-weight:600}
|
||||
.section-header .muted{color:var(--muted);font-size:12px}
|
||||
|
||||
/* Empty state */
|
||||
.empty{padding:40px;text-align:center;color:var(--muted);font-size:13px}
|
||||
|
||||
/* Checkbox */
|
||||
.page-info{color:var(--muted);font-size:11px}
|
||||
.empty-state{padding:48px;text-align:center;color:var(--muted)}
|
||||
.loading-state{padding:24px;text-align:center;color:var(--muted);font-size:12px}
|
||||
input[type=checkbox]{width:14px;height:14px;accent-color:var(--accent);cursor:pointer}
|
||||
textarea{width:100%;resize:vertical;font-family:monospace;font-size:12px}
|
||||
.section-pad{padding:0 24px}
|
||||
</style>
|
||||
</head>
|
||||
<body x-data="app()" x-init="init()">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<div class="logo">
|
||||
BeautyLeads
|
||||
<span>Cosmetics B2B Intelligence</span>
|
||||
<div>
|
||||
<span class="logo">BeautyLeads</span>
|
||||
<span class="logo-sub" style="display:block;font-size:11px;margin-top:1px">Cosmetics B2B Intelligence</span>
|
||||
</div>
|
||||
<div class="tabs">
|
||||
<button class="tab-btn" :class="{active:tab==='browse'}" @click="tab='browse';loadDomains()">Browse</button>
|
||||
<button class="tab-btn" :class="{active:tab==='validator'}" @click="tab='validator';loadValStatus()">Validator</button>
|
||||
<button class="tab-btn" :class="{active:tab==='pipeline'}" @click="tab='pipeline';loadLeads()">B2B Pipeline</button>
|
||||
<button class="tab-btn" :class="{active:tab==='prescreen'}" @click="tab='prescreen'">Pre-screen</button>
|
||||
<button class="tab-btn" :class="{active:tab==='export'}" @click="tab='export'">Export</button>
|
||||
<button class="tab-btn" :class="{active:tab==='browse'}" @click="tab='browse';loadDomains()">Browse</button>
|
||||
<button class="tab-btn" :class="{active:tab==='validator'}" @click="tab='validator';loadValStatus()">Validator</button>
|
||||
<button class="tab-btn" :class="{active:tab==='pipeline'}" @click="tab='pipeline';loadLeads()">B2B Pipeline</button>
|
||||
<button class="tab-btn" :class="{active:tab==='prescreen'}" @click="tab='prescreen'">Pre-screen</button>
|
||||
<button class="tab-btn" :class="{active:tab==='export'}" @click="tab='export'">Export</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats row -->
|
||||
<!-- Stats -->
|
||||
<div class="stat-row">
|
||||
<div class="stat-box"><div class="stat-val" x-text="(stats.total_domains||0).toLocaleString()"></div><div class="stat-lbl">Total Domains</div></div>
|
||||
<div class="stat-box"><div class="stat-val live-color" x-text="(stats.beauty_live||0).toLocaleString()"></div><div class="stat-lbl">Beauty Live</div></div>
|
||||
<div class="stat-box"><div class="stat-val hot-color" x-text="(aiSt.hot||0).toLocaleString()"></div><div class="stat-lbl">HOT Leads</div></div>
|
||||
<div class="stat-box"><div class="stat-val warm-color" x-text="(aiSt.warm||0).toLocaleString()"></div><div class="stat-lbl">WARM Leads</div></div>
|
||||
<div class="stat-box"><div class="stat-val" style="color:var(--success)" x-text="(stats.beauty_live||0).toLocaleString()"></div><div class="stat-lbl">Beauty Live</div></div>
|
||||
<div class="stat-box"><div class="stat-val" style="color:var(--danger)" x-text="(aiSt.hot||0).toLocaleString()"></div><div class="stat-lbl">HOT Leads</div></div>
|
||||
<div class="stat-box"><div class="stat-val" style="color:var(--warn)" x-text="(aiSt.warm||0).toLocaleString()"></div><div class="stat-lbl">WARM Leads</div></div>
|
||||
<div class="stat-box"><div class="stat-val" x-text="(aiSt.total||0).toLocaleString()"></div><div class="stat-lbl">Assessed</div></div>
|
||||
<div class="stat-box"><div class="stat-val" :style="aiSt.pending>0?'color:var(--warn)':''" x-text="(aiSt.pending||0).toLocaleString()"></div><div class="stat-lbl">In Queue</div></div>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════ BROWSE TAB ══════════════════ -->
|
||||
<!-- ══════════════════════ BROWSE ══════════════════════ -->
|
||||
<div x-show="tab==='browse'">
|
||||
<div class="filter-bar">
|
||||
<input x-model="f.keyword" @keyup.enter="loadDomains()" placeholder="Keyword…" style="width:140px">
|
||||
<input x-model="f.tld" @keyup.enter="loadDomains()" placeholder="TLD (es, ro…)" style="width:100px">
|
||||
<select x-model="f.prescreen_status" @change="loadDomains()">
|
||||
<option value="">All Statuses</option>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filter-bar" style="padding-top:4px">
|
||||
<input x-model="f.keyword" placeholder="Keyword in domain/title…" style="width:170px" @keyup.enter="goSearch()">
|
||||
<input x-model="f.tld" placeholder="TLD (es, ro, fr…)" style="width:110px" @keyup.enter="goSearch()">
|
||||
<select x-model="f.prescreen_status" @change="goSearch()">
|
||||
<option value="live">Live</option>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="error">Error (4xx/5xx)</option>
|
||||
<option value="redirect">Redirect</option>
|
||||
<option value="parked">Parked</option>
|
||||
<option value="dead">Dead</option>
|
||||
<option value="none">Not checked</option>
|
||||
</select>
|
||||
<select x-model="f.niche" @change="loadDomains()">
|
||||
<select x-model="f.niche" @change="goSearch()">
|
||||
<option value="beauty_cosmetics">Beauty & Cosmetics</option>
|
||||
<option value="">All Niches</option>
|
||||
<option value="fashion_retail">Fashion Retail</option>
|
||||
<option value="medical_health">Medical / Health</option>
|
||||
</select>
|
||||
<select x-model="f.site_type" @change="loadDomains()">
|
||||
<select x-model="f.site_type" @change="goSearch()">
|
||||
<option value="ecommerce">E-commerce</option>
|
||||
<option value="">All Types</option>
|
||||
<option value="corporate">Corporate</option>
|
||||
<option value="landing_page">Landing Page</option>
|
||||
</select>
|
||||
<input x-model="f.country" @keyup.enter="loadDomains()" placeholder="Country (ES, FR…)" style="width:100px">
|
||||
<select x-model="f.limit" @change="loadDomains()">
|
||||
<input x-model="f.country" placeholder="Country (ES, FR…)" style="width:100px" @keyup.enter="goSearch()">
|
||||
<select x-model="f.limit" @change="goSearch()">
|
||||
<option value="50">50</option>
|
||||
<option value="100" selected>100</option>
|
||||
<option value="200">200</option>
|
||||
<option value="500">500</option>
|
||||
</select>
|
||||
<button class="btn-primary" @click="loadDomains()">Search</button>
|
||||
<button class="btn-primary" @click="goSearch()">Search</button>
|
||||
<button class="btn-secondary" @click="resetFilters()">Reset</button>
|
||||
<span style="margin-left:auto;color:var(--muted);font-size:11px" x-text="''+domains.length+' shown'"></span>
|
||||
<span class="page-info" style="margin-left:auto" x-text="domainsTotal.toLocaleString()+' results · page '+f.page"></span>
|
||||
</div>
|
||||
|
||||
<!-- Bulk action bar -->
|
||||
<div class="filter-bar" style="padding-top:0" x-show="selected.length>0">
|
||||
<span style="color:var(--accent);font-weight:600" x-text="selected.length+' selected'"></span>
|
||||
<button class="btn-primary btn-sm" @click="queueSelected()">Assess B2B Selected</button>
|
||||
<!-- Bulk bar (visible when items selected) -->
|
||||
<div class="bulk-bar" x-show="selected.length>0">
|
||||
<span style="color:var(--accent);font-weight:600;font-size:12px" x-text="selected.length+' selected'"></span>
|
||||
<button class="btn-ok btn-sm" @click="prescreenSelected()">Pre-screen Selected</button>
|
||||
<button class="btn-primary btn-sm" @click="assessSelected()">B2B Assess Selected</button>
|
||||
<button class="btn-secondary btn-sm" @click="selected=[]">Clear</button>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="tbl-wrap">
|
||||
<div class="empty" x-show="!loading && domains.length===0">No domains found. Adjust filters or run the validator first.</div>
|
||||
<div style="padding:20px;text-align:center;color:var(--muted)" x-show="loading">Loading…</div>
|
||||
<div class="loading-state" x-show="loading">Loading…</div>
|
||||
<div class="empty-state" x-show="!loading && domains.length===0">
|
||||
No domains found. Try adjusting filters, running the Validator, or Pre-screening first.
|
||||
</div>
|
||||
<table x-show="!loading && domains.length>0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:28px"><input type="checkbox" @change="toggleAll($event)" :checked="selected.length===domains.length && domains.length>0"></th>
|
||||
<th style="width:30px">
|
||||
<input type="checkbox" @change="toggleAll($event)" :checked="selected.length===domains.length && domains.length>0">
|
||||
</th>
|
||||
<th>Domain</th>
|
||||
<th>Status</th>
|
||||
<th>Country</th>
|
||||
@@ -207,29 +184,31 @@ input[type=checkbox]{width:14px;height:14px;accent-color:var(--accent);cursor:po
|
||||
<th>Niche</th>
|
||||
<th>Type</th>
|
||||
<th>B2B</th>
|
||||
<th></th>
|
||||
<th style="width:130px"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="row in domains" :key="row.domain">
|
||||
<tr>
|
||||
<td><input type="checkbox" :value="row.domain" x-model="selected"></td>
|
||||
<td><a :href="'https://'+row.domain" target="_blank" x-text="row.domain"></a></td>
|
||||
<td style="white-space:nowrap">
|
||||
<a :href="'https://'+row.domain" target="_blank" x-text="row.domain" @click.stop></a>
|
||||
</td>
|
||||
<td>
|
||||
<span x-show="row.prescreen_status" :class="statusBadge(row.prescreen_status)" class="badge" x-text="row.prescreen_status||''"></span>
|
||||
<span x-show="row.prescreen_status" class="badge" :class="statusBadge(row.prescreen_status)" x-text="row.prescreen_status"></span>
|
||||
<span x-show="!row.prescreen_status" style="color:var(--muted)">—</span>
|
||||
</td>
|
||||
<td x-text="row.ip_country||'—'"></td>
|
||||
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" :title="row.page_title||''" x-text="row.page_title||'—'"></td>
|
||||
<td x-text="row.niche||'—'"></td>
|
||||
<td x-text="row.site_type||'—'"></td>
|
||||
<td style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" :title="row.page_title||''" x-text="row.page_title||'—'"></td>
|
||||
<td x-text="(row.niche||'—').replace('_',' ')"></td>
|
||||
<td x-text="(row.site_type||'—').replace('_',' ')"></td>
|
||||
<td>
|
||||
<span x-show="row.beauty_lead_quality" :class="qualityBadge(row.beauty_lead_quality)" class="badge" x-text="row.beauty_lead_quality||''"></span>
|
||||
<span x-show="row.beauty_lead_quality" class="badge" :class="qualityBadge(row.beauty_lead_quality)" x-text="row.beauty_lead_quality"></span>
|
||||
<span x-show="!row.beauty_lead_quality" style="color:var(--muted)">—</span>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn-secondary btn-sm" @click="assessSingle(row.domain)" x-show="!row.beauty_lead_quality">Assess</button>
|
||||
<button class="btn-secondary btn-sm" @click="assessSingle(row.domain)" x-show="row.beauty_lead_quality">Re-assess</button>
|
||||
<td style="white-space:nowrap;display:flex;gap:4px">
|
||||
<button class="btn-secondary btn-sm" @click="prescreenOne(row.domain)" title="HTTP check + niche classify">Screen</button>
|
||||
<button class="btn-primary btn-sm" @click="assessOne(row.domain)" title="Beauty B2B AI assessment">Assess</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
@@ -237,69 +216,69 @@ input[type=checkbox]{width:14px;height:14px;accent-color:var(--accent);cursor:po
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="filter-bar" x-show="!loading && domains.length>0">
|
||||
<button class="btn-secondary btn-sm" @click="f.page=Math.max(1,f.page-1);loadDomains()" :disabled="f.page<=1">← Prev</button>
|
||||
<span style="color:var(--muted);font-size:11px" x-text="'Page '+f.page"></span>
|
||||
<button class="btn-secondary btn-sm" @click="f.page++;loadDomains()" :disabled="domains.length<parseInt(f.limit)">Next →</button>
|
||||
<span class="page-info" x-text="'Page '+f.page+' of '+Math.max(1,Math.ceil(domainsTotal/parseInt(f.limit)))"></span>
|
||||
<button class="btn-secondary btn-sm" @click="f.page++;loadDomains()" :disabled="f.page>=Math.ceil(domainsTotal/parseInt(f.limit))">Next →</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════ VALIDATOR TAB ══════════════════ -->
|
||||
<div x-show="tab==='validator'" style="padding:0 24px">
|
||||
<div style="padding:16px 0 8px;color:var(--muted);font-size:12px">
|
||||
Bulk HTTP validator — checks all domains in the dataset and marks them live/dead/error/parked/redirect.
|
||||
Run this first, then Pre-screen to classify niches, then use Browse to find beauty leads.
|
||||
</div>
|
||||
|
||||
<div class="val-grid" style="padding:0 0 16px">
|
||||
<!-- ══════════════════════ VALIDATOR ══════════════════════ -->
|
||||
<div x-show="tab==='validator'" class="section-pad" style="padding-top:16px">
|
||||
<p style="color:var(--muted);font-size:12px;margin-bottom:14px">
|
||||
Bulk HTTP validator — checks all domains in the dataset and tags them live / dead / error / parked / redirect.
|
||||
Run this first, then Pre-screen to classify niches, then Browse to find beauty leads.
|
||||
</p>
|
||||
<div class="val-grid">
|
||||
<div class="esb"><div class="ev" x-text="(valSt.processed||0).toLocaleString()"></div><div class="el">Checked</div></div>
|
||||
<div class="esb"><div class="ev live-color" x-text="(valSt.live||0).toLocaleString()"></div><div class="el">Live</div></div>
|
||||
<div class="esb"><div class="ev dead-color" x-text="(valSt.dead||0).toLocaleString()"></div><div class="el">Dead</div></div>
|
||||
<div class="esb"><div class="ev error-color" x-text="(valSt.error||0).toLocaleString()"></div><div class="el">Error</div></div>
|
||||
<div class="esb"><div class="ev" style="color:#fbbf24" x-text="(valSt.parked||0).toLocaleString()"></div><div class="el">Parked</div></div>
|
||||
<div class="esb"><div class="ev" style="color:var(--info)" x-text="(valSt.rate||0)+'/s'"></div><div class="el">Rate</div></div>
|
||||
<div class="esb"><div class="ev" style="color:var(--success)" x-text="(valSt.live||0).toLocaleString()"></div><div class="el">Live</div></div>
|
||||
<div class="esb"><div class="ev" style="color:var(--danger)" x-text="(valSt.dead||0).toLocaleString()"></div><div class="el">Dead</div></div>
|
||||
<div class="esb"><div class="ev" style="color:var(--warn)" x-text="(valSt.error||0).toLocaleString()"></div><div class="el">Error</div></div>
|
||||
<div class="esb"><div class="ev" style="color:#fbbf24" x-text="(valSt.parked||0).toLocaleString()"></div><div class="el">Parked</div></div>
|
||||
<div class="esb"><div class="ev" style="color:var(--info)" x-text="(valSt.rate||0)+'/s'"></div><div class="el">Rate</div></div>
|
||||
</div>
|
||||
|
||||
<div x-show="valSt.running||valSt.processed>0" style="margin-bottom:12px;font-size:12px;color:var(--muted)">
|
||||
<span x-text="'Offset: '+valSt.offset+' · Skipped: '+valSt.skipped"></span>
|
||||
<span x-show="valSt.tld_filter" x-text="' · TLD: '+valSt.tld_filter"></span>
|
||||
<div x-show="valSt.processed>0||valSt.running" style="font-size:11px;color:var(--muted);margin-bottom:12px">
|
||||
Offset: <span x-text="valSt.offset"></span> · Skipped: <span x-text="valSt.skipped"></span>
|
||||
<span x-show="valSt.tld_filter"> · TLD filter: <span x-text="valSt.tld_filter"></span></span>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:12px">
|
||||
<input x-model="valTld" placeholder="TLD filter (es, ro…)" style="width:140px">
|
||||
<label style="color:var(--muted);font-size:11px;display:flex;align-items:center;gap:5px">
|
||||
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
|
||||
<input x-model="valTld" placeholder="TLD filter (es, ro…)" style="width:150px">
|
||||
<label style="display:flex;align-items:center;gap:6px;color:var(--muted);font-size:12px">
|
||||
<input type="checkbox" x-model="valRescan"> Rescan dead
|
||||
</label>
|
||||
<button class="btn-primary" @click="startValidator()" :disabled="valSt.running">Start</button>
|
||||
<button class="btn-primary" @click="startValidator()" :disabled="valSt.running">Start Validator</button>
|
||||
<button class="btn-danger" @click="stopValidator()" :disabled="!valSt.running">Stop</button>
|
||||
<span x-show="valSt.running" style="color:var(--success);font-size:11px;animation:pulse 1.5s infinite">● Running</span>
|
||||
<span x-show="valSt.running" style="color:var(--success);font-size:11px">● Running</span>
|
||||
<span x-show="!valSt.running && valSt.processed>0" style="color:var(--muted);font-size:11px">● Stopped</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════ B2B PIPELINE TAB ══════════════════ -->
|
||||
<!-- ══════════════════════ B2B PIPELINE ══════════════════════ -->
|
||||
<div x-show="tab==='pipeline'">
|
||||
<div class="filter-bar">
|
||||
<div class="filter-bar" style="padding-top:4px">
|
||||
<select x-model="pf.quality" @change="loadLeads()">
|
||||
<option value="">All Qualities</option>
|
||||
<option value="HOT">HOT 🔥</option>
|
||||
<option value="HOT">🔥 HOT</option>
|
||||
<option value="WARM">WARM</option>
|
||||
<option value="COLD">COLD</option>
|
||||
</select>
|
||||
<input x-model="pf.country" @keyup.enter="loadLeads()" placeholder="Country (ES, FR…)" style="width:100px">
|
||||
<input x-model="pf.country" placeholder="Country (ES, FR…)" style="width:110px" @keyup.enter="loadLeads()">
|
||||
<select x-model="pf.limit" @change="loadLeads()">
|
||||
<option value="50">50</option>
|
||||
<option value="100" selected>100</option>
|
||||
<option value="200">200</option>
|
||||
</select>
|
||||
<button class="btn-primary" @click="loadLeads()">Filter</button>
|
||||
<button class="btn-primary" @click="loadLeads()">Filter</button>
|
||||
<button class="btn-secondary" @click="pf={quality:'',country:'',page:1,limit:'100'};loadLeads()">Reset</button>
|
||||
<span style="margin-left:auto;color:var(--muted);font-size:11px" x-text="leadsTotal.toLocaleString()+' total leads'"></span>
|
||||
<span class="page-info" style="margin-left:auto" x-text="leadsTotal.toLocaleString()+' leads · page '+pf.page"></span>
|
||||
</div>
|
||||
|
||||
<div class="tbl-wrap">
|
||||
<div class="empty" x-show="!loadingLeads && leads.length===0">No B2B assessments yet. Go to Browse → select domains → Assess B2B.</div>
|
||||
<div style="padding:20px;text-align:center;color:var(--muted)" x-show="loadingLeads">Loading…</div>
|
||||
<div class="loading-state" x-show="loadingLeads">Loading…</div>
|
||||
<div class="empty-state" x-show="!loadingLeads && leads.length===0">
|
||||
No B2B assessments yet. Go to Browse → select domains → B2B Assess Selected.
|
||||
</div>
|
||||
<table x-show="!loadingLeads && leads.length>0">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -310,73 +289,80 @@ input[type=checkbox]{width:14px;height:14px;accent-color:var(--accent);cursor:po
|
||||
<th>Categories</th>
|
||||
<th>Portfolio Match</th>
|
||||
<th>Contact</th>
|
||||
<th></th>
|
||||
<th style="width:80px"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="row in leads" :key="row.domain">
|
||||
<!-- Main row -->
|
||||
<tr @click="toggleLead(row.domain)" style="cursor:pointer">
|
||||
<td><a :href="'https://'+row.domain" target="_blank" @click.stop x-text="row.domain"></a></td>
|
||||
<td><span :class="qualityBadge(row.beauty_lead_quality)" class="badge" x-text="row.beauty_lead_quality||'—'"></span></td>
|
||||
<td x-text="(row._beauty||{}).business_name||row.page_title||'—'"></td>
|
||||
<td><span class="badge" :class="qualityBadge(row.beauty_lead_quality)" x-text="row.beauty_lead_quality||'—'"></span></td>
|
||||
<td style="max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" x-text="(row._beauty||{}).business_name||(row.page_title||'—')"></td>
|
||||
<td x-text="(row._beauty||{}).country_fiscal||(row.ip_country||'—')"></td>
|
||||
<td>
|
||||
<td style="max-width:160px">
|
||||
<template x-for="cat in ((row._beauty||{}).categories||[]).slice(0,3)" :key="cat">
|
||||
<span class="chip" x-text="cat"></span>
|
||||
</template>
|
||||
</td>
|
||||
<td>
|
||||
<td style="max-width:200px">
|
||||
<template x-if="((row._beauty||{}).dist_matches||[]).length>0">
|
||||
<span>
|
||||
<div>
|
||||
<template x-for="b in ((row._beauty||{}).dist_matches||[]).slice(0,4)" :key="b">
|
||||
<span class="chip chip-match" x-text="b"></span>
|
||||
</template>
|
||||
<span x-show="((row._beauty||{}).dist_matches||[]).length>4" class="chip" x-text="'+'+(((row._beauty||{}).dist_matches||[]).length-4)+' more'"></span>
|
||||
</span>
|
||||
<span x-show="((row._beauty||{}).dist_matches||[]).length>4" class="chip" x-text="'+'+(((row._beauty||{}).dist_matches||[]).length-4)"></span>
|
||||
</div>
|
||||
</template>
|
||||
<span x-show="!((row._beauty||{}).dist_matches||[]).length" style="color:var(--muted)">—</span>
|
||||
</td>
|
||||
<td>
|
||||
<span x-text="(row._beauty||{}).contact_email||'—'" style="font-size:11px"></span>
|
||||
</td>
|
||||
<td @click.stop>
|
||||
<button class="copy-btn" @click="copyEmail(row)" title="Copy outreach email">Copy Email</button>
|
||||
<td style="font-size:11px;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
|
||||
x-text="(row._beauty||{}).contact_email||row.emails||'—'"></td>
|
||||
<td @click.stop style="white-space:nowrap;display:flex;gap:4px">
|
||||
<button class="btn-secondary btn-sm" @click="copyOutreach(row)">Copy email</button>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Expanded detail row -->
|
||||
<tr class="detail-row" x-show="expandedLead===row.domain">
|
||||
<!-- Expanded detail -->
|
||||
<tr class="detail-row" x-show="expandedLead===row.domain" @click="expandedLead=null" style="cursor:pointer">
|
||||
<td colspan="8">
|
||||
<div class="detail-grid">
|
||||
<div class="detail-section">
|
||||
<div class="detail-grid" @click.stop>
|
||||
<div class="detail-box">
|
||||
<h4>B2B Proposal</h4>
|
||||
<p x-text="(row._beauty||{}).b2b_proposal||'—'"></p>
|
||||
</div>
|
||||
<div class="detail-section">
|
||||
<div class="detail-box">
|
||||
<h4>Lead Reasoning</h4>
|
||||
<p x-text="(row._beauty||{}).lead_reasoning||'—'"></p>
|
||||
</div>
|
||||
<div class="detail-section" style="grid-column:1/-1">
|
||||
<h4 style="margin-bottom:4px">
|
||||
<div class="detail-box" style="grid-column:1/-1">
|
||||
<h4 style="display:flex;align-items:center;gap:8px">
|
||||
Outreach Email
|
||||
<button class="copy-btn" style="margin-left:8px" @click="copyText((row._beauty||{}).outreach_email||'')">Copy</button>
|
||||
<button class="btn-secondary btn-sm" @click="copyText((row._beauty||{}).outreach_email||'')">Copy</button>
|
||||
<span style="color:var(--muted);font-size:10px" x-text="'Subject: '+((row._beauty||{}).outreach_subject||'')"></span>
|
||||
</h4>
|
||||
<p style="white-space:pre-wrap;font-size:11px;color:var(--muted)" x-text="(row._beauty||{}).outreach_email||'—'"></p>
|
||||
<p style="white-space:pre-wrap;font-size:11px;color:var(--text);margin-top:6px;line-height:1.6" x-text="(row._beauty||{}).outreach_email||'—'"></p>
|
||||
</div>
|
||||
<div class="detail-section">
|
||||
<h4>Detected Brands on Site</h4>
|
||||
<div class="detail-box">
|
||||
<h4>Brands Detected on Site</h4>
|
||||
<p style="font-size:11px">
|
||||
<template x-for="b in ((row._beauty||{}).detected_brands||[]).slice(0,20)" :key="b">
|
||||
<template x-for="b in ((row._beauty||{}).detected_brands||[]).slice(0,30)" :key="b">
|
||||
<span class="chip" x-text="b"></span>
|
||||
</template>
|
||||
<span x-show="!((row._beauty||{}).detected_brands||[]).length" style="color:var(--muted)">None detected</span>
|
||||
<span x-show="!((row._beauty||{}).detected_brands||[]).length" style="color:var(--muted)">None detected in scraped text</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="detail-section">
|
||||
<div class="detail-box">
|
||||
<h4>Contact Details</h4>
|
||||
<p style="font-size:11px">
|
||||
<span x-show="(row._beauty||{}).contact_email">Email: <span x-text="(row._beauty||{}).contact_email"></span><br></span>
|
||||
<span x-show="(row._beauty||{}).contact_phone">Phone: <span x-text="(row._beauty||{}).contact_phone"></span><br></span>
|
||||
<span x-show="row.emails" x-text="'On-site emails: '+(row.emails||'')"></span>
|
||||
<p style="font-size:12px;line-height:1.7">
|
||||
<template x-if="(row._beauty||{}).contact_email">
|
||||
<span>Email: <a :href="'mailto:'+(row._beauty||{}).contact_email" x-text="(row._beauty||{}).contact_email"></a><br></span>
|
||||
</template>
|
||||
<template x-if="(row._beauty||{}).contact_phone">
|
||||
<span>Phone: <span x-text="(row._beauty||{}).contact_phone"></span><br></span>
|
||||
</template>
|
||||
<template x-if="row.emails">
|
||||
<span style="color:var(--muted);font-size:11px">On-site: <span x-text="row.emails"></span></span>
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -389,56 +375,58 @@ input[type=checkbox]{width:14px;height:14px;accent-color:var(--accent);cursor:po
|
||||
|
||||
<div class="filter-bar" x-show="!loadingLeads && leads.length>0">
|
||||
<button class="btn-secondary btn-sm" @click="pf.page=Math.max(1,pf.page-1);loadLeads()" :disabled="pf.page<=1">← Prev</button>
|
||||
<span style="color:var(--muted);font-size:11px" x-text="'Page '+pf.page"></span>
|
||||
<button class="btn-secondary btn-sm" @click="pf.page++;loadLeads()" :disabled="leads.length<parseInt(pf.limit)">Next →</button>
|
||||
<span class="page-info" x-text="'Page '+pf.page+' of '+Math.max(1,Math.ceil(leadsTotal/parseInt(pf.limit)))"></span>
|
||||
<button class="btn-secondary btn-sm" @click="pf.page++;loadLeads()" :disabled="pf.page>=Math.ceil(leadsTotal/parseInt(pf.limit))">Next →</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════ PRE-SCREEN TAB ══════════════════ -->
|
||||
<div x-show="tab==='prescreen'" class="prescreen-wrap">
|
||||
<div style="padding:16px 0 12px;color:var(--muted);font-size:12px">
|
||||
Phase 1 — HTTP check each domain (live/dead/parked/redirect).<br>
|
||||
Phase 2 — DeepSeek classifies niche & type (beauty_cosmetics, ecommerce, etc.).<br>
|
||||
Paste up to 200 domains, one per line.
|
||||
</div>
|
||||
<textarea x-model="prescreenInput" rows="12" placeholder="domain1.com domain2.es …"></textarea>
|
||||
<div style="display:flex;gap:10px;margin-top:10px;align-items:center">
|
||||
<!-- ══════════════════════ PRE-SCREEN ══════════════════════ -->
|
||||
<div x-show="tab==='prescreen'" class="section-pad" style="padding-top:16px">
|
||||
<p style="color:var(--muted);font-size:12px;margin-bottom:12px">
|
||||
<strong style="color:var(--text)">Phase 1</strong> — HTTP check: marks domains live / dead / parked / redirect / error.<br>
|
||||
<strong style="color:var(--text)">Phase 2</strong> — DeepSeek classifies niche (beauty_cosmetics, fashion_retail…) and site type (ecommerce, corporate…).<br>
|
||||
Paste up to 200 domains, one per line. Results saved automatically — then use Browse to filter by beauty + ecommerce.
|
||||
</p>
|
||||
<textarea x-model="prescreenInput" rows="12" placeholder="domain1.com domain2.es domain3.fr …"></textarea>
|
||||
<div style="display:flex;gap:10px;margin-top:10px;align-items:center;flex-wrap:wrap">
|
||||
<button class="btn-primary" @click="runPrescreen()" :disabled="prescreenRunning">
|
||||
<span x-show="!prescreenRunning">Run Pre-screen</span>
|
||||
<span x-show="prescreenRunning">Running…</span>
|
||||
</button>
|
||||
<span x-show="prescreenResult" style="font-size:12px;color:var(--muted)" x-text="prescreenResult"></span>
|
||||
<span x-show="prescreenResult" style="font-size:12px" x-text="prescreenResult"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════ EXPORT TAB ══════════════════ -->
|
||||
<div x-show="tab==='export'" style="padding:24px">
|
||||
<div class="card" style="max-width:480px">
|
||||
<h3 style="margin-bottom:16px;font-size:14px;color:var(--accent)">Export Beauty Leads</h3>
|
||||
<!-- ══════════════════════ EXPORT ══════════════════════ -->
|
||||
<div x-show="tab==='export'" class="section-pad" style="padding-top:24px">
|
||||
<div class="card" style="max-width:460px">
|
||||
<h3 style="margin-bottom:16px;font-size:14px;color:var(--accent)">Export Beauty B2B Leads</h3>
|
||||
<div style="display:flex;flex-direction:column;gap:12px">
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<label style="color:var(--muted);width:70px">Quality</label>
|
||||
<label class="filter-label" style="width:65px">Quality</label>
|
||||
<select x-model="exportQuality" style="flex:1">
|
||||
<option value="">All</option>
|
||||
<option value="HOT">HOT only</option>
|
||||
<option value="WARM">WARM only</option>
|
||||
<option value="COLD">COLD only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<label style="color:var(--muted);width:70px">Country</label>
|
||||
<label class="filter-label" style="width:65px">Country</label>
|
||||
<input x-model="exportCountry" placeholder="ES, FR, DE …" style="flex:1">
|
||||
</div>
|
||||
<button class="btn-primary" @click="exportLeads()">Download CSV</button>
|
||||
<button class="btn-primary" @click="doExport()">Download CSV</button>
|
||||
</div>
|
||||
<p style="margin-top:14px;font-size:11px;color:var(--muted)">
|
||||
Exports: domain, quality, business name, country, categories, detected brands,
|
||||
portfolio matches, contact email, B2B proposal, outreach email.
|
||||
<p style="margin-top:14px;font-size:11px;color:var(--muted);line-height:1.6">
|
||||
Columns: domain · quality · business name · fiscal country · active countries ·
|
||||
categories · detected brands · portfolio matches · contact email · phone ·
|
||||
B2B proposal · outreach subject · outreach email
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toasts -->
|
||||
<div class="toast-container">
|
||||
<div class="toast-wrap">
|
||||
<template x-for="t in toasts" :key="t.id">
|
||||
<div class="toast" :class="t.type" x-text="t.msg"></div>
|
||||
</template>
|
||||
@@ -448,36 +436,27 @@ input[type=checkbox]{width:14px;height:14px;accent-color:var(--accent);cursor:po
|
||||
function app() {
|
||||
return {
|
||||
tab: 'browse',
|
||||
loading: false,
|
||||
loadingLeads: false,
|
||||
domains: [],
|
||||
leads: [],
|
||||
leadsTotal: 0,
|
||||
selected: [],
|
||||
expandedLead: null,
|
||||
stats: {},
|
||||
aiSt: {hot:0, warm:0, cold:0, total:0, pending:0},
|
||||
valSt: {running:false, processed:0, live:0, dead:0, error:0, parked:0, redirect:0, skipped:0, offset:0, rate:0},
|
||||
valTld: '',
|
||||
valRescan: false,
|
||||
loading: false, loadingLeads: false,
|
||||
domains: [], domainsTotal: 0,
|
||||
leads: [], leadsTotal: 0,
|
||||
selected: [], expandedLead: null,
|
||||
stats: {}, aiSt: {hot:0,warm:0,total:0,pending:0,running:0,done:0,failed:0},
|
||||
valSt: {running:false,processed:0,live:0,dead:0,error:0,parked:0,redirect:0,skipped:0,offset:0,rate:0},
|
||||
valTld: '', valRescan: false,
|
||||
toasts: [],
|
||||
prescreenInput: '',
|
||||
prescreenRunning: false,
|
||||
prescreenResult: '',
|
||||
exportQuality: '',
|
||||
exportCountry: '',
|
||||
f: {keyword:'', tld:'', prescreen_status:'live', niche:'beauty_cosmetics', site_type:'ecommerce', country:'', limit:'100', page:1},
|
||||
prescreenInput: '', prescreenRunning: false, prescreenResult: '',
|
||||
exportQuality: '', exportCountry: '',
|
||||
f: {keyword:'', tld:'', prescreen_status:'live', niche:'beauty_cosmetics',
|
||||
site_type:'ecommerce', country:'', limit:'100', page:1},
|
||||
pf: {quality:'', country:'', limit:'100', page:1},
|
||||
|
||||
async init() {
|
||||
await this.loadStats();
|
||||
await this.loadAiStatus();
|
||||
await this.loadValStatus();
|
||||
await Promise.all([this.loadStats(), this.loadAiStatus(), this.loadValStatus()]);
|
||||
await this.loadDomains();
|
||||
setInterval(async () => {
|
||||
await this.loadStats();
|
||||
await this.loadAiStatus();
|
||||
if (this.tab === 'validator') await this.loadValStatus();
|
||||
this.loadStats();
|
||||
this.loadAiStatus();
|
||||
if (this.tab==='validator') this.loadValStatus();
|
||||
}, 4000);
|
||||
},
|
||||
|
||||
@@ -485,67 +464,64 @@ function app() {
|
||||
try {
|
||||
const d = await fetch('/api/stats').then(r=>r.json());
|
||||
this.stats = d;
|
||||
// Count beauty live: rough proxy via enriched stats
|
||||
} catch(e) {}
|
||||
// Count beauty live in background
|
||||
fetch('/api/enriched?prescreen_status=live&niche=beauty_cosmetics&limit=1')
|
||||
.then(r=>r.json()).then(d=>{ this.stats = {...this.stats, beauty_live: d.total||0}; })
|
||||
.catch(()=>{});
|
||||
} catch(e){}
|
||||
},
|
||||
|
||||
async loadAiStatus() {
|
||||
try {
|
||||
const d = await fetch('/api/beauty/status').then(r=>r.json());
|
||||
// Also count HOT/WARM from leads
|
||||
const hot = await fetch('/api/beauty/leads?quality=HOT&limit=1').then(r=>r.json()).catch(()=>({total:0}));
|
||||
const warm = await fetch('/api/beauty/leads?quality=WARM&limit=1').then(r=>r.json()).catch(()=>({total:0}));
|
||||
this.aiSt = { ...d, hot: hot.total||0, warm: warm.total||0 };
|
||||
// beauty live count
|
||||
try {
|
||||
const bl = await fetch('/api/enriched?prescreen_status=live&niche=beauty_cosmetics&limit=1').then(r=>r.json());
|
||||
this.stats.beauty_live = bl.total || 0;
|
||||
} catch(e) {}
|
||||
} catch(e) {}
|
||||
const [st, hot, warm] = await Promise.all([
|
||||
fetch('/api/beauty/status').then(r=>r.json()),
|
||||
fetch('/api/beauty/leads?quality=HOT&limit=1').then(r=>r.json()).catch(()=>({total:0})),
|
||||
fetch('/api/beauty/leads?quality=WARM&limit=1').then(r=>r.json()).catch(()=>({total:0})),
|
||||
]);
|
||||
this.aiSt = {...st, hot: hot.total||0, warm: warm.total||0};
|
||||
} catch(e){}
|
||||
},
|
||||
|
||||
async loadValStatus() {
|
||||
try { this.valSt = await fetch('/api/validator/status').then(r=>r.json()); }
|
||||
catch(e) {}
|
||||
try { this.valSt = await fetch('/api/validator/status').then(r=>r.json()); } catch(e){}
|
||||
},
|
||||
|
||||
goSearch() { this.f.page=1; this.loadDomains(); },
|
||||
|
||||
async loadDomains() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const p = new URLSearchParams({
|
||||
page: this.f.page,
|
||||
limit: this.f.limit,
|
||||
});
|
||||
if (this.f.keyword) p.set('keyword', this.f.keyword);
|
||||
if (this.f.tld) p.set('tld', this.f.tld);
|
||||
const p = new URLSearchParams({page: this.f.page, limit: this.f.limit});
|
||||
if (this.f.keyword) p.set('keyword', this.f.keyword.trim());
|
||||
if (this.f.tld) p.set('tld', this.f.tld.trim());
|
||||
if (this.f.prescreen_status) p.set('prescreen_status', this.f.prescreen_status);
|
||||
if (this.f.niche) p.set('niche', this.f.niche);
|
||||
if (this.f.site_type) p.set('site_type', this.f.site_type);
|
||||
if (this.f.country) p.set('country', this.f.country.trim().toUpperCase());
|
||||
const d = await fetch('/api/enriched?' + p).then(r=>r.json());
|
||||
let rows = d.results || [];
|
||||
// Client-side filters for prescreen_status, niche, site_type, country
|
||||
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);
|
||||
if (this.f.country) rows = rows.filter(r => (r.ip_country||'').toUpperCase() === this.f.country.toUpperCase());
|
||||
this.domains = rows;
|
||||
} catch(e) { this.notify('Failed to load domains: '+e.message, 'error'); }
|
||||
this.domains = d.results || [];
|
||||
this.domainsTotal = d.total || 0;
|
||||
} catch(e) { this.notify('Failed to load: '+e.message, 'error'); }
|
||||
finally { this.loading = false; }
|
||||
},
|
||||
|
||||
async loadLeads() {
|
||||
this.loadingLeads = true;
|
||||
try {
|
||||
const p = new URLSearchParams({ page: this.pf.page, limit: this.pf.limit });
|
||||
if (this.pf.quality) p.set('quality', this.pf.quality);
|
||||
if (this.pf.country) p.set('country', this.pf.country);
|
||||
const p = new URLSearchParams({page: this.pf.page, limit: this.pf.limit});
|
||||
if (this.pf.quality) p.set('quality', this.pf.quality);
|
||||
if (this.pf.country) p.set('country', this.pf.country.trim().toUpperCase());
|
||||
const d = await fetch('/api/beauty/leads?' + p).then(r=>r.json());
|
||||
this.leads = d.results || [];
|
||||
this.leadsTotal = d.total || 0;
|
||||
} catch(e) { this.notify('Failed to load leads: '+e.message, 'error'); }
|
||||
} catch(e) { this.notify('Failed: '+e.message, 'error'); }
|
||||
finally { this.loadingLeads = false; }
|
||||
},
|
||||
|
||||
resetFilters() {
|
||||
this.f = {keyword:'', tld:'', prescreen_status:'live', niche:'beauty_cosmetics', site_type:'ecommerce', country:'', limit:'100', page:1};
|
||||
this.f = {keyword:'', tld:'', prescreen_status:'live', niche:'beauty_cosmetics',
|
||||
site_type:'ecommerce', country:'', limit:'100', page:1};
|
||||
this.selected = [];
|
||||
this.loadDomains();
|
||||
},
|
||||
|
||||
@@ -553,7 +529,29 @@ function app() {
|
||||
this.selected = e.target.checked ? this.domains.map(r=>r.domain) : [];
|
||||
},
|
||||
|
||||
async queueSelected() {
|
||||
async prescreenSelected() {
|
||||
if (!this.selected.length) return;
|
||||
this.notify(`Pre-screening ${this.selected.length} domains…`, 'info');
|
||||
try {
|
||||
const chunks = [];
|
||||
for (let i=0; i<this.selected.length; i+=200) chunks.push(this.selected.slice(i,i+200));
|
||||
let totals = {live:0,dead:0,parked:0,redirect:0,classified:0};
|
||||
for (const chunk of chunks) {
|
||||
const d = await fetch('/api/prescreen/batch', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({domains: chunk}),
|
||||
}).then(r=>r.json());
|
||||
totals.live += d.live||0; totals.dead += d.dead||0;
|
||||
totals.parked += d.parked||0; totals.redirect += d.redirect||0;
|
||||
totals.classified += d.classified||0;
|
||||
}
|
||||
this.notify(`✅ ${totals.live} live · ☠ ${totals.dead} dead · 🅿 ${totals.parked} parked · 🏷 ${totals.classified} classified`, 'success');
|
||||
this.selected = [];
|
||||
await this.loadDomains();
|
||||
} catch(e) { this.notify('Pre-screen failed: '+e.message, 'error'); }
|
||||
},
|
||||
|
||||
async assessSelected() {
|
||||
if (!this.selected.length) return;
|
||||
try {
|
||||
const d = await fetch('/api/beauty/assess/batch', {
|
||||
@@ -565,44 +563,49 @@ function app() {
|
||||
} catch(e) { this.notify('Queue failed: '+e.message, 'error'); }
|
||||
},
|
||||
|
||||
async assessSingle(domain) {
|
||||
this.notify(`Queuing ${domain}…`, 'info');
|
||||
async prescreenOne(domain) {
|
||||
this.notify(`Pre-screening ${domain}…`, 'info');
|
||||
try {
|
||||
const d = await fetch('/api/prescreen/batch', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({domains:[domain]}),
|
||||
}).then(r=>r.json());
|
||||
this.notify(`${domain}: ${d.live?'live':'dead/parked'}, classified: ${d.classified}`, 'success');
|
||||
await this.loadDomains();
|
||||
} catch(e) { this.notify('Failed: '+e.message, 'error'); }
|
||||
},
|
||||
|
||||
async assessOne(domain) {
|
||||
try {
|
||||
await fetch('/api/beauty/assess/batch', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({domains:[domain]}),
|
||||
});
|
||||
this.notify(`${domain} queued for assessment`, 'success');
|
||||
this.notify(`${domain} queued for B2B assessment`, 'success');
|
||||
} catch(e) { this.notify('Failed: '+e.message, 'error'); }
|
||||
},
|
||||
|
||||
toggleLead(domain) {
|
||||
this.expandedLead = this.expandedLead === domain ? null : domain;
|
||||
this.expandedLead = this.expandedLead===domain ? null : domain;
|
||||
},
|
||||
|
||||
copyEmail(row) {
|
||||
copyOutreach(row) {
|
||||
const b = row._beauty || {};
|
||||
const text = [
|
||||
b.outreach_subject ? 'Subject: ' + b.outreach_subject : '',
|
||||
'',
|
||||
b.outreach_email || '',
|
||||
].join('\n').trim();
|
||||
const text = ['Subject: '+(b.outreach_subject||''), '', b.outreach_email||''].join('\n').trim();
|
||||
this.copyText(text);
|
||||
},
|
||||
|
||||
copyText(text) {
|
||||
navigator.clipboard.writeText(text).then(
|
||||
() => this.notify('Copied to clipboard', 'success'),
|
||||
() => this.notify('Copy failed', 'error'),
|
||||
);
|
||||
navigator.clipboard.writeText(text)
|
||||
.then(()=> this.notify('Copied!', 'success'))
|
||||
.catch(()=> this.notify('Copy failed', 'error'));
|
||||
},
|
||||
|
||||
async runPrescreen() {
|
||||
const lines = this.prescreenInput.split('\n').map(l=>l.trim()).filter(Boolean);
|
||||
if (!lines.length) return;
|
||||
if (lines.length > 200) { this.notify('Max 200 domains per batch', 'error'); return; }
|
||||
this.prescreenRunning = true;
|
||||
this.prescreenResult = '';
|
||||
if (!lines.length) { this.notify('No domains entered', 'error'); return; }
|
||||
if (lines.length > 200) { this.notify('Max 200 per batch', 'error'); return; }
|
||||
this.prescreenRunning = true; this.prescreenResult = '';
|
||||
try {
|
||||
const d = await fetch('/api/prescreen/batch', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
@@ -610,7 +613,7 @@ function app() {
|
||||
}).then(r=>r.json());
|
||||
this.prescreenResult = `✅ ${d.live} live · ☠ ${d.dead} dead · 🅿 ${d.parked} parked · ↗ ${d.redirect} redirect · 🏷 ${d.classified} classified`;
|
||||
this.notify(this.prescreenResult, 'success');
|
||||
} catch(e) { this.notify('Pre-screen failed: '+e.message, 'error'); }
|
||||
} catch(e) { this.notify('Failed: '+e.message, 'error'); }
|
||||
finally { this.prescreenRunning = false; }
|
||||
},
|
||||
|
||||
@@ -619,38 +622,36 @@ function app() {
|
||||
if (this.valTld) p.set('tld', this.valTld);
|
||||
if (this.valRescan) p.set('rescan_dead', 'true');
|
||||
try {
|
||||
this.valSt = await fetch('/api/validator/start?' + p, {method:'POST'}).then(r=>r.json());
|
||||
this.valSt = await fetch('/api/validator/start?'+p, {method:'POST'}).then(r=>r.json());
|
||||
this.notify('Validator started', 'success');
|
||||
} catch(e) { this.notify('Failed: '+e.message, 'error'); }
|
||||
},
|
||||
|
||||
async stopValidator() {
|
||||
await fetch('/api/validator/stop', {method:'POST'});
|
||||
this.notify('Validator stop requested', 'info');
|
||||
this.notify('Stop requested', 'info');
|
||||
await this.loadValStatus();
|
||||
},
|
||||
|
||||
exportLeads() {
|
||||
doExport() {
|
||||
const p = new URLSearchParams();
|
||||
if (this.exportQuality) p.set('quality', this.exportQuality);
|
||||
if (this.exportCountry) p.set('country', this.exportCountry);
|
||||
if (this.exportQuality) p.set('quality', this.exportQuality);
|
||||
if (this.exportCountry) p.set('country', this.exportCountry);
|
||||
window.open('/api/beauty/export?' + p, '_blank');
|
||||
},
|
||||
|
||||
qualityBadge(q) {
|
||||
if (!q) return 'badge-nr';
|
||||
const m = {HOT:'badge-hot', WARM:'badge-warm', COLD:'badge-cold', NOT_RELEVANT:'badge-nr'};
|
||||
return m[q] || 'badge-cold';
|
||||
return {HOT:'badge-hot', WARM:'badge-warm', COLD:'badge-cold', NOT_RELEVANT:'badge-nr'}[q]||'badge-nr';
|
||||
},
|
||||
|
||||
statusBadge(s) {
|
||||
const m = {live:'badge-live', dead:'badge-dead', error:'badge-error', parked:'badge-parked', redirect:'badge-redirect'};
|
||||
return m[s] || 'badge-nr';
|
||||
return {live:'badge-live', dead:'badge-dead', error:'badge-error', parked:'badge-parked', redirect:'badge-redirect'}[s]||'badge-nr';
|
||||
},
|
||||
|
||||
notify(msg, type='info') {
|
||||
const id = Date.now() + Math.random();
|
||||
const id = Date.now()+Math.random();
|
||||
this.toasts.push({id, msg, type});
|
||||
setTimeout(() => { this.toasts = this.toasts.filter(t=>t.id!==id); }, 4500);
|
||||
setTimeout(()=>{ this.toasts=this.toasts.filter(t=>t.id!==id); }, 4500);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user