Files
DomGod/app/static/beauty/index.html
Malin daccb99a0c fix: prescreen returns immediately after HTTP check, DeepSeek runs in background
Previously /api/prescreen/batch blocked for 4-10 minutes waiting for Replicate/
DeepSeek, causing browser connection timeout and zero results saved.

- Phase 1 (HTTP check) runs synchronously and saves results immediately
- Phase 2 (DeepSeek classify) fires as asyncio.create_task and runs in background
- Response is returned to client as soon as phase 1 completes (~30-90s)
- Frontend toast shows "classifying N in background" so user knows niche/type
  will appear shortly without waiting
- Each DeepSeek sub-batch saves independently so partial results are preserved

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 08:28:26 +02:00

671 lines
35 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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;
}
*{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}
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}
.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{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;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)}
.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{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}
.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;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)}
.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)}
.badge-nr{background:rgba(100,116,139,.15);color:var(--muted)}
.badge-live{background:rgba(52,211,153,.15);color:var(--success)}
.badge-dead{background:rgba(244,63,94,.15);color:var(--danger)}
.badge-error{background:rgba(249,115,22,.15);color:var(--warn)}
.badge-parked{background:rgba(251,191,36,.15);color:#fbbf24}
.badge-redirect{background:rgba(148,163,184,.15);color:#94a3b8}
.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)}
.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}
.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(20px);opacity:0}to{transform:translateX(0);opacity:1}}
.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>
<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==='export'}" @click="tab='export'">Export</button>
</div>
</div>
<!-- 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" 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 ══════════════════════ -->
<div x-show="tab==='browse'">
<!-- 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="goSearch()">
<option value="beauty_cosmetics">Beauty &amp; 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="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" placeholder="Country (ES, FR…)" style="width:100px" @keyup.enter="goSearch()">
<label class="tog" style="font-size:12px;display:flex;align-items:center;gap:4px;cursor:pointer;white-space:nowrap">
<input type="checkbox" x-model="f.alpha_only" @change="goSearch()"><span>Alpha only</span>
</label>
<label class="tog" style="font-size:12px;display:flex;align-items:center;gap:4px;cursor:pointer;white-space:nowrap">
<input type="checkbox" x-model="f.no_sld" @change="goSearch()"><span>No SLD</span>
</label>
<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="goSearch()">Search</button>
<button class="btn-secondary" @click="resetFilters()">Reset</button>
<span class="page-info" style="margin-left:auto" x-text="domains.length+' shown · '+domainsTotal.toLocaleString()+' matching · page '+f.page"></span>
</div>
<!-- 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-secondary btn-sm" @click="validateSelected()" :disabled="validating">
<span x-show="!validating">Validate Selected</span>
<span x-show="validating">Validating…</span>
</button>
<button class="btn-ok btn-sm" @click="prescreenSelected()" :disabled="prescreening">
<span x-show="!prescreening">Pre-screen Selected</span>
<span x-show="prescreening">Screening…</span>
</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="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: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>
<th>Title</th>
<th>Niche</th>
<th>Type</th>
<th>B2B</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 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="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: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="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 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>
</tbody>
</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 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 ══════════════════════ -->
<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" 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.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;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 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">● Running</span>
<span x-show="!valSt.running && valSt.processed>0" style="color:var(--muted);font-size:11px">● Stopped</span>
</div>
</div>
<!-- ══════════════════════ B2B PIPELINE ══════════════════════ -->
<div x-show="tab==='pipeline'">
<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="WARM">WARM</option>
<option value="COLD">COLD</option>
</select>
<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-secondary" @click="pf={quality:'',country:'',page:1,limit:'100'};loadLeads()">Reset</button>
<span class="page-info" style="margin-left:auto" x-text="leadsTotal.toLocaleString()+' leads · page '+pf.page"></span>
</div>
<div class="tbl-wrap">
<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>
<th>Domain</th>
<th>Quality</th>
<th>Business</th>
<th>Country</th>
<th>Categories</th>
<th>Portfolio Match</th>
<th>Contact</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="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 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 style="max-width:200px">
<template x-if="((row._beauty||{}).dist_matches||[]).length>0">
<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)"></span>
</div>
</template>
<span x-show="!((row._beauty||{}).dist_matches||[]).length" style="color:var(--muted)"></span>
</td>
<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 -->
<tr class="detail-row" x-show="expandedLead===row.domain" @click="expandedLead=null" style="cursor:pointer">
<td colspan="8">
<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-box">
<h4>Lead Reasoning</h4>
<p x-text="(row._beauty||{}).lead_reasoning||'—'"></p>
</div>
<div class="detail-box" style="grid-column:1/-1">
<h4 style="display:flex;align-items:center;gap:8px">
Outreach Email
<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(--text);margin-top:6px;line-height:1.6" x-text="(row._beauty||{}).outreach_email||'—'"></p>
</div>
<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,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 in scraped text</span>
</p>
</div>
<div class="detail-box">
<h4>Contact Details</h4>
<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>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<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 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 ══════════════════════ -->
<!-- ══════════════════════ 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 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 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="doExport()">Download CSV</button>
</div>
<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-wrap">
<template x-for="t in toasts" :key="t.id">
<div class="toast" :class="t.type" x-text="t.msg"></div>
</template>
</div>
<script>
function app() {
return {
tab: 'browse',
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: [],
prescreening: false, validating: false,
exportQuality: '', exportCountry: '',
f: {keyword:'', tld:'', prescreen_status:'live', niche:'beauty_cosmetics',
site_type:'ecommerce', country:'', alpha_only:false, no_sld:false, limit:'100', page:1},
pf: {quality:'', country:'', limit:'100', page:1},
async init() {
await Promise.all([this.loadStats(), this.loadAiStatus(), this.loadValStatus()]);
await this.loadDomains();
setInterval(async () => {
this.loadStats();
this.loadAiStatus();
if (this.tab==='validator') this.loadValStatus();
}, 4000);
},
async loadStats() {
try {
const d = await fetch('/api/stats').then(r=>r.json());
this.stats = d;
// 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 [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){}
},
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.trim());
if (this.f.tld) p.set('tld', this.f.tld.trim());
if (this.f.alpha_only) p.set('alpha_only', 'true');
if (this.f.no_sld) p.set('no_sld', 'true');
const d = await fetch('/api/domains?' + p).then(r=>r.json());
this.domainsTotal = d.total || 0;
let rows = d.results || [];
// Client-side filters (same pattern as main DomGod)
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 === this.f.country.trim().toUpperCase());
this.domains = rows;
} 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.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: '+e.message, 'error'); }
finally { this.loadingLeads = false; }
},
resetFilters() {
this.f = {keyword:'', tld:'', prescreen_status:'live', niche:'beauty_cosmetics',
site_type:'ecommerce', country:'', alpha_only:false, no_sld:false, limit:'100', page:1};
this.selected = [];
this.loadDomains();
},
toggleAll(e) {
this.selected = e.target.checked ? this.domains.map(r=>r.domain) : [];
},
async validateSelected() {
if (!this.selected.length || this.validating) return;
this.validating = true;
this.notify(`Validating ${this.selected.length} domains…`, 'info');
try {
const chunks = [];
for (let i=0; i<this.selected.length; i+=500) chunks.push(this.selected.slice(i,i+500));
let totals = {live:0, dead:0, parked:0, redirect:0, error:0};
for (const chunk of chunks) {
const d = await fetch('/api/validate/batch', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({domains: chunk}),
}).then(r=>r.json());
for (const k of Object.keys(totals)) totals[k] += d[k]||0;
}
this.notify(`${totals.live} live · ☠ ${totals.dead} dead · 🅿 ${totals.parked} parked · ↗ ${totals.redirect} redirect`, 'success');
this.selected = [];
await this.loadDomains();
} catch(e) { this.notify('Validate failed: '+e.message, 'error'); }
finally { this.validating = false; }
},
async prescreenSelected() {
if (!this.selected.length || this.prescreening) return;
this.prescreening = true;
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,error:0,classifying: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.error += d.error||0; totals.classifying += d.classifying||0;
}
const cls = totals.classifying > 0 ? ` · 🏷 classifying ${totals.classifying} in background` : '';
this.notify(`${totals.live} live · ☠ ${totals.dead} dead · 🅿 ${totals.parked} parked${cls}`, 'success');
this.selected = [];
await this.loadDomains();
} catch(e) { this.notify('Pre-screen failed: '+e.message, 'error'); }
finally { this.prescreening = false; }
},
async assessSelected() {
if (!this.selected.length) return;
try {
const d = await fetch('/api/beauty/assess/batch', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({domains: this.selected}),
}).then(r=>r.json());
this.notify(`Queued ${d.queued} domains for B2B assessment`, 'success');
this.selected = [];
} catch(e) { this.notify('Queue failed: '+e.message, 'error'); }
},
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 B2B assessment`, 'success');
} catch(e) { this.notify('Failed: '+e.message, 'error'); }
},
toggleLead(domain) {
this.expandedLead = this.expandedLead===domain ? null : domain;
},
copyOutreach(row) {
const b = row._beauty || {};
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!', 'success'))
.catch(()=> this.notify('Copy failed', 'error'));
},
async startValidator() {
const p = new URLSearchParams();
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.notify('Validator started', 'success');
} catch(e) { this.notify('Failed: '+e.message, 'error'); }
},
async stopValidator() {
await fetch('/api/validator/stop', {method:'POST'});
this.notify('Stop requested', 'info');
await this.loadValStatus();
},
doExport() {
const p = new URLSearchParams();
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) {
return {HOT:'badge-hot', WARM:'badge-warm', COLD:'badge-cold', NOT_RELEVANT:'badge-nr'}[q]||'badge-nr';
},
statusBadge(s) {
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();
this.toasts.push({id, msg, type});
setTimeout(()=>{ this.toasts=this.toasts.filter(t=>t.id!==id); }, 4500);
},
};
}
</script>
</body>
</html>