Files
DomGod/app/static/beauty/index.html

660 lines
32 KiB
HTML
Raw Normal View History

<!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-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}
.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)}
/* 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}
/* 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}
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-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}
/* 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)}
/* 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)}
/* Toast */
.toast-container{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}}
/* 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 */
input[type=checkbox]{width:14px;height:14px;accent-color:var(--accent);cursor:pointer}
</style>
</head>
<body x-data="app()" x-init="init()">
<!-- Header -->
<div class="header">
<div class="logo">
BeautyLeads
<span>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>
</div>
</div>
<!-- Stats row -->
<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" 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 ══════════════════ -->
<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>
<option value="live">Live</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()">
<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="loadDomains()">
<option value="ecommerce">E-commerce</option>
<option value="">All Types</option>
<option value="corporate">Corporate</option>
</select>
<input x-model="f.country" @keyup.enter="loadDomains()" placeholder="Country (ES, FR…)" style="width:100px">
<select x-model="f.limit" @change="loadDomains()">
<option value="50">50</option>
<option value="100" selected>100</option>
<option value="200">200</option>
</select>
<button class="btn-primary" @click="loadDomains()">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>
</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>
<button class="btn-secondary btn-sm" @click="selected=[]">Clear</button>
</div>
<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>
<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>Domain</th>
<th>Status</th>
<th>Country</th>
<th>Title</th>
<th>Niche</th>
<th>Type</th>
<th>B2B</th>
<th></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>
<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" 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>
<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" 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>
</tr>
</template>
</tbody>
</table>
</div>
<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>
</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">
<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>
<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>
<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">
<input type="checkbox" x-model="valRescan"> Rescan dead
</label>
<button class="btn-primary" @click="startValidator()" :disabled="valSt.running">Start</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 && valSt.processed>0" style="color:var(--muted);font-size:11px">● Stopped</span>
</div>
</div>
<!-- ══════════════════ B2B PIPELINE TAB ══════════════════ -->
<div x-show="tab==='pipeline'">
<div class="filter-bar">
<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" @keyup.enter="loadLeads()" placeholder="Country (ES, FR…)" style="width:100px">
<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 style="margin-left:auto;color:var(--muted);font-size:11px" x-text="leadsTotal.toLocaleString()+' total leads'"></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>
<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></th>
</tr>
</thead>
<tbody>
<template x-for="row in leads" :key="row.domain">
<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 x-text="(row._beauty||{}).country_fiscal||(row.ip_country||'—')"></td>
<td>
<template x-for="cat in ((row._beauty||{}).categories||[]).slice(0,3)" :key="cat">
<span class="chip" x-text="cat"></span>
</template>
</td>
<td>
<template x-if="((row._beauty||{}).dist_matches||[]).length>0">
<span>
<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>
</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>
</tr>
<!-- Expanded detail row -->
<tr class="detail-row" x-show="expandedLead===row.domain">
<td colspan="8">
<div class="detail-grid">
<div class="detail-section">
<h4>B2B Proposal</h4>
<p x-text="(row._beauty||{}).b2b_proposal||'—'"></p>
</div>
<div class="detail-section">
<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">
Outreach Email
<button class="copy-btn" style="margin-left:8px" @click="copyText((row._beauty||{}).outreach_email||'')">Copy</button>
</h4>
<p style="white-space:pre-wrap;font-size:11px;color:var(--muted)" x-text="(row._beauty||{}).outreach_email||'—'"></p>
</div>
<div class="detail-section">
<h4>Detected Brands on Site</h4>
<p style="font-size:11px">
<template x-for="b in ((row._beauty||{}).detected_brands||[]).slice(0,20)" :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>
</p>
</div>
<div class="detail-section">
<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>
</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 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>
</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 &amp; type (beauty_cosmetics, ecommerce, etc.).<br>
Paste up to 200 domains, one per line.
</div>
<textarea x-model="prescreenInput" rows="12" placeholder="domain1.com&#10;domain2.es&#10;…"></textarea>
<div style="display:flex;gap:10px;margin-top:10px;align-items:center">
<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>
</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>
<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>
<select x-model="exportQuality" style="flex:1">
<option value="">All</option>
<option value="HOT">HOT only</option>
<option value="WARM">WARM only</option>
</select>
</div>
<div style="display:flex;gap:8px;align-items:center">
<label style="color:var(--muted);width:70px">Country</label>
<input x-model="exportCountry" placeholder="ES, FR, DE …" style="flex:1">
</div>
<button class="btn-primary" @click="exportLeads()">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>
</div>
</div>
<!-- Toasts -->
<div class="toast-container">
<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: [],
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,
toasts: [],
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 this.loadDomains();
setInterval(async () => {
await this.loadStats();
await this.loadAiStatus();
if (this.tab === 'validator') await this.loadValStatus();
}, 4000);
},
async loadStats() {
try {
const d = await fetch('/api/stats').then(r=>r.json());
this.stats = d;
// Count beauty live: rough proxy via enriched stats
} 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) {}
},
async loadValStatus() {
try { this.valSt = await fetch('/api/validator/status').then(r=>r.json()); }
catch(e) {}
},
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 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'); }
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 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'); }
finally { this.loadingLeads = false; }
},
resetFilters() {
this.f = {keyword:'', tld:'', prescreen_status:'live', niche:'beauty_cosmetics', site_type:'ecommerce', country:'', limit:'100', page:1};
this.loadDomains();
},
toggleAll(e) {
this.selected = e.target.checked ? this.domains.map(r=>r.domain) : [];
},
async queueSelected() {
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 assessSingle(domain) {
this.notify(`Queuing ${domain}…`, 'info');
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');
} catch(e) { this.notify('Failed: '+e.message, 'error'); }
},
toggleLead(domain) {
this.expandedLead = this.expandedLead === domain ? null : domain;
},
copyEmail(row) {
const b = row._beauty || {};
const text = [
b.outreach_subject ? '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'),
);
},
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 = '';
try {
const d = await fetch('/api/prescreen/batch', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({domains: lines}),
}).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'); }
finally { this.prescreenRunning = false; }
},
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('Validator stop requested', 'info');
},
exportLeads() {
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) {
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';
},
statusBadge(s) {
const m = {live:'badge-live', dead:'badge-dead', error:'badge-error', parked:'badge-parked', redirect:'badge-redirect'};
return m[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>