feat: MARKET - Sortable columns + new header design
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

- All columns now sortable (Domain, Score, Price, Time, Source)
- Click column header to sort asc/desc
- New professional header with icon, title, and live stats
- Cleaner, more compact design
- Better mobile responsiveness
- Improved filter bar layout
This commit is contained in:
2025-12-10 22:38:53 +01:00
parent 43724837be
commit ba297c09ca

View File

@ -12,12 +12,15 @@ import {
Zap, Zap,
Filter, Filter,
ChevronDown, ChevronDown,
ChevronUp,
Plus, Plus,
Check, Check,
TrendingUp, TrendingUp,
RefreshCw, RefreshCw,
ArrowUpDown,
Sparkles,
BarChart3,
} from 'lucide-react' } from 'lucide-react'
import Link from 'next/link'
import clsx from 'clsx' import clsx from 'clsx'
// ============================================================================ // ============================================================================
@ -49,6 +52,7 @@ interface MarketItem {
priceType: 'bid' | 'fixed' priceType: 'bid' | 'fixed'
status: 'auction' | 'instant' status: 'auction' | 'instant'
timeLeft?: string timeLeft?: string
endTime?: string
source: 'GoDaddy' | 'Sedo' | 'NameJet' | 'DropCatch' | 'Pounce' source: 'GoDaddy' | 'Sedo' | 'NameJet' | 'DropCatch' | 'Pounce'
isPounce: boolean isPounce: boolean
verified?: boolean verified?: boolean
@ -57,6 +61,9 @@ interface MarketItem {
numBids?: number numBids?: number
} }
type SortField = 'domain' | 'score' | 'price' | 'time' | 'source'
type SortDirection = 'asc' | 'desc'
// ============================================================================ // ============================================================================
// POUNCE SCORE ALGORITHM // POUNCE SCORE ALGORITHM
// ============================================================================ // ============================================================================
@ -65,7 +72,7 @@ function calculatePounceScore(domain: string, tld: string, numBids?: number, age
let score = 50 let score = 50
const name = domain.split('.')[0] const name = domain.split('.')[0]
// Length bonus (shorter = better) // Length bonus
if (name.length <= 3) score += 30 if (name.length <= 3) score += 30
else if (name.length === 4) score += 25 else if (name.length === 4) score += 25
else if (name.length === 5) score += 20 else if (name.length === 5) score += 20
@ -83,7 +90,7 @@ function calculatePounceScore(domain: string, tld: string, numBids?: number, age
else if (ageYears && ageYears > 10) score += 7 else if (ageYears && ageYears > 10) score += 7
else if (ageYears && ageYears > 5) score += 3 else if (ageYears && ageYears > 5) score += 3
// Activity bonus (more bids = more valuable) // Activity bonus
if (numBids && numBids >= 20) score += 8 if (numBids && numBids >= 20) score += 8
else if (numBids && numBids >= 10) score += 5 else if (numBids && numBids >= 10) score += 5
else if (numBids && numBids >= 5) score += 2 else if (numBids && numBids >= 5) score += 2
@ -92,7 +99,7 @@ function calculatePounceScore(domain: string, tld: string, numBids?: number, age
if (name.includes('-')) score -= 25 if (name.includes('-')) score -= 25
if (/\d/.test(name) && name.length > 3) score -= 20 if (/\d/.test(name) && name.length > 3) score -= 20
if (name.length > 15) score -= 15 if (name.length > 15) score -= 15
if (/(.)\1{2,}/.test(name)) score -= 10 // repeated characters if (/(.)\1{2,}/.test(name)) score -= 10
return Math.max(0, Math.min(100, score)) return Math.max(0, Math.min(100, score))
} }
@ -106,12 +113,28 @@ function isSpamDomain(domain: string, tld: string): boolean {
return false return false
} }
// Parse time remaining to seconds for sorting
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
}
// ============================================================================ // ============================================================================
// COMPONENTS // COMPONENTS
// ============================================================================ // ============================================================================
// Score Badge with color coding // Score Badge
function ScoreBadge({ score, showLabel = false }: { score: number; showLabel?: boolean }) { function ScoreBadge({ score }: { score: number }) {
const color = score >= 80 const color = score >= 80
? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30'
: score >= 40 : score >= 40
@ -119,13 +142,12 @@ function ScoreBadge({ score, showLabel = false }: { score: number; showLabel?: b
: 'bg-red-500/20 text-red-400 border-red-500/30' : 'bg-red-500/20 text-red-400 border-red-500/30'
return ( return (
<div className={clsx( <span className={clsx(
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md border font-mono text-sm font-bold", "inline-flex items-center justify-center w-12 h-8 rounded-lg border font-mono text-sm font-bold",
color color
)}> )}>
{score} {score}
{showLabel && <span className="text-xs font-normal opacity-70">pts</span>} </span>
</div>
) )
} }
@ -133,25 +155,22 @@ function ScoreBadge({ score, showLabel = false }: { score: number; showLabel?: b
function SourceBadge({ source, isPounce }: { source: string; isPounce: boolean }) { function SourceBadge({ source, isPounce }: { source: string; isPounce: boolean }) {
if (isPounce) { if (isPounce) {
return ( 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"> <div className="inline-flex items-center gap-1 px-2 py-1 bg-emerald-500/10 border border-emerald-500/30 rounded">
<Diamond className="w-3.5 h-3.5 text-emerald-400" /> <Diamond className="w-3 h-3 text-emerald-400" />
<span className="text-xs font-semibold text-emerald-400">Pounce</span> <span className="text-[11px] font-bold text-emerald-400 uppercase">Pounce</span>
</div> </div>
) )
} }
const colors: Record<string, string> = { const colors: Record<string, string> = {
GoDaddy: 'bg-orange-500/10 border-orange-500/20 text-orange-400', GoDaddy: 'text-orange-400/80',
Sedo: 'bg-blue-500/10 border-blue-500/20 text-blue-400', Sedo: 'text-blue-400/80',
NameJet: 'bg-purple-500/10 border-purple-500/20 text-purple-400', NameJet: 'text-purple-400/80',
DropCatch: 'bg-cyan-500/10 border-cyan-500/20 text-cyan-400', DropCatch: 'text-cyan-400/80',
} }
return ( return (
<span className={clsx( <span className={clsx("text-[11px] font-medium uppercase tracking-wide", colors[source] || 'text-zinc-500')}>
"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} {source}
</span> </span>
) )
@ -161,31 +180,28 @@ function SourceBadge({ source, isPounce }: { source: string; isPounce: boolean }
function StatusBadge({ status, timeLeft }: { status: 'auction' | 'instant'; timeLeft?: string }) { function StatusBadge({ status, timeLeft }: { status: 'auction' | 'instant'; timeLeft?: string }) {
if (status === 'instant') { if (status === 'instant') {
return ( 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"> <div className="inline-flex items-center gap-1 px-2 py-1 bg-emerald-500/15 rounded">
<Zap className="w-3.5 h-3.5 text-emerald-400" /> <Zap className="w-3 h-3 text-emerald-400" />
<span className="text-xs font-bold text-emerald-400">Instant</span> <span className="text-[11px] font-bold text-emerald-400 uppercase">Instant</span>
</div> </div>
) )
} }
// Check urgency
const isUrgent = timeLeft?.includes('m') && !timeLeft?.includes('d') && !timeLeft?.includes('h') const isUrgent = timeLeft?.includes('m') && !timeLeft?.includes('d') && !timeLeft?.includes('h')
const isWarning = timeLeft?.includes('h') && parseInt(timeLeft) <= 4 const isWarning = timeLeft?.includes('h') && parseInt(timeLeft) <= 4
return ( return (
<div className={clsx( <div className={clsx(
"inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md border", "inline-flex items-center gap-1 px-2 py-1 rounded",
isUrgent ? "bg-red-500/20 border-red-500/30" : isUrgent ? "bg-red-500/15" : isWarning ? "bg-amber-500/15" : "bg-zinc-800/50"
isWarning ? "bg-amber-500/20 border-amber-500/30" :
"bg-zinc-800 border-zinc-700"
)}> )}>
<Timer className={clsx( <Timer className={clsx(
"w-3.5 h-3.5", "w-3 h-3",
isUrgent ? "text-red-400" : isWarning ? "text-amber-400" : "text-zinc-400" isUrgent ? "text-red-400" : isWarning ? "text-amber-400" : "text-zinc-500"
)} /> )} />
<span className={clsx( <span className={clsx(
"text-xs font-medium", "text-[11px] font-medium",
isUrgent ? "text-red-400" : isWarning ? "text-amber-400" : "text-zinc-400" isUrgent ? "text-red-400" : isWarning ? "text-amber-400" : "text-zinc-500"
)}> )}>
{timeLeft} {timeLeft}
</span> </span>
@ -193,60 +209,83 @@ function StatusBadge({ status, timeLeft }: { status: 'auction' | 'instant'; time
) )
} }
// Toggle Button // Sortable Column Header
function ToggleButton({ function SortHeader({
active, label,
onClick, field,
children currentSort,
currentDirection,
onSort,
align = 'left'
}: { }: {
active: boolean label: string
onClick: () => void field: SortField
children: React.ReactNode currentSort: SortField
currentDirection: SortDirection
onSort: (field: SortField) => void
align?: 'left' | 'center' | 'right'
}) { }) {
const isActive = currentSort === field
return ( return (
<button <button
onClick={onClick} onClick={() => onSort(field)}
className={clsx( className={clsx(
"flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all", "flex items-center gap-1 text-[11px] font-semibold uppercase tracking-wider transition-colors group",
active align === 'right' && "justify-end",
? "bg-emerald-500/20 text-emerald-400 border border-emerald-500/30" align === 'center' && "justify-center",
: "bg-zinc-800/50 text-zinc-400 border border-zinc-700/50 hover:bg-zinc-800 hover:text-zinc-300" isActive ? "text-emerald-400" : "text-zinc-500 hover:text-zinc-300"
)} )}
> >
{children} {label}
{active && <Check className="w-3.5 h-3.5" />} <span className={clsx(
"transition-opacity",
isActive ? "opacity-100" : "opacity-0 group-hover:opacity-50"
)}>
{isActive && currentDirection === 'desc' ? (
<ChevronDown className="w-3.5 h-3.5" />
) : isActive && currentDirection === 'asc' ? (
<ChevronUp className="w-3.5 h-3.5" />
) : (
<ArrowUpDown className="w-3 h-3" />
)}
</span>
</button> </button>
) )
} }
// Dropdown Select // Toggle Button
function DropdownSelect({ function ToggleButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
value,
onChange,
options,
label
}: {
value: string
onChange: (v: string) => void
options: { value: string; label: string }[]
label: string
}) {
return ( return (
<div className="relative"> <button
<select onClick={onClick}
value={value} className={clsx(
onChange={(e) => onChange(e.target.value)} "flex items-center gap-2 px-3 py-1.5 rounded text-xs font-medium transition-all",
className="appearance-none px-4 py-2 pr-10 bg-zinc-800/50 border border-zinc-700/50 rounded-lg active
text-sm text-zinc-300 font-medium cursor-pointer ? "bg-emerald-500/20 text-emerald-400 border border-emerald-500/30"
hover:bg-zinc-800 hover:border-zinc-600 transition-all : "bg-zinc-800/50 text-zinc-500 border border-zinc-700/50 hover:text-zinc-300 hover:border-zinc-600"
focus:outline-none focus:border-emerald-500/50" )}
> >
{options.map(opt => ( {children}
<option key={opt.value} value={opt.value}>{opt.label}</option> {active && <Check className="w-3 h-3" />}
))} </button>
</select> )
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500 pointer-events-none" /> }
</div>
// Dropdown
function Dropdown({ value, onChange, options }: { value: string; onChange: (v: string) => void; options: { value: string; label: string }[] }) {
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="appearance-none px-3 py-1.5 pr-8 bg-zinc-800/50 border border-zinc-700/50 rounded
text-xs text-zinc-400 font-medium cursor-pointer
hover:border-zinc-600 focus:outline-none focus:border-emerald-500/50 transition-all"
>
{options.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
) )
} }
@ -255,25 +294,28 @@ function DropdownSelect({
// ============================================================================ // ============================================================================
export default function MarketPage() { export default function MarketPage() {
const { isAuthenticated, subscription } = useStore() const { subscription } = useStore()
// Data State // Data
const [auctions, setAuctions] = useState<Auction[]>([]) const [auctions, setAuctions] = useState<Auction[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false) const [refreshing, setRefreshing] = useState(false)
// Filter State // Filters
const [hideSpam, setHideSpam] = useState(true) // Default: ON const [hideSpam, setHideSpam] = useState(true)
const [pounceOnly, setPounceOnly] = useState(false) const [pounceOnly, setPounceOnly] = useState(false)
const [selectedTld, setSelectedTld] = useState('all') const [selectedTld, setSelectedTld] = useState('all')
const [selectedPrice, setSelectedPrice] = useState('all') const [selectedPrice, setSelectedPrice] = useState('all')
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
// Watchlist State // Sorting
const [sortField, setSortField] = useState<SortField>('score')
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)
// Options
const TLD_OPTIONS = [ const TLD_OPTIONS = [
{ value: 'all', label: 'All TLDs' }, { value: 'all', label: 'All TLDs' },
{ value: 'com', label: '.com' }, { value: 'com', label: '.com' },
@ -286,7 +328,7 @@ export default function MarketPage() {
const PRICE_OPTIONS = [ const PRICE_OPTIONS = [
{ value: 'all', label: 'Any Price' }, { value: 'all', label: 'Any Price' },
{ value: '100', label: '< $100' }, { value: '100', label: '< $100' },
{ value: '1000', label: '< $1,000' }, { value: '1000', label: '< $1k' },
{ value: '10000', label: 'High Roller' }, { value: '10000', label: 'High Roller' },
] ]
@ -297,7 +339,7 @@ export default function MarketPage() {
const data = await api.getAuctions() const data = await api.getAuctions()
setAuctions(data.auctions || []) setAuctions(data.auctions || [])
} catch (error) { } catch (error) {
console.error('Failed to load market data:', error) console.error('Failed to load:', error)
} finally { } finally {
setLoading(false) setLoading(false)
} }
@ -313,293 +355,281 @@ export default function MarketPage() {
setRefreshing(false) setRefreshing(false)
}, [loadData]) }, [loadData])
const handleSort = useCallback((field: SortField) => {
if (sortField === field) {
setSortDirection(d => d === 'asc' ? 'desc' : 'asc')
} else {
setSortField(field)
setSortDirection(field === 'domain' || field === 'source' ? 'asc' : 'desc')
}
}, [sortField])
const handleTrack = useCallback(async (domain: string) => { const handleTrack = useCallback(async (domain: string) => {
if (trackedDomains.has(domain) || trackingInProgress) return if (trackedDomains.has(domain) || trackingInProgress) return
setTrackingInProgress(domain) setTrackingInProgress(domain)
try { try {
await api.addDomain(domain) await api.addDomain(domain)
setTrackedDomains(prev => new Set([...Array.from(prev), domain])) setTrackedDomains(prev => new Set([...Array.from(prev), domain]))
} catch (error) { } catch (error) {
console.error('Failed to track:', error) console.error('Failed:', error)
} finally { } finally {
setTrackingInProgress(null) setTrackingInProgress(null)
} }
}, [trackedDomains, trackingInProgress]) }, [trackedDomains, trackingInProgress])
// Transform and Filter Data // Process Data
const marketItems = useMemo(() => { const marketItems = useMemo(() => {
// Convert auctions to market items let items: MarketItem[] = auctions.map(a => ({
const items: MarketItem[] = auctions.map(auction => ({ id: `${a.domain}-${a.platform}`,
id: `${auction.domain}-${auction.platform}`, domain: a.domain,
domain: auction.domain, pounceScore: calculatePounceScore(a.domain, a.tld, a.num_bids, a.age_years ?? undefined),
pounceScore: calculatePounceScore(auction.domain, auction.tld, auction.num_bids, auction.age_years ?? undefined), price: a.current_bid,
price: auction.current_bid,
priceType: 'bid' as const, priceType: 'bid' as const,
status: 'auction' as const, status: 'auction' as const,
timeLeft: auction.time_remaining, timeLeft: a.time_remaining,
source: auction.platform as any, endTime: a.end_time,
source: a.platform as any,
isPounce: false, isPounce: false,
affiliateUrl: auction.affiliate_url, affiliateUrl: a.affiliate_url,
tld: auction.tld, tld: a.tld,
numBids: auction.num_bids, numBids: a.num_bids,
})) }))
// Apply Filters // Filter
let filtered = items if (hideSpam) items = items.filter(i => !isSpamDomain(i.domain, i.tld))
if (pounceOnly) items = items.filter(i => i.isPounce)
// 1. Hide Spam (Default: ON) if (selectedTld !== 'all') items = items.filter(i => i.tld === selectedTld)
if (hideSpam) {
filtered = filtered.filter(item => !isSpamDomain(item.domain, item.tld))
}
// 2. Pounce Only
if (pounceOnly) {
filtered = filtered.filter(item => item.isPounce)
}
// 3. TLD Filter
if (selectedTld !== 'all') {
filtered = filtered.filter(item => item.tld === selectedTld)
}
// 4. Price Filter
if (selectedPrice !== 'all') { if (selectedPrice !== 'all') {
const maxPrice = parseInt(selectedPrice) const max = parseInt(selectedPrice)
if (selectedPrice === '10000') { items = selectedPrice === '10000'
// High Roller = above $10k ? items.filter(i => i.price >= 10000)
filtered = filtered.filter(item => item.price >= 10000) : items.filter(i => i.price < max)
} else {
filtered = filtered.filter(item => item.price < maxPrice)
}
} }
// 5. Search
if (searchQuery) { if (searchQuery) {
const q = searchQuery.toLowerCase() const q = searchQuery.toLowerCase()
filtered = filtered.filter(item => item.domain.toLowerCase().includes(q)) items = items.filter(i => i.domain.toLowerCase().includes(q))
} }
// Sort by Pounce Score (highest first) // Sort
filtered.sort((a, b) => b.pounceScore - a.pounceScore) items.sort((a, b) => {
const mult = sortDirection === 'asc' ? 1 : -1
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
}
})
return filtered return items
}, [auctions, hideSpam, pounceOnly, selectedTld, selectedPrice, searchQuery]) }, [auctions, hideSpam, pounceOnly, selectedTld, selectedPrice, searchQuery, sortField, sortDirection])
// Stats
const stats = useMemo(() => ({ const stats = useMemo(() => ({
total: marketItems.length, total: marketItems.length,
highScore: marketItems.filter(i => i.pounceScore >= 80).length, highScore: marketItems.filter(i => i.pounceScore >= 80).length,
avgScore: marketItems.length > 0 avgScore: marketItems.length > 0
? Math.round(marketItems.reduce((sum, i) => sum + i.pounceScore, 0) / marketItems.length) ? Math.round(marketItems.reduce((s, i) => s + i.pounceScore, 0) / marketItems.length) : 0,
: 0,
}), [marketItems]) }), [marketItems])
// Format currency const formatPrice = (p: number) => p >= 1000 ? `$${(p / 1000).toFixed(1)}k` : `$${p.toLocaleString()}`
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 ( return (
<TerminalLayout <TerminalLayout title="Market" subtitle="">
title="Market" <div className="space-y-4">
subtitle={loading ? 'Loading opportunities...' : `${stats.total} domains • ${stats.highScore} with score ≥80`}
>
<div className="space-y-6">
{/* ================================================================ */} {/* ================================================================ */}
{/* FILTER BAR */} {/* HEADER - New Professional Style */}
{/* ================================================================ */} {/* ================================================================ */}
<div className="p-4 bg-zinc-900/50 border border-zinc-800 rounded-xl"> <div className="flex items-center justify-between pb-4 border-b border-zinc-800/50">
<div className="flex flex-wrap items-center gap-3"> <div>
{/* Filter Icon */} <div className="flex items-center gap-3 mb-1">
<div className="flex items-center gap-2 text-zinc-500 mr-2"> <div className="w-8 h-8 bg-emerald-500/10 border border-emerald-500/20 rounded-lg flex items-center justify-center">
<Filter className="w-4 h-4" /> <TrendingUp className="w-4 h-4 text-emerald-400" />
<span className="text-sm font-medium hidden sm:inline">Filters</span> </div>
<h1 className="text-xl font-bold text-white tracking-tight">Market Feed</h1>
</div> </div>
<p className="text-sm text-zinc-500">
{/* Toggle: Hide Spam (Default ON) */} {loading ? 'Loading...' : (
<ToggleButton active={hideSpam} onClick={() => setHideSpam(!hideSpam)}> <>
Hide Spam <span className="text-zinc-400">{stats.total}</span> domains
</ToggleButton> <span className="mx-2 text-zinc-700"></span>
<span className="text-emerald-400">{stats.highScore}</span> high-score
{/* Toggle: Pounce Direct Only */} <span className="mx-2 text-zinc-700"></span>
<ToggleButton active={pounceOnly} onClick={() => setPounceOnly(!pounceOnly)}> Avg score: <span className="text-zinc-400">{stats.avgScore}</span>
<Diamond className="w-3.5 h-3.5" /> </>
Pounce Only )}
</ToggleButton> </p>
</div>
{/* Divider */}
<div className="w-px h-8 bg-zinc-700 hidden sm:block" /> <div className="flex items-center gap-2">
{/* Dropdown: TLD */}
<DropdownSelect
value={selectedTld}
onChange={setSelectedTld}
options={TLD_OPTIONS}
label="TLD"
/>
{/* Dropdown: Price */}
<DropdownSelect
value={selectedPrice}
onChange={setSelectedPrice}
options={PRICE_OPTIONS}
label="Price"
/>
{/* Search */}
<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>
{/* Refresh */}
<button <button
onClick={handleRefresh} onClick={handleRefresh}
disabled={refreshing} disabled={refreshing}
className="p-2 bg-zinc-800/50 border border-zinc-700/50 rounded-lg text-zinc-400 className="flex items-center gap-2 px-3 py-2 bg-zinc-800/50 border border-zinc-700/50 rounded-lg
hover:bg-zinc-800 hover:text-zinc-300 transition-all disabled:opacity-50" text-xs font-medium text-zinc-400 hover:text-white hover:border-zinc-600 transition-all"
> >
<RefreshCw className={clsx("w-4 h-4", refreshing && "animate-spin")} /> <RefreshCw className={clsx("w-3.5 h-3.5", refreshing && "animate-spin")} />
Refresh
</button> </button>
</div> </div>
</div> </div>
{/* ================================================================ */} {/* ================================================================ */}
{/* MARKET TABLE */} {/* FILTER BAR */}
{/* ================================================================ */} {/* ================================================================ */}
<div className="bg-zinc-900/30 border border-zinc-800 rounded-xl overflow-hidden"> <div className="flex flex-wrap items-center gap-2 py-3 px-4 bg-zinc-900/30 border border-zinc-800/50 rounded-lg">
<Filter className="w-3.5 h-3.5 text-zinc-600" />
{/* Table Header */} <ToggleButton active={hideSpam} onClick={() => setHideSpam(!hideSpam)}>
<div className="grid grid-cols-12 gap-4 px-6 py-4 bg-zinc-900/50 border-b border-zinc-800 text-xs font-semibold text-zinc-500 uppercase tracking-wider"> <Sparkles className="w-3 h-3" />
<div className="col-span-4">Domain</div> Hide Spam
<div className="col-span-1 text-center hidden lg:block">Score</div> </ToggleButton>
<div className="col-span-2 text-right">Price / Bid</div>
<div className="col-span-2 text-center hidden md:block">Status</div> <ToggleButton active={pounceOnly} onClick={() => setPounceOnly(!pounceOnly)}>
<div className="col-span-1 text-center hidden lg:block">Source</div> <Diamond className="w-3 h-3" />
<div className="col-span-2 text-right">Action</div> Pounce Only
</ToggleButton>
<div className="w-px h-5 bg-zinc-800 mx-1" />
<Dropdown value={selectedTld} onChange={setSelectedTld} options={TLD_OPTIONS} />
<Dropdown value={selectedPrice} onChange={setSelectedPrice} options={PRICE_OPTIONS} />
<div className="flex-1" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search..."
className="w-40 px-3 py-1.5 bg-zinc-800/30 border border-zinc-700/50 rounded
text-xs text-zinc-300 placeholder:text-zinc-600
focus:outline-none focus:border-emerald-500/50"
/>
</div>
{/* ================================================================ */}
{/* TABLE */}
{/* ================================================================ */}
<div className="bg-zinc-900/20 border border-zinc-800/50 rounded-lg overflow-hidden">
{/* Header Row */}
<div className="grid grid-cols-12 gap-3 px-4 py-3 bg-zinc-900/50 border-b border-zinc-800/50">
<div className="col-span-4">
<SortHeader label="Domain" field="domain" currentSort={sortField} currentDirection={sortDirection} onSort={handleSort} />
</div>
<div className="col-span-1 hidden lg:block">
<SortHeader label="Score" field="score" currentSort={sortField} currentDirection={sortDirection} onSort={handleSort} align="center" />
</div>
<div className="col-span-2">
<SortHeader label="Price" field="price" currentSort={sortField} currentDirection={sortDirection} onSort={handleSort} align="right" />
</div>
<div className="col-span-2 hidden md:block">
<SortHeader label="Time" field="time" currentSort={sortField} currentDirection={sortDirection} onSort={handleSort} align="center" />
</div>
<div className="col-span-1 hidden lg:block">
<SortHeader label="Source" field="source" currentSort={sortField} currentDirection={sortDirection} onSort={handleSort} align="center" />
</div>
<div className="col-span-2 text-right">
<span className="text-[11px] font-semibold uppercase tracking-wider text-zinc-500">Action</span>
</div>
</div> </div>
{/* Table Body */} {/* Body */}
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-20"> <div className="flex items-center justify-center py-16">
<Loader2 className="w-6 h-6 text-emerald-500 animate-spin" /> <Loader2 className="w-5 h-5 text-emerald-500 animate-spin" />
</div> </div>
) : marketItems.length === 0 ? ( ) : marketItems.length === 0 ? (
<div className="text-center py-20"> <div className="text-center py-16">
<TrendingUp className="w-12 h-12 text-zinc-700 mx-auto mb-4" /> <BarChart3 className="w-10 h-10 text-zinc-700 mx-auto mb-3" />
<p className="text-zinc-500 font-medium">No domains match your filters</p> <p className="text-sm text-zinc-500">No domains match your filters</p>
<p className="text-zinc-600 text-sm mt-1">Try adjusting your filter settings</p>
</div> </div>
) : ( ) : (
<div className="divide-y divide-zinc-800/50"> <div className="divide-y divide-zinc-800/30">
{marketItems.map((item) => ( {marketItems.map((item) => (
<div <div
key={item.id} key={item.id}
className={clsx( className={clsx(
"grid grid-cols-12 gap-4 px-6 py-4 items-center transition-colors", "grid grid-cols-12 gap-3 px-4 py-3 items-center transition-colors",
item.isPounce item.isPounce ? "bg-emerald-500/[0.02] hover:bg-emerald-500/[0.05]" : "hover:bg-zinc-800/20"
? "bg-emerald-500/[0.03] hover:bg-emerald-500/[0.06]"
: "hover:bg-zinc-800/30"
)} )}
> >
{/* Domain */} {/* Domain */}
<div className="col-span-4"> <div className="col-span-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-2">
{item.isPounce && ( {item.isPounce && <Diamond className="w-3.5 h-3.5 text-emerald-400 flex-shrink-0" />}
<Diamond className="w-4 h-4 text-emerald-400 flex-shrink-0" /> <span className="font-mono text-sm font-semibold text-white truncate">{item.domain}</span>
{item.verified && (
<span className="text-[10px] bg-emerald-500/20 text-emerald-400 px-1 rounded"></span>
)} )}
<div> </div>
<span className="font-mono font-semibold text-white">{item.domain}</span> {/* Mobile info */}
{item.verified && ( <div className="flex items-center gap-2 mt-1 lg:hidden">
<span className="ml-2 text-xs bg-emerald-500/20 text-emerald-400 px-1.5 py-0.5 rounded"> <ScoreBadge score={item.pounceScore} />
Verified <SourceBadge source={item.source} isPounce={item.isPounce} />
</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>
</div>
</div> </div>
</div> </div>
{/* Pounce Score */} {/* Score */}
<div className="col-span-1 text-center hidden lg:block"> <div className="col-span-1 hidden lg:flex justify-center">
<ScoreBadge score={item.pounceScore} /> <ScoreBadge score={item.pounceScore} />
</div> </div>
{/* Price / Bid */} {/* Price */}
<div className="col-span-2 text-right"> <div className="col-span-2 text-right">
<span className="font-semibold text-white font-mono"> <span className="font-mono text-sm font-semibold text-white">{formatPrice(item.price)}</span>
{formatPrice(item.price)} {item.priceType === 'bid' && <span className="text-zinc-600 text-[10px] ml-1">bid</span>}
</span>
{item.priceType === 'bid' && (
<span className="text-zinc-500 text-xs ml-1">(bid)</span>
)}
{item.numBids && item.numBids > 0 && ( {item.numBids && item.numBids > 0 && (
<p className="text-xs text-zinc-500 mt-0.5">{item.numBids} bids</p> <p className="text-[10px] text-zinc-600">{item.numBids} bids</p>
)} )}
</div> </div>
{/* Status / Time */} {/* Status */}
<div className="col-span-2 text-center hidden md:flex justify-center"> <div className="col-span-2 hidden md:flex justify-center">
<StatusBadge status={item.status} timeLeft={item.timeLeft} /> <StatusBadge status={item.status} timeLeft={item.timeLeft} />
</div> </div>
{/* Source */} {/* Source */}
<div className="col-span-1 text-center hidden lg:flex justify-center"> <div className="col-span-1 hidden lg:flex justify-center">
<SourceBadge source={item.source} isPounce={item.isPounce} /> <SourceBadge source={item.source} isPounce={item.isPounce} />
</div> </div>
{/* Actions */} {/* Actions */}
<div className="col-span-2 flex items-center gap-2 justify-end"> <div className="col-span-2 flex items-center gap-1.5 justify-end">
{/* Track Button */}
<button <button
onClick={() => handleTrack(item.domain)} onClick={() => handleTrack(item.domain)}
disabled={trackedDomains.has(item.domain) || trackingInProgress === item.domain} disabled={trackedDomains.has(item.domain) || trackingInProgress === item.domain}
className={clsx( className={clsx(
"p-2 rounded-lg transition-all", "p-1.5 rounded transition-all",
trackedDomains.has(item.domain) trackedDomains.has(item.domain)
? "bg-emerald-500/20 text-emerald-400" ? "bg-emerald-500/20 text-emerald-400"
: "bg-zinc-800 text-zinc-400 hover:text-white hover:bg-zinc-700" : "bg-zinc-800/50 text-zinc-500 hover:text-white"
)} )}
title={trackedDomains.has(item.domain) ? 'Tracked' : 'Add to Watchlist'}
> >
{trackingInProgress === item.domain ? ( {trackingInProgress === item.domain ? (
<Loader2 className="w-4 h-4 animate-spin" /> <Loader2 className="w-3.5 h-3.5 animate-spin" />
) : trackedDomains.has(item.domain) ? ( ) : trackedDomains.has(item.domain) ? (
<Check className="w-4 h-4" /> <Check className="w-3.5 h-3.5" />
) : ( ) : (
<Plus className="w-4 h-4" /> <Plus className="w-3.5 h-3.5" />
)} )}
</button> </button>
{/* Action Button */}
<a <a
href={item.affiliateUrl || '#'} href={item.affiliateUrl || '#'}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className={clsx( className={clsx(
"inline-flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-semibold transition-all", "inline-flex items-center gap-1 px-3 py-1.5 rounded text-xs font-semibold transition-all",
item.isPounce item.isPounce
? "bg-emerald-500 text-black hover:bg-emerald-400" ? "bg-emerald-500 text-black hover:bg-emerald-400"
: "bg-white text-black hover:bg-zinc-200" : "bg-white text-black hover:bg-zinc-200"
)} )}
> >
{item.isPounce ? 'Buy' : 'Bid'} {item.isPounce ? 'Buy' : 'Bid'}
<ExternalLink className="w-3.5 h-3.5" /> <ExternalLink className="w-3 h-3" />
</a> </a>
</div> </div>
</div> </div>
@ -608,18 +638,11 @@ export default function MarketPage() {
)} )}
</div> </div>
{/* ================================================================ */} {/* Footer */}
{/* FOOTER INFO */} <div className="flex items-center justify-between text-[11px] text-zinc-600 px-1">
{/* ================================================================ */} <span>{marketItems.length} of {auctions.length} listings</span>
<div className="flex items-center justify-between text-xs text-zinc-600"> <span>GoDaddy Sedo NameJet DropCatch</span>
<span>
Showing {marketItems.length} of {auctions.length} total listings
</span>
<span>
Data from GoDaddy, Sedo, NameJet, DropCatch Updated every 15 minutes
</span>
</div> </div>
</div> </div>
</TerminalLayout> </TerminalLayout>
) )