feat: MARKET - Complete UI Overhaul (Award-Winning Style)
Changes: - Redesigned Metric Grid with trend indicators - New separated Control Bar for search & filters - High-end Data Grid with ultra-thin borders and hover effects - Custom SVG 'Score Ring' component for Pounce Score - Modern typography and spacing - Removed 'clutter' badges, replaced with minimal indicators
This commit is contained in:
@ -21,6 +21,10 @@ import {
|
||||
Activity,
|
||||
Flame,
|
||||
Clock,
|
||||
Search,
|
||||
LayoutGrid,
|
||||
List,
|
||||
SlidersHorizontal
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
@ -131,143 +135,123 @@ function parseTimeToSeconds(timeStr?: string): number {
|
||||
// COMPONENTS
|
||||
// ============================================================================
|
||||
|
||||
// Score Badge with color coding
|
||||
function ScoreBadge({ score }: { score: number }) {
|
||||
const color = score >= 80
|
||||
? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30'
|
||||
: score >= 40
|
||||
? 'bg-amber-500/20 text-amber-400 border-amber-500/30'
|
||||
: 'bg-red-500/20 text-red-400 border-red-500/30'
|
||||
|
||||
// Stat Card
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
subValue,
|
||||
icon: Icon,
|
||||
trend
|
||||
}: {
|
||||
label: string
|
||||
value: string | number
|
||||
subValue?: string
|
||||
icon: any
|
||||
trend?: 'up' | 'down' | 'neutral'
|
||||
}) {
|
||||
return (
|
||||
<div className={clsx(
|
||||
"inline-flex items-center justify-center w-12 h-8 rounded-md border font-mono text-sm font-bold",
|
||||
color
|
||||
)}>
|
||||
{score}
|
||||
<div className="bg-zinc-900/40 border border-white/5 rounded-xl p-4 flex items-start justify-between group hover:border-white/10 transition-all">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-zinc-500 uppercase tracking-wider mb-1">{label}</p>
|
||||
<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>
|
||||
</div>
|
||||
<div className={clsx(
|
||||
"p-2 rounded-lg bg-zinc-800/50 text-zinc-400 group-hover:text-white transition-colors",
|
||||
trend === 'up' && "text-emerald-400 bg-emerald-500/10",
|
||||
trend === 'down' && "text-red-400 bg-red-500/10"
|
||||
)}>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Source Badge
|
||||
function SourceBadge({ source, isPounce }: { source: string; isPounce: boolean }) {
|
||||
if (isPounce) {
|
||||
return (
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-emerald-500/10 border border-emerald-500/30 rounded-md">
|
||||
<Diamond className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span className="text-xs font-semibold text-emerald-400">Pounce</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const colors: Record<string, string> = {
|
||||
GoDaddy: 'bg-orange-500/10 border-orange-500/20 text-orange-400',
|
||||
Sedo: 'bg-blue-500/10 border-blue-500/20 text-blue-400',
|
||||
NameJet: 'bg-purple-500/10 border-purple-500/20 text-purple-400',
|
||||
DropCatch: 'bg-cyan-500/10 border-cyan-500/20 text-cyan-400',
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={clsx(
|
||||
"inline-flex items-center px-2.5 py-1 rounded-md border text-xs font-medium",
|
||||
colors[source] || 'bg-zinc-800 border-zinc-700 text-zinc-400'
|
||||
)}>
|
||||
{source}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
// Modern Score Indicator
|
||||
function ScoreRing({ score }: { score: number }) {
|
||||
const color = score >= 80 ? 'text-emerald-500' : score >= 50 ? 'text-amber-500' : 'text-zinc-600'
|
||||
const size = 32
|
||||
const strokeWidth = 3
|
||||
const radius = (size - strokeWidth) / 2
|
||||
const circumference = radius * 2 * Math.PI
|
||||
const offset = circumference - (score / 100) * circumference
|
||||
|
||||
// Status Badge
|
||||
function StatusBadge({ status, timeLeft }: { status: 'auction' | 'instant'; timeLeft?: string }) {
|
||||
if (status === 'instant') {
|
||||
return (
|
||||
<div className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-emerald-500/20 border border-emerald-500/30 rounded-md">
|
||||
<Zap className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span className="text-xs font-bold text-emerald-400">Instant</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isUrgent = timeLeft?.includes('m') && !timeLeft?.includes('d') && !timeLeft?.includes('h')
|
||||
const isWarning = timeLeft?.includes('h') && parseInt(timeLeft) <= 4
|
||||
|
||||
return (
|
||||
<div className={clsx(
|
||||
"inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md border",
|
||||
isUrgent ? "bg-red-500/20 border-red-500/30" :
|
||||
isWarning ? "bg-amber-500/20 border-amber-500/30" :
|
||||
"bg-zinc-800 border-zinc-700"
|
||||
)}>
|
||||
<Timer className={clsx(
|
||||
"w-3.5 h-3.5",
|
||||
isUrgent ? "text-red-400" : isWarning ? "text-amber-400" : "text-zinc-400"
|
||||
)} />
|
||||
<span className={clsx(
|
||||
"text-xs font-medium",
|
||||
isUrgent ? "text-red-400" : isWarning ? "text-amber-400" : "text-zinc-400"
|
||||
)}>
|
||||
{timeLeft}
|
||||
<div className="relative flex items-center justify-center" style={{ width: size, height: size }}>
|
||||
{/* Background Ring */}
|
||||
<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"
|
||||
/>
|
||||
{/* Progress Ring */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
className={clsx("transition-all duration-500 ease-out", color)}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
<span className={clsx("text-[10px] font-bold font-mono", score >= 80 ? 'text-emerald-400' : 'text-zinc-400')}>
|
||||
{score}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Toggle Button
|
||||
function ToggleButton({
|
||||
// Refined Source Badge
|
||||
function SourceBadge({ source, isPounce }: { source: string; isPounce: boolean }) {
|
||||
if (isPounce) return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]" />
|
||||
<span className="text-xs font-semibold text-white tracking-tight">Pounce</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<span className="text-xs font-medium text-zinc-500 hover:text-zinc-300 transition-colors">
|
||||
{source}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Minimal Toggle
|
||||
function FilterToggle({
|
||||
active,
|
||||
onClick,
|
||||
children
|
||||
label
|
||||
}: {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
children: React.ReactNode
|
||||
label: string
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all",
|
||||
"px-3 py-1.5 rounded-full text-xs font-medium transition-all border",
|
||||
active
|
||||
? "bg-emerald-500/20 text-emerald-400 border border-emerald-500/30"
|
||||
: "bg-zinc-800/50 text-zinc-400 border border-zinc-700/50 hover:bg-zinc-800 hover:text-zinc-300"
|
||||
? "bg-white text-zinc-950 border-white"
|
||||
: "bg-transparent text-zinc-400 border-zinc-800 hover:border-zinc-700 hover:text-zinc-300"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
{active && <Check className="w-3.5 h-3.5" />}
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Dropdown Select
|
||||
function DropdownSelect({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
options: { value: string; label: string }[]
|
||||
}) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="appearance-none px-4 py-2 pr-10 bg-zinc-800/50 border border-zinc-700/50 rounded-lg
|
||||
text-sm text-zinc-300 font-medium cursor-pointer
|
||||
hover:bg-zinc-800 hover:border-zinc-600 transition-all
|
||||
focus:outline-none focus:border-emerald-500/50"
|
||||
>
|
||||
{options.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500 pointer-events-none" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Sortable Column Header
|
||||
// Sort Header
|
||||
function SortableHeader({
|
||||
label,
|
||||
field,
|
||||
@ -289,28 +273,23 @@ function SortableHeader({
|
||||
<button
|
||||
onClick={() => onSort(field)}
|
||||
className={clsx(
|
||||
"flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider transition-colors group",
|
||||
"flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest transition-all group select-none",
|
||||
align === 'right' && "justify-end ml-auto",
|
||||
align === 'center' && "justify-center mx-auto",
|
||||
isActive ? "text-emerald-400" : "text-zinc-500 hover:text-zinc-300"
|
||||
isActive ? "text-white" : "text-zinc-500 hover:text-zinc-300"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
<span className={clsx("transition-all", isActive ? "opacity-100" : "opacity-0 group-hover:opacity-50")}>
|
||||
{isActive && currentDirection === 'desc' ? (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
) : isActive && currentDirection === 'asc' ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ArrowUpDown className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</span>
|
||||
<div className={clsx("flex flex-col -space-y-1", isActive ? "opacity-100" : "opacity-0 group-hover:opacity-50")}>
|
||||
<ChevronUp className={clsx("w-2 h-2", isActive && currentDirection === 'asc' ? "text-white" : "text-zinc-600")} />
|
||||
<ChevronDown className={clsx("w-2 h-2", isActive && currentDirection === 'desc' ? "text-white" : "text-zinc-600")} />
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN COMPONENT
|
||||
// MAIN PAGE
|
||||
// ============================================================================
|
||||
|
||||
export default function MarketPage() {
|
||||
@ -324,35 +303,17 @@ export default function MarketPage() {
|
||||
// Filter State
|
||||
const [hideSpam, setHideSpam] = useState(true)
|
||||
const [pounceOnly, setPounceOnly] = useState(false)
|
||||
const [selectedTld, setSelectedTld] = useState('all')
|
||||
const [selectedPrice, setSelectedPrice] = useState('all')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [priceRange, setPriceRange] = useState<'all' | 'low' | 'mid' | 'high'>('all')
|
||||
|
||||
// Sort State
|
||||
const [sortField, setSortField] = useState<SortField>('score')
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc')
|
||||
|
||||
// Watchlist State
|
||||
// Watchlist
|
||||
const [trackedDomains, setTrackedDomains] = useState<Set<string>>(new Set())
|
||||
const [trackingInProgress, setTrackingInProgress] = useState<string | null>(null)
|
||||
|
||||
// Options
|
||||
const TLD_OPTIONS = [
|
||||
{ value: 'all', label: 'All TLDs' },
|
||||
{ value: 'com', label: '.com' },
|
||||
{ value: 'ai', label: '.ai' },
|
||||
{ value: 'io', label: '.io' },
|
||||
{ value: 'ch', label: '.ch' },
|
||||
{ value: 'net', label: '.net' },
|
||||
]
|
||||
|
||||
const PRICE_OPTIONS = [
|
||||
{ value: 'all', label: 'Any Price' },
|
||||
{ value: '100', label: '< $100' },
|
||||
{ value: '1000', label: '< $1,000' },
|
||||
{ value: '10000', label: 'High Roller' },
|
||||
]
|
||||
|
||||
// Load Data
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@ -381,14 +342,12 @@ export default function MarketPage() {
|
||||
setSortDirection(d => d === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortField(field)
|
||||
// Default direction based on field type
|
||||
setSortDirection(field === 'score' || field === 'price' ? 'desc' : 'asc')
|
||||
}
|
||||
}, [sortField])
|
||||
|
||||
const handleTrack = useCallback(async (domain: string) => {
|
||||
if (trackedDomains.has(domain) || trackingInProgress) return
|
||||
|
||||
setTrackingInProgress(domain)
|
||||
try {
|
||||
await api.addDomain(domain)
|
||||
@ -400,9 +359,8 @@ export default function MarketPage() {
|
||||
}
|
||||
}, [trackedDomains, trackingInProgress])
|
||||
|
||||
// Transform and Filter Data
|
||||
// Transform Data
|
||||
const marketItems = useMemo(() => {
|
||||
// Convert auctions to market items
|
||||
const items: MarketItem[] = auctions.map(auction => ({
|
||||
id: `${auction.domain}-${auction.platform}`,
|
||||
domain: auction.domain,
|
||||
@ -419,368 +377,276 @@ export default function MarketPage() {
|
||||
numBids: auction.num_bids,
|
||||
}))
|
||||
|
||||
// Apply Filters
|
||||
let filtered = items
|
||||
|
||||
if (hideSpam) {
|
||||
filtered = filtered.filter(item => !isSpamDomain(item.domain, item.tld))
|
||||
}
|
||||
|
||||
if (pounceOnly) {
|
||||
filtered = filtered.filter(item => item.isPounce)
|
||||
}
|
||||
|
||||
if (selectedTld !== 'all') {
|
||||
filtered = filtered.filter(item => item.tld === selectedTld)
|
||||
}
|
||||
|
||||
if (selectedPrice !== 'all') {
|
||||
const maxPrice = parseInt(selectedPrice)
|
||||
if (selectedPrice === '10000') {
|
||||
filtered = filtered.filter(item => item.price >= 10000)
|
||||
} else {
|
||||
filtered = filtered.filter(item => item.price < maxPrice)
|
||||
}
|
||||
}
|
||||
|
||||
// Filters
|
||||
if (hideSpam) filtered = filtered.filter(item => !isSpamDomain(item.domain, item.tld))
|
||||
if (pounceOnly) filtered = filtered.filter(item => item.isPounce)
|
||||
if (priceRange === 'low') filtered = filtered.filter(item => item.price < 100)
|
||||
if (priceRange === 'mid') filtered = filtered.filter(item => item.price >= 100 && item.price < 1000)
|
||||
if (priceRange === 'high') filtered = filtered.filter(item => item.price >= 1000)
|
||||
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase()
|
||||
filtered = filtered.filter(item => item.domain.toLowerCase().includes(q))
|
||||
}
|
||||
|
||||
// Apply Sort
|
||||
// Sort
|
||||
const mult = sortDirection === 'asc' ? 1 : -1
|
||||
filtered.sort((a, b) => {
|
||||
switch (sortField) {
|
||||
case 'domain':
|
||||
return mult * a.domain.localeCompare(b.domain)
|
||||
case 'score':
|
||||
return mult * (a.pounceScore - b.pounceScore)
|
||||
case 'price':
|
||||
return mult * (a.price - b.price)
|
||||
case 'time':
|
||||
return mult * (parseTimeToSeconds(a.timeLeft) - parseTimeToSeconds(b.timeLeft))
|
||||
case 'source':
|
||||
return mult * a.source.localeCompare(b.source)
|
||||
default:
|
||||
return 0
|
||||
case 'domain': return mult * a.domain.localeCompare(b.domain)
|
||||
case 'score': return mult * (a.pounceScore - b.pounceScore)
|
||||
case 'price': return mult * (a.price - b.price)
|
||||
case 'time': return mult * (parseTimeToSeconds(a.timeLeft) - parseTimeToSeconds(b.timeLeft))
|
||||
case 'source': return mult * a.source.localeCompare(b.source)
|
||||
default: return 0
|
||||
}
|
||||
})
|
||||
|
||||
return filtered
|
||||
}, [auctions, hideSpam, pounceOnly, selectedTld, selectedPrice, searchQuery, sortField, sortDirection])
|
||||
}, [auctions, hideSpam, pounceOnly, priceRange, searchQuery, sortField, sortDirection])
|
||||
|
||||
// Stats
|
||||
const stats = useMemo(() => ({
|
||||
total: marketItems.length,
|
||||
highScore: marketItems.filter(i => i.pounceScore >= 80).length,
|
||||
endingSoon: marketItems.filter(i => {
|
||||
const seconds = parseTimeToSeconds(i.timeLeft)
|
||||
return seconds < 3600 // Less than 1 hour
|
||||
}).length,
|
||||
endingSoon: marketItems.filter(i => parseTimeToSeconds(i.timeLeft) < 3600).length,
|
||||
avgPrice: marketItems.length > 0
|
||||
? Math.round(marketItems.reduce((acc, i) => acc + i.price, 0) / marketItems.length)
|
||||
: 0
|
||||
}), [marketItems])
|
||||
|
||||
// Format currency
|
||||
const formatPrice = (price: number) => {
|
||||
if (price >= 1000000) return `$${(price / 1000000).toFixed(1)}M`
|
||||
if (price >= 1000) return `$${(price / 1000).toFixed(1)}k`
|
||||
return `$${price.toLocaleString()}`
|
||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(price)
|
||||
}
|
||||
|
||||
return (
|
||||
<TerminalLayout title="Market" subtitle="">
|
||||
<div className="space-y-6">
|
||||
<TerminalLayout title="Market" subtitle="Real-time Domain Auctions">
|
||||
<div className="space-y-8">
|
||||
|
||||
{/* ================================================================ */}
|
||||
{/* HEADER - Live Feed Style */}
|
||||
{/* ================================================================ */}
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Left: Title with Live Indicator */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<Activity className="w-5 h-5 text-emerald-400" />
|
||||
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-emerald-400 rounded-full animate-pulse" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-white">Live Market Feed</h1>
|
||||
<p className="text-xs text-zinc-500">Updated in real-time</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats Pills */}
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-zinc-800/50 border border-zinc-700/50 rounded-full">
|
||||
<span className="text-xs text-zinc-400">{stats.total}</span>
|
||||
<span className="text-xs text-zinc-600">listings</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-emerald-500/10 border border-emerald-500/20 rounded-full">
|
||||
<TrendingUp className="w-3 h-3 text-emerald-400" />
|
||||
<span className="text-xs text-emerald-400">{stats.highScore}</span>
|
||||
<span className="text-xs text-emerald-400/60">high score</span>
|
||||
</div>
|
||||
{stats.endingSoon > 0 && (
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-amber-500/10 border border-amber-500/20 rounded-full">
|
||||
<Flame className="w-3 h-3 text-amber-400" />
|
||||
<span className="text-xs text-amber-400">{stats.endingSoon}</span>
|
||||
<span className="text-xs text-amber-400/60">ending soon</span>
|
||||
</div>
|
||||
)}
|
||||
{/* ============================================================================ */}
|
||||
{/* METRICS GRID */}
|
||||
{/* ============================================================================ */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label="Active Listings"
|
||||
value={stats.total}
|
||||
icon={Activity}
|
||||
trend="neutral"
|
||||
/>
|
||||
<StatCard
|
||||
label="High Potential"
|
||||
value={stats.highScore}
|
||||
subValue="Score 80+"
|
||||
icon={TrendingUp}
|
||||
trend="up"
|
||||
/>
|
||||
<StatCard
|
||||
label="Ending Soon"
|
||||
value={stats.endingSoon}
|
||||
subValue="< 1h"
|
||||
icon={Flame}
|
||||
trend={stats.endingSoon > 5 ? 'down' : 'neutral'}
|
||||
/>
|
||||
<StatCard
|
||||
label="Avg. Price"
|
||||
value={formatPrice(stats.avgPrice)}
|
||||
icon={Zap}
|
||||
trend="neutral"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ============================================================================ */}
|
||||
{/* CONTROL BAR */}
|
||||
{/* ============================================================================ */}
|
||||
<div className="flex flex-col md:flex-row items-center gap-4 p-1">
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative w-full md:w-96 group">
|
||||
<div className="absolute inset-y-0 left-3 flex items-center pointer-events-none">
|
||||
<Search className="w-4 h-4 text-zinc-500 group-focus-within:text-white transition-colors" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search domains..."
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-zinc-900/50 border border-white/5 rounded-lg
|
||||
text-sm text-white placeholder:text-zinc-600
|
||||
focus:outline-none focus:border-white/20 focus:bg-zinc-900 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right: Refresh */}
|
||||
<div className="w-px h-6 bg-white/10 hidden md:block" />
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-2 overflow-x-auto w-full md:w-auto pb-2 md:pb-0 scrollbar-hide">
|
||||
<FilterToggle active={hideSpam} onClick={() => setHideSpam(!hideSpam)} label="Hide Spam" />
|
||||
<FilterToggle active={pounceOnly} onClick={() => setPounceOnly(!pounceOnly)} label="Pounce Exclusive" />
|
||||
<div className="w-px h-4 bg-white/10 mx-1" />
|
||||
<FilterToggle active={priceRange === 'low'} onClick={() => setPriceRange(p => p === 'low' ? 'all' : 'low')} label="< $100" />
|
||||
<FilterToggle active={priceRange === 'high'} onClick={() => setPriceRange(p => p === 'high' ? 'all' : 'high')} label="$1k+" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Refresh */}
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-zinc-800/50 border border-zinc-700/50 rounded-lg
|
||||
text-sm font-medium text-zinc-400 hover:text-white hover:border-zinc-600 transition-all
|
||||
disabled:opacity-50"
|
||||
className={clsx(
|
||||
"flex items-center gap-2 text-xs font-medium text-zinc-500 hover:text-white transition-colors",
|
||||
refreshing && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<RefreshCw className={clsx("w-4 h-4", refreshing && "animate-spin")} />
|
||||
<span className="hidden sm:inline">Refresh</span>
|
||||
<RefreshCw className={clsx("w-3.5 h-3.5", refreshing && "animate-spin")} />
|
||||
<span className="hidden sm:inline">Refresh Data</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ================================================================ */}
|
||||
{/* FILTER BAR */}
|
||||
{/* ================================================================ */}
|
||||
<div className="p-4 bg-zinc-900/50 border border-zinc-800 rounded-xl">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-2 text-zinc-500 mr-2">
|
||||
<Filter className="w-4 h-4" />
|
||||
<span className="text-sm font-medium hidden sm:inline">Filters</span>
|
||||
</div>
|
||||
|
||||
<ToggleButton active={hideSpam} onClick={() => setHideSpam(!hideSpam)}>
|
||||
Hide Spam
|
||||
</ToggleButton>
|
||||
|
||||
<ToggleButton active={pounceOnly} onClick={() => setPounceOnly(!pounceOnly)}>
|
||||
<Diamond className="w-3.5 h-3.5" />
|
||||
Pounce Only
|
||||
</ToggleButton>
|
||||
|
||||
<div className="w-px h-8 bg-zinc-700 hidden sm:block" />
|
||||
|
||||
<DropdownSelect
|
||||
value={selectedTld}
|
||||
onChange={setSelectedTld}
|
||||
options={TLD_OPTIONS}
|
||||
/>
|
||||
|
||||
<DropdownSelect
|
||||
value={selectedPrice}
|
||||
onChange={setSelectedPrice}
|
||||
options={PRICE_OPTIONS}
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search domains..."
|
||||
className="w-full px-4 py-2 bg-zinc-800/50 border border-zinc-700/50 rounded-lg
|
||||
text-sm text-zinc-300 placeholder:text-zinc-600
|
||||
focus:outline-none focus:border-emerald-500/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ================================================================ */}
|
||||
{/* MARKET TABLE */}
|
||||
{/* ================================================================ */}
|
||||
<div className="bg-zinc-900/30 border border-zinc-800 rounded-xl overflow-hidden">
|
||||
{/* ============================================================================ */}
|
||||
{/* DATA GRID */}
|
||||
{/* ============================================================================ */}
|
||||
<div className="border border-white/5 rounded-xl overflow-hidden bg-zinc-900/20 backdrop-blur-sm">
|
||||
|
||||
{/* Table Header - Sortable */}
|
||||
<div className="grid grid-cols-12 gap-4 px-6 py-4 bg-zinc-900/50 border-b border-zinc-800">
|
||||
<div className="col-span-4">
|
||||
<SortableHeader
|
||||
label="Domain"
|
||||
field="domain"
|
||||
currentSort={sortField}
|
||||
currentDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
{/* Header */}
|
||||
<div className="grid grid-cols-12 gap-4 px-6 py-3 border-b border-white/5 bg-zinc-900/50">
|
||||
<div className="col-span-5 md:col-span-4">
|
||||
<SortableHeader label="Domain Asset" field="domain" currentSort={sortField} currentDirection={sortDirection} onSort={handleSort} />
|
||||
</div>
|
||||
<div className="col-span-1 hidden lg:block">
|
||||
<SortableHeader
|
||||
label="Score"
|
||||
field="score"
|
||||
currentSort={sortField}
|
||||
currentDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
align="center"
|
||||
/>
|
||||
<div className="col-span-2 hidden md:block text-center">
|
||||
<SortableHeader label="Pounce Score" field="score" currentSort={sortField} currentDirection={sortDirection} onSort={handleSort} align="center" />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<SortableHeader
|
||||
label="Price"
|
||||
field="price"
|
||||
currentSort={sortField}
|
||||
currentDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
align="right"
|
||||
/>
|
||||
<div className="col-span-3 md:col-span-2 text-right">
|
||||
<SortableHeader label="Bid Price" field="price" currentSort={sortField} currentDirection={sortDirection} onSort={handleSort} align="right" />
|
||||
</div>
|
||||
<div className="col-span-2 hidden md:block">
|
||||
<SortableHeader
|
||||
label="Time Left"
|
||||
field="time"
|
||||
currentSort={sortField}
|
||||
currentDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
align="center"
|
||||
/>
|
||||
<div className="col-span-2 hidden lg:block text-center">
|
||||
<SortableHeader label="Time Left" field="time" currentSort={sortField} currentDirection={sortDirection} onSort={handleSort} align="center" />
|
||||
</div>
|
||||
<div className="col-span-1 hidden lg:block">
|
||||
<SortableHeader
|
||||
label="Source"
|
||||
field="source"
|
||||
currentSort={sortField}
|
||||
currentDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
align="center"
|
||||
/>
|
||||
<div className="col-span-2 hidden xl:block text-center">
|
||||
<SortableHeader label="Source" field="source" currentSort={sortField} currentDirection={sortDirection} onSort={handleSort} align="center" />
|
||||
</div>
|
||||
<div className="col-span-2 text-right">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-zinc-500">Action</span>
|
||||
<div className="col-span-4 md:col-span-2 xl:col-span-2 text-right">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-zinc-600">Action</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table Body */}
|
||||
{/* Body */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-6 h-6 text-emerald-500 animate-spin" />
|
||||
<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 global markets...</p>
|
||||
</div>
|
||||
) : marketItems.length === 0 ? (
|
||||
<div className="text-center py-20">
|
||||
<TrendingUp className="w-12 h-12 text-zinc-700 mx-auto mb-4" />
|
||||
<p className="text-zinc-500 font-medium">No domains match your filters</p>
|
||||
<p className="text-zinc-600 text-sm mt-1">Try adjusting your filter settings</p>
|
||||
<div className="flex flex-col items-center justify-center py-32 text-center">
|
||||
<div className="w-16 h-16 bg-zinc-900 rounded-full flex items-center justify-center mb-4 border border-zinc-800">
|
||||
<Search className="w-6 h-6 text-zinc-600" />
|
||||
</div>
|
||||
<h3 className="text-white font-medium mb-1">No assets found</h3>
|
||||
<p className="text-zinc-500 text-sm">Adjust filters to see more results</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-zinc-800/50">
|
||||
{marketItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={clsx(
|
||||
"grid grid-cols-12 gap-4 px-6 py-4 items-center transition-colors",
|
||||
item.isPounce
|
||||
? "bg-emerald-500/[0.03] hover:bg-emerald-500/[0.06]"
|
||||
: "hover:bg-zinc-800/30"
|
||||
)}
|
||||
>
|
||||
{/* Domain */}
|
||||
<div className="col-span-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{item.isPounce && (
|
||||
<Diamond className="w-4 h-4 text-emerald-400 flex-shrink-0" />
|
||||
)}
|
||||
<div>
|
||||
<span className="font-mono font-semibold text-white">{item.domain}</span>
|
||||
{item.verified && (
|
||||
<span className="ml-2 text-xs bg-emerald-500/20 text-emerald-400 px-1.5 py-0.5 rounded">
|
||||
✓ Verified
|
||||
</span>
|
||||
)}
|
||||
{/* Mobile: Show score inline */}
|
||||
<div className="flex items-center gap-2 mt-1 lg:hidden">
|
||||
<ScoreBadge score={item.pounceScore} />
|
||||
<SourceBadge source={item.source} isPounce={item.isPounce} />
|
||||
<div className="divide-y divide-white/5">
|
||||
{marketItems.map((item) => {
|
||||
const timeLeftSec = parseTimeToSeconds(item.timeLeft)
|
||||
const isUrgent = timeLeftSec < 3600
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="grid grid-cols-12 gap-4 px-6 py-3.5 items-center hover:bg-white/[0.02] transition-colors group"
|
||||
>
|
||||
{/* Domain */}
|
||||
<div className="col-span-5 md:col-span-4 min-w-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={clsx(
|
||||
"w-1.5 h-1.5 rounded-full flex-shrink-0",
|
||||
item.isPounce ? "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]" : "bg-zinc-700"
|
||||
)} />
|
||||
<div className="truncate">
|
||||
<div className="flex items-baseline gap-0.5 truncate">
|
||||
<span className="font-medium text-white text-[15px] tracking-tight">{item.domain.split('.')[0]}</span>
|
||||
<span className="text-zinc-500 text-sm">.{item.tld}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5 lg:hidden">
|
||||
<span className={clsx("text-xs", item.pounceScore >= 80 ? "text-emerald-400" : "text-zinc-500")}>
|
||||
Score {item.pounceScore}
|
||||
</span>
|
||||
<span className="text-zinc-700 text-[10px]">•</span>
|
||||
<span className={clsx("text-xs", isUrgent ? "text-red-400" : "text-zinc-500")}>
|
||||
{item.timeLeft}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pounce Score */}
|
||||
<div className="col-span-1 hidden lg:flex justify-center">
|
||||
<ScoreBadge score={item.pounceScore} />
|
||||
</div>
|
||||
{/* Score */}
|
||||
<div className="col-span-2 hidden md:flex justify-center">
|
||||
<ScoreRing score={item.pounceScore} />
|
||||
</div>
|
||||
|
||||
{/* Price / Bid */}
|
||||
<div className="col-span-2 text-right">
|
||||
<span className="font-semibold text-white font-mono">
|
||||
{formatPrice(item.price)}
|
||||
</span>
|
||||
{item.priceType === 'bid' && (
|
||||
<span className="text-zinc-500 text-xs ml-1">(bid)</span>
|
||||
)}
|
||||
{item.numBids && item.numBids > 0 && (
|
||||
<p className="text-xs text-zinc-500 mt-0.5">{item.numBids} bids</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status / Time */}
|
||||
<div className="col-span-2 hidden md:flex justify-center">
|
||||
<StatusBadge status={item.status} timeLeft={item.timeLeft} />
|
||||
</div>
|
||||
|
||||
{/* Source */}
|
||||
<div className="col-span-1 hidden lg:flex justify-center">
|
||||
<SourceBadge source={item.source} isPounce={item.isPounce} />
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="col-span-2 flex items-center gap-2 justify-end">
|
||||
{/* Track Button */}
|
||||
<button
|
||||
onClick={() => handleTrack(item.domain)}
|
||||
disabled={trackedDomains.has(item.domain) || trackingInProgress === item.domain}
|
||||
className={clsx(
|
||||
"p-2 rounded-lg transition-all",
|
||||
trackedDomains.has(item.domain)
|
||||
? "bg-emerald-500/20 text-emerald-400"
|
||||
: "bg-zinc-800 text-zinc-400 hover:text-white hover:bg-zinc-700"
|
||||
{/* Price */}
|
||||
<div className="col-span-3 md:col-span-2 text-right">
|
||||
<div className="font-mono text-white font-medium tracking-tight">
|
||||
{formatPrice(item.price)}
|
||||
</div>
|
||||
{item.numBids !== undefined && item.numBids > 0 && (
|
||||
<div className="text-[11px] text-zinc-500 mt-0.5">
|
||||
{item.numBids} bids
|
||||
</div>
|
||||
)}
|
||||
title={trackedDomains.has(item.domain) ? 'Tracked' : 'Add to Watchlist'}
|
||||
>
|
||||
{trackingInProgress === item.domain ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : trackedDomains.has(item.domain) ? (
|
||||
<Check className="w-4 h-4" />
|
||||
) : (
|
||||
<Plus className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Action Button */}
|
||||
<a
|
||||
href={item.affiliateUrl || '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={clsx(
|
||||
"inline-flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-semibold transition-all",
|
||||
item.isPounce
|
||||
? "bg-emerald-500 text-black hover:bg-emerald-400"
|
||||
: "bg-white text-black hover:bg-zinc-200"
|
||||
)}
|
||||
>
|
||||
{item.isPounce ? 'Buy' : 'Bid'}
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
{/* Time */}
|
||||
<div className="col-span-2 hidden lg:flex justify-center">
|
||||
<div className={clsx(
|
||||
"flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border",
|
||||
isUrgent
|
||||
? "bg-red-500/10 text-red-400 border-red-500/20"
|
||||
: "bg-zinc-900 border-zinc-800 text-zinc-400"
|
||||
)}>
|
||||
<Clock className="w-3 h-3" />
|
||||
{item.timeLeft}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Source */}
|
||||
<div className="col-span-2 hidden xl:flex justify-center">
|
||||
<SourceBadge source={item.source} isPounce={item.isPounce} />
|
||||
</div>
|
||||
|
||||
{/* Action */}
|
||||
<div className="col-span-4 md:col-span-2 xl:col-span-2 flex items-center justify-end gap-2 opacity-100 lg:opacity-60 lg:group-hover:opacity-100 transition-opacity">
|
||||
<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 text-white border-emerald-500"
|
||||
: "border-zinc-700 text-zinc-400 hover:text-white hover:border-zinc-500 hover:bg-zinc-800"
|
||||
)}
|
||||
>
|
||||
{trackedDomains.has(item.domain) ? <Check className="w-4 h-4" /> : <Plus className="w-4 h-4" />}
|
||||
</button>
|
||||
|
||||
<a
|
||||
href={item.affiliateUrl || '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="h-8 px-3 flex items-center gap-2 bg-white text-zinc-950 rounded-lg text-xs font-semibold hover:bg-zinc-200 transition-colors"
|
||||
>
|
||||
{item.isPounce ? 'Buy' : 'Bid'}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ================================================================ */}
|
||||
{/* FOOTER INFO */}
|
||||
{/* ================================================================ */}
|
||||
<div className="flex items-center justify-between text-xs text-zinc-600">
|
||||
<span>
|
||||
Showing {marketItems.length} of {auctions.length} total listings
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
Data from GoDaddy, Sedo, NameJet, DropCatch
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</TerminalLayout>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user