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
Adds HUNT (Sniper/Trend/Forge), CFO dashboard (burn rate + kill list), and a plugin-based Analyze side panel with caching and SSRF hardening.
222 lines
8.4 KiB
TypeScript
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 < $10, age ≥ 5y, backlinks > 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>
|
|
)
|
|
}
|
|
|