diff --git a/frontend/src/app/terminal/market/page.tsx b/frontend/src/app/terminal/market/page.tsx
index 1ead235..c691c36 100644
--- a/frontend/src/app/terminal/market/page.tsx
+++ b/frontend/src/app/terminal/market/page.tsx
@@ -12,12 +12,15 @@ import {
Zap,
Filter,
ChevronDown,
+ ChevronUp,
Plus,
Check,
TrendingUp,
RefreshCw,
+ ArrowUpDown,
+ Sparkles,
+ BarChart3,
} from 'lucide-react'
-import Link from 'next/link'
import clsx from 'clsx'
// ============================================================================
@@ -49,6 +52,7 @@ interface MarketItem {
priceType: 'bid' | 'fixed'
status: 'auction' | 'instant'
timeLeft?: string
+ endTime?: string
source: 'GoDaddy' | 'Sedo' | 'NameJet' | 'DropCatch' | 'Pounce'
isPounce: boolean
verified?: boolean
@@ -57,6 +61,9 @@ interface MarketItem {
numBids?: number
}
+type SortField = 'domain' | 'score' | 'price' | 'time' | 'source'
+type SortDirection = 'asc' | 'desc'
+
// ============================================================================
// POUNCE SCORE ALGORITHM
// ============================================================================
@@ -65,7 +72,7 @@ function calculatePounceScore(domain: string, tld: string, numBids?: number, age
let score = 50
const name = domain.split('.')[0]
- // Length bonus (shorter = better)
+ // Length bonus
if (name.length <= 3) score += 30
else if (name.length === 4) score += 25
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 > 5) score += 3
- // Activity bonus (more bids = more valuable)
+ // Activity bonus
if (numBids && numBids >= 20) score += 8
else if (numBids && numBids >= 10) score += 5
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 (/\d/.test(name) && name.length > 3) score -= 20
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))
}
@@ -106,12 +113,28 @@ function isSpamDomain(domain: string, tld: string): boolean {
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
// ============================================================================
-// Score Badge with color coding
-function ScoreBadge({ score, showLabel = false }: { score: number; showLabel?: boolean }) {
+// Score Badge
+function ScoreBadge({ score }: { score: number }) {
const color = score >= 80
? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30'
: 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'
return (
-
{score}
- {showLabel && pts}
-
+
)
}
@@ -133,25 +155,22 @@ function ScoreBadge({ score, showLabel = false }: { score: number; showLabel?: b
function SourceBadge({ source, isPounce }: { source: string; isPounce: boolean }) {
if (isPounce) {
return (
-
-
-
Pounce
+
+
+ Pounce
)
}
const colors: Record
= {
- 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',
+ GoDaddy: 'text-orange-400/80',
+ Sedo: 'text-blue-400/80',
+ NameJet: 'text-purple-400/80',
+ DropCatch: 'text-cyan-400/80',
}
return (
-
+
{source}
)
@@ -161,31 +180,28 @@ function SourceBadge({ source, isPounce }: { source: string; isPounce: boolean }
function StatusBadge({ status, timeLeft }: { status: 'auction' | 'instant'; timeLeft?: string }) {
if (status === 'instant') {
return (
-
-
-
Instant
+
+
+ Instant
)
}
- // Check urgency
const isUrgent = timeLeft?.includes('m') && !timeLeft?.includes('d') && !timeLeft?.includes('h')
const isWarning = timeLeft?.includes('h') && parseInt(timeLeft) <= 4
return (
{timeLeft}
@@ -193,60 +209,83 @@ function StatusBadge({ status, timeLeft }: { status: 'auction' | 'instant'; time
)
}
-// Toggle Button
-function ToggleButton({
- active,
- onClick,
- children
+// Sortable Column Header
+function SortHeader({
+ label,
+ field,
+ currentSort,
+ currentDirection,
+ onSort,
+ align = 'left'
}: {
- active: boolean
- onClick: () => void
- children: React.ReactNode
+ label: string
+ field: SortField
+ currentSort: SortField
+ currentDirection: SortDirection
+ onSort: (field: SortField) => void
+ align?: 'left' | 'center' | 'right'
}) {
+ const isActive = currentSort === field
+
return (
)
}
-// Dropdown Select
-function DropdownSelect({
- value,
- onChange,
- options,
- label
-}: {
- value: string
- onChange: (v: string) => void
- options: { value: string; label: string }[]
- label: string
-}) {
+// Toggle Button
+function ToggleButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
return (
-
-
-
-
+
+ )
+}
+
+// Dropdown
+function Dropdown({ value, onChange, options }: { value: string; onChange: (v: string) => void; options: { value: string; label: string }[] }) {
+ return (
+
)
}
@@ -255,25 +294,28 @@ function DropdownSelect({
// ============================================================================
export default function MarketPage() {
- const { isAuthenticated, subscription } = useStore()
+ const { subscription } = useStore()
- // Data State
+ // Data
const [auctions, setAuctions] = useState
([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
- // Filter State
- const [hideSpam, setHideSpam] = useState(true) // Default: ON
+ // Filters
+ const [hideSpam, setHideSpam] = useState(true)
const [pounceOnly, setPounceOnly] = useState(false)
const [selectedTld, setSelectedTld] = useState('all')
const [selectedPrice, setSelectedPrice] = useState('all')
const [searchQuery, setSearchQuery] = useState('')
- // Watchlist State
+ // Sorting
+ const [sortField, setSortField] = useState('score')
+ const [sortDirection, setSortDirection] = useState('desc')
+
+ // Watchlist
const [trackedDomains, setTrackedDomains] = useState>(new Set())
const [trackingInProgress, setTrackingInProgress] = useState(null)
- // Options
const TLD_OPTIONS = [
{ value: 'all', label: 'All TLDs' },
{ value: 'com', label: '.com' },
@@ -286,7 +328,7 @@ export default function MarketPage() {
const PRICE_OPTIONS = [
{ value: 'all', label: 'Any Price' },
{ value: '100', label: '< $100' },
- { value: '1000', label: '< $1,000' },
+ { value: '1000', label: '< $1k' },
{ value: '10000', label: 'High Roller' },
]
@@ -297,7 +339,7 @@ export default function MarketPage() {
const data = await api.getAuctions()
setAuctions(data.auctions || [])
} catch (error) {
- console.error('Failed to load market data:', error)
+ console.error('Failed to load:', error)
} finally {
setLoading(false)
}
@@ -313,293 +355,281 @@ export default function MarketPage() {
setRefreshing(false)
}, [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) => {
if (trackedDomains.has(domain) || trackingInProgress) return
-
setTrackingInProgress(domain)
try {
await api.addDomain(domain)
setTrackedDomains(prev => new Set([...Array.from(prev), domain]))
} catch (error) {
- console.error('Failed to track:', error)
+ console.error('Failed:', error)
} finally {
setTrackingInProgress(null)
}
}, [trackedDomains, trackingInProgress])
- // Transform and Filter Data
+ // Process Data
const marketItems = useMemo(() => {
- // Convert auctions to market items
- const items: MarketItem[] = auctions.map(auction => ({
- id: `${auction.domain}-${auction.platform}`,
- domain: auction.domain,
- pounceScore: calculatePounceScore(auction.domain, auction.tld, auction.num_bids, auction.age_years ?? undefined),
- price: auction.current_bid,
+ let items: MarketItem[] = auctions.map(a => ({
+ id: `${a.domain}-${a.platform}`,
+ domain: a.domain,
+ pounceScore: calculatePounceScore(a.domain, a.tld, a.num_bids, a.age_years ?? undefined),
+ price: a.current_bid,
priceType: 'bid' as const,
status: 'auction' as const,
- timeLeft: auction.time_remaining,
- source: auction.platform as any,
+ timeLeft: a.time_remaining,
+ endTime: a.end_time,
+ source: a.platform as any,
isPounce: false,
- affiliateUrl: auction.affiliate_url,
- tld: auction.tld,
- numBids: auction.num_bids,
+ affiliateUrl: a.affiliate_url,
+ tld: a.tld,
+ numBids: a.num_bids,
}))
- // Apply Filters
- let filtered = items
-
- // 1. Hide Spam (Default: ON)
- 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
+ // Filter
+ if (hideSpam) items = items.filter(i => !isSpamDomain(i.domain, i.tld))
+ if (pounceOnly) items = items.filter(i => i.isPounce)
+ if (selectedTld !== 'all') items = items.filter(i => i.tld === selectedTld)
if (selectedPrice !== 'all') {
- const maxPrice = parseInt(selectedPrice)
- if (selectedPrice === '10000') {
- // High Roller = above $10k
- filtered = filtered.filter(item => item.price >= 10000)
- } else {
- filtered = filtered.filter(item => item.price < maxPrice)
- }
+ const max = parseInt(selectedPrice)
+ items = selectedPrice === '10000'
+ ? items.filter(i => i.price >= 10000)
+ : items.filter(i => i.price < max)
}
-
- // 5. Search
if (searchQuery) {
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)
- filtered.sort((a, b) => b.pounceScore - a.pounceScore)
+ // Sort
+ 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
- }, [auctions, hideSpam, pounceOnly, selectedTld, selectedPrice, searchQuery])
+ return items
+ }, [auctions, hideSpam, pounceOnly, selectedTld, selectedPrice, searchQuery, sortField, sortDirection])
- // Stats
const stats = useMemo(() => ({
total: marketItems.length,
highScore: marketItems.filter(i => i.pounceScore >= 80).length,
avgScore: marketItems.length > 0
- ? Math.round(marketItems.reduce((sum, i) => sum + i.pounceScore, 0) / marketItems.length)
- : 0,
+ ? Math.round(marketItems.reduce((s, i) => s + i.pounceScore, 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()}`
- }
+ const formatPrice = (p: number) => p >= 1000 ? `$${(p / 1000).toFixed(1)}k` : `$${p.toLocaleString()}`
return (
-
-
+
+
{/* ================================================================ */}
- {/* FILTER BAR */}
+ {/* HEADER - New Professional Style */}
{/* ================================================================ */}
-
-
- {/* Filter Icon */}
-
-
-
Filters
+
+
+
-
- {/* Toggle: Hide Spam (Default ON) */}
-
setHideSpam(!hideSpam)}>
- Hide Spam
-
-
- {/* Toggle: Pounce Direct Only */}
-
setPounceOnly(!pounceOnly)}>
-
- Pounce Only
-
-
- {/* Divider */}
-
-
- {/* Dropdown: TLD */}
-
-
- {/* Dropdown: Price */}
-
-
- {/* Search */}
-
- 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"
- />
-
-
- {/* Refresh */}
+
+ {loading ? 'Loading...' : (
+ <>
+ {stats.total} domains
+ •
+ {stats.highScore} high-score
+ •
+ Avg score: {stats.avgScore}
+ >
+ )}
+
+
+
+
{/* ================================================================ */}
- {/* MARKET TABLE */}
+ {/* FILTER BAR */}
{/* ================================================================ */}
-
+
+
- {/* Table Header */}
-
-
Domain
-
Score
-
Price / Bid
-
Status
-
Source
-
Action
+
setHideSpam(!hideSpam)}>
+
+ Hide Spam
+
+
+
setPounceOnly(!pounceOnly)}>
+
+ Pounce Only
+
+
+
+
+
+
+
+
+
+
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"
+ />
+
+
+ {/* ================================================================ */}
+ {/* TABLE */}
+ {/* ================================================================ */}
+
+
+ {/* Header Row */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Action
+
- {/* Table Body */}
+ {/* Body */}
{loading ? (
-
-
+
+
) : marketItems.length === 0 ? (
-
-
-
No domains match your filters
-
Try adjusting your filter settings
+
+
+
No domains match your filters
) : (
-
+
{marketItems.map((item) => (
{/* Domain */}
-
- {item.isPounce && (
-
+
+ {item.isPounce &&
}
+
{item.domain}
+ {item.verified && (
+
✓
)}
-
-
{item.domain}
- {item.verified && (
-
- ✓ Verified
-
- )}
- {/* Mobile: Show score inline */}
-
-
-
-
-
+
+ {/* Mobile info */}
+
+
+
- {/* Pounce Score */}
-
+ {/* Score */}
+
- {/* Price / Bid */}
+ {/* Price */}
-
- {formatPrice(item.price)}
-
- {item.priceType === 'bid' && (
-
(bid)
- )}
+
{formatPrice(item.price)}
+ {item.priceType === 'bid' &&
bid}
{item.numBids && item.numBids > 0 && (
-
{item.numBids} bids
+
{item.numBids} bids
)}
- {/* Status / Time */}
-
+ {/* Status */}
+
{/* Source */}
-
+
{/* Actions */}
-
- {/* Track Button */}
+
-
- {/* Action Button */}
{item.isPounce ? 'Buy' : 'Bid'}
-
+
@@ -608,18 +638,11 @@ export default function MarketPage() {
)}
- {/* ================================================================ */}
- {/* FOOTER INFO */}
- {/* ================================================================ */}
-
-
- Showing {marketItems.length} of {auctions.length} total listings
-
-
- Data from GoDaddy, Sedo, NameJet, DropCatch • Updated every 15 minutes
-
+ {/* Footer */}
+
+ {marketItems.length} of {auctions.length} listings
+ GoDaddy • Sedo • NameJet • DropCatch
-
)