diff --git a/frontend/src/app/terminal/market/page.tsx b/frontend/src/app/terminal/market/page.tsx
index 8ad547c..c800ebc 100644
--- a/frontend/src/app/terminal/market/page.tsx
+++ b/frontend/src/app/terminal/market/page.tsx
@@ -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 }) => (
-
-))
-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
-}) => (
-
-
-
-
-
-
-
- {label}
-
-
- {value}
- {subValue && {subValue}}
-
- {highlight && (
-
- ● LIVE
-
- )}
-
-
-))
-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 (
- = 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}
-
- )
- }
-
- const size = 36
- const strokeWidth = 3
- const radius = (size - strokeWidth) / 2
- const circumference = radius * 2 * Math.PI
- const offset = circumference - (score / 100) * circumference
-
- return (
-
-
-
- = 80 ? 'text-emerald-400' : 'text-zinc-400')}>
- {score}
-
-
-
- )
-})
-ScoreDisplay.displayName = 'ScoreDisplay'
-
-// Filter Toggle
-const FilterToggle = memo(({ active, onClick, label, icon: Icon }: {
- active: boolean
- onClick: () => void
- label: string
- icon?: any
-}) => (
-
-))
-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 (
-
-
- {tooltip && (
-
-
-
- )}
-
- )
-})
-SortableHeader.displayName = 'SortableHeader'
-
// ============================================================================
// MAIN PAGE
// ============================================================================
@@ -306,29 +107,23 @@ SortableHeader.displayName = 'SortableHeader'
export default function MarketPage() {
const { subscription } = useStore()
- // Data
const [items, setItems] = useState([])
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('all')
const [searchQuery, setSearchQuery] = useState('')
const [priceRange, setPriceRange] = useState('all')
- const [verifiedOnly, setVerifiedOnly] = useState(false)
const [hideSpam, setHideSpam] = useState(true)
const [tldFilter, setTldFilter] = useState('all')
- // Sort
const [sortField, setSortField] = useState('score')
const [sortDirection, setSortDirection] = useState('desc')
- // Watchlist
const [trackedDomains, setTrackedDomains] = useState>(new Set())
const [trackingInProgress, setTrackingInProgress] = useState(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 (
-
-
-
- {/* Ambient Background Glow (Matched to Watchlist) */}
-
-
-
-
- {/* Header Section (Matched to Watchlist) */}
-
-
-
-
- Real-time auctions from Pounce Direct, GoDaddy, Sedo, and DropCatch.
-
+
+ {/* ═══════════════════════════════════════════════════════════════════════ */}
+ {/* HEADER */}
+ {/* ═══════════════════════════════════════════════════════════════════════ */}
+
+
+
+
+
+ Live Auctions
- {/* Quick Stats Pills */}
-
-
-
- {stats.pounceCount} Exclusive
-
-
-
- {stats.auctionCount} External
-
-
+
+ Market
+ {stats.total}
+
-
- {/* Metric Grid (Matched to Watchlist) */}
-
-
-
-
-
-
-
- {/* Control Bar (Matched to Watchlist) */}
-
- {/* Filter Pills */}
-
-
setHideSpam(!hideSpam)}
- label="Hide Spam"
- icon={Ban}
- />
-
- setSourceFilter(f => f === 'pounce' ? 'all' : 'pounce')}
- label="Pounce Only"
- icon={Diamond}
- />
-
- {/* TLD Dropdown (Simulated with select) */}
-
-
-
-
-
-
-
- setPriceRange(p => p === 'low' ? 'all' : 'low')}
- label="< $100"
- />
- setPriceRange(p => p === 'mid' ? 'all' : 'mid')}
- label="< $1k"
- />
- setPriceRange(p => p === 'high' ? 'all' : 'high')}
- label="High Roller"
- />
+
+
+
+
{stats.pounceCount}
+
Pounce Direct
-
- {/* Refresh Button (Mobile) */}
-
-
- {/* Search Filter */}
-
-
-
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"
- />
+
+
{stats.auctionCount}
+
External
-
-
- {/* DATA GRID */}
-
- {/* Unified Table Header */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Action
+
+
{stats.highScore}
+
Score 80+
-
- {loading ? (
-
-
-
Scanning live markets...
-
- ) : filteredItems.length === 0 ? (
-
-
-
-
-
No matches found
-
- Try adjusting your filters or search query.
-
-
- ) : (
-
- {filteredItems.map((item) => {
- const timeLeftSec = getSecondsUntilEnd(item.end_time)
- const isUrgent = timeLeftSec > 0 && timeLeftSec < 3600
- const isPounce = item.is_pounce
- const displayTime = item.status === 'auction' ? calcTimeRemaining(item.end_time) : null
-
- return (
-
- {/* Domain */}
-
-
- {isPounce ? (
-
- ) : (
-
- {item.source.substring(0, 2).toUpperCase()}
-
- )}
-
-
-
- {item.domain}
-
-
- {item.source}
- {isPounce && item.verified && (
- <>
- •
-
-
- Verified
-
- >
- )}
- {!isPounce && item.num_bids ? `• ${item.num_bids} bids` : ''}
-
-
-
-
-
- {/* Score */}
-
-
-
-
- {/* Price */}
-
-
- {formatPrice(item.price, item.currency)}
-
-
- {item.price_type === 'bid' ? 'Current Bid' : 'Buy Now'}
-
-
-
- {/* Status/Time */}
-
- {isPounce ? (
-
-
- Instant
-
- ) : (
-
-
- {displayTime || 'N/A'}
-
- )}
-
-
- {/* Actions */}
-
-
-
-
-
-
- {isPounce ? 'Buy' : 'Bid'}
- {isPounce ? : }
-
-
-
- )
- })}
-
- )}
-
-
- )
-}
+
+ {/* ═══════════════════════════════════════════════════════════════════════ */}
+ {/* FILTERS */}
+ {/* ═══════════════════════════════════════════════════════════════════════ */}
+
+
+ {/* Source Filters */}
+
+
+
+
+
+ {/* TLD Filter */}
+
+
+
+
+ {/* Price Filters */}
+ {[
+ { value: 'low', label: '< $100' },
+ { value: 'mid', label: '< $1k' },
+ { value: 'high', label: '$1k+' },
+ ].map((item) => (
+
+ ))}
+
+
+
+ {/* Hide Spam */}
+
+
+ {/* Search - Right aligned */}
+
+
+
+ setSearchQuery(e.target.value)}
+ placeholder="Search domains..."
+ className="bg-[#050505] border border-white/10 pl-9 pr-4 py-2 text-sm text-white placeholder:text-white/25 outline-none focus:border-accent/40 w-48 lg:w-64"
+ />
+
+
+ {/* Refresh */}
+
+
+
+
+ {/* ═══════════════════════════════════════════════════════════════════════ */}
+ {/* TABLE */}
+ {/* ═══════════════════════════════════════════════════════════════════════ */}
+
+ {loading ? (
+
+
+
+ ) : filteredItems.length === 0 ? (
+
+
+
+
+
No domains found
+
Try adjusting your filters
+
+ ) : (
+
+ {/* Table Header */}
+
+
Domain
+
Score
+
Price
+
Time
+
Actions
+
+
+ {/* 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 (
+
+ {/* Mobile */}
+
+
+
+ {isPounce ? (
+
+ ) : (
+ {item.source.substring(0, 3)}
+ )}
+ {item.domain}
+
+
+ {formatPrice(item.price)}
+
+
+
+ Score: {item.pounce_score}
+ {displayTime || 'Instant'}
+
+
+
+ {/* Desktop */}
+
+ {/* Domain */}
+
+ {isPounce ? (
+
+
+
+ ) : (
+
+ {item.source.substring(0, 2).toUpperCase()}
+
+ )}
+
+
{item.domain}
+
+ {item.source}
+ {isPounce && item.verified && (
+ <>
+ ·
+
+
+ Verified
+
+ >
+ )}
+ {item.num_bids ? <>·{item.num_bids} bids> : null}
+
+
+
+
+ {/* 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}
+
+
+
+ {/* Price */}
+
+
+ {formatPrice(item.price)}
+
+
+ {item.price_type === 'bid' ? 'Bid' : 'Buy Now'}
+
+
+
+ {/* Time */}
+
+ {isPounce ? (
+
+
+ Instant
+
+ ) : (
+
+ {displayTime || 'N/A'}
+
+ )}
+
+
+ {/* Actions */}
+
+
+
+
+ {isPounce ? 'Buy' : 'Bid'}
+ {!isPounce && }
+
+
+
+
+ )
+ })}
+
+ )}
+
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/app/terminal/watchlist/page.tsx b/frontend/src/app/terminal/watchlist/page.tsx
index e40acdc..007f533 100755
--- a/frontend/src/app/terminal/watchlist/page.tsx
+++ b/frontend/src/app/terminal/watchlist/page.tsx
@@ -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 {