Yves Gugger 3485668b5e
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
feat: add Alpha Terminal HUNT/CFO modules and Analyze framework
Adds HUNT (Sniper/Trend/Forge), CFO dashboard (burn rate + kill list), and a plugin-based Analyze side panel with caching and SSRF hardening.
2025-12-15 16:15:58 +01:00

222 lines
8.4 KiB
TypeScript

'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import clsx from 'clsx'
import { ExternalLink, Loader2, RefreshCw, Shield, Eye } from 'lucide-react'
import { api } from '@/lib/api'
import { useAnalyzePanelStore } from '@/lib/analyze-store'
import { useStore } from '@/lib/store'
function calcTimeRemaining(endTimeIso: string): string {
const end = new Date(endTimeIso).getTime()
const now = Date.now()
const diff = end - now
if (diff <= 0) return 'Ended'
const seconds = Math.floor(diff / 1000)
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const mins = Math.floor((seconds % 3600) / 60)
if (days > 0) return `${days}d ${hours}h`
if (hours > 0) return `${hours}h ${mins}m`
if (mins > 0) return `${mins}m`
return '< 1m'
}
export function SniperTab({ showToast }: { showToast: (message: string, type?: any) => void }) {
const openAnalyze = useAnalyzePanelStore((s) => s.open)
const addDomain = useStore((s) => s.addDomain)
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [items, setItems] = useState<
Array<{
domain: string
platform: string
auction_url: string
current_bid: number
currency: string
end_time: string
age_years: number | null
backlinks: number | null
pounce_score: number | null
}>
>([])
const load = useCallback(async () => {
setError(null)
const res = await api.getHuntBargainBin(200)
setItems(res.items || [])
}, [])
useEffect(() => {
let cancelled = false
const run = async () => {
setLoading(true)
try {
await load()
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : String(e))
} finally {
if (!cancelled) setLoading(false)
}
}
run()
return () => {
cancelled = true
}
}, [load])
const handleRefresh = useCallback(async () => {
setRefreshing(true)
try {
await load()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setRefreshing(false)
}
}, [load])
const rows = useMemo(() => items.slice(0, 150), [items])
const [tracking, setTracking] = useState<string | null>(null)
const track = useCallback(
async (domain: string) => {
if (tracking) return
setTracking(domain)
try {
await addDomain(domain)
showToast(`Tracked ${domain}`, 'success')
} catch (e) {
showToast(e instanceof Error ? e.message : 'Failed to track domain', 'error')
} finally {
setTracking(null)
}
},
[addDomain, showToast, tracking]
)
if (loading) {
return (
<div className="flex items-center justify-center py-16">
<Loader2 className="w-6 h-6 text-accent animate-spin" />
</div>
)
}
return (
<div className="border border-white/[0.08] bg-[#020202]">
<div className="px-4 py-3 border-b border-white/[0.08] flex items-center justify-between">
<div>
<div className="text-[10px] font-mono uppercase tracking-widest text-white/40">Closeout Sniper</div>
<div className="text-sm font-bold text-white">Bargain Bin</div>
<div className="text-[10px] font-mono text-white/30 mt-1">Only real scraped data: price &lt; $10, age 5y, backlinks &gt; 0.</div>
</div>
<button
onClick={handleRefresh}
disabled={refreshing}
className="p-2 border border-white/10 text-white/40 hover:text-white hover:bg-white/5 transition-colors disabled:opacity-50"
title="Refresh"
>
<RefreshCw className={clsx('w-4 h-4', refreshing && 'animate-spin')} />
</button>
</div>
{error ? (
<div className="p-4 text-[12px] font-mono text-red-300">{error}</div>
) : rows.length === 0 ? (
<div className="p-8 text-center text-white/40 font-mono text-sm">No sniper items right now.</div>
) : (
<div className="divide-y divide-white/[0.06]">
<div className="hidden lg:grid grid-cols-[1fr_70px_90px_80px_90px_120px] gap-3 px-4 py-2 text-[10px] font-mono text-white/40 uppercase tracking-wider">
<div>Domain</div>
<div className="text-center">Age</div>
<div className="text-center">Backlinks</div>
<div className="text-right">Price</div>
<div className="text-center">Time</div>
<div className="text-right">Action</div>
</div>
{rows.map((r) => (
<div key={r.domain} className="px-4 py-3 hover:bg-white/[0.02] transition-colors">
<div className="lg:grid lg:grid-cols-[1fr_70px_90px_80px_90px_120px] lg:gap-3 flex flex-col gap-2">
<div className="flex items-center justify-between gap-3">
<button
onClick={() => openAnalyze(r.domain)}
className="text-white font-mono font-bold truncate text-left hover:text-accent transition-colors"
title="Analyze"
>
{r.domain}
</button>
<div className="flex items-center gap-2 lg:hidden">
<button
onClick={() => openAnalyze(r.domain)}
className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/40 hover:text-accent hover:border-accent/20 hover:bg-accent/10"
title="Analyze"
>
<Shield className="w-4 h-4" />
</button>
<a
href={r.auction_url}
target="_blank"
rel="noopener noreferrer"
className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/40 hover:text-white hover:bg-white/5"
title="Open auction"
>
<ExternalLink className="w-4 h-4" />
</a>
</div>
</div>
<div className="lg:text-center text-[11px] font-mono text-white/60">
{r.age_years !== null ? `${r.age_years}y` : '—'}
</div>
<div className="lg:text-center text-[11px] font-mono text-white/60">
{r.backlinks !== null ? String(r.backlinks) : '—'}
</div>
<div className="lg:text-right text-[12px] font-mono font-bold text-white">
${Math.round(r.current_bid)}
</div>
<div className="lg:text-center text-[11px] font-mono text-white/50">{calcTimeRemaining(r.end_time)}</div>
<div className="hidden lg:flex items-center justify-end gap-2">
<button
onClick={() => openAnalyze(r.domain)}
className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/40 hover:text-accent hover:border-accent/20 hover:bg-accent/10"
title="Analyze"
>
<Shield className="w-4 h-4" />
</button>
<button
onClick={() => track(r.domain)}
disabled={tracking === r.domain}
className={clsx(
'w-8 h-8 flex items-center justify-center border transition-colors',
tracking === r.domain
? 'border-white/10 text-white/20'
: 'border-white/10 text-white/40 hover:text-white hover:bg-white/5'
)}
title="Track in Watchlist"
>
{tracking === r.domain ? <Loader2 className="w-4 h-4 animate-spin" /> : <Eye className="w-4 h-4" />}
</button>
<a
href={r.auction_url}
target="_blank"
rel="noopener noreferrer"
className="h-8 px-3 flex items-center gap-2 border border-white/10 bg-white/[0.03] text-white/70 hover:bg-white/[0.06] transition-colors font-mono text-[10px] uppercase tracking-wider"
>
Buy
<ExternalLink className="w-3.5 h-3.5" />
</a>
</div>
</div>
</div>
))}
</div>
)}
</div>
)
}