| import { useState, useEffect, useRef } from 'react'; |
| import gsap from 'gsap'; |
|
|
| const API_BASE = '/api'; |
|
|
| export default function App() { |
| const [niche, setNiche] = useState(''); |
| const [location, setLocation] = useState(''); |
| const [limit, setLimit] = useState(10); |
| const [jobStatus, setJobStatus] = useState({ status: 'idle', message: 'Awaiting transmission...', progress: 0 }); |
| const [results, setResults] = useState([]); |
| const [activeTab, setActiveTab] = useState('search'); |
|
|
| const heroRef = useRef(null); |
| const panelRef = useRef(null); |
| const gridRef = useRef(null); |
| const particlesRef = useRef([]); |
|
|
| const isRunning = ['scraping','enriching','saving'].includes(jobStatus.status); |
|
|
| |
| useEffect(() => { |
| const ctx = gsap.context(() => { |
| gsap.fromTo(heroRef.current, |
| { opacity: 0, y: -40 }, |
| { opacity: 1, y: 0, duration: 1.1, ease: 'power4.out' } |
| ); |
| gsap.fromTo(panelRef.current, |
| { opacity: 0, y: 30, scale: 0.97 }, |
| { opacity: 1, y: 0, scale: 1, duration: 0.9, delay: 0.3, ease: 'back.out(1.4)' } |
| ); |
| }); |
| return () => ctx.revert(); |
| }, []); |
|
|
| |
| useEffect(() => { |
| particlesRef.current.forEach((el, i) => { |
| if (!el) return; |
| gsap.to(el, { |
| y: `random(-30, 30)`, |
| x: `random(-20, 20)`, |
| opacity: `random(0.2, 0.8)`, |
| duration: `random(3, 6)`, |
| repeat: -1, |
| yoyo: true, |
| ease: 'sine.inOut', |
| delay: i * 0.3, |
| }); |
| }); |
| }, []); |
|
|
| |
| useEffect(() => { |
| if (!isRunning) return; |
| const id = setInterval(async () => { |
| try { |
| const res = await fetch(`${API_BASE}/status`); |
| const data = await res.json(); |
| setJobStatus(data); |
| if (data.status === 'complete') { |
| clearInterval(id); |
| loadResults(); |
| } else if (data.status === 'error') { |
| clearInterval(id); |
| } |
| } catch (_) {} |
| }, 1000); |
| return () => clearInterval(id); |
| }, [isRunning]); |
|
|
| |
| useEffect(() => { |
| if (results.length > 0 && gridRef.current) { |
| const rows = gridRef.current.querySelectorAll('tbody tr'); |
| gsap.fromTo(rows, { opacity: 0, x: -15 }, { |
| opacity: 1, x: 0, stagger: 0.06, duration: 0.4, ease: 'power2.out' |
| }); |
| } |
| }, [results]); |
|
|
| const loadResults = async () => { |
| try { |
| const r = await fetch(`${API_BASE}/results`); |
| setResults(await r.json()); |
| setActiveTab('results'); |
| } catch (_) {} |
| }; |
|
|
| const handleSubmit = async (e) => { |
| e.preventDefault(); |
| if (!niche.trim() || !location.trim()) return; |
| setResults([]); |
| setJobStatus({ status: 'scraping', message: 'Initiating agent...', progress: 3 }); |
| try { |
| await fetch(`${API_BASE}/scrape`, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ niche, location, limit }), |
| }); |
| } catch (_) { |
| setJobStatus({ status: 'error', message: 'Cannot reach backend on :8000', progress: 0 }); |
| } |
| }; |
|
|
| const exportCSV = () => { |
| const cols = ['name','website','phone','email','rating','facebook','instagram','linkedin','status']; |
| const lines = [cols.join(','), ...results.map(r => cols.map(k => `"${(r[k]||'').replace(/"/g,'""')}"`).join(','))]; |
| const a = document.createElement('a'); |
| a.href = URL.createObjectURL(new Blob([lines.join('\n')], { type: 'text/csv' })); |
| a.download = `leads_${niche}_${location}.csv`; |
| a.click(); |
| }; |
| |
| const safeHost = url => { try { return new URL(url).hostname; } catch { return url; } }; |
| |
| const statusColor = { idle:'#4a5568', scraping:'#00f2ff', enriching:'#8b5cf6', saving:'#10b981', complete:'#22c55e', error:'#ef4444' }; |
| |
| /* ββ Grid layout ββ */ |
| return ( |
| <div id="root-app"> |
| {/* Particles */} |
| <div className="particles"> |
| {[...Array(18)].map((_, i) => ( |
| <div key={i} className="particle" ref={el => particlesRef.current[i] = el} |
| style={{ left: `${Math.random()*100}%`, top: `${Math.random()*100}%`, width: `${2+Math.random()*3}px`, height: `${2+Math.random()*3}px`, opacity: 0.3 + Math.random() * 0.5 }} /> |
| ))} |
| </div> |
| |
| <div className="layout"> |
| |
| {/* ββ HERO ββ */} |
| <header className="hero" ref={heroRef}> |
| <div className="hero-icon"> |
| <svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#00f2ff" strokeWidth="1.5"> |
| <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/> |
| </svg> |
| </div> |
| <div> |
| <h1>LEAD HUNTER <span className="dim">AI</span></h1> |
| <p className="subtitle">AGENTIC BUSINESS INTELLIGENCE ENGINE v2.0</p> |
| </div> |
| </header> |
| |
| {/* ββ STATUS BAR ββ */} |
| {jobStatus.status !== 'idle' && ( |
| <div className="status-bar"> |
| <div className="status-dot" style={{ background: statusColor[jobStatus.status] || '#4a5568' }} /> |
| <span className="status-msg">{jobStatus.message}</span> |
| <span className="status-pct">{jobStatus.progress}%</span> |
| </div> |
| )} |
| {jobStatus.status !== 'idle' && ( |
| <div className="progress-track"> |
| <div className="progress-fill" style={{ width: `${jobStatus.progress}%`, background: statusColor[jobStatus.status] || '#00f2ff' }} /> |
| </div> |
| )} |
| |
| {/* ββ TABS ββ */} |
| <div className="tabs"> |
| <button className={`tab ${activeTab==='search'?'active':''}`} onClick={() => setActiveTab('search')}>β‘ Search</button> |
| <button className={`tab ${activeTab==='results'?'active':''}`} onClick={() => setActiveTab('results')}> |
| π‘ Results {results.length > 0 && <span className="badge">{results.length}</span>} |
| </button> |
| </div> |
| |
| {/* ββ SEARCH PANEL ββ */} |
| {activeTab === 'search' && ( |
| <div className="panel" ref={panelRef}> |
| <form onSubmit={handleSubmit} className="search-form"> |
| <div className="field-group"> |
| <label className="field-label">TARGET NICHE</label> |
| <div className="input-wrap"> |
| <span className="input-icon">π</span> |
| <input className="input" placeholder="e.g. Roofers, Pool Cleaners..." value={niche} onChange={e => setNiche(e.target.value)} disabled={isRunning} required /> |
| </div> |
| </div> |
| <div className="field-group"> |
| <label className="field-label">TARGET LOCATION</label> |
| <div className="input-wrap"> |
| <span className="input-icon">π</span> |
| <input className="input" placeholder="e.g. Miami, New York..." value={location} onChange={e => setLocation(e.target.value)} disabled={isRunning} required /> |
| </div> |
| </div> |
| <div className="field-group"> |
| <label className="field-label">LEAD COUNT</label> |
| <div className="input-wrap"> |
| <span className="input-icon">#</span> |
| <input className="input" type="number" min="1" max="50" value={limit} onChange={e => setLimit(Number(e.target.value))} disabled={isRunning} /> |
| </div> |
| </div> |
| <button className="btn-execute" type="submit" disabled={isRunning}> |
| {isRunning ? <span className="spinner">β³</span> : 'β‘'} {isRunning ? 'AGENT ACTIVE...' : 'INITIALIZE HUNT'} |
| </button> |
| </form> |
| |
| <div className="info-cards"> |
| <div className="info-card"><div className="info-num">3</div><div className="info-label">AI Agents</div></div> |
| <div className="info-card"><div className="info-num">β</div><div className="info-label">Niches</div></div> |
| <div className="info-card"><div className="info-num">Free</div><div className="info-label">Cost</div></div> |
| </div> |
| </div> |
| )} |
| |
| {/* ββ RESULTS PANEL ββ */} |
| {activeTab === 'results' && ( |
| <div className="panel results-panel"> |
| {results.length === 0 ? ( |
| <div className="empty-state"> |
| <p>π‘ No data yet. Run a search first.</p> |
| </div> |
| ) : ( |
| <> |
| <div className="results-header"> |
| <span className="results-count">{results.length} leads discovered</span> |
| <button className="btn-export" onClick={exportCSV}>β¬ Export CSV</button> |
| </div> |
| <div className="table-wrap"> |
| <table className="results-table" ref={gridRef}> |
| <thead> |
| <tr> |
| <th>BUSINESS</th> |
| <th>CONTACT</th> |
| <th>RATING</th> |
| <th>CHANNELS</th> |
| <th>STATUS</th> |
| </tr> |
| </thead> |
| <tbody> |
| {results.map((row, i) => ( |
| <tr key={i}> |
| <td> |
| <div className="biz-name">{row.name}</div> |
| {row.website && <a className="biz-url" href={row.website} target="_blank" rel="noreferrer">{safeHost(row.website)}</a>} |
| {row.phone && <div className="biz-phone">{row.phone}</div>} |
| </td> |
| <td><div className="email-cell">{row.email || <span className="dim-text">β</span>}</div></td> |
| <td><span className="rating-badge">β
{row.rating || 'β'}</span></td> |
| <td> |
| <div className="channels"> |
| {row.facebook && <a href={row.facebook} target="_blank" rel="noreferrer" className="ch-btn">fb</a>} |
| {row.instagram && <a href={row.instagram} target="_blank" rel="noreferrer" className="ch-btn">ig</a>} |
| {row.linkedin && <a href={row.linkedin} target="_blank" rel="noreferrer" className="ch-btn">in</a>} |
| </div> |
| </td> |
| <td><span className={`status-chip ${row.status === 'Success' ? 'chip-ok' : 'chip-warn'}`}>{row.status || 'β'}</span></td> |
| </tr> |
| ))} |
| </tbody> |
| </table> |
| </div> |
| </> |
| )} |
| </div> |
| )} |
| |
| <footer className="footer">LEAD HUNTER AI Β· Running on localhost Β· $0 Budget</footer> |
| </div> |
| </div> |
| ); |
| } |
| |