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
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:
@ -3,37 +3,27 @@
|
||||
import { useEffect, useState, useMemo, useCallback, memo } from 'react'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { api } from '@/lib/api'
|
||||
import { TerminalLayout } from '@/components/TerminalLayout'
|
||||
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
||||
import {
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Diamond,
|
||||
Timer,
|
||||
Zap,
|
||||
Filter,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Plus,
|
||||
Check,
|
||||
TrendingUp,
|
||||
RefreshCw,
|
||||
ArrowUpDown,
|
||||
Activity,
|
||||
Flame,
|
||||
Clock,
|
||||
Search,
|
||||
LayoutGrid,
|
||||
List,
|
||||
SlidersHorizontal,
|
||||
MoreHorizontal,
|
||||
Eye,
|
||||
Info,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Store,
|
||||
DollarSign,
|
||||
Gavel,
|
||||
Ban
|
||||
Ban,
|
||||
Activity,
|
||||
Target,
|
||||
ArrowRight
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import Link from 'next/link'
|
||||
@ -69,25 +59,9 @@ type SourceFilter = 'all' | 'pounce' | 'external'
|
||||
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 {
|
||||
if (!endTimeIso) return 'N/A'
|
||||
const end = new Date(endTimeIso).getTime()
|
||||
@ -107,9 +81,6 @@ function calcTimeRemaining(endTimeIso?: string): string {
|
||||
return '< 1m'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get seconds until end from ISO string (for sorting/urgency).
|
||||
*/
|
||||
function getSecondsUntilEnd(endTimeIso?: string): number {
|
||||
if (!endTimeIso) return Infinity
|
||||
const diff = new Date(endTimeIso).getTime() - Date.now()
|
||||
@ -125,180 +96,10 @@ function formatPrice(price: number, currency = 'USD'): string {
|
||||
}
|
||||
|
||||
function isSpam(domain: string): boolean {
|
||||
// Check for hyphens or numbers in the name part (excluding TLD)
|
||||
const name = domain.split('.')[0]
|
||||
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
|
||||
// ============================================================================
|
||||
@ -306,29 +107,23 @@ SortableHeader.displayName = 'SortableHeader'
|
||||
export default function MarketPage() {
|
||||
const { subscription } = useStore()
|
||||
|
||||
// Data
|
||||
const [items, setItems] = useState<MarketItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [stats, setStats] = useState({ total: 0, pounceCount: 0, auctionCount: 0, highScore: 0 })
|
||||
|
||||
// Filters
|
||||
const [sourceFilter, setSourceFilter] = useState<SourceFilter>('all')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [priceRange, setPriceRange] = useState<PriceRange>('all')
|
||||
const [verifiedOnly, setVerifiedOnly] = useState(false)
|
||||
const [hideSpam, setHideSpam] = useState(true)
|
||||
const [tldFilter, setTldFilter] = useState<string>('all')
|
||||
|
||||
// Sort
|
||||
const [sortField, setSortField] = useState<SortField>('score')
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc')
|
||||
|
||||
// Watchlist
|
||||
const [trackedDomains, setTrackedDomains] = useState<Set<string>>(new Set())
|
||||
const [trackingInProgress, setTrackingInProgress] = useState<string | null>(null)
|
||||
|
||||
// Load data
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
@ -338,7 +133,6 @@ export default function MarketPage() {
|
||||
tld: tldFilter === 'all' ? undefined : tldFilter,
|
||||
minPrice: priceRange === 'low' ? undefined : priceRange === 'mid' ? 100 : priceRange === 'high' ? 1000 : undefined,
|
||||
maxPrice: priceRange === 'low' ? 100 : priceRange === 'mid' ? 1000 : undefined,
|
||||
verifiedOnly,
|
||||
sortBy: sortField === 'score' ? 'score' :
|
||||
sortField === 'price' ? (sortDirection === 'asc' ? 'price_asc' : 'price_desc') :
|
||||
sortField === 'time' ? 'time' : 'newest',
|
||||
@ -358,7 +152,7 @@ export default function MarketPage() {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [sourceFilter, searchQuery, priceRange, verifiedOnly, sortField, sortDirection, tldFilter])
|
||||
}, [sourceFilter, searchQuery, priceRange, sortField, sortDirection, tldFilter])
|
||||
|
||||
useEffect(() => { loadData() }, [loadData])
|
||||
|
||||
@ -390,36 +184,29 @@ export default function MarketPage() {
|
||||
}
|
||||
}, [trackedDomains, trackingInProgress])
|
||||
|
||||
// Client-side filtering for immediate UI feedback & SPAM FILTER
|
||||
const filteredItems = useMemo(() => {
|
||||
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()
|
||||
filtered = filtered.filter(item => {
|
||||
if (item.status !== 'auction') return true
|
||||
if (!item.end_time) return true
|
||||
const t = Date.parse(item.end_time)
|
||||
if (Number.isNaN(t)) return true
|
||||
return t > (nowMs - 2000) // 2s grace
|
||||
return t > (nowMs - 2000)
|
||||
})
|
||||
|
||||
// Additional client-side search
|
||||
if (searchQuery && !loading) {
|
||||
const query = searchQuery.toLowerCase()
|
||||
filtered = filtered.filter(item => item.domain.toLowerCase().includes(query))
|
||||
}
|
||||
|
||||
// Hide Spam (Client-side)
|
||||
if (hideSpam) {
|
||||
filtered = filtered.filter(item => !isSpam(item.domain))
|
||||
}
|
||||
|
||||
// Sort
|
||||
const mult = sortDirection === 'asc' ? 1 : -1
|
||||
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) {
|
||||
return a.is_pounce ? -1 : 1
|
||||
}
|
||||
@ -438,311 +225,324 @@ export default function MarketPage() {
|
||||
}, [items, searchQuery, sortField, sortDirection, loading, hideSpam])
|
||||
|
||||
return (
|
||||
<TerminalLayout hideHeaderSearch={true}>
|
||||
<div className="relative font-sans text-zinc-100 selection:bg-emerald-500/30">
|
||||
|
||||
{/* Ambient Background Glow (Matched to Watchlist) */}
|
||||
<div className="fixed inset-0 pointer-events-none overflow-hidden">
|
||||
<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="absolute bottom-0 left-1/4 w-[600px] h-[500px] bg-blue-500/5 rounded-full blur-[100px] mix-blend-screen" />
|
||||
<CommandCenterLayout minimal>
|
||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||
{/* HEADER */}
|
||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||
<section className="pt-6 lg:pt-8 pb-6">
|
||||
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6">
|
||||
<div className="space-y-3">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<Gavel className="w-4 h-4 text-accent" />
|
||||
<span className="text-[10px] font-mono tracking-wide text-accent">Live Auctions</span>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-[1600px] mx-auto p-4 md:p-8 space-y-8">
|
||||
|
||||
{/* 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>
|
||||
<h1 className="font-display text-[2rem] lg:text-[2.5rem] leading-[1] tracking-[-0.02em]">
|
||||
<span className="text-white">Market</span>
|
||||
<span className="text-white/30 ml-3">{stats.total}</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats Pills */}
|
||||
<div className="flex gap-2">
|
||||
<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">
|
||||
<Diamond className="w-3.5 h-3.5 text-emerald-400" />
|
||||
{stats.pounceCount} Exclusive
|
||||
<div className="flex gap-6 lg:gap-8">
|
||||
<div className="text-right">
|
||||
<div className="text-xl font-display text-accent">{stats.pounceCount}</div>
|
||||
<div className="text-[9px] tracking-wide text-white/30 font-mono">Pounce Direct</div>
|
||||
</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 className="text-right">
|
||||
<div className="text-xl font-display text-white">{stats.auctionCount}</div>
|
||||
<div className="text-[9px] tracking-wide text-white/30 font-mono">External</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xl font-display text-amber-400">{stats.highScore}</div>
|
||||
<div className="text-[9px] tracking-wide text-white/30 font-mono">Score 80+</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Metric Grid (Matched to Watchlist) */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label="Total Opportunities"
|
||||
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>
|
||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||
{/* 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>
|
||||
|
||||
{/* 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}
|
||||
/>
|
||||
<div className="w-px h-5 bg-white/10" />
|
||||
|
||||
{/* TLD Dropdown (Simulated with select) */}
|
||||
<div className="relative">
|
||||
{/* TLD Filter */}
|
||||
<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"
|
||||
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">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>
|
||||
<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>
|
||||
<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" />
|
||||
<div className="w-px h-5 bg-white/10" />
|
||||
|
||||
<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>
|
||||
|
||||
{/* Refresh Button (Mobile) */}
|
||||
{/* Price Filters */}
|
||||
{[
|
||||
{ value: 'low', label: '< $100' },
|
||||
{ value: 'mid', label: '< $1k' },
|
||||
{ value: 'high', label: '$1k+' },
|
||||
].map((item) => (
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="md:hidden p-2 text-zinc-400 hover:text-white"
|
||||
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"
|
||||
)}
|
||||
>
|
||||
<RefreshCw className={clsx("w-4 h-4", refreshing && "animate-spin")} />
|
||||
{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 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" />
|
||||
{/* 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="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"
|
||||
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>
|
||||
</div>
|
||||
|
||||
{/* DATA GRID */}
|
||||
<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>
|
||||
{/* 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 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 className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-6 h-6 text-accent animate-spin" />
|
||||
</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 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>
|
||||
<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>
|
||||
<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="divide-y divide-white/5">
|
||||
<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(
|
||||
"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]"
|
||||
"group border border-white/[0.05] hover:border-white/[0.08] transition-all",
|
||||
isPounce ? "bg-accent/[0.02]" : "bg-white/[0.01]"
|
||||
)}
|
||||
>
|
||||
{/* Domain */}
|
||||
<div className="col-span-12 md:col-span-4">
|
||||
{/* Mobile */}
|
||||
<div className="lg:hidden p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<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" />
|
||||
<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 rounded bg-zinc-800 flex items-center justify-center text-[10px] font-bold text-zinc-500 border border-zinc-700">
|
||||
<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>
|
||||
<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">
|
||||
<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-zinc-700">•</span>
|
||||
<span className="text-emerald-400 flex items-center gap-1 font-medium">
|
||||
<span className="text-white/20">·</span>
|
||||
<span className="text-accent flex items-center gap-0.5">
|
||||
<ShieldCheck className="w-3 h-3" />
|
||||
Verified
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{!isPounce && item.num_bids ? `• ${item.num_bids} bids` : ''}
|
||||
</div>
|
||||
{item.num_bids ? <><span className="text-white/20">·</span>{item.num_bids} bids</> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Score */}
|
||||
<div className="hidden md:flex col-span-2 justify-center">
|
||||
<ScoreDisplay score={item.pounce_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="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 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-zinc-600 mt-0.5">
|
||||
{item.price_type === 'bid' ? 'Current Bid' : 'Buy Now'}
|
||||
<div className="text-[10px] text-white/30">
|
||||
{item.price_type === 'bid' ? 'Bid' : 'Buy Now'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status/Time */}
|
||||
<div className="hidden md:flex col-span-2 justify-center">
|
||||
{/* Time */}
|
||||
<div className="text-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" />
|
||||
<span className="text-xs text-accent font-medium flex items-center justify-center gap-1">
|
||||
<Zap className="w-3 h-3" />
|
||||
Instant
|
||||
</div>
|
||||
</span>
|
||||
) : (
|
||||
<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"
|
||||
<span className={clsx(
|
||||
"text-xs font-mono",
|
||||
isUrgent ? "text-orange-400" : "text-white/50"
|
||||
)}>
|
||||
<Clock className="w-3 h-3" />
|
||||
{displayTime || 'N/A'}
|
||||
</div>
|
||||
</span>
|
||||
)}
|
||||
</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">
|
||||
<div className="flex items-center justify-end gap-2 opacity-50 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => handleTrack(item.domain)}
|
||||
disabled={trackedDomains.has(item.domain)}
|
||||
disabled={isTracked || isTracking}
|
||||
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"
|
||||
"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"
|
||||
)}
|
||||
>
|
||||
{trackedDomains.has(item.domain) ? <Check className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
{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>
|
||||
</Tooltip>
|
||||
|
||||
<Link
|
||||
<a
|
||||
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",
|
||||
"h-7 px-3 flex items-center gap-1.5 text-xs font-semibold transition-colors",
|
||||
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"
|
||||
? "bg-accent text-black hover:bg-white"
|
||||
: "bg-white/10 text-white hover:bg-white/20"
|
||||
)}
|
||||
>
|
||||
{isPounce ? 'Buy' : 'Bid'}
|
||||
{isPounce ? <Zap className="w-3 h-3" /> : <ExternalLink className="w-3 h-3 opacity-50" />}
|
||||
</Link>
|
||||
{!isPounce && <ExternalLink className="w-3 h-3" />}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TerminalLayout>
|
||||
</section>
|
||||
</CommandCenterLayout>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -120,9 +120,19 @@ export default function WatchlistPage() {
|
||||
if (!newDomain.trim()) return
|
||||
setAdding(true)
|
||||
try {
|
||||
await addDomain(newDomain.trim())
|
||||
showToast(`Target locked: ${newDomain.trim()}`, 'success')
|
||||
const result = await addDomain(newDomain.trim())
|
||||
showToast(`Added: ${newDomain.trim()}`, 'success')
|
||||
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) {
|
||||
showToast(err.message || 'Failed', 'error')
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user