Auto health check on add, Market page redesign
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

This commit is contained in:
2025-12-12 22:34:39 +01:00
parent fd66a86408
commit 718a7d64e5
2 changed files with 332 additions and 522 deletions

View File

@ -3,37 +3,27 @@
import { useEffect, useState, useMemo, useCallback, memo } from 'react' import { useEffect, useState, useMemo, useCallback, memo } from 'react'
import { useStore } from '@/lib/store' import { useStore } from '@/lib/store'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { TerminalLayout } from '@/components/TerminalLayout' import { CommandCenterLayout } from '@/components/CommandCenterLayout'
import { import {
ExternalLink, ExternalLink,
Loader2, Loader2,
Diamond, Diamond,
Timer,
Zap, Zap,
Filter,
ChevronDown, ChevronDown,
ChevronUp,
Plus, Plus,
Check, Check,
TrendingUp, TrendingUp,
RefreshCw, RefreshCw,
ArrowUpDown,
Activity,
Flame,
Clock, Clock,
Search, Search,
LayoutGrid,
List,
SlidersHorizontal,
MoreHorizontal,
Eye, Eye,
Info,
ShieldCheck, ShieldCheck,
Sparkles,
Store, Store,
DollarSign,
Gavel, Gavel,
Ban Ban,
Activity,
Target,
ArrowRight
} from 'lucide-react' } from 'lucide-react'
import clsx from 'clsx' import clsx from 'clsx'
import Link from 'next/link' import Link from 'next/link'
@ -69,25 +59,9 @@ type SourceFilter = 'all' | 'pounce' | 'external'
type PriceRange = 'all' | 'low' | 'mid' | 'high' type PriceRange = 'all' | 'low' | 'mid' | 'high'
// ============================================================================ // ============================================================================
// HELPER FUNCTIONS // HELPERS
// ============================================================================ // ============================================================================
function parseTimeToSeconds(timeStr?: string): number {
if (!timeStr) return Infinity
let seconds = 0
const days = timeStr.match(/(\d+)d/)
const hours = timeStr.match(/(\d+)h/)
const mins = timeStr.match(/(\d+)m/)
if (days) seconds += parseInt(days[1]) * 86400
if (hours) seconds += parseInt(hours[1]) * 3600
if (mins) seconds += parseInt(mins[1]) * 60
return seconds || Infinity
}
/**
* Calculate time remaining from end_time ISO string (UTC).
* Returns human-readable string like "2h 15m" or "Ended".
*/
function calcTimeRemaining(endTimeIso?: string): string { function calcTimeRemaining(endTimeIso?: string): string {
if (!endTimeIso) return 'N/A' if (!endTimeIso) return 'N/A'
const end = new Date(endTimeIso).getTime() const end = new Date(endTimeIso).getTime()
@ -107,9 +81,6 @@ function calcTimeRemaining(endTimeIso?: string): string {
return '< 1m' return '< 1m'
} }
/**
* Get seconds until end from ISO string (for sorting/urgency).
*/
function getSecondsUntilEnd(endTimeIso?: string): number { function getSecondsUntilEnd(endTimeIso?: string): number {
if (!endTimeIso) return Infinity if (!endTimeIso) return Infinity
const diff = new Date(endTimeIso).getTime() - Date.now() const diff = new Date(endTimeIso).getTime() - Date.now()
@ -125,180 +96,10 @@ function formatPrice(price: number, currency = 'USD'): string {
} }
function isSpam(domain: string): boolean { function isSpam(domain: string): boolean {
// Check for hyphens or numbers in the name part (excluding TLD)
const name = domain.split('.')[0] const name = domain.split('.')[0]
return /[-\d]/.test(name) return /[-\d]/.test(name)
} }
// ============================================================================
// COMPONENTS
// ============================================================================
// Tooltip
const Tooltip = memo(({ children, content }: { children: React.ReactNode; content: string }) => (
<div className="relative flex items-center group/tooltip w-fit">
{children}
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-zinc-900 border border-zinc-800 rounded text-[10px] text-zinc-300 whitespace-nowrap opacity-0 group-hover/tooltip:opacity-100 transition-opacity pointer-events-none z-50 shadow-xl">
{content}
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-px border-4 border-transparent border-t-zinc-800" />
</div>
</div>
))
Tooltip.displayName = 'Tooltip'
// Stat Card (Matched to Watchlist Page)
const StatCard = memo(({
label,
value,
subValue,
icon: Icon,
highlight
}: {
label: string
value: string | number
subValue?: string
icon: any
highlight?: boolean
}) => (
<div className={clsx(
"bg-zinc-900/40 border p-4 relative overflow-hidden group hover:border-white/10 transition-colors",
highlight ? "border-emerald-500/30" : "border-white/5"
)}>
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<Icon className="w-16 h-16" />
</div>
<div className="relative z-10">
<div className="flex items-center gap-2 text-zinc-400 mb-1">
<Icon className={clsx("w-4 h-4", highlight && "text-emerald-400")} />
<span className="text-xs font-medium uppercase tracking-wider">{label}</span>
</div>
<div className="flex items-baseline gap-2">
<span className="text-2xl font-bold text-white tracking-tight">{value}</span>
{subValue && <span className="text-xs text-zinc-500 font-medium">{subValue}</span>}
</div>
{highlight && (
<div className="mt-2 text-[10px] font-medium px-1.5 py-0.5 w-fit rounded border text-emerald-400 border-emerald-400/20 bg-emerald-400/5">
LIVE
</div>
)}
</div>
</div>
))
StatCard.displayName = 'StatCard'
// Score Ring
const ScoreDisplay = memo(({ score, mobile = false }: { score: number; mobile?: boolean }) => {
const color = score >= 80 ? 'text-emerald-500' : score >= 50 ? 'text-amber-500' : 'text-zinc-600'
if (mobile) {
return (
<div className={clsx(
"px-2 py-0.5 rounded text-[10px] font-bold font-mono border",
score >= 80 ? "bg-emerald-500/10 text-emerald-400 border-emerald-500/20" :
score >= 50 ? "bg-amber-500/10 text-amber-400 border-amber-500/20" :
"bg-zinc-800 text-zinc-400 border-zinc-700"
)}>
{score}
</div>
)
}
const size = 36
const strokeWidth = 3
const radius = (size - strokeWidth) / 2
const circumference = radius * 2 * Math.PI
const offset = circumference - (score / 100) * circumference
return (
<Tooltip content={`Pounce Score: ${score}/100`}>
<div className="relative flex items-center justify-center cursor-help" style={{ width: size, height: size }}>
<svg className="absolute w-full h-full -rotate-90">
<circle cx={size/2} cy={size/2} r={radius} className="stroke-zinc-800" strokeWidth={strokeWidth} fill="none" />
<circle
cx={size/2}
cy={size/2}
r={radius}
className={clsx("transition-all duration-700 ease-out", color)}
strokeWidth={strokeWidth}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
fill="none"
/>
</svg>
<span className={clsx("text-[11px] font-bold font-mono", score >= 80 ? 'text-emerald-400' : 'text-zinc-400')}>
{score}
</span>
</div>
</Tooltip>
)
})
ScoreDisplay.displayName = 'ScoreDisplay'
// Filter Toggle
const FilterToggle = memo(({ active, onClick, label, icon: Icon }: {
active: boolean
onClick: () => void
label: string
icon?: any
}) => (
<button
onClick={onClick}
className={clsx(
"px-4 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-2 whitespace-nowrap border",
active
? "bg-zinc-800 text-white border-zinc-600 shadow-sm"
: "bg-transparent text-zinc-400 border-zinc-800 hover:text-zinc-200 hover:bg-white/5"
)}
>
{Icon && <Icon className="w-3.5 h-3.5" />}
{label}
</button>
))
FilterToggle.displayName = 'FilterToggle'
// Sort Header
const SortableHeader = memo(({
label, field, currentSort, currentDirection, onSort, align = 'left', tooltip
}: {
label: string
field: SortField
currentSort: SortField
currentDirection: SortDirection
onSort: (field: SortField) => void
align?: 'left'|'center'|'right'
tooltip?: string
}) => {
const isActive = currentSort === field
return (
<div className={clsx(
"flex items-center gap-1",
align === 'right' && "justify-end ml-auto",
align === 'center' && "justify-center mx-auto"
)}>
<button
onClick={() => onSort(field)}
className={clsx(
"flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider transition-all group select-none py-2",
isActive ? "text-zinc-300" : "text-zinc-500 hover:text-zinc-400"
)}
>
{label}
<div className={clsx("flex flex-col -space-y-1 transition-opacity", isActive ? "opacity-100" : "opacity-0 group-hover:opacity-30")}>
<ChevronUp className={clsx("w-2 h-2", isActive && currentDirection === 'asc' ? "text-zinc-300" : "text-zinc-600")} />
<ChevronDown className={clsx("w-2 h-2", isActive && currentDirection === 'desc' ? "text-zinc-300" : "text-zinc-600")} />
</div>
</button>
{tooltip && (
<Tooltip content={tooltip}>
<Info className="w-3 h-3 text-zinc-700 hover:text-zinc-500 transition-colors cursor-help" />
</Tooltip>
)}
</div>
)
})
SortableHeader.displayName = 'SortableHeader'
// ============================================================================ // ============================================================================
// MAIN PAGE // MAIN PAGE
// ============================================================================ // ============================================================================
@ -306,29 +107,23 @@ SortableHeader.displayName = 'SortableHeader'
export default function MarketPage() { export default function MarketPage() {
const { subscription } = useStore() const { subscription } = useStore()
// Data
const [items, setItems] = useState<MarketItem[]>([]) const [items, setItems] = useState<MarketItem[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false) const [refreshing, setRefreshing] = useState(false)
const [stats, setStats] = useState({ total: 0, pounceCount: 0, auctionCount: 0, highScore: 0 }) const [stats, setStats] = useState({ total: 0, pounceCount: 0, auctionCount: 0, highScore: 0 })
// Filters
const [sourceFilter, setSourceFilter] = useState<SourceFilter>('all') const [sourceFilter, setSourceFilter] = useState<SourceFilter>('all')
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [priceRange, setPriceRange] = useState<PriceRange>('all') const [priceRange, setPriceRange] = useState<PriceRange>('all')
const [verifiedOnly, setVerifiedOnly] = useState(false)
const [hideSpam, setHideSpam] = useState(true) const [hideSpam, setHideSpam] = useState(true)
const [tldFilter, setTldFilter] = useState<string>('all') const [tldFilter, setTldFilter] = useState<string>('all')
// Sort
const [sortField, setSortField] = useState<SortField>('score') const [sortField, setSortField] = useState<SortField>('score')
const [sortDirection, setSortDirection] = useState<SortDirection>('desc') const [sortDirection, setSortDirection] = useState<SortDirection>('desc')
// Watchlist
const [trackedDomains, setTrackedDomains] = useState<Set<string>>(new Set()) const [trackedDomains, setTrackedDomains] = useState<Set<string>>(new Set())
const [trackingInProgress, setTrackingInProgress] = useState<string | null>(null) const [trackingInProgress, setTrackingInProgress] = useState<string | null>(null)
// Load data
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
setLoading(true) setLoading(true)
try { try {
@ -338,7 +133,6 @@ export default function MarketPage() {
tld: tldFilter === 'all' ? undefined : tldFilter, tld: tldFilter === 'all' ? undefined : tldFilter,
minPrice: priceRange === 'low' ? undefined : priceRange === 'mid' ? 100 : priceRange === 'high' ? 1000 : undefined, minPrice: priceRange === 'low' ? undefined : priceRange === 'mid' ? 100 : priceRange === 'high' ? 1000 : undefined,
maxPrice: priceRange === 'low' ? 100 : priceRange === 'mid' ? 1000 : undefined, maxPrice: priceRange === 'low' ? 100 : priceRange === 'mid' ? 1000 : undefined,
verifiedOnly,
sortBy: sortField === 'score' ? 'score' : sortBy: sortField === 'score' ? 'score' :
sortField === 'price' ? (sortDirection === 'asc' ? 'price_asc' : 'price_desc') : sortField === 'price' ? (sortDirection === 'asc' ? 'price_asc' : 'price_desc') :
sortField === 'time' ? 'time' : 'newest', sortField === 'time' ? 'time' : 'newest',
@ -358,7 +152,7 @@ export default function MarketPage() {
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [sourceFilter, searchQuery, priceRange, verifiedOnly, sortField, sortDirection, tldFilter]) }, [sourceFilter, searchQuery, priceRange, sortField, sortDirection, tldFilter])
useEffect(() => { loadData() }, [loadData]) useEffect(() => { loadData() }, [loadData])
@ -390,36 +184,29 @@ export default function MarketPage() {
} }
}, [trackedDomains, trackingInProgress]) }, [trackedDomains, trackingInProgress])
// Client-side filtering for immediate UI feedback & SPAM FILTER
const filteredItems = useMemo(() => { const filteredItems = useMemo(() => {
let filtered = items let filtered = items
// Hard safety: never show ended auctions client-side.
// (Server already filters, this is a guardrail against any drift/cache.)
const nowMs = Date.now() const nowMs = Date.now()
filtered = filtered.filter(item => { filtered = filtered.filter(item => {
if (item.status !== 'auction') return true if (item.status !== 'auction') return true
if (!item.end_time) return true if (!item.end_time) return true
const t = Date.parse(item.end_time) const t = Date.parse(item.end_time)
if (Number.isNaN(t)) return true if (Number.isNaN(t)) return true
return t > (nowMs - 2000) // 2s grace return t > (nowMs - 2000)
}) })
// Additional client-side search
if (searchQuery && !loading) { if (searchQuery && !loading) {
const query = searchQuery.toLowerCase() const query = searchQuery.toLowerCase()
filtered = filtered.filter(item => item.domain.toLowerCase().includes(query)) filtered = filtered.filter(item => item.domain.toLowerCase().includes(query))
} }
// Hide Spam (Client-side)
if (hideSpam) { if (hideSpam) {
filtered = filtered.filter(item => !isSpam(item.domain)) filtered = filtered.filter(item => !isSpam(item.domain))
} }
// Sort
const mult = sortDirection === 'asc' ? 1 : -1 const mult = sortDirection === 'asc' ? 1 : -1
filtered = [...filtered].sort((a, b) => { filtered = [...filtered].sort((a, b) => {
// Pounce Direct always appears first within same score tier if score sort
if (sortField === 'score' && a.is_pounce !== b.is_pounce) { if (sortField === 'score' && a.is_pounce !== b.is_pounce) {
return a.is_pounce ? -1 : 1 return a.is_pounce ? -1 : 1
} }
@ -438,311 +225,324 @@ export default function MarketPage() {
}, [items, searchQuery, sortField, sortDirection, loading, hideSpam]) }, [items, searchQuery, sortField, sortDirection, loading, hideSpam])
return ( return (
<TerminalLayout hideHeaderSearch={true}> <CommandCenterLayout minimal>
<div className="relative font-sans text-zinc-100 selection:bg-emerald-500/30"> {/* ═══════════════════════════════════════════════════════════════════════ */}
{/* HEADER */}
{/* Ambient Background Glow (Matched to Watchlist) */} {/* ═══════════════════════════════════════════════════════════════════════ */}
<div className="fixed inset-0 pointer-events-none overflow-hidden"> <section className="pt-6 lg:pt-8 pb-6">
<div className="absolute top-0 right-1/4 w-[800px] h-[600px] bg-emerald-500/5 rounded-full blur-[120px] mix-blend-screen" /> <div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6">
<div className="absolute bottom-0 left-1/4 w-[600px] h-[500px] bg-blue-500/5 rounded-full blur-[100px] mix-blend-screen" /> <div className="space-y-3">
</div> <div className="inline-flex items-center gap-2">
<Gavel className="w-4 h-4 text-accent" />
<div className="relative z-10 max-w-[1600px] mx-auto p-4 md:p-8 space-y-8"> <span className="text-[10px] font-mono tracking-wide text-accent">Live Auctions</span>
{/* Header Section (Matched to Watchlist) */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-6 border-b border-white/5 pb-8">
<div className="space-y-2">
<div className="flex items-center gap-3">
<div className="h-8 w-1 bg-emerald-500 rounded-full shadow-[0_0_10px_rgba(16,185,129,0.5)]" />
<h1 className="text-3xl font-bold tracking-tight text-white">Market Feed</h1>
</div>
<p className="text-zinc-400 max-w-lg">
Real-time auctions from Pounce Direct, GoDaddy, Sedo, and DropCatch.
</p>
</div> </div>
{/* Quick Stats Pills */} <h1 className="font-display text-[2rem] lg:text-[2.5rem] leading-[1] tracking-[-0.02em]">
<div className="flex gap-2"> <span className="text-white">Market</span>
<div className="px-3 py-1.5 rounded-full bg-white/5 border border-white/10 flex items-center gap-2 text-xs font-medium text-zinc-300"> <span className="text-white/30 ml-3">{stats.total}</span>
<Diamond className="w-3.5 h-3.5 text-emerald-400" /> </h1>
{stats.pounceCount} Exclusive
</div>
<div className="px-3 py-1.5 rounded-full bg-white/5 border border-white/10 flex items-center gap-2 text-xs font-medium text-zinc-300">
<Store className="w-3.5 h-3.5 text-blue-400" />
{stats.auctionCount} External
</div>
</div>
</div> </div>
{/* Metric Grid (Matched to Watchlist) */} <div className="flex gap-6 lg:gap-8">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> <div className="text-right">
<StatCard <div className="text-xl font-display text-accent">{stats.pounceCount}</div>
label="Total Opportunities" <div className="text-[9px] tracking-wide text-white/30 font-mono">Pounce Direct</div>
value={stats.total}
icon={Activity}
highlight={true}
/>
<StatCard
label="Pounce Direct"
value={stats.pounceCount}
subValue="0% Fee"
icon={Diamond}
/>
<StatCard
label="High Value"
value={stats.highScore}
subValue="Score > 80"
icon={TrendingUp}
/>
<StatCard
label="Market Status"
value="ACTIVE"
subValue="Live Feed"
icon={Zap}
/>
</div>
{/* Control Bar (Matched to Watchlist) */}
<div className="sticky top-4 z-30 bg-black/80 backdrop-blur-md border border-white/10 rounded-xl p-2 flex flex-col md:flex-row gap-4 items-center justify-between shadow-2xl">
{/* Filter Pills */}
<div className="flex items-center gap-2 overflow-x-auto w-full pb-1 md:pb-0 scrollbar-hide">
<FilterToggle
active={hideSpam}
onClick={() => setHideSpam(!hideSpam)}
label="Hide Spam"
icon={Ban}
/>
<div className="w-px h-5 bg-white/10 flex-shrink-0" />
<FilterToggle
active={sourceFilter === 'pounce'}
onClick={() => setSourceFilter(f => f === 'pounce' ? 'all' : 'pounce')}
label="Pounce Only"
icon={Diamond}
/>
{/* TLD Dropdown (Simulated with select) */}
<div className="relative">
<select
value={tldFilter}
onChange={(e) => setTldFilter(e.target.value)}
className="appearance-none bg-black/50 border border-white/10 text-white text-xs font-medium rounded-md pl-3 pr-8 py-1.5 focus:outline-none hover:bg-white/5 cursor-pointer"
>
<option value="all">All TLDs</option>
<option value="com">.com</option>
<option value="ai">.ai</option>
<option value="io">.io</option>
<option value="net">.net</option>
<option value="org">.org</option>
<option value="ch">.ch</option>
<option value="de">.de</option>
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-3 h-3 text-zinc-500 pointer-events-none" />
</div>
<div className="w-px h-5 bg-white/10 flex-shrink-0" />
<FilterToggle
active={priceRange === 'low'}
onClick={() => setPriceRange(p => p === 'low' ? 'all' : 'low')}
label="< $100"
/>
<FilterToggle
active={priceRange === 'mid'}
onClick={() => setPriceRange(p => p === 'mid' ? 'all' : 'mid')}
label="< $1k"
/>
<FilterToggle
active={priceRange === 'high'}
onClick={() => setPriceRange(p => p === 'high' ? 'all' : 'high')}
label="High Roller"
/>
</div> </div>
<div className="text-right">
{/* Refresh Button (Mobile) */} <div className="text-xl font-display text-white">{stats.auctionCount}</div>
<button <div className="text-[9px] tracking-wide text-white/30 font-mono">External</div>
onClick={handleRefresh}
className="md:hidden p-2 text-zinc-400 hover:text-white"
>
<RefreshCw className={clsx("w-4 h-4", refreshing && "animate-spin")} />
</button>
{/* Search Filter */}
<div className="relative w-full md:w-64 flex-shrink-0">
<Search className="absolute left-3 top-2.5 w-4 h-4 text-zinc-500" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search domains..."
className="w-full bg-black/50 border border-white/10 rounded-lg pl-9 pr-4 py-2 text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-white/20 transition-all"
/>
</div> </div>
</div> <div className="text-right">
<div className="text-xl font-display text-amber-400">{stats.highScore}</div>
{/* DATA GRID */} <div className="text-[9px] tracking-wide text-white/30 font-mono">Score 80+</div>
<div className="bg-zinc-900/40 border border-white/5 rounded-xl overflow-hidden backdrop-blur-sm">
{/* Unified Table Header */}
<div className="grid grid-cols-12 gap-4 px-6 py-3 bg-white/[0.02] border-b border-white/5 text-[11px] font-semibold text-zinc-500 uppercase tracking-wider sticky top-0 z-20 backdrop-blur-sm">
<div className="col-span-12 md:col-span-4">
<SortableHeader label="Domain" field="domain" currentSort={sortField} currentDirection={sortDirection} onSort={handleSort} />
</div>
<div className="hidden md:block md:col-span-2 text-center">
<SortableHeader label="Score" field="score" currentSort={sortField} currentDirection={sortDirection} onSort={handleSort} align="center" />
</div>
<div className="hidden md:block md:col-span-2 text-right">
<SortableHeader label="Price / Bid" field="price" currentSort={sortField} currentDirection={sortDirection} onSort={handleSort} align="right" />
</div>
<div className="hidden md:block md:col-span-2 text-center">
<SortableHeader label="Status / Time" field="time" currentSort={sortField} currentDirection={sortDirection} onSort={handleSort} align="center" />
</div>
<div className="hidden md:block md:col-span-2 text-right py-2">Action</div>
</div> </div>
{loading ? (
<div className="flex flex-col items-center justify-center py-32 space-y-4">
<Loader2 className="w-8 h-8 text-emerald-500 animate-spin" />
<p className="text-zinc-500 text-sm animate-pulse">Scanning live markets...</p>
</div>
) : filteredItems.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="w-16 h-16 rounded-full bg-white/5 flex items-center justify-center mb-4">
<Search className="w-8 h-8 text-zinc-600" />
</div>
<h3 className="text-lg font-medium text-white mb-1">No matches found</h3>
<p className="text-zinc-500 text-sm max-w-xs mx-auto">
Try adjusting your filters or search query.
</p>
</div>
) : (
<div className="divide-y divide-white/5">
{filteredItems.map((item) => {
const timeLeftSec = getSecondsUntilEnd(item.end_time)
const isUrgent = timeLeftSec > 0 && timeLeftSec < 3600
const isPounce = item.is_pounce
const displayTime = item.status === 'auction' ? calcTimeRemaining(item.end_time) : null
return (
<div
key={item.id}
className={clsx(
"grid grid-cols-12 gap-4 px-6 py-4 items-center transition-all group relative",
isPounce
? "bg-emerald-500/[0.02] hover:bg-emerald-500/[0.05]"
: "hover:bg-white/[0.04]"
)}
>
{/* Domain */}
<div className="col-span-12 md:col-span-4">
<div className="flex items-center gap-3">
{isPounce ? (
<div className="relative">
<div className="w-8 h-8 rounded bg-emerald-500/10 flex items-center justify-center border border-emerald-500/20">
<Diamond className="w-4 h-4 text-emerald-400 fill-emerald-400/20" />
</div>
</div>
) : (
<div className="w-8 h-8 rounded bg-zinc-800 flex items-center justify-center text-[10px] font-bold text-zinc-500 border border-zinc-700">
{item.source.substring(0, 2).toUpperCase()}
</div>
)}
<div>
<div className="flex items-center gap-2">
<span className="font-mono font-bold text-white text-[15px] tracking-tight">{item.domain}</span>
</div>
<div className="text-[11px] text-zinc-500 mt-0.5 flex items-center gap-1.5">
{item.source}
{isPounce && item.verified && (
<>
<span className="text-zinc-700"></span>
<span className="text-emerald-400 flex items-center gap-1 font-medium">
<ShieldCheck className="w-3 h-3" />
Verified
</span>
</>
)}
{!isPounce && item.num_bids ? `${item.num_bids} bids` : ''}
</div>
</div>
</div>
</div>
{/* Score */}
<div className="hidden md:flex col-span-2 justify-center">
<ScoreDisplay score={item.pounce_score} />
</div>
{/* Price */}
<div className="hidden md:block col-span-2 text-right">
<div className={clsx("font-mono font-medium", isPounce ? "text-emerald-400" : "text-white")}>
{formatPrice(item.price, item.currency)}
</div>
<div className="text-[10px] text-zinc-600 mt-0.5">
{item.price_type === 'bid' ? 'Current Bid' : 'Buy Now'}
</div>
</div>
{/* Status/Time */}
<div className="hidden md:flex col-span-2 justify-center">
{isPounce ? (
<div className="flex items-center gap-1.5 px-2.5 py-1 rounded border text-xs font-medium font-mono text-emerald-400 border-emerald-400/20 bg-emerald-400/5 uppercase tracking-wide">
<Zap className="w-3 h-3 fill-current" />
Instant
</div>
) : (
<div className={clsx(
"flex items-center gap-1.5 px-2.5 py-1 rounded border text-xs font-medium font-mono",
isUrgent
? "text-orange-400 border-orange-400/20 bg-orange-400/5"
: "text-zinc-400 border-zinc-700 bg-zinc-800/50"
)}>
<Clock className="w-3 h-3" />
{displayTime || 'N/A'}
</div>
)}
</div>
{/* Actions */}
<div className="col-span-12 md:col-span-2 flex items-center justify-end gap-3 md:opacity-0 md:group-hover:opacity-100 transition-opacity">
<Tooltip content="Add to Watchlist">
<button
onClick={() => handleTrack(item.domain)}
disabled={trackedDomains.has(item.domain)}
className={clsx(
"w-8 h-8 flex items-center justify-center rounded-lg border transition-all",
trackedDomains.has(item.domain)
? "bg-emerald-500/10 text-emerald-400 border-emerald-500/20 cursor-default"
: "border-zinc-700 bg-zinc-900 text-zinc-400 hover:text-white hover:border-zinc-500"
)}
>
{trackedDomains.has(item.domain) ? <Check className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</Tooltip>
<Link
href={item.url}
target={isPounce ? "_self" : "_blank"}
rel={isPounce ? undefined : "noopener noreferrer"}
className={clsx(
"h-8 px-4 flex items-center gap-2 rounded-lg text-xs font-bold transition-all hover:scale-105 shadow-lg uppercase tracking-wide",
isPounce
? "bg-emerald-500 text-white hover:bg-emerald-400 shadow-emerald-500/20"
: "bg-white text-black hover:bg-zinc-200 shadow-white/10"
)}
>
{isPounce ? 'Buy' : 'Bid'}
{isPounce ? <Zap className="w-3 h-3" /> : <ExternalLink className="w-3 h-3 opacity-50" />}
</Link>
</div>
</div>
)
})}
</div>
)}
</div> </div>
</div> </div>
</div> </section>
</TerminalLayout>
)
}
{/* ═══════════════════════════════════════════════════════════════════════ */}
{/* FILTERS */}
{/* ═══════════════════════════════════════════════════════════════════════ */}
<section className="pb-6 border-b border-white/[0.08]">
<div className="flex flex-wrap items-center gap-2">
{/* Source Filters */}
<button
onClick={() => setSourceFilter('all')}
className={clsx(
"px-3 py-1.5 text-xs font-medium transition-colors",
sourceFilter === 'all' ? "bg-white/10 text-white" : "text-white/40 hover:text-white/60"
)}
>
All
</button>
<button
onClick={() => setSourceFilter('pounce')}
className={clsx(
"px-3 py-1.5 text-xs font-medium transition-colors flex items-center gap-1.5",
sourceFilter === 'pounce' ? "bg-accent/20 text-accent" : "text-white/40 hover:text-white/60"
)}
>
<Diamond className="w-3 h-3" />
Pounce Only
</button>
<div className="w-px h-5 bg-white/10" />
{/* TLD Filter */}
<select
value={tldFilter}
onChange={(e) => setTldFilter(e.target.value)}
className="bg-transparent border border-white/10 text-white text-xs px-3 py-1.5 outline-none hover:border-white/20"
>
<option value="all" className="bg-black">All TLDs</option>
<option value="com" className="bg-black">.com</option>
<option value="ai" className="bg-black">.ai</option>
<option value="io" className="bg-black">.io</option>
<option value="net" className="bg-black">.net</option>
</select>
<div className="w-px h-5 bg-white/10" />
{/* Price Filters */}
{[
{ value: 'low', label: '< $100' },
{ value: 'mid', label: '< $1k' },
{ value: 'high', label: '$1k+' },
].map((item) => (
<button
key={item.value}
onClick={() => setPriceRange(p => p === item.value ? 'all' : item.value as PriceRange)}
className={clsx(
"px-3 py-1.5 text-xs font-medium transition-colors",
priceRange === item.value ? "bg-white/10 text-white" : "text-white/40 hover:text-white/60"
)}
>
{item.label}
</button>
))}
<div className="w-px h-5 bg-white/10" />
{/* Hide Spam */}
<button
onClick={() => setHideSpam(!hideSpam)}
className={clsx(
"px-3 py-1.5 text-xs font-medium transition-colors flex items-center gap-1.5",
hideSpam ? "bg-white/10 text-white" : "text-white/40 hover:text-white/60"
)}
>
<Ban className="w-3 h-3" />
Hide spam
</button>
{/* Search - Right aligned */}
<div className="flex-1" />
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/30" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search domains..."
className="bg-[#050505] border border-white/10 pl-9 pr-4 py-2 text-sm text-white placeholder:text-white/25 outline-none focus:border-accent/40 w-48 lg:w-64"
/>
</div>
{/* Refresh */}
<button
onClick={handleRefresh}
disabled={refreshing}
className="p-2 text-white/30 hover:text-white transition-colors"
>
<RefreshCw className={clsx("w-4 h-4", refreshing && "animate-spin")} />
</button>
</div>
</section>
{/* ═══════════════════════════════════════════════════════════════════════ */}
{/* TABLE */}
{/* ═══════════════════════════════════════════════════════════════════════ */}
<section className="py-6">
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 text-accent animate-spin" />
</div>
) : filteredItems.length === 0 ? (
<div className="text-center py-16">
<div className="w-14 h-14 mx-auto bg-white/[0.02] border border-white/10 rounded-lg flex items-center justify-center mb-4">
<Search className="w-6 h-6 text-white/20" />
</div>
<p className="text-white/40 text-sm">No domains found</p>
<p className="text-white/25 text-xs mt-1">Try adjusting your filters</p>
</div>
) : (
<div className="space-y-px">
{/* Table Header */}
<div className="hidden lg:grid grid-cols-[1fr_80px_100px_100px_120px] gap-4 px-4 py-2 text-xs text-white/40 border-b border-white/[0.06]">
<div>Domain</div>
<div className="text-center">Score</div>
<div className="text-right">Price</div>
<div className="text-center">Time</div>
<div className="text-right">Actions</div>
</div>
{/* Rows */}
{filteredItems.map((item) => {
const timeLeftSec = getSecondsUntilEnd(item.end_time)
const isUrgent = timeLeftSec > 0 && timeLeftSec < 3600
const isPounce = item.is_pounce
const displayTime = item.status === 'auction' ? calcTimeRemaining(item.end_time) : null
const isTracked = trackedDomains.has(item.domain)
const isTracking = trackingInProgress === item.domain
return (
<div
key={item.id}
className={clsx(
"group border border-white/[0.05] hover:border-white/[0.08] transition-all",
isPounce ? "bg-accent/[0.02]" : "bg-white/[0.01]"
)}
>
{/* Mobile */}
<div className="lg:hidden p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
{isPounce ? (
<Diamond className="w-4 h-4 text-accent" />
) : (
<span className="text-[10px] font-mono text-white/30">{item.source.substring(0, 3)}</span>
)}
<span className="text-sm font-medium text-white">{item.domain}</span>
</div>
<span className={clsx(
"text-sm font-mono font-medium",
isPounce ? "text-accent" : "text-white"
)}>
{formatPrice(item.price)}
</span>
</div>
<div className="flex items-center justify-between text-xs text-white/40">
<span>Score: {item.pounce_score}</span>
<span>{displayTime || 'Instant'}</span>
</div>
</div>
{/* Desktop */}
<div className="hidden lg:grid grid-cols-[1fr_80px_100px_100px_120px] gap-4 items-center px-4 py-3">
{/* Domain */}
<div className="flex items-center gap-3 min-w-0">
{isPounce ? (
<div className="w-8 h-8 bg-accent/10 border border-accent/20 flex items-center justify-center shrink-0">
<Diamond className="w-4 h-4 text-accent" />
</div>
) : (
<div className="w-8 h-8 bg-white/5 border border-white/10 flex items-center justify-center text-[10px] font-mono text-white/40 shrink-0">
{item.source.substring(0, 2).toUpperCase()}
</div>
)}
<div className="min-w-0">
<div className="text-sm font-medium text-white truncate">{item.domain}</div>
<div className="text-[10px] text-white/30 flex items-center gap-1.5">
{item.source}
{isPounce && item.verified && (
<>
<span className="text-white/20">·</span>
<span className="text-accent flex items-center gap-0.5">
<ShieldCheck className="w-3 h-3" />
Verified
</span>
</>
)}
{item.num_bids ? <><span className="text-white/20">·</span>{item.num_bids} bids</> : null}
</div>
</div>
</div>
{/* Score */}
<div className="text-center">
<span className={clsx(
"text-xs font-mono font-medium px-2 py-0.5",
item.pounce_score >= 80 ? "text-accent bg-accent/10" :
item.pounce_score >= 50 ? "text-amber-400 bg-amber-400/10" :
"text-white/40 bg-white/5"
)}>
{item.pounce_score}
</span>
</div>
{/* Price */}
<div className="text-right">
<div className={clsx(
"font-mono text-sm font-medium",
isPounce ? "text-accent" : "text-white"
)}>
{formatPrice(item.price)}
</div>
<div className="text-[10px] text-white/30">
{item.price_type === 'bid' ? 'Bid' : 'Buy Now'}
</div>
</div>
{/* Time */}
<div className="text-center">
{isPounce ? (
<span className="text-xs text-accent font-medium flex items-center justify-center gap-1">
<Zap className="w-3 h-3" />
Instant
</span>
) : (
<span className={clsx(
"text-xs font-mono",
isUrgent ? "text-orange-400" : "text-white/50"
)}>
{displayTime || 'N/A'}
</span>
)}
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-2 opacity-50 group-hover:opacity-100 transition-opacity">
<button
onClick={() => handleTrack(item.domain)}
disabled={isTracked || isTracking}
className={clsx(
"w-7 h-7 flex items-center justify-center border transition-colors",
isTracked
? "bg-accent/10 text-accent border-accent/20"
: "text-white/30 border-white/10 hover:text-white hover:bg-white/5"
)}
>
{isTracking ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : isTracked ? (
<Check className="w-3.5 h-3.5" />
) : (
<Eye className="w-3.5 h-3.5" />
)}
</button>
<a
href={item.url}
target={isPounce ? "_self" : "_blank"}
rel={isPounce ? undefined : "noopener noreferrer"}
className={clsx(
"h-7 px-3 flex items-center gap-1.5 text-xs font-semibold transition-colors",
isPounce
? "bg-accent text-black hover:bg-white"
: "bg-white/10 text-white hover:bg-white/20"
)}
>
{isPounce ? 'Buy' : 'Bid'}
{!isPounce && <ExternalLink className="w-3 h-3" />}
</a>
</div>
</div>
</div>
)
})}
</div>
)}
</section>
</CommandCenterLayout>
)
}

View File

@ -120,9 +120,19 @@ export default function WatchlistPage() {
if (!newDomain.trim()) return if (!newDomain.trim()) return
setAdding(true) setAdding(true)
try { try {
await addDomain(newDomain.trim()) const result = await addDomain(newDomain.trim())
showToast(`Target locked: ${newDomain.trim()}`, 'success') showToast(`Added: ${newDomain.trim()}`, 'success')
setNewDomain('') setNewDomain('')
// Trigger health check for the newly added domain
if (result?.id) {
setLoadingHealth(prev => ({ ...prev, [result.id]: true }))
try {
const report = await api.getDomainHealth(result.id, { refresh: true })
setHealthReports(prev => ({ ...prev, [result.id]: report }))
} catch {}
finally { setLoadingHealth(prev => ({ ...prev, [result.id]: false })) }
}
} catch (err: any) { } catch (err: any) {
showToast(err.message || 'Failed', 'error') showToast(err.message || 'Failed', 'error')
} finally { } finally {